list_metrics declares availability, the Coach-backed reads declare
searchedWindow and available, and get_intraday_pulse declares min and
max per bucket. An integration test validates every tool's real output
against its advertised schema under a strict 2020-12 validator.
The metric-status discovery rows and the search clinical-signal probe
filter deletedAt, guarded for every measurement read in the MCP and
Coach tool trees.
Own-plan turns get 165k tokens, 200 s and 16 rounds, operator turns 55k,
120 s and 8; the final-answer reserve, the concurrent-turn slot and the
step cap move with them.
Codex requests carry a per-conversation prompt_cache_key and a stable
session id, api.openai.com gets the same key, Anthropic usage counts
cache reads and writes in the gross, and local servers report cached
prompt tokens, so the turn budget discounts cached input on every
provider.
While a turn runs the Coach now shows a single row: a brain icon, the
current step in the answer's text size with a shimmer passing through it
and fading dots, and a chevron once there are steps to open. There is no
running clock any more, and the interim table preview with its sparkline
no longer renders under the row; interim tables stay in state until the
answer's own results show them.
When the answer is done the row reads "Thought process · N steps" with
proper plurals, and "Answer needed" while a clarifying question waits.
It never opens by itself, and a row opened during the run closes when
the turn ends. Opened, the steps form a vertical list with an icon per
kind of work on a thin connector line, and a reasoning round shows its
summary under its title.
The shimmer and the dots are a `text-shimmer` utility and a reusable
WaitingText component, both still under reduced motion.
The demo's write block now carries `meta.errorCode: "demo.readOnly"`,
listed in the error-code catalogue, and the Coach answers it with an
honest line instead of the provider-outage copy.
Removes the interim preview component, the elapsed-seconds and summary
helpers, and their message keys in all seven locales.
The Coach loop's final round sent toolChoice "none" without the tool
definitions. With tool_use/tool_result blocks in the history Anthropic
refuses such a request (400), and OpenAI Chat Completions refuses
tool_choice without tools, so every budget, time, cap and no-progress stop
ended in an error on those wires. The final round now carries the tools
with toolChoice "none", which every wire accepts, thinking included.
Codex kept reasoning items as provider state even when the request asked
for no encrypted reasoning; replaying such an item under store:false is a
400. Items are kept only when the request included
reasoning.encrypted_content and the item carries it.
The reasoning dialect cache learned from any 4xx that mentioned
reasoning or thinking (auth, rate limits, model names such as
deepseek-reasoner) and from replay errors, and kept the downgrade for the
life of the process. Learning now requires an HTTP 400 that refuses a
sent parameter, ignores replay errors and the model name, and a learned
downgrade expires after an hour.
remember_fact and propose_plan wrote their rows during the tool round. An
answer that was then blocked, failed or abandoned left the fact in memory
without its note and Undo, and left the plan listed, activatable and fed
into later turns and the briefing. Both are now staged under an id minted
up front and written in the transaction that stores the assistant message,
only when its provenance carries them. A background proposal counts as
offered only once an answer carrying it is stored.
Plan text is model text: ifCue, thenAction and target pass the outbound
dose and risk screen in the person's locale, with their scheduled doses and
medication names, and the injection screen; a trip declines as "unsafe".
The trail route serves recalled facts and the proposal's fact to the owner
whatever the AI capability (only the model-written entries are gated), and
withholds medication facts while the medications module is off.
Accepting a fact proposal is decided under a per-message advisory lock, so
concurrent taps write one fact. Editing a fact re-reads its wording: text
naming a medication moves to "medication", other health wording moves a
non-health fact to "condition", and a health fact the person edited counts
as confirmed.
The dose-change class demanded a dosage-form noun next to every skip, stop
or extra imperative, and a drug name never counted. "Skip the amlodipine",
"then take an extra ramipril", "take one more tablet of ramipril", "Nimm
abends eine Tablette mehr", "Lass das Ramipril morgen weg" and "Stop your
blood thinner before the run" all passed.
A medication noun is now a dosage-form word, a drug-class word per locale
(blood thinner, Blutverdünner, anticoagulant, statin, insulin, ...), a
common generic-name stem (-pril, -sartan, -olol, -dipine, -formin, ...),
or one of the person's own medication names. The Coach loads the names
with the turn context and passes them to the reply screen and the trail
screen; a scheduled brand name is only known that way.
Add the extra / another / one more shape in en, de, fr, es, it, pl and ko,
spelled-out counts as a dose target, and stop / leave out / go without a
named medication. The number-free shapes use a two-word slot that a
preposition ends, so "take an extra minute to log your medication" and
"stop worrying about your medication" pass. A sentence that only names a
drug, a schedule description, a past change from the log, a negated
warning and a referral ("ask your doctor whether you should stop ...")
still pass.
The running status line now carries its seconds right after the text, in
the line's own muted tone, instead of at the far edge of the column. The
live count and every finished round read "4 s", the same spacing as the
settled summary, with a no-break space so a round never wraps between
number and unit.
Reply pills (follow-ups, clarifying choices, memory and plan decisions)
sit directly under the answer, and the action row is the last thing in
the column. Beside a pointer the row stays invisible until hover or
focus; above the pills it left an empty band between an answer and its
replies. Hovering or focusing the message still reveals the row without
moving anything.
Keyboard focus in the Coach panel is a solid ring in the neutral
text-field focus tone, never the purple ring: the pills, Undo and Change
merge it over the button primitive, and the hand-built controls carry it
directly. A structural test keeps the purple ring out of the panel.
A disabled native select now looks disabled, like Input and Select, so a
thinking depth locked by the operator no longer reads as a live choice.
In Settings, thinking depth takes the shape of Tone and Reply length
beside it; a memory entry's Forget is an icon like its edit; the
operator's highest-depth label sits on the switches' left edge.
The signals of the day held today's newest single reading against the
7- and 30-day mean of every reading. For glucose, whose level climbs
with each meal, a fasting morning read as low against an all-day mean
and fed that into the briefing and status prompts. A type in
SAME_HOURS_TYPES now compares today's mean so far with the earlier
days cut at the local time of today's latest reading, the basis the
coincident-deviation flag and the metric strip already use. Below a
week of like-for-like days there is no signal. The signal carries
basis "sameHours" and both briefing prompts say what it means. Other
metrics keep the reading-against-window comparison.
The usual-range strip called the newest reading today's on every
metric page, even when it was days old. CoachReadBaseline now carries
latestDate (the reading's day in the account's zone) and latestIsToday
(isFromToday), on the REST response and the get_metric_baseline MCP
output. The strip names the date when the reading is not from today,
in all seven locales.
The chat POST is answered from a stream the spec feeds, so each live
state is observed: the status line moving from thinking to fetching to
summarising, a table previewed before the first token, the trail closed
in every state and after a reload, at 1440 and 390 px in both themes.
Clarifying choices, memory and plan decisions answer through pills, an
entry in the memory list is edited in place, and the thinking depth
shows the operator's cap and lock. The dialog spec reads the pills
where it read the card.
Thinking depth (Off, Low, Medium, High) sits under the model in the
Coach's quick settings and in Settings → Coach. It renders the resolved
block from the account payload: levels above the operator's cap are
disabled and say so, the field locks with one sentence when the
operator switched reasoning off, and Off reads Minimal where the
provider cannot stop thinking. The provider's own reasoning select
leaves the quick settings and says the Coach has its own.
The operator gets a switch to allow deeper thinking and a select for
the highest depth. The memory list shows where each entry came from,
lets it be edited in place, and is linked from the quick settings with
its count. The deep-linked data section no longer draws a ring.
The top of an answer is one line that says what the Coach is doing right
now (thinking, fetching a metric, summarising the readings, writing),
live from the activity frames, with the seconds so far. It never opens
by itself: the trail stays closed while the turn runs, when it asks,
when it stops early, after the answer and after a reload, and opens
only on a tap. Once the answer is in, the line settles into one summary
of lookups and seconds. A table read during the turn shows as a
one-line preview with a sparkline until the answer takes it over.
Clarifying choices, follow-ups and the answers to a fact or plan
proposal share one pattern: quiet pills in the answer's column whose
tap sends the label as the person's own message. The clarification
card and the in-thread plan card are gone. A fact the Coach kept is one
meta line with Undo, an assumption one line with Change. The remember
button on a message now keeps it in the Coach's memory.
The compare chart replaces the placeholder that rendered nothing: two
periods overlaid on one axis (the earlier one dashed), or two metrics on
two axes whose grid lines meet. Same tokens, legend and tooltip as the
other result charts, which stay as they were. The legend key is spelled
out so the chart runtime does not pull the Coach's schemas into every
chart.
The usual-range strip on a metric page placed the latest single reading
against a band built from whole-day means. For glucose the day mean climbs
with every meal, so at nine in the morning a fasting reading sat under a
band of whole days and the strip said "below your range" on an ordinary
morning; the MCP baseline read reported the same.
Glucose is now placed by its day mean. A day still in progress, in the
reader's own zone, is held against the earlier days cut at the local time
of today's latest reading, the comparison the health status already makes;
with fewer than seven earlier days that have readings in those hours there
is no verdict. The baseline carries `basis: "sameHours"` on the coach-read
route and the MCP read, and the strip names the range as the one for this
time of day, in all seven locales. Other metrics keep their placement.
The guard allowed every unnamed coachConversation write in a listed file,
so a background write added beside the rename and the document attach
would have passed. Each listed file now carries how many such writes it
holds, and a changed count fails.
"Increase your metformin to 2000 mg daily" passed the outbound dose
screen. The English change pattern accepted only "it" or "your dose"
between the verb and "to", so a drug name in that slot walked past it,
and no pattern covered an imperative to take a stated amount, a count
unit (tablets, capsules, puffs), or an imperative to skip or stop a
medication. Each locale bank had the same narrow object slot.
Add a per-locale dose-change class (en, de, fr, es, it, pl, ko) with
three shapes: an imperative or infinitive change verb with up to four
words of object and a target dose; an imperative to take or inject a
stated amount; and an imperative to halve, double, skip, pause or stop
a medication. Negated imperatives, past-tense log restatements,
present-tense descriptions ("you take 1000 mg"), clinician referrals
without a target, and nutrition advice (sodium, water, steps) still pass.
A unit now ends on an explicit lookahead instead of \b, so a lab
concentration ("under 140 mg/dL") no longer trips the dose bank while a
rate ("2000 mg/day") still does. "as usual" and "wie gewohnt" join the
continuation anchors, still gated on the user's schedule.
The chat route hands the turn the level resolveCoachTurnReasoningLevel
returns: the stored Coach preference, absent read as medium, after the
operator's switch and cap. The turn still applies the operator-paid
ceiling once its chain is known. The controls loader no longer throws
when the request cache cannot serve it; it fails closed to off. The
admin guard pins the route to the resolver.
Build the memory block for the providers the turn sends to, send it on
both paths, keep the facts it recalled in the owner's trail, and offer a
health fact the background found as the answer's one note. Every turn
enqueues the memory refresh; the job's debounce and singleton key decide
when it runs. A remembered fact points back to the stored user message.
The briefing prompt carries the server-computed progress line of up to
two active Coach plans, fenced as data because it quotes the person's
own goal words. The model may mention one and may not invent one; the
figures in the lines are admitted by the grounding gate like any other
figure the server handed over. The lines join the snapshot fingerprint
only when there is one, so an account without an active plan keeps its
stored fingerprint, and a failure to build them drops the lines, never
the briefing.
runStatusCompletion and runBriefingCompletion take an optional
reasoningJob. Only a named job carries reasoning, resolved against the
operator's switch and cap and the payer; a call without one, which is
every status card, batch note, derived score and workout line, sends
exactly what it sent before. Thinking is reserved on the day's ledger
with the answer budget, and a reasoning status call falls back to a
90 s ceiling instead of 60 s.
The briefing reasons on its generation and phrasing re-roll, the weekly
and monthly narratives on their first pass; the JSON and grounding
repairs do not. An inventory test freezes which files may name a job.
GET /api/auth/me gains coachReasoning: the level a turn runs at, the
person's own pick, the highest level the settings may offer, whether
reasoning can run at all, whether the lowest option is a real off, and
the layer that decided. Resolved on the server so the web settings and
the native client read the same answer.
The web reads it through useCoachReasoning. The Coach preferences route
names the stored level on its wide event, and a unit test round-trips
the level through the account-settings backup section.
PUT /api/admin/settings/assistant-flags accepts aiReasoningEnabled and
aiReasoningMaxEffort (low, medium, high), writes them field by field
with an audit row like the other switches, and both methods echo them
as reasoning: { enabled, maxEffort }.
Add resolveReasoning, the one decision for how hard a call may think:
the operator's switch, then the provider's ability, then the requested
level (the person's Coach preference, or a background job's own level),
then the operator's cap, then a medium ceiling when the operator pays.
The answer names the layer that decided it.
loadReasoningControls reads the two app_settings columns, memoised per
request, read afresh in background runs, and fails closed to "off".
loadCoachReasoningState resolves the published Coach state from the
memoised provider presence; resolveJobReasoning resolves one background
call and never fails it.
A guard proves the switch and the cap over the whole input space and
requires every call that carries reasoning to take it from the
resolver.
Unit tests for the turn budget and the no-progress brake, the budgeted
loop (every stop reason, the round-by-round ledger, repeated calls,
questions, comparisons, reasoning and provider state), the trail recorder
and its screen, clarifications through the tool and the brake, the
comparison table and chart, the trail column and its owner-only read, the
reply frames, and the turn's frames end to end through the route. An
integration test proves the trail against a real database. Eval
scenarios cover a multi-round why question, a period comparison with its
chart, a question asked through the tool, and keep the charts the
existing table scenarios already showed.
Take the effective reasoning level from the route rather than resolving
it in the turn; the turn only caps it when the operator pays.
Give the Coach one statement of how an answer is shaped, in the German and
English bodies and on every other locale: the first sentence answers, one
carrying number with its unit and window, the person's own baseline, an
assumption in one line, at most one next step and no habitual closing
question, 40 to 120 words, and a closed list of filler it never writes.
- Brief is 30 to 70 words in one paragraph, detailed up to 220 in three.
- Motivational-interviewing moves only when the person opens a change topic,
at most one, never as a closing question; the confidence ruler only on an
explicit plan request.
- The few-shot examples are rewritten to model the voice.
- Result tables: show the numbers whenever they carry the answer and span
more than one value, a table for exact values or up to twelve like rows,
one figure alone only for a moment.
- Follow-up chips are the answer's only "what next".
- A voice grader for the eval (length, filler, emoji, next steps, closing
question, warm-up opening) plugs into the golden set as criteria; five
voice cases in German and English.
- A guard holds both prompt bodies to the rules and every example answer to
the grader, each check broken once on purpose.
What a person told the Coach never reached a tool-mode turn: facts and plans
rode the snapshot's memory block, which tool mode does not send, and the
extraction ran only past twenty turns and failed to parse on every
non-Codex chain, because the status chain runs in JSON-object mode while the
prompt asked for a bare array.
- Memory block: WHAT YOU KNOW ABOUT THIS PERSON, built per turn behind the
wire egress check (nothing is read on a refusal), fenced as data, at most
1,500 characters, never trimmed before the inventory. Confirmed facts
first, then the newest; active plans with a server-computed progress line;
reminders the person asked for. Medication facts stay out while the
medications module is off. Building it stamps last_used_at without moving
updated_at. buildCoachToolRequest and buildCoachProviderPrompts take it as
an optional memoryBlock; the snapshot memory keeps only the narrative and
trend recall.
- remember_fact: preferences, goals and context are saved at once
(source coach, source_message_id set); anything that names a condition,
limitation or medication, by category or by wording, is only proposed.
A fact must come from the person's current message; one note per answer.
- The person's answer reads the proposed fact from the encrypted trail on
the assistant message, never from the request.
- propose_plan writes a proposed plan; the tap activates it with its review
window counted from the tap. One per answer, three open at most.
- Progress: one sentence per active plan from the person's own day series
and units, also offered to the briefing (two lines at most, behind the
briefing capability). The plan review and the progress line read a step
plan as daily totals instead of per-sample means.
- Background extraction asks for {"facts":[]} / {"plans":[]} and reads
either shape, runs once a conversation has been quiet for 30 minutes, and
stores health facts as proposals offered once at the next turn.
- Unanswered plan and fact proposals lapse after 14 days in the reminder
sweep.
- Memory list API: POST saves one of the caller's own messages (the
remember button), PATCH edits a fact's wording, GET reports source, origin
and last use and never lists a waiting proposal.
- A structural guard keeps the egress check ahead of every read and the
callers of both builders to the turn and the briefing.
providerState may be read only in the clients, the contract and the Coach tool loop, never on a line that logs, annotates or persists, and has no schema column.
The mock reports a fixed summary, title and provider state for calls that ask for reasoning. The runner passes reasoning and onReasoning to every candidate unchanged and adds the reasoning token and summary counts to the wide event, never the text or the state.
The key now appears twice in each client: the bound chain setting, sent only when the call carries no level, and the call's resolved level. The guard pins both, the condition in front of the bound value, and the absence of a literal.
OpenAI sends reasoning_effort for reasoning models on rounds without tools, off mapped to each family's lowest value. Gateways send reasoning_effort, OpenRouter its reasoning object, and the reasoning details or thinking blocks a gateway returns go back on the next round's assistant message. Local servers send reasoning_effort and read reasoning_content, reasoning and a leading think block, live on the stream without letting it reach the answer.
The thinking budget rides on top of the answer budget where reasoning counts against it. A refused reasoning field is retried once without it and remembered per endpoint and model, before the JSON dialect check. A call's own level wins over the stored chain setting, which still applies when the call has none.
Up to 4.5 a thinking budget (with the interleaved-thinking header on tool rounds), from 4.6 adaptive thinking with an effort and summarized display. From the 5 generation thinking cannot be switched off, so off becomes the lowest effort. Temperature is never sent from 4.7 on, nor with thinking, which also removes a 400 on the newer model presets.
Thinking blocks travel to the next round unchanged, signature included; a tool round another provider started runs without thinking. Every text block is joined instead of only the first. The JSON instruction goes only to callers that ask for JSON. Unknown model names learn the dialect and the sampling rule from a refusal.
A call that carries a reasoning level sends reasoning {effort, summary} with include [reasoning.encrypted_content]; off is effort none. Summary titles are reported as soon as the bold first line closes, the text when the part is done. Reasoning items come back as provider state and are re-inserted before the next round's message and calls, only for the slug that made them.
A 400 that names a reasoning parameter is answered with the lesser request the backend lists (no summary, the nearest supported effort, no reasoning), remembered per slug. Calls without a level keep reasoning null and include [].
reasoningSupport() reads what each provider can do with reasoning from the provider type and model name: effort, summaries, live summaries, state that must travel between tool rounds, and whether off is a real off. Unknown names are treated optimistically.
The dialect cache records what an endpoint refused (keyed by provider, endpoint and model) so a client retries with the lesser request once and never pays the refused round-trip again. Downgrades are marked on the wide event with meta keys only.
Replace the fixed three tool rounds with a budget fixed by who pays:
120k tokens, 150 s and 12 rounds on the person's own plan, key or local
model, 40k, 90 s and 6 rounds when the operator pays. Cached input counts
at a tenth. The final answer is reserved from the start, so a turn whose
budget, clock or round cap runs out answers from what it read. A
no-progress brake stops a turn that repeats calls or reads nothing new,
and a repeated call is never run again.
The day's ledger is booked round by round: round one and the final
reserve when the turn starts, each further round right before it runs,
every round settled as it returns, the unused reserve given back at the
end. A round the daily ceiling refuses ends the turn with an answer.
Every phase goes out live as an activity frame (thinking, fetching,
summarising, asking, remembering, planning, the stop, the answer), with
screened reasoning titles and summaries; the structure rides the
provenance and the text is sealed in trail_encrypted, served by
GET /api/insights/chat/{id}/messages/{messageId}/trail. Tables go out as
interim results as soon as they are read.
Add ask_clarification with a server brake, remember_fact and
propose_plan over the memory contract, model-free decision turns,
compare_series with the compare chart, eight tables per message,
assumptions with change-assumption chips, a reasoning level per turn and
at most two concurrent turns per person.
Add the shared contract the reasoning release builds on, with no change
to how a turn runs today.
- Schema: operator reasoning controls on app_settings (0376), the
encrypted trail text on coach_messages (0377), and source,
last_used_at and source_message_id on coach_facts (0378).
- Provider contract: CompletionParams.reasoning and onReasoning,
CompletionResult.reasoning and providerState, AiMessage.providerState.
- Reasoning levels, thinking budgets and the background-job table in
src/lib/ai/reasoning/levels.ts; coachPrefsJson.reasoning, absent
meaning medium, salvaged field by field like every other pref.
- Stream: activity, memoryNote and planProposal frames, done.stop and
done.withheldResults, result.interim, a compare chart kind, eight
table refs, three more clarification kinds with an assumed choice,
the change_assumption chip, and assumptions, activity, stop,
memoryNote and planProposal on the provenance. Zod mirror, type
parity and the native-client key rule cover every new frame.
- Chat request: memoryDecision and planDecision, parsed and not yet
acted on.
- Stub contracts for the memory functions and the activity recorder,
inert until their implementations land.
- Backup, restore and rotation carry the new columns; a fact's message
reference is resolved against the restored messages like its
conversation reference.
- Message keys for the trail, memory, plans, assumptions, replies,
interim tables, comparison charts and reasoning settings in all seven
locales, each reachable from a catalog and checked both ways.
The panel groups conversations by the browser's local day, so the e2e
fixture's "an hour ago" landed in yesterday when CI ran just after
midnight. The turn-steps test reads its markup by splitting rather than
with a replace CodeQL reads as an incomplete sanitiser.
The today check now reads briefingGeneratedAt from the cached payload
and treats a payload without it as not from today. The integration
and e2e fixtures that seed a fresh briefing write it, as every real
generation does.
Below md the lines under the 80 px dial (the score delta, how long it
has held, what it rests on) were capped at the dial's width. In German
they broke into three to five rows each, and beside a short lead that
column set the height of the read row and left a gap above the facts.
The score column now dissolves into the grid below md: the dial stays
the top-right cell beside the lead, and its meta lines take a
full-width row right after the read row, flush with the dial's edge,
one line each. The facts move one row down. From md up the column is
the same flex column as before (desktop captures are pixel-identical).
The Today overview e2e renders German at 390 and 360 px with a short
and a long lead and asserts the dial size, one row per meta line, the
lines flush with the dial, the meta row right after the read row, and
exactly one row gap above the facts.
With signals of the day resting on today's readings only, the briefing
had to be written after those readings, and it was only written at the
04:30 Berlin warm, the post-sleep morning refresh, or a page open on a
day-old cache. A morning blood pressure or weigh-in never became a
signal, and since an earlier day's briefing is no longer served, a
reader in Los Angeles or Seoul had none for most of the day: the Berlin
warm lands the previous evening there, or at noon.
The snapshot read every surface goes through (dashboard, iOS, digest,
morning push) now asks for a briefing for today at two moments: the
first read of the reader's local day with no briefing written today,
and the first read after a signal metric's first reading of the day
that the briefing has not read. Each reason is claimed once per user
per local day in a Postgres bucket and remembered in-process, so later
reads cost no query; no write path needs a hook.
A claim enqueues one today warm: immediately for a new day, after a
five-minute window for readings, so a burst (weigh-in, then blood
pressure) folds into one job. The warm writes the comprehensive
briefing only, past the one-hour freshness gate but under the failure
backoff, the daily forced-warm cap (two per 24 h) and the content-hash
gate.
Worst case per user per local day: one enqueue per reason, at most
eight (the day plus seven signal reading types, measured over a day of
reads every two minutes); at most two forced comprehensive attempts
under the existing cap, each at most three completions (the answer, a
JSON retry, a grounding retry), so six provider calls at the ceiling.
A warm whose data has not changed costs at most the existing
once-a-day paragraph re-roll, inside the same cap.
The SDK renders every tool's Zod shape with a fixed draft-07 target and
stamps "$schema": "http://json-schema.org/draft-07/schema#" on each
inputSchema and outputSchema. Clients that validate with a 2020-12-only
validator refuse that dialect when the tool list arrives, so every tool
was unusable there (#1170).
Wrap the tools/list handler once, after the last tool is registered, and
re-render each advertised schema from the same Zod shape with the
draft-2020-12 target, without a $schema key. A schema with no $schema is
2020-12 under the MCP spec and still compiles in a draft-07 client. All
other fields of a tool entry pass through unchanged, and the SDK keeps
validating structuredContent against the Zod shape, so tool results are
unaffected. For the current surface the 54 rendered schemas differ from
the previous ones only by the dropped key.
The new guard reads tools/list through a real client for a write-scoped
session, rejects any draft-07 marker or construct, and compiles every
schema with Ajv's 2020-12 build (ajv added as a dev dependency at the
version the SDK already resolves).
The summary of a condensed block was taken over the block's mix of daily
values and weekly means: over a year the newest fourteen days weighed as
much as fourteen weeks, min and max were extremes of means, and change was
one day minus a weekly mean.
Each series block now hands the budget pass its daily values over the
whole read, computed only when the block is condensed. The summary states
the first and last day, the lowest and highest day, the mean of the days,
and the change from the first calendar week to the latest one. A block
without daily values gets no summary rather than one of what is left. The
coverage note says how the summary is taken.
Only the digest applied briefingForToday. The dashboard snapshot (the
web dashboard, its server prefetch and the iOS cold-launch read) and
the /insights advisor payload still served an earlier day's text and
the raw model deltas ("+33.72 bpm").
The snapshot read now resolves the briefing after the cache, beside
the capability decision, from the payload's generation moment and the
snapshot's own newest reading per type; a withheld briefing reads
"preparing", never "ready" with nothing in it. The digest takes the
resolved briefing from that read on its own clock instead of resolving
again. The advisor route resolves dailyBriefing on every branch it
returns, reading the newest reading per signal type in one grouped
query only when a reading-backed signal is present; text generated on
an earlier day no longer counts as fresh on the read GET (so the warm
is enqueued) and no longer short-circuits a provider-backed POST.
The OpenAPI descriptions of both advisor responses say so.
The check that keeps an earlier day's briefing off the Today surfaces
read the cache row's insightsCachedAt. Every warm moves that column,
including the unchanged-hash short-circuit that keeps yesterday's text
and only refreshes the stamp, so text written the day before passed as
today's after the nightly warm.
The cached payload now carries briefingGeneratedAt, written only where
new text is written: a full generation in the warm and in the
on-demand route, and the daily paragraph re-roll. The stamp refresh
leaves it untouched. briefingForToday reads that moment, and a payload
without it counts as not from today, so the hero falls back to its own
lead until the next generation.
The condensing step read every block as a value series: daily rows under
value, weekly rows under mean and count. Sleep nights carry minutes, so a
condensed sleep summary ended on the last weekly mean weeks before the
newest night; adherence rates fold by doses, not readings; glucose keeps a
series per measurement context; workouts are a sessions list.
Condensing now knows each block's shape: sleep reads minutes, adherence
folds rate weighted by total doses, glucose condenses every context, and a
workouts block keeps its newest sessions beside the whole-window rollup.
formatSignalDelta matched only the locale's decimal mark, so an
English-formatted figure inside German text was cut at the comma
("+1,234.5 kg" read "+1,2.5 kg"), and a difference that rounded to
nothing lost its sign ("+0.04 kg" read "0.0 kg").
A figure with a decimal or grouping mark is now read as a whole: with
a point and a comma the later one is the decimal, thin spaces and
repeated marks are grouping, and a single mark is grouping only when
it is the locale's grouping mark before exactly three digits. The
figure is re-rounded to the metric's precision and re-written with the
locale's own marks. A non-zero difference that rounds to nothing reads
"±0" at that precision. Plain integers stay as written.
Glucose joins the reading types, its precision following the unit the
delta is written in (whole mg/dL, one decimal of mmol/L). The map is
keyed by string, because the published sourceMetric enum has no
glucose value yet and widening it would change the iOS contract.
Tests cover all seven locales.
The Coach's figure check ran before the MCP branch, so an MCP client was
told retrieval_failed for a block it used to receive, and the plain-text
renderer then said no data was on file. Any single-source build also
condensed its own block, which reached MCP through the source snapshot's
fallback to the full builder.
The figure check now runs after the MCP branch, and condensing is an
explicit option of the snapshot build that only the Coach tools set (it is
part of the cache key). MCP output is what it was before.
A block trimmed to its unit and freshness stamp passed the figure check,
because asOf.daysAgo is a number. The check now skips the asOf and scope
fields, so that shape answers retrieval_failed like any other block
without a reading.
Below md the score ring sat centred under the Today facts as a 168 px
dial, where the bottom navigation covered it. The hero is now one
grid with two arrangements: on a phone the ring is a compact 80 px
dial at the top right beside the lead and signal line, flush with the
card's trailing edge, and the Today facts run the full card width
under both; from md up the ring spans both rows of the trailing
column exactly as before (desktop captures are pixel-identical).
Without a lead the facts take the leading cell, so the ring never
sits beside an empty row.
ScoreRing gains a `responsive` size (80 px, md:168 px) whose label is
left out below md; the ring keeps its aria-label and the surrounding
link names it. The meta lines under the phone dial are capped to its
width, so the lead keeps at least 180 px beside it at 360 px.
Each fact now stacks its muted label above its foreground value on
the reading edge at every width, instead of a label-left /
value-right row that broke long values into a ragged right-aligned
block. Rows stay at the 44 px tap floor below md.
The Today overview e2e covers 1440, 390 and 360 px in both themes
for an AI lead, a long AI lead, an AI-off day and a quiet day, and
asserts the dial size, its place beside the lead, symmetric insets,
full-width facts and stacked 44 px rows on a phone.
Inside a chat turn every tool read lands on the turn's shared full-source
snapshot when its window matches. On an account with many synced series
that build passes the prompt budget, and the budget pass collapses blocks
to a bare unit and then to an `{ omitted }` marker. get_metric_series
sliced the marker out and answered `present: true` with it, so the model
was told the readings exist and was given no figure. The same held for
sleep, glucose, compliance and workouts.
- The snapshot result names the blocks the budget pass cut. A tool whose
block was cut in the shared build reads it again from its own source at
the same window.
- A read scoped to one source condenses its own blocks last and never
swaps them for the marker: it keeps a summary (first, last, min, max,
mean, change) taken before anything is cut, the newest daily values,
weekly means folded into monthly means, and names each cut.
- get_metric_series never answers present with a block holding no figure;
that read reports retrieval_failed instead.
- A chat series read carries the readings it counted, so the step shows
them, and on a long or condensed window a note on what it covers. The
tool description and a new tool-mode rule tell the model to call
get_metric_table itself for longer history instead of asking.
- The screen-reader comma between step parts is unselectable, so a copied
row no longer reads "Weight · ,all time".
MCP output is unchanged: the source-snapshot equality suite stays green.
A statement that says "today", or compares today against a baseline,
now only rests on a reading taken on the current calendar day in the
reader's zone. Yesterday morning's pulse read at nine in the evening
was narrated as today's: the signals of the day accepted any reading
from the last two weeks, and the Today overview, the coincident
deviation flag and the health status counted yesterday as current.
- computeSignalsOfDay drops a metric with no reading today, so the
briefing prompt and its content hash never see a stale value as the
day's; the prompt says so in both languages.
- The digest serves no cached briefing written on an earlier day and
drops a reading-backed signal whose metric was not measured today,
so the web hero, the iOS topSignal and the morning push agree.
- The Today overview's vitals line and deterministic lead, the
coincident flag and the health status deviations use the new
isFromToday rule (calendar day, not elapsed hours).
Signal deltas now carry each metric's display precision: the prompt
input is rounded (whole bpm and mmHg, one decimal of kg), and the
delta string under the hero lead is re-read at that precision in the
reader's locale ("+34 bpm", not "+33,72 bpm").
Every text-entry control (Input, Textarea, Select and NativeSelect
triggers, the date and time fields, the calendar's dropdowns, the Coach
composer and the hand-built note fields) now marks focus by turning its
border the new neutral input-focus tone, about 5:1 on the dark
background and 4:1 on the light one, with no ring. A text field matches
:focus-visible on every mouse click, so the ring was on for everyone.
An invalid field keeps its red border and draws its destructive ring
only while focused. Buttons, links, checkboxes and switches keep their
rings.
text-field-focus-guard.test.ts fails when a text-field class string
brings a focus ring or a primary/ring-coloured focus border back.
A standalone PWA on a tablet reserves the status bar in the top bar but
not in the docked panel's header row, which could tuck its title and
icons under the status bar; the new-chat button sat without the home
indicator inset. Both now reserve the same insets as the top bar.
The docked panel was part of the page, below the top bar. It is now a
column of the shell beside the content column, portalled through a new
side-panel slot: it runs from the top of the window to the bottom, flush
right, and the top bar ends at its left edge. Shut, the panel is gone
entirely and the top bar runs to the window's edge. The panel's header row
is the top bar's band, so the two bottom borders draw one line; the panel
takes the sidebar surface and border, like the rail on the left.
The top bar says where the reader is on desktop: "Coach", then the open
conversation's title, truncated. The panel toggle is its last item, 12 px
from its trailing edge, so it stands against the open panel and at the
window's edge when the panel is shut; it carries a tooltip and is 28 px
beside a fine pointer, 44 px with touch.
New chat is a round 48 px primary button with a plus, 24 px in from the
panel's corner (16 px in the phone sheet), with a tooltip. The top bar's
action slot and the new context and side-panel slots share one portal
helper.
Reading a stored reply back drops a view chip whose table the answer
already shows with its toggle, but the resolver reads the raw stored
chips. A tap from a client that still shows such a chip is answered, by
design: the chip was a valid offer when it was stored. The test pins both
halves for "as a table" and "as a chart".
The condition moves out of the shell into isFullBleedPage(), a pure
helper with its own test: /coach renders edge to edge; /coach/plans,
/coach/conversations, a trailing slash, look-alike paths and other
routes keep the padded frame, and so does a refusal on /coach (outside a
shared record, module off).
Focus opens the details tooltip, and the click that Enter or Space then
fires toggled it shut again. A click with no pointer-down before it is a
keyboard activation and now always opens; a tap or a mouse click still
toggles, and Escape closes it.
The flag helper sat between the module gate's doc comment and the
component, so the comment read as its own. It is now
switchedOffModule(), which returns the owning module when it is off and
has a doc comment of its own above the gate's; the gate narrows on its
result directly instead of looking the module up twice.
The panel header buttons shrank to 28 px from md and the conversation rows
to 36 px from sm, so a touch tablet wide enough to dock the panel got
pointer-sized targets. They now shrink only under pointer-fine: 28 px
header buttons, a 36 px gear in the drawer, 36 px rows and row menus
beside a mouse or trackpad, the 44 px floor with touch at any width.
A unit test refuses a width breakpoint on a control size in the three
panel files; an e2e case measures the docked panel at 1280 px with touch
and with a fine pointer.
A table the answer displays with a chart renders its own chart/table
toggle, and the "Show as a chart" / "Show as a table" chips under it
repeated that control. The server no longer derives a view chip for such
a table; a table the answer only used, folded under "Data used", still
gets "Show as a chart", since that brings it into the answer. A reuse
turn shows its table with the toggle and so offers no view chip either.
The rule is one predicate in a leaf module, applied where chips are
derived and where a stored reply is read back, so a reply stored before
the change offers the same chips as a new one on every client.
The tokens and model no longer sit as right-aligned text under a Coach
answer. The row is one left-aligned group: copy, read aloud, try again, a
details icon, then the time. The details icon is a focusable button whose
tooltip names the model and the token count; it opens on hover, on
keyboard focus and on a tap, and a second tap, a tap elsewhere or Escape
closes it. Strings in all seven locales; the joined tokens-with-model
string is gone.
The toggle shows the panel-right open glyph while the panel is open and
the close glyph while it is shut, both mirrored so the chevron points the
way the panel moves on a click. Docked and open, the toggle keeps the
panel's width as its right margin, so it sits beside the panel's leading
edge and travels with it (200 ms, none under reduced motion). Labels and
aria-expanded are unchanged.
The Coach page cancelled the shell's container padding with negative
margins, so <main> kept its reserved scrollbar gutter and painted a dead
strip to the right of the panel on classic-scrollbar platforms. The shell
now renders /coach full bleed: no centred container, no padding and no
reserved gutter, and the page fills what <main> leaves below the top bar.
A refusal card (shared record, module off) keeps the padded frame.
The panel header takes one 12 px inset with a gap of 8 between title and
icons, a text-lg title, and 28 px icon buttons beside a pointer (44 px on
touch). Conversation rows take the control radius and a 36 px pointer
floor.
The same-hours standing averaged each day as sum / n. Pulse already folds
its day as the mean of its local hours' means, and the day-aggregate reader
returns that as dayMean; a type moved onto that reader would have been
compared raw. The standing now reads dayMean where the reader carries one.
Text with no space inside the budget was cut at 160 characters and then
given an ellipsis, so the lead came out 161 long. The ellipsis now counts
toward the budget: a space at the edge still ends a whole word, and
unbroken text is cut one short.
The same-hours comparison cut the earlier days at the current clock time.
At two in the afternoon a day holding only its fasting reading was then
held against mornings plus lunch, and read as below its range again. The
earlier days are now cut at the local time of today's latest reading.
A range read this way is the usual range for this time of day, not the
whole-day band other surfaces show. The deviation carries a basis, the
health-status API publishes it as `basis` (`day` or `sameHours`), and the
Today lead and the health-status card name the range as the one for this
time of day, in all seven locales.
Key rotation rewrote every ciphertext column through a plain Prisma update,
in the in-app corpus walk and in the rotation script alike, so a rotation
stamped the run time on every encrypted row: the Coach panel then listed
old conversations as recent, and every client re-pulled measurements and
mood entries whose sync cursor had moved. Both now read each row's
updatedAt and write it back, matching on it, so a row edited since the
read is left for the next run instead of being stamped. The models that
carry the column are listed beside the registry and checked against the
schema, and the updatedAt guard now covers the rotation writes.
The terms that decide whether the Today lead already names the top
signal's metric were bare stems: "risen" and "chosen" matched the Polish
"sen" (sleep), "impulse" matched "puls", "Passing" matched the Italian
"passi", and the Korean "잠" matched "잠시" (a moment). A lead about
weight then dropped a sleep headline. Every term now has to start a word,
whole-word terms have to end one (Unicode-aware on both sides), and the
Korean terms carry their particle.
Earlier rewrites of conversation rows stamped updated_at with the run time,
so the Coach panel listed untouched conversations under Today. Migration
0375 moves updated_at back to the newest message's created_at where it is
more than a minute later. A rename or attachment shortly after the last
turn keeps its stamp, conversations without messages are left alone, and a
second run changes nothing. One set-based statement; the per-conversation
maximum reads from the (conversation_id, created_at) index.
Today's glucose mean was held against a band built from whole days. By
mid-morning the day holds a fasting reading and nothing after, so an
ordinary morning read as "below your range". A glucose day still in
progress is now compared with the earlier days cut at the same local clock
time, read in SQL so a dense stream is averaged whole rather than through
its newest fifty rows. With fewer than seven earlier days that have
readings in those hours, today gets no verdict. The day-aggregate reader
gains the local-clock cut; an integration case pins it against the
in-memory fold in a half-hour zone.
The title encryption backfill moved each Coach conversation's title into
ciphertext with a plain Prisma update, which stamps updatedAt. The Coach
panel orders and groups conversations by that column, so threads untouched
for months showed up under Today. The memory summary refresh did the same
in the background.
Storage-only rewrites now carry the row's own updatedAt and match on it, so
a turn that lands meanwhile wins and the row is retried on the next pass:
conversation titles, practitioner contacts, measurement and mood notes
(where updatedAt is the sync cursor), inventory notes, and the mood-note
script. The summary refresh writes through raw SQL. A guard freezes both
rules: every backfill write on a model with @updatedAt names the column or
is listed with the reason the stamp is wanted, and every conversation
write outside the person's own actions keeps it.
When most days in the window carry the same value, the median ± k·MAD band
has no width: the coach strip read "Your usual range is 61–61 bpm" and the
next ordinary reading of 62 counted as outside it. The shared band builder
now keeps the edges at least the measurement's own day-to-day wobble apart
(per metric, in its canonical unit, falling back to one display step), so
every surface built on it reads a real range and classifies against it.
The reported spread stays the measured dispersion.
The chart's label map covered eleven measurement types and fell back to
the raw enum for the rest, so a resting-pulse chart with its trend
overlays on showed "RESTING_HEART_RATE" in the trend line, the legend,
the tooltip and the spoken summary. Types outside the chart's short
labels now take their measurement label, which exists for every type in
every locale.
The muted line under the lead repeated what the lead had just said: a lead
about blood pressure sat above "Your latest blood pressure is sitting in
the optimal band · ↓ ~10 mmHg…". The web client only dropped the headline
when the lead quoted it word for word, and other clients had no rule at
all. The digest now carries `signalLine`, built next to the lead: when the
lead already talks about the top signal's metric (matched in every shipped
language) or says its headline, only the delta remains, and with no delta
there is no line. The hero renders the field verbatim.
A lead past the 160-character budget was sliced at a character count, so
a long briefing sentence reached the hero as "…sleep landed r…". Model
text now leads only when it fits whole: the reaction line keeps the
complete sentences that fit, the briefing leads with its first sentence
when it fits, and otherwise the headline or the deterministic sentence
takes the slot. Only when no source fits is the first sentence shortened,
at a word boundary. The budget is unchanged, so the hero keeps its
height.
Recapture the dashboard, Insights, resting heart rate, Medications and
Coach after a fresh demo seed. The dashboard image now shows the Today
overview and the worth-a-look rail; the Coach image shows the history
grouped by day and a full answer with its steps and action row. Frames
match the previous refresh (2552x1308; the dashboard keeps 2434x1247).
A readable title is moved to title_encrypted by the boot backfill, and
that update also sets updated_at to the moment it ran, so every seeded
thread sorted under "Today" a minute after the restart. The seed now
writes the ciphertext directly.
The vitals check holds a day's glucose mean against the band of full
days, so a part day seeded up to the current hour read "below your
range". Spot readings and the CGM stream now end with yesterday.
The aggregate sleep walk drifted to almost nine hours, against which the
~7.5 h per-stage nights read as an hour short. A tighter walk keeps the
trailing average near the nights it is compared with.
The demo seed predates the Today overview, the grouped Coach history and
the procedure filter on documents, and it carried a few defects that a
fresh seed made visible:
- Store every Date as UTC. node-postgres serialises a Date in the host's
zone and the cast to timestamp drops the offset, so a seed run from a
machine on Berlin time wrote every instant two hours ahead.
- Keep today's later glucose readings, CGM samples and the evening mood
entry out of the future.
- Close the seasonal allergy and its flare in the spring. Any open
episode puts the account in Rest Mode, and the open one read
"Rest mode, day 401" on the start page.
- Pin today's vitals to the median of the prior four weeks, so the
Today overview does not report a random tail value as out of range.
- Confirm the key-backup step for the key the demo runs with, which
removes the backup banner every visitor saw above the dashboard.
- Add two practitioners, a knee arthroscopy with its operative report
and discharge letter linked (small generated PDFs), its follow-up and
a check-up booked for tomorrow.
- Replace the single Coach exchange with five conversations from today
back over a few weeks, the newest with a full answer and its steps.
- Drop the layout field `heroVisible`, which the layout type no longer
has.
/coach?settings=data opened the conversations sheet from the first
render. The server and hydration renders do not know the viewport and
take the narrow layout, so on a desktop the sheet was open until the
docked panel replaced it; only the batching of the hydration re-render
kept it off the screen. The deep link now opens the sheet only once
hydrated on a viewport that does not dock the panel; on a docked one the
panel is revealed as before.
The desktop deep-link spec records every mount of the sheet from the
first byte and expects none; a phone deep-link spec covers the sheet
path and serves as the watcher's positive control.
The digest is polled every 120 seconds. Its cycle line read the whole
cycle history, 90 days of day logs and wrist temperature on every poll,
outside any cache, and the coincident-deviation engine walked its eleven
vitals one after another, two queries each.
The cycle read now rides the digest extras cell, which every cycle write
already hard-evicts; the cycle preferences route evicts it too, and a
module switch does through the health-score invalidator. A record without
a cycle profile stops after one point read. The per-vital reads run
together and are folded in registry order, so the result does not depend
on which finished first.
On a seeded four-year account (1.25M measurements, 52 cycles) under a
792 MB heap, the warm digest went from 9.9 to 5.0 ms and the vitals engine
from 11.1 to 4.6 ms (medians of four interleaved rounds).
The digest line is the body of the morning push and lands on a locked
phone. It took the briefing's first sentence, the top signal's headline
or the deterministic signal sentence verbatim, so a resting heart rate or
a blood pressure average could sit on the lock screen, and its score
floor stated the score.
A candidate with a figure in it is now passed over for the next, and the
floor says the score is ready instead of what it is. The hero's lead in
the app keeps its numbers. Tests that pinned the numeric line are turned
around, and the push builder is checked on a digest whose lead carries a
reading.
The lead filter dropped any sentence containing the score's digits, so a
score of 52 threw out "You slept 7 h 52 min" and a resting heart rate of
52 bpm. A sentence now repeats the ring only when it names the score (in
any shipped language, or as "out of 100") and carries the number on its
own, not as part of a time, a decimal or a measurement with a unit.
The steady line compared the newest stored score with the number on
screen but not with its band, version or composition, so a score whose
band moved overnight at the same number was still called steady. A run
that reached back past the 120 days read was also told as if it started
there.
The newest stored day now has to match the ring in value, band and,
where the digest carries them, algorithm version and composition. A run
the read cannot see the start of is marked as open (steadyAtLeast on the
digest score) and the hero says "at least" in seven locales.
The lookback limit governs what the Coach reads from now on. Answers it
gave earlier in an older conversation stay as written, so the note under
the limit now says they may still quote values from before it.
The derived block shortened each engine's window to the lookback limit,
so a 7-day limit produced a readiness computed over 7 days instead of the
30 the ring on the dashboard uses, under the same name. A block whose own
window does not fit is now left out, as the lookback rule says, and the
coincident-deviation flag follows its 30-day baseline the same way.
The GLP-1 block sent every dose change with its decrypted note whatever
the lookback limit said. Dose changes are entries, so only those inside
the limit reach the prompt; the dose in force stays as the current dose.
Jumping to the latest message pinned the thread first, which hides the
button behind aria-hidden, and only then moved focus to the composer.
Focus now leaves the button before its state changes.
The hero reads only digest.lead. A digest the service worker cached
before the update carries no such field, so the hero lost its lead line
until the next fetch. Such a digest now falls back to the reaction line
or the briefing lead it was built to show; a current digest whose lead
is null stays silent.
The provider and model selects in the Coach's settings already lock while
a write is in flight; the reasoning select did not, so a second PATCH
could race the first and one change could be lost.
Settings → Coach pointed at the gear with a muted paragraph. It now says
how many data areas the Coach reads and how far back it looks, from the
saved preferences, next to an Open Coach settings button that lands on
that section.
The facet triggers and the clear control were 32 px beside a 44/40 px
search field, below the phone tap floor. They now share its height and
reach 44 px square on a phone, and the browser's own clear button no
longer sits on the search icon when the field shrinks.
The overview sat 12 px under the lead sentence; it now keeps a section's
distance (16 px, 24 px from md). On a phone each fact row is a link and
now meets the 44 px tap floor.
Bottom toasts landed on the round New chat button for their whole
lifetime, so the delete Undo blocked the next action. While the
conversations panel is open, docked or as a sheet, bottom toasts rise by
the button's footprint.
The model fields carried the form label's colon and the lookback picker
an uppercase section-style label and a fit-content 36 px trigger. All four
fields now share one unadorned label and the full-width 44/40 px control.
buildMedsTodayBlock ended the day at local midnight plus 24 hours. On the
day the clocks go back that stops an hour early, so a dose between 23:00
and midnight was neither projected nor counted in the doses tally. The
exclusive end is now the next local day's start, as the digest loader
already computes it.
parseCoachPrefs parsed the stored blob as a whole and returned the
defaults on any invalid field. A lookback value this version does not
know therefore dropped the metric exclusions with it. Each field now
falls back on its own, and the exclusion and cluster lists keep their
known entries, so drift narrows what the Coach reads instead of widening
it.
The deploy runbook's rollback section notes the one case this cannot
cover: v1.40.1 and earlier still parse the blob whole, so an account that
saved the new "Last year" lookback loses its exclusions there.
The earlier-tables block names each stored table with its window and row
count. The list was filtered by module and exclusion only, so a table
stored before the limit was set or narrowed still told the model how much
history it covered. It now answers to the same range test show_result
uses, and the history-reach guard checks both the filter and that the
call site passes the limit.
The operator's shared Codex is appended to the resolved chain but is never
a chain entry, so the Coach's provider select had no option for it and
showed its first option as the one answering. The answering provider now
appears as a disabled option, with a note that the operator manages the
connection.
The provider-chain summary marked every entry available, so the Coach's
provider select offered entries without a key or sign-in. Choosing one
saved it to the front of the chain, the resolver skipped it, and the
select jumped back without a word.
Availability now comes from the presence probe, which names every chain
type whose credential is present. After a switch the picker reads the
chain back and says so when the server still answers with another
provider.
Taking the weekly preview from the writer dropped the schema check the
old scanner ran at the end, so a copy that violates the schema was
described as restorable. The collector now checks each section in its
JSON form as the writer shows it, an array section and a bulk table one
element at a time, plus the schema's one whole-document rule (a
canonical copy names every measurement by id). A copy that fails gets
no stored preview, and the preview route reads the copy itself, as it
did before. Nothing is kept beyond one element.
Measured on the preview memory test under --max-old-space-size=524:
held after the producer 3 MB with and without the check, raw peak
heapUsed 293 MB with it (335 MB without). The test now loads the
backup schema before its baseline: a module loaded once per process,
not memory that grows with the record.
A sample carrying the HealthLog origin marker was always dropped from an
export import. After a move to a new instance without a backup, the
export is how the mirrored values come back, and every one of them was
silently left out. A marked sample now counts as written by HealthLog
only when its HKExternalUUID names a row of this account of the same
kind, deleted rows included; a marked cycle sample only when the
account holds a day-log on its day. Every other marked sample is
imported, and still meets the manual-value match. A correlation's own
HKExternalUUID now covers records that carry none.
The day weight joined its source to an hour grouping of itself. With
stale or missing planner statistics, as after a new account imports a
year of pulse below the autoanalyze threshold, the planner ran that
join as a nested loop and the all-time pulse aggregate ran into the
60 s statement timeout. The weight is now two window passes over one
scan: the readings in a row's hour, and one marker per hour summed over
the day for the hour count. The weights are unchanged.
A new integration test seeds over 100k pulse rows with autovacuum off
and no ANALYZE, bounds the all-time reader and a zoned per-source mean
well under the timeout, and compares every row's weight against the
earlier self-join after ANALYZE. The ANALYZE the metric-series snapshot
test needed for the same trap is gone.
The vault's filter row gains a procedure facet beside the condition
facet. It lists the record's procedures and surgeries that hold at least
one live document, headed the way the visits page heads them (the
reason, else the practice, else the kind) with the body site and date
beneath, and narrows the list through the existing `encounterId` filter.
Same control, placement and clear behaviour as the condition facet; the
choice rides the URL as `?encounter=`, and an upload dropped into the
filtered view is filed against the procedure.
The usage read publishes the choices as `linkedProcedures` (additive),
decrypting only the label columns, and returns none when a sharing
grant does not cover the visits section.
On a phone an active facet now gives up label width before the search
field does, and the search keeps room for its icon and padding.
The AI-optional overview and account journeys asserted zero error cards,
Coach hand-offs and briefings without scrolling the deferred sections
into view, so the sections below the first three never mounted and the
zero counts held whatever they would paint. Both journeys now reveal
every section and wait for the reads those sections fire to land;
waiting on networkidle again resolves at once, because the page reached
that state before.
Getting started names the APP_MEMORY_LIMIT default and how to lower it
on a 2 GB host; the scaling guide says that web and worker share one
pool in the bundled single-container setup. The rollup comment names
migration 0372, which carries the hourly-mean statistic.
An account whose last backup attempt never came back was kept behind
every other account for as long as its attempt stayed open. A pass that
regularly stops on its time budget never reached it, so it never got a
copy again. It is now held back only until a later run has started on
another account; if its record kills the process again, its attempt is
the newest once more and it goes last again. A crash loop still leaves
the other accounts their copies, and the account itself is attempted at
least every second run.
The drill now reads every account's newest off-host object but still ran
on the default fifteen-minute expiry, without a lock and with two
retries, so a large instance could see it killed, run beside itself, and
page every failed account three times. It now carries a four-hour expiry,
stops between accounts on its budget and reports the objects it did not
reach, holds a pass lock at the binding, and is not retried.
A hover prefetch excused the mount refetch even after a dose logged
elsewhere had invalidated the medication reads, so the page showed the
compliance from before the dose. An invalidated entry now always
refetches.
The saved Coach window only seeded the snapshot and each tool's default.
A tool could still read back to all time, the availability probe reported
counts and means for history outside the window, and several blocks read
fixed windows of their own (labs 12 months, correlations 180 days, the
coarse tail, the all-time extremes) whatever the setting said.
coachPrefs.defaultWindow is now enforced as a limit through one module,
history-reach.ts: the turn context clamps the client window, the snapshot
clamps its window and gives every fixed-window block either the shorter
window or, when the block's own window cannot fit, leaves it out. Every
retrieval tool clamps its window, an earlier period or a stored table that
reaches past the limit answers outside_reach, and the probe reads only
inside the limit and names older history as outside_reach without any
figure. Follow-up chips that would read past the limit are not offered.
The default (allTime) is no limit and reads as before. MCP clients pass no
limit and keep their own window arguments.
Adds the twelve-month option, rewrites the picker text to state what is
read with the numbers the readers use, shows the same options in the
managed-profile settings, counts cluster members with a singular, and
drops the opacity on switched-off rows that put their text below WCAG AA.
Under an answer: copy, read aloud, try again and the time as text on the
left; tokens and model on the right, on one line down to 390 px. The
thumbs leave the web (the feedback route stays for the native client),
and so does the "What I'm looking at" disclosure: the method line and
the tables the answer read move into the steps list, and an older
message without steps lists the areas it drew on, never a raw key.
Remember becomes a bookmark icon in the question's row and confirms in
place with a toast. Follow-up chips render inside the latest answer's
column at its text size. The answer column takes the full width below
`sm` and a fixed 80 % above, so nothing moves sideways as a reply
streams. A round button fades in when the thread is scrolled away from
the end and brings it back.
The hero led with the first sentence of the cached briefing, which was
often a greeting ("Good morning.") while the sentence with content was
never shown. Without an AI provider it fell back to "Your health score
today is N.", which the hero then removed because the ring already
shows the number, leaving a ring and "Nothing needs your attention".
The daily digest now publishes the lead resolved on the server and a
short list of facts about the day:
- lead: the reaction line or the first briefing sentence that is not a
greeting and does not repeat the score while AI text is available,
otherwise a deterministic sentence about the strongest signal (a vital
outside its personal range, a night an hour off the usual, vitals in
range, last night). A fact the lead was built from leaves the list.
- today: up to five statements in priority order (Rest Mode, doses taken
today, an appointment today or tomorrow that is not already on the
rail, last night's sleep against the usual, vitals against their
personal range, cycle day). Module-gated on the server, never padded,
never asking for anything. A phone shows the first four.
- restMode: passed through while an illness episode is active.
- score.steadyWeeks: how long the score has held, read off the stored
daily scores, shown under the ring in place of a delta.
briefingLead skips greetings and score sentences the same way, so the
morning push improves with it. Visit times are rendered in the profile
timezone and hour-cycle preference; the cycle day comes from the same
verdict the cycle page uses. The ring, the rail and the charts are
unchanged. OpenAPI regenerated; additive fields only.
Measures that the thread and composer keep the same width with the panel
open and closed, that the choice survives a reload, Escape and focus
return for the panel, the sheet and the settings overlay, rename and
delete with undo from the row menu, the settings deep link, and axe on the
panel and the overlay in both themes. Screenshots are attached to the
report.
The Coach page gets a conversations panel on the right of the thread. From
1280 px it docks beside the thread and slides open and shut; the reading
column keeps its width either way and only its centring moves. It is open
on a first visit and remembers the last choice per device. Below 1280 px it
is a sheet from the right. The toggle sits at the trailing edge of the top
bar, through a small page-owned slot the top bar now renders.
The panel lists every conversation grouped by recency, with a row menu to
rename in place (Enter saves, Escape cancels without closing the panel) and
delete with the existing undo toast. The undo window now survives the sheet
closing. New chat is the round button at the panel's bottom right, and
picking or starting a conversation keeps ?c= in the URL.
The gear in the panel header opens quick settings: a popover from md up, a
bottom sheet on a phone. It switches the answering provider (chain reordered
through the existing route), the model and reasoning level where the person
owns them, and shows what the Coach can see. Keys, the Codex sign-in and the
fallback chain stay in Settings > AI. The drawer's gear opens the same
overlay. The composer's + menu is gone.
Settings > Coach links to /coach?settings=data, which opens the overlay on
what the Coach can see instead of a rail that no longer exists.
The first case spent 14 s per full-snapshot build in the all-time pulse
aggregate, planned against a table with no statistics, until autovacuum
analyzed it; on a loaded runner that took it past its 60 s budget. Analyze
measurements and rollups after seeding, as a production table would be.
The cases and the comparison they make are unchanged.
The parity test built a fresh Intl.DateTimeFormat for each of ~36k
comparisons and crossed the 5 s timeout on CI runners. One formatter per
zone halves its time; it also gets an explicit 20 s budget, since it is
meant to be exhaustive.
backupPayloadSchema is built when validations/backup.ts is evaluated and
holds about 12 MB of heap. The restore job, the stored-backup readers and
the export writer import that module, and they sit on the boot path, so
every server process built the schema at start whether or not it ever
touched a backup.
The schema version, the compatibility check and the per-section summary
move to validations/backup-summary.ts, which never builds the schema. The
call sites that validate a file (upload, restore, the summary and download
routes, and the streamed reader's element check) load backup.ts with a
dynamic import at that moment; everything else takes only types. The
OpenAPI registry keeps its static import, since only the spec generator
loads it.
After warm-up on a production build under --max-old-space-size=524, the
used heap drops by about 29 MB, and the schema no longer appears in a heap
snapshot. A structural test refuses a new static value import of the
module outside the registry.
get_metric_series (and get_metrics, the metric resources and the review
prompts, which fan out over it) answered with one section of a Coach
snapshot, but built the whole snapshot to get it: the all-type feature
extraction, the derived wellness layer, trajectory, correlations, memory,
GLP-1, cycle, illness, labs and visits. On a seeded account with about
215k measurements a resting heart rate read ran 208 SQL statements and
blocked the event loop for 175 ms to return under 2 KB.
The MCP surface now asks the executor for a source snapshot. It runs the
same prelude (prefs, module gates, admitted sources, cutoffs, now shared in
snapshot-prelude.ts) and the same block builders over the requested
source's reads only. The core clinical aggregates come from extractFeatures
scoped to the one block, whose bulk read keeps its all-type cap so the rows
a block sees do not change. A section large enough for the full builder's
prompt-budget pass to trim, and the environment cluster, go to the full
builder, so their results are the old ones by construction.
The integration test compares the tool's serialised result with the full
builder's section and grounding for nine sources over four windows, the
get_metrics batch and the metric resource, on a seeded account dense enough
to put the feature row cap into play.
Node sizes its heap from the container's memory cap. The 1g default gave
524 MB, which a long-used account fills in normal operation: on a live
instance 490 to 507 MB stayed live after a full GC, garbage collection
stalled requests for up to 8.5 s, and the process ran out of heap 26 times
in a week, taking the weekly backup with it. 1536m gives 792 MB. The
example env and the backup runbook say how to lower it on a small host.
Both grids carry an aria-label while their data loads, on a plain div,
which axe reports as aria-prohibited-attr: the label has no role to
name. A scan that lands while the scores strip is still loading fails on
it. The loading grids now take role=status, the role a loading region
has and which already implies the polite live region; the loaded vitals
grid carries neither.
Entering the Insights overview fired every section's reads at once, about
two dozen requests on a seeded account, and the server worked through
them in a queue: the hero and the first sections waited behind trends,
the period review, cycle, signals, rhythm events, health status,
breathing, labs and ECG, all far below the fold.
The first three sections in the account's own order still mount with
the page. Every later one renders a 1 px sentinel until it comes within
400 px of the visible area of the shell's scroll container, then mounts
and fetches; once mounted it stays. Measured on a seeded account under a
4x CPU slowdown: 24 -> 13 requests on entry, the entry network span
1.3-1.8 s -> 0.5-0.9 s, LCP about 1.35 s -> 0.9 s, blocking time about
550 ms -> 200 ms. Scrolling to the end still loads all 25 and renders
all ten sections.
Specs that assert on, measure or scan the lower sections scroll them in
first through a shared helper, so their coverage is unchanged.
On a short window (around 785 px and below) the nav list is taller than
the room between the logo band and the footer group, so it scrolls. With
nothing marking the edge, the last visible entry was cut off right at
the Collapse row and read as the footer covering it; macOS hides the
scrollbar that would have said otherwise.
While the list overflows, the footer group draws the same hairline the
user section does. A ResizeObserver on the nav and its list keeps the
state current as the window or the entries change; with room to spare
the rail is unchanged. The e2e spec checks the edge, that the last entry
scrolls fully clear of the footer, and that a tall window draws no edge.
The in-memory day-aggregate fake re-filtered every row for every day,
converting each timestamp to the profile timezone again, so a 1000-day
series took about five seconds and the graded pulse-status test hit its
timeout. It now groups by day and segment in one pass (4.8 s to 85 ms).
The medications list and the compliance summary refetch on every mount,
so a take or skip logged on another device shows on return (#316). The
nav link's hover / touch / focus intent and the route-commit preload
prefetch both reads too, so every visit through a link sent each of
them twice: the prefetch, then the identical request from the mount a
few hundred milliseconds after the first answer landed.
The mount now skips its refetch only when the cached data answers a
prefetch that went to the network for this visit: within the last 10 s,
and since the route last left /medications. A direct load, a return
with an older entry, or a quick return whose prefetch found the entry
still fresh keep the refetch on mount.
A Coach snapshot, a derived-metric batch or the Insights page fans out
into a dozen readers that each probe the same account's rollup coverage
at once. In production one MCP get_metrics call held 59 of these probes
open on the database together, each taking about 320 ms under that
contention, and the same burst repeated every 15 minutes.
Concurrent probes for one account now join the query already running.
Nothing is kept after it settles, each caller gets its own map, and a
measurement write drops the in-flight entry so a probe that starts after
a write always reads afresh. The registry lives on globalThis so every
server bundle shares it.
A document whose preview render fails for good (a PDF whose first page
renders blank) was queued by the boot-time backfill, refused, and queued
again at the next boot, without end.
The thumbnail job now keeps the failure with the document
(document_thumbnail_failures, migration 0374): the reason, the attempts
since the last success and when the newest failed. The backfill, both the
boot discovery and the per-account pass, leaves a document out after one
deterministic refusal (raster-failed, empty-render, pixel-cap) or three of
any other kind, until the document changes. A direct enqueue still renders
it, and a preview that is made clears the record.
A weekly or nightly backup job whose process died (a restart, a
container out of memory) stayed active until its two- or four-hour
expiry, and its retry walked the accounts in the same order: the account
that killed it has the oldest copy, so it led again, and every account
after it went without a copy for as long as that record stayed too
large.
Both passes now send with a five-minute pg-boss heartbeat, so a dead run
fails within minutes and its retry picks it up. Each pass records when it
started and finished every account (backup_pass_attempts, migration
0373); an account whose last attempt never finished goes behind all the
others. The backups page shows when the run in progress started and names
an account a run died under, for the weekly and the off-host card, and a
heartbeat timeout reads as what it means.
Since v1.39.6 the preview scanner parsed the JSON on its way into the
pieces, kept every section but the three bulk tables, and validated the
whole document at the end: a second copy of the record beside the
writer's own, inside the weekly job. On an account with a document vault
that is most of the record.
The writer now shows each section and each bulk row to an observer just
before writing it. The preview collects key uses from that and takes its
counts from the writer's own FullBackupCounts; nothing is parsed or kept.
An upload hands over the summary and key uses it has already validated.
The round trip pins the writer's counts and key uses to a full read of
the copy on an account with a row in every model, and a memory test holds
what the pass keeps once the producer is done.
Measured on 70,000 measurements, 3,000 mood entries and 40 one-megabyte
documents under --max-old-space-size=524: held after the producer
56 MB before, 3 MB after; peak heapUsed 398 MB before, 334 MB after.
The drill read only the alphabetically last object of the newest date, so
one account stood in for all of them. It now checks every object of that
date one at a time, records a failure against the account it belongs to,
keeps going, and pages once per failing object with its key and how many
accounts were checked.
A text column written before key ids existed holds the bare base64 of
nonce, tag and ciphertext. The key check read such a value as an encoded
Bytes column, decoded it once too often, and accused the v1 key of not
opening a copy it opens. The instance's stored AI key has exactly that
shape and is the shortest v1 value in every disaster-recovery copy, so it
was picked as the probe and refused every restore of an account with v1
values, even with the instance settings left out of the verdict.
A legacy value now reads as a string unless its decoded head is base64
text. Samples are kept per section and only the sections the verdict
counts are probed; a key is unreadable when none of its probes opens.
Binary values open with the label their column is sealed under, taken
from the encrypted-column registry, and are not probed when no label is
known. A preview stored by an earlier release is still read, and its
sample's layout is derived again from the value.
Day keys and wall-clock fields are computed per row in the analytics,
digest and personal-record loops, and each call ran
Intl.DateTimeFormat#formatToParts plus a resolvedOptions() allocation
to re-validate the zone. In a CPU profile of an Insights page load the
zone helpers were 17-19% of the busy time on the request thread.
Probe the offset once per UTC hour at both ends of the hour and derive
the wall clock arithmetically; an hour that contains a transition, an
instant outside 1971-2199 or an invalid date keeps the formatter path.
Zone validity is memoised per name. userDayKey drops from 9.3 to 1.5 us
per call, wallClockInTz from 6.4 to 0.5 us; a parity test checks the
result against Intl around every 2026 transition in ten zones.
The health score card passed the episode onset, an ISO instant, straight
into its sentence, so it read "Rest Mode active since
2025-08-04T09:55:00.000Z". It now shows the day that instant falls on in
the profile timezone, in the user's date format. The test that pinned the
raw value now pins the formatted day.
probeRollupCoverage runs ahead of the dashboard snapshot, the analytics
slices and the comprehensive insights. It read every live row of the
account for SELECT DISTINCT type and counted every DAY bucket per type,
which the planner served with a sequential scan of measurement_rollups.
Walk the distinct types with a recursive CTE along the live index and
test coverage with EXISTS. On a copy of a long-lived account the probe
drops from 24-50 ms to under 2 ms and returns the same map; an
integration test keeps the old query as the oracle.
A production build loads src/lib/db.ts once per server bundle, and the
client was only parked on globalThis outside production. The running
process held three clients: about 28 MB of query-compiler state each on
a heap capped near 520 MB, and three pg pools each sized to the full
per-process connection budget. Park the client on globalThis in every
environment so each copy of the module resolves the same one.
Recharts sizes every tick and label by writing it into one off-screen
span and reading getBoundingClientRect(). Under <body> each read forced
a document-wide style recalc, because the stylesheet's descendant-subject
:has() rules leave Chromium no way to scope the invalidation. A seeded
dashboard made 149 such reads per cold load at about 2.8 ms each, about
1.8 s of main-thread time under a 4x CPU slowdown.
The chart runtime now parks that span in a zero-size, contain: strict
host under <html>, which Recharts finds by id and keeps using. Each read
drops to about 0.01 ms; the dashboard's total blocking time under the
same slowdown falls from about 2.5 s to about 0.75 s. The host copies
<body>'s classes, where the app font is set, so every string measures
exactly as before; an e2e spec checks that parity on the drawn labels
and that the host survives a client-side round trip.
The tracker posts to `${data-host-url}${collect path}`, and the collect
path is compiled into the operator's Umami build: /api/send by default,
anything else when the instance sets COLLECT_API_ENDPOINT. The tag
pointed data-host-url at the app origin and the proxy listened only on
/api/send, so a renamed endpoint sent every page view and event to an
app path that does not exist and got a full 404 page back each time.
Point data-host-url at /api/monitoring/umami and answer every path under
it with one proxy route. The route reads the collect path from the
configured tracker (cached for an hour), forwards only that path to the
Umami host, and answers anything else, or an unreadable tracker, with an
empty response. The admin test event posts to the same learnt path. The
send-URL resolver lives in one module instead of two copies.
The rail animates its width from w-16 to w-64, and the labels laid out
against the growing width: every row overran the nav and multi-word
labels wrapped, so the nav's overflow-y-auto painted a vertical and a
horizontal scrollbar for the length of the transition.
Lay the expanded column out at its final width from the first frame and
let the rail clip it. The final layout is unchanged; the column fills
the expanded rail exactly. A unit test pins the structure and an e2e
spec samples the nav on every frame of a real expand and collapse.
Recapture Insights, resting heart rate, Medications and Coach at the
same 2552x1308 frame. The resting heart rate shot no longer shows the
raw metric enum in the chart legend. Images are palette-quantised, about
a fifth of their previous size.
The note had one form per locale, so a single left-out record read as a
plural. It now resolves One, Few and Other through tCount like the other
counted strings, with the singular in every locale and the Polish few form.
A sample the app wrote before the origin marker existed still carries the
HealthLog row id as HKExternalUUID. A hit on one of the account's rows of
the same kind now leaves it out exactly, for Withings and import mirrors as
well as manual ones; either id of a blood pressure pair covers both halves,
since the app stamps both samples of a reading with one id.
The manual-mirror lookup read one OR clause per row on every batch, even
for an account without a single MANUAL row. The types that hold MANUAL rows
are now read once per import; a batch asks nothing for any other type, and
one time window per type for those.
Cycle samples carry the marker too and are now left out like the rest, and
a sample the export repeats (a correlation's records also stand on their
own) is counted once.
The comment claimed the app's own sync filters by the marker. It does not:
the app's echo check is HKExternalUUID plus the sample's authoring source
being the app itself, and the marker exists for the deletion path, where
the source is not visible. Say so, and say why the export import reads the
marker and the row id instead.
The wide tier let the trend column grow to 8rem while the sparkline it
holds is a fixed 72px glyph, so at 1920 px an empty 56 px strip opened
between the sparkline and the chevron. The column now stays 72px in both
tiers; the extra width goes to the range bar and the name, as before.
The wide-list spec reads the resolved grid tracks and asserts the trend
track is 72px at every width it covers.
The systolic and diastolic reads each took the ladder's source per day on
their own. Whenever a source held only half of a reading on a day (a deleted
diastolic, an orphaned sample, a sub-floor systolic), the two picks diverged
and the pairing joined one source's systolic to another source's diastolic.
A sub-floor systolic could also claim a day and then contribute nothing,
leaving the day empty although a lower-ranked source had a valid reading.
The source is now picked per day from the systolic rows that pass the floor,
both halves are read from it in one statement, and a pair is only made
within one source, which also covers a reading that straddles midnight.
src/app/favicon.ico was still the filled heart from the first release on a
solid white square. Next serves it next to favicon.svg, and browsers that
pick the ICO (Safari, bookmarks, history, some tab states) showed the old
icon on a white slab. It is now the current logo at 16, 32 and 48 px with a
transparent background.
A structural test lists every file that takes a database or in-memory mean
over measurement values and names pulse, and requires each to use the shared
statistic, hand the type to a fold that does, or state why it never averages
pulse readings. A new plain mean over pulse, or a reader that drops the type,
fails it.
The insights comparison snapshot took its current and baseline pulse figures
from a reading AVG, and the Coach's data inventory told the model a reading
mean of the whole pulse history. Both now read the mean of the user's local
days, each the mean of its hours' means, through the day-mean helper. Every
other type keeps its aggregate.
The feature extraction, the signals of the day, the status cards, the graded
series, the Coach snapshot, the mood correlations and the report's trend arrow
each still averaged pulse over its readings, so a dense workout hour decided
the day and a workout day the window. They now read a pulse day as the mean of
its hours' means and a window as the mean of its days, through the day-mean
helper. Counts, extremes and spreads stay over every reading; every other type
is unchanged.
The doctor report's pulse average and day points, the correlation channels,
the illness correlation, the personal baseline and the Coach's tiered context
each folded pulse readings into a day by plain mean. They now take the day as
the mean of its local hours' means and a window as the mean of its days, in
the zone each reader buckets by. The day aggregates carry the weighted sums for
an hourly-mean type, so every caller reads one day value. Every other type is
unchanged.
A blood pressure reading is a <Correlation> wrapping its systolic and diastolic
records, and the marker can sit on the correlation instead of on each record.
The import only read it from a record's own metadata, so a marked correlation
would have come back in full.
A correlation's records are now held until it closes, then all kept or all left
out together: the marker on the correlation, before or after its records, or on
a record, applies. Tests cover each, the marker not spilling onto the next
record, and a self-closing record.
The dashboard summaries, the insights aggregate and the series stats strip
still took a plain mean over pulse readings for their 7-day, 30-day, prior-month
and all-time figures, while the year-ago figure and the chart already weighed
each day once, so the year delta compared two different statistics. They now
go through the day-mean helper: each day is the mean of its hours' means and a
window the mean of its days, in the frame each reader buckets its days by.
Count, min, max, the median and the spread stay over every reading; every other
type is unchanged.
The blood-pressure branch of /api/measurements/series still read systolic and
diastolic with an unranked findMany, so with a second provider the pairs blended
both sources and a reading present in both showed twice, while the other kinds
already follow the ladder since the dense and raw reads were moved onto the
shared helper.
Each of the two types now reads the ladder-winning source per day through the
same helper. The physiological floors (systolic 40, diastolic 20) and the
five-minute pairing are unchanged.
Rows written before this change keep the reading mean: the boot backfill and
the freshness check fill only missing days, so nothing rewrites them. Migration
0373 recomputes the mean of every stored PULSE row from the readings, DAY as
the mean of its hours' means and WEEK / MONTH / YEAR as the mean of their days,
and leaves count, min, max and the sums alone. The writer stores the coarse
rows the same way, so a refold and the migration agree.
The fixture is invented, in the shape Apple's DTD documents, and says so in its
header and its file name. It shows the parser reads a file laid out like an
export; it cannot show that Apple writes a custom metadata key verbatim.
A day of pulse is the mean of its hours' means and a window the mean of its
days, but only the rollup tier and the chart knew it. The helper gives every
other reader the same statistic in both shapes it needs: in TypeScript over
readings it holds, and in SQL as a per-reading day weight whose sums give the
mean of the day values over any window. Every other type keeps its plain mean.
On a wide window the compact lab list was one five-column row whose only
flexible column was the name: the name column sat mostly empty while the range
bar and the trend stayed small at the right edge.
The list's card is now a container, and the columns follow its width instead of
the window's. Below 64 rem of card the row is the lg table it was, with the
range bar at 12 rem; from 64 rem the bar column grows from 12 to 18 rem and the
trend column from 72 px to 8 rem, and the bar fills its column. The two tiers
exclude each other, so the result does not depend on the order the CSS is
emitted in. The trend sparkline itself is unchanged.
From lg up a long analyte name wraps instead of being cut off; a phone keeps the
single line with an ellipsis, and the bar keeps its own 12 rem cap there.
The shared 1280 px frame is untouched; a Playwright spec measures it on /labs,
/measurements and /mood at 1280, 1920 and 3440 px.
Fixes#1057
The iOS app writes manual entries into Apple Health, and mirrors Withings and
import rows into it, each stamped with dev.healthlog.app.origin = healthlog.
The server already holds those readings under their own source, so the same
sample arriving through an Apple Health export is a second copy: the series
shows it twice and the rollups count it twice.
The export import now leaves out a record carrying that marker. The marker is a
child <MetadataEntry>, read after the record's open tag, so a record is
committed at its close tag. As a fallback for manual entries mirrored before the
app set the marker, a record is also left out when a MANUAL row of the same
type and value sits within 2 s (same rule and tolerance as the sync path).
Withings and import rows are not matched by value and time: they reach Apple
Health only through the mirror, which always sets the marker.
The import summary says how many records were left out for this reason.
Fixes#1109
An endpoint that does not know reasoning_effort answers with an unknown
parameter error, which the JSON-mode probe read as a refusal of
response_format and switched that base URL to plain prompts.
One select under the model field of the Local and OpenAI-compatible
provider forms: Default, Off, Low, Medium, High, with a one-line hint
that Off leaves the answer budget for the answer itself. It reuses the
NativeSelect, label and hint layout of the field above it, seeds from
the stored entry, and saves with the form. Strings in all seven
locales.
Refs #1126
A client that rebuilds a form for a retry picks a fresh random boundary,
so the raw bytes differ and a repeated document upload ran a second time
instead of replaying. The boundary named in Content-Type is replaced by a
fixed token before hashing.
Reasoning models on Ollama and similar servers think by default and can
spend the whole max_tokens budget before the visible answer starts, so
the call comes back empty or times out. The Local and OpenAI-compatible
chain entries now carry an optional reasoning setting: Default sends
nothing, Off sends reasoning_effort "none", Low / Medium / High send
their own value. It is stored on the entry in aiProviderChain, so a
chain can mix a thinking model with one that does not know the field.
- Every exported resolver binds the entry's value onto the client, the
connection test included, keyed on the instance type so the operator
provider, OpenAI, Anthropic and Codex never carry it.
- PATCH /api/user/ai-provider accepts localReasoningEffort and
compatReasoningEffort and GET reports them. A provider absent from
the chain holds its value on a disabled entry; restating Default on a
chain that was never customised writes nothing.
- PUT /api/insights/provider-chain accepts reasoningEffort on local and
openai-compatible entries, refuses it on any other, and keeps the
stored value when the body leaves it out. Its schema is now shared
with the OpenAPI registry.
- A Default request body is byte-identical to the one sent before.
Refs #1126
A watch samples pulse about every 5 to 10 minutes at rest and about every
5 seconds in a workout. The daily value was the plain mean over a day's
readings, so a workout hour of several hundred readings outweighed the other
23 hours: a day with one hard hour and 23 resting hours (60 bpm) read about
128 bpm, not 64.
A day of pulse is now the mean of its per-hour means, in the rollup DAY
bucket, the live per-day read the chart serves, and the series route's day
grain. Windows over several days weigh each day once instead of by its
reading count, so the bias does not return one level up. Reading counts,
min/max, and the regression sums still run over every reading. The chart
tooltip and the data table name the statistic, in all seven locales.
The statistic is limited to pulse; HRV, SpO2 and glucose keep the plain mean
(see src/lib/measurements/day-statistic.ts). Stored DAY rows are rewritten by
the next rollup recompute (admin recompute route), not on read.
Fixes#1107
Refs #1124. The thumbnail job opened no wide event, so every annotation
it made went nowhere: one instance ran it hundreds of times, left sixteen
PDFs without a preview, and the log never said so.
Each run is now a `job.document_thumbnail` background event. A run that
should have produced a preview and did not is raised to warn and names
the reason; a type that never has a preview stays at info.
Refs #1124. The rasterizer opened pdfjs without its standard font, CMap
and wasm directories and told it to use system fonts. The runtime image
has none, so any text pdfjs could not draw from an embedded program (a
non-embedded standard font, or an embedded one it cannot parse) came out
as nothing, and an image-only provider was sent the page background. A
workstation hides this because its own fonts stand in.
pdfjs now gets the directories from its own package, resolved at runtime
from the working directory, and Liberation Sans from that package is
registered as the canvas' sans-serif, serif and monospace faces for the
text pdfjs draws with a substitute.
A document whose every rendered page is blank now reports render-failed,
so the read records raster-failed instead of indexing a transcript of an
empty page as a success.
Refs #1124. The store holds two @napi-rs/canvas versions, the pinned one
and an older one that pdf-parse's own pdfjs-dist pulls in. The runner
stage took the musl binary with `find | head -1`, got the old one, and
copied it into the new loader's directory, where the loader looks first.
Rendering still worked, but `loadImage` calls `Image.decode`, which the
old binary lacks, so every preview thumbnail failed with "image.decode is
not a function" on both architectures.
The builder now records the resolved canvas version and the runner hoists
that version by name, as it already does for pdfjs-dist. The build stops
when the binary it copies belongs to another version.
With every sealed column filled, the column round trip now also builds
the portable file of the same account and fails on any section that
carries a sealed column as stored ciphertext, unless the section is named
with a reason. The disaster-recovery file of the same account is the
floor, so a matcher that finds nothing cannot pass the check.
The account's own mood categories, tags and hidden set were written only
inside the restore's mood-entry branch, so a vocabulary set up before the
first entry did not come back. They are written whether or not the file
carries an entry.
A practitioner's note, a visit's reason, outcome and body site and a
vaccination's note travelled as stored ciphertext in a portable file, the
same defect the cycle and mood sections had. They now go through the
shared sealed-text helpers: readable in a portable file, sealed under the
receiving host's key on restore, ciphertext kept only where this host
opens it and otherwise reported as visitCiphertext or
vaccinationCiphertext. The practitioner address and phone ciphertext of a
disaster-recovery file gets the same check, since the key preflight now
leaves these sections to it.
The replay cell was keyed on (user, key, method, path). A client that sends
two different writes under one key got the first write's cached response for
the second, and the second was never saved. Native clients up to 1.1.0 post
the two halves of a manual blood pressure exactly that way, so the diastolic
value was silently dropped.
Each cell now stores a SHA-256 fingerprint of the request body (JSON with
recursively sorted keys, otherwise the raw bytes; scoped by method and path;
at most the first MiB is read). Lookup replays, or refuses as in flight, only
when the fingerprint matches. A different body, or a row written before the
column existed, runs the handler without claiming or caching and leaves the
existing cell for its own retries; a lost claim race re-checks the winner's
fingerprint the same way. The body itself is never stored.
Migration 0371 adds the nullable idempotency_keys.request_fingerprint column.
The OpenAPI parameter description, the client notes and the Home Assistant
guide say what a replay now requires. docs/ops gains a read-only query that
lists systolic readings left without their diastolic half, held to the
schema by an integration test.
Refs #1085. Logging a workout right after finishing it meant changing the
start first, or the form refused it as ending in the future. The start field
now opens blank; left blank, the workout is saved as starting the duration
before now. A start the person enters is validated as before.
The update retried without pausedAt and notificationsEnabled for a
database that lacked the columns. Migrations guarantee both, and inside
the transaction a failed statement aborts it, so the retry could not
work anyway.
The labels of the mood tags and mood categories an account created
travelled as the stored ciphertext in a portable file, so a restore onto
a host with another key refused the file or wrote labels nobody could
open. They now follow the cycle note: readable in a portable file, sealed
under the receiving host's key on restore, ciphertext kept only where
this host opens it and otherwise reported as moodLabelCiphertext.
The sealing helpers move to one module both sections share.
The row update (name, schedule, oneShot) committed before the course
write. When a concurrent course made the course write refuse, the client
got the refusal while the rest was already written. The schedule replace,
the row update, the pause era and the course write now commit together,
so a refusal leaves the medication unchanged. The rollup recompute runs
after the commit.
A portable file carried the day-log note, the sensitive-category envelope
and custom symptom labels as the stored ciphertext, so a restore onto a
host with another key either refused the file or wrote values nobody
could open. The portable file now carries them readable and the restore
seals them under the receiving host's key; a disaster-recovery file keeps
the ciphertext.
A file written before this still restores: each ciphertext value is
opened with this host's keys first, and one that does not open is kept
out of the row and reported in the skip summary as cycleCiphertext. The
key preflight leaves the cycle sections to that per-value check.
The probe decided on the eight oldest values alone. An install
reinstalled under a fresh key long ago holds old rows sealed under a key
that is gone and all current data under the key it runs with; it works
today, and the check would refuse it with 503 on every request.
When every oldest value fails, the newest values of the same columns are
now sampled in reverse order. One that opens makes the verdict
inconclusive (mixed): the process serves, warns and probes again at the
next start. Only old and newest values that all fail are a mismatch. A
newest-only open never seals; sealing stays with the oldest-first rule.
Every probe query now runs in its own transaction under a
transaction-local statement_timeout (2 s), so a large or locked table
ends that column with an error and the probe as incomplete instead of
holding up the boot. The newest order is the exact mirror of the oldest,
so an index on the timestamp serves both directions.
The module comment lists the three conditions that still yield a
mismatch.
The rule that keeps an earlier course's intake from anchoring a rolling
cadence applied to every caller unless one opted out, which is how a
booster reminder picked it up. It now applies only when the context
declares rollingAnchor: "courseStart"; the four medication context
builders declare it, and every other caller keeps the previous
behaviour, where the last intake always anchors. A guard enumerates
every RecurrenceContext builder in src, requires each to be listed as a
medication or a non-medication builder, and checks the declaration on
both sides.
The course rule that stops an old intake from anchoring a rolling
cadence read a reminder's anchor date as a course start, so a booster
fourteen years past on a ten-year cadence came due later today instead
of staying overdue. Measurement and booster reminders now opt out with
rollingAnchor: "lastIntake"; the rule stays on medications.
At a first start there is no recorded key yet, so an operator facing a
refusal they are certain is false has no row to delete. With
ENCRYPTION_KEY_CHECK=warn (default enforce) the check still runs and logs
its full outcome, /api/health names a mismatch as a warning field without
failing, requests are served, and no key is recorded unless every key id
passes.
The variable is on the compose whitelist, in both example files, the env
manifest, the Unraid and Portainer templates and the published-env guard.
The log block, the mismatch page and the self-hosting pages offer it as the
last resort after the three real fixes, and say that it disables a safety
check.
An intake before startsOn stopped anchoring a rolling cadence, which
also moved the next dose of a single-course medication whose start had
merely been pushed forward a few days. The intake is now ignored only
when the dose it implies would fall before the course starts (a new
course after a gap); otherwise it anchors as before.
The backfill and the restore of an older file read the creation day of
an end-only window in UTC while every reader of the window uses the
account's zone; both now use the account's zone (the default zone when
the stored one is unknown to Postgres). A window whose start lies after
its end, which the API refuses but older rows hold, becomes a one-day
course on the end day: every reader treated it as empty and ended, and
a swap would have invented expected doses. One shared rule
(courseFromWindow) serves the restore and the course writer.
apiHandler answers 503 encryption.key_mismatch before auth, but the remote
MCP endpoint and the OAuth authorize, token and register routes wrap in
withBackgroundEvent instead and never checked. An MCP write tool could seal
new rows under the wrong key, and those rows would open under it at the
next boot.
The refusal moves into one helper used by apiHandler, /mcp (same envelope)
and the OAuth routes (RFC 6749 temporarily_unavailable with the error code
as an extension member). A structural test fails when a route outside
apiHandler, or a server action, lacks the refusal in every exported handler
and is not on a short allowlist of routes that never touch the database.
The boot key check recorded whatever key was configured whenever its probe
found nothing to try. It only read string columns, so an install whose
ciphertext sat in Bytes columns (notes, documents, ECG, symptoms) read as
empty; a probe that ran out of time did the same; and rows written by a
process that served under a wrong key could open on the next boot and get
that key recorded as the right one.
The probe now reads every registered column, string and Bytes, in all four
stored layouts, and tries the oldest values first, ordered by the row's
updatedAt (else createdAt, else primary key). A key is recorded only when
the oldest value that opens is preceded by at most one that does not, or
when no column holds a single value under the key id. A time-out, an
unreadable table, oversized values or mixed results are inconclusive: the
process serves, records nothing and checks again at the next start.
When the first insert loses a race, the stored record is read back and must
open under this process's key, else the outcome is a mismatch.
A rotation that walked every column without errors removes the record of
each previous key id that no longer holds any value. The refusal log block
and the self-hosting pages explain how to reset the check for a key id.
The PUT wrote startsOn/endsOn on the row and then rewrote the latest
course in a second transaction, so a failure between the two left the
row and its courses disagreeing. The window now reaches the row only
through the course writer, which writes both under one lock.
The paragraph described the old values. It is deleted with them, its generation claim is detached the way a delete detaches it (the day's slot stays spent), and the workout's arrival is raised again so the paragraph is written from the corrected values through the ordinary path, under the same daily cap.
The history minted bands only from the latest course, so misses in an
earlier course counted in the rate and appeared nowhere in the history.
It now mints over the span of every course and keeps the slots on a
course day. The compliance ledger and the cadence missed tally drop the
gap slots before the reconstruction instead of after it, so a dose
logged between courses stays visible as an off-schedule take in both.
The rate counted misses in earlier courses while the streak and the
cadence chart's missed tally minted only the latest course, so a miss
in a previous course never broke the streak. The cadence engine context
now carries the courses and expands over their span, keeping only course
days, the same rule as the compliance ledger.
A medication with no course rows and no window is running since its
creation; a course added beside it projected the medication into ending,
which stopped its reminders and dropped its history from compliance. The
create now refuses that (medications.course.currentOpen; End course
writes the first course). A window the rows never recorded is
materialised as the first course before the new one is checked.
An iPhone build that predates custom categories decodes an unknown category
as OTHER and sends it back on every edit. A Bearer update sending OTHER over
a custom category now keeps the custom one; the web can still move it back.
A medication taken now and then (#1024) keeps one row and gains course
rows: calendar spans, both ends inclusive, an optional encrypted note, no
schedule or dose of their own. Courses never overlap and every course but
the latest has ended, so the latest course is always the one the
medication's startsOn/endsOn project. src/lib/medications/courses.ts is
the only writer of those two columns for a medication with courses; it
writes under a per-medication advisory lock and re-projects in the same
transaction, so reminders, the next-due line and intakeActionable keep
reading the columns and stay right.
- Migration 0368 adds medication_courses and backfills one course per
medication window (an end-only window starts on the creation day, or
on its end day when that lies earlier); a medication without a window
stays course-less. Idempotent, data-preserving.
- GET/POST /api/medications/{id}/courses and PATCH/DELETE
.../courses/{courseId} (owner-only); create seeds the first course
from a window; the medication PUT edits the latest course and refuses
a window that would break the rules (medications.course.*).
- List, detail, create and update carry courses, courseCount,
previousCourseEndedOn and canStartCourse, resolved on the server.
- Compliance expands over the span of all courses and drops the slots
between them; every compliance read selects the courses. A rolling
cadence is not anchored by an intake from a previous course.
- The card names the last course's end and the running course's number;
the detail Lifecycle group lists the courses with start, edit and
delete. The Coach compliance block, the MCP resource, the doctor
report window and table, the clinician view and FHIR (one
MedicationStatement per course with effectivePeriod and status) read
the courses.
- Backup carries courses per medication and the restore writes them and
re-projects; an older file derives the one course its window
describes. Wipe plan, encrypted columns, rotation and OpenAPI follow.
The full backup now reads the account's own medication categories, and
three route tests that drive the payload builder against a mocked client
had no delegate for it.
Label SICK_NOTE in all seven bundles and pin that every document kind
the database knows has a label in each locale, a place in the vault's
kind list and a glyph: the kind reaches the screen through a dynamic
key, which the call-site coverage guard cannot see.
A certificate of incapacity for work (in Germany the
Arbeitsunfähigkeitsbescheinigung) is now its own document kind,
SICK_NOTE, instead of Other. The enum value rides migration 0370.
Uploads, edits, the kind filter, bulk edits, the import script and the
backup format accept it; the vault files it with a thermometer glyph
between prescriptions and referrals; the filing suggestion can propose
it and names the words a sick note carries. The extraction's own kind
field keeps its three values.
The capture picker and Add menu entries, the quick-entry sheet, the
illness page section and the episode card, the activity-feed verbs, the
restore skip label and the symptoms.* API error messages, in de, en, es,
fr, it, ko and pl (German informal).
The full-backup route tests mock the symptom definition read the payload
now makes; the destructive-control registry names the symptom hooks (an
entry or a whole symptom, confirmed by an AlertDialog); the symptom quick
entry's save toast is pinned beside the measurement and mood forms it
mirrors.
A medication can be filed under a category the person named, next to the
built-in ones (#1041). The labels live in medication_category_labels,
encrypted at rest, capped at 20 per account; a medication filed under one
carries its custom:<uuid> key in the category column.
- GET/POST /api/medications/categories (POST idempotent, 30/min per actor,
admitted at MANAGE like the cycle symptom vocabulary) and owner-only
PATCH/DELETE /api/medications/categories/{key}. DELETE moves every
medication filed under the category to OTHER in the same transaction
and answers movedCount.
- category is widened to a built-in value or custom:<uuid> on the wire,
the create and update bodies refuse a key that is not the caller's
(422 medications.category.unknown), and list, detail, the insights
medication tiles and the MCP medications resource carry categoryLabel.
A key with no owned label reads as OTHER and is counted on the event.
- The wizard's class step lists the person's categories under the
built-in rows with an inline add row; the list page gains a category
filter (session only) and a manage sheet for rename, hide and delete.
- Backup carries customMedicationCategories (decrypted labels) and the
restore writes them before the medications; wipe plan, encrypted
column registry, rotation script, sharing guards and OpenAPI follow.
The dashboard and the bottom bar's capture picker imported all five
quick-entry forms eagerly, so every authenticated paint carried them for
sheets most visits never open. One module now wraps them in next/dynamic
(client-only, a skeleton holding the sheet body while the chunk loads)
and both callers use it. The discard guard stays static. The picker tests
mock the one module instead of five.
The Coach illness block now carries the last 14 days of the person's own
symptoms (name sanitised for the prompt, count, highest intensity, last
occurrence; at most ten, most frequent first), and exists when there are
symptoms but no episode. The prompt describes the field in English and
German. The MCP get_illness_recovery result leaves it out in this
release.
- A new capture kind "symptom" (illness module, illness write grant) in
the bottom bar's picker and the dashboard Add menu. The sheet follows
the measurement and mood forms: the person's symptoms as chips with the
cycle sheet's dashed "new symptom" chip, the 0-10 slider with its
anchors, the time prefilled with now, an optional note, and a "during"
selector only while an illness episode is open.
- "Your symptoms" on the illness page: one line per symptom (how often in
30 days, how strong at worst, when last), the last 30 days grouped by
day, edit and delete per entry, rename, icon, hide and delete per
symptom. Renders nothing until a symptom exists.
- "Symptoms logged" on an episode's page: the entries filed against it or
logged while it ran, the filed ones marked. This is the reader of the
episode link.
- Query keys live under the illness root, so either side's writes
refresh both.
The extract route accepts `Prefer: respond-async` for all three inputs
(text, stored, vision) and queues a DOCUMENT_EXTRACT run (added to
migration 0370). The run's result is the count of staged facts and the
document status; the facts themselves are read from the document.
Without the header the route answers with the document detail as
before; both paths run one shared body.
A background read can take minutes, and a review the person finishes in
the meantime must not have its approved facts replaced. Staging now
locks the document row and asks again whether it may stage, refusing
with the route's own alreadyConfirmed / alreadyPartlyConfirmed codes.
The synchronous path gets the same re-check.
The vault's stored-text extract uses the background path and shows the
shared waiting line in place of its hint while the read runs.
Every active symptom definition enters pattern discovery as an outcome
channel SYMPTOM:<id>, labelled with its decrypted name (the data label
the interpretation already prefers over the key). The daily value is the
day's highest intensity in the person's zone; days without an occurrence
count as 0 only between the first and the last logged day. At most eight,
the active-definition cap. The SYMPTOM: prefix resolves to the illness
module in the surface map, so the family leaves the matrix with the
module, and the discovery guard lists the new fetcher.
A symptom of one's own ("aura", "headache") is a definition with an
encrypted name and an icon from the cycle custom-symptom allowlist; each
occurrence carries a time, a 0-10 intensity (the pain score's scale), an
optional encrypted note and an optional link to one of the record's open
illness episodes. Both ride the illness module: every route answers
illness.disabled when it is off.
- Migration 0369 (idempotent): symptom_definitions and symptom_events,
intensity CHECK 0-10, episode link ON DELETE SET NULL.
- /api/symptoms/definitions{,/[id]} and /api/symptoms/events{,/[id]}:
eight active definitions per record (a hidden one frees its slot,
showing it again re-checks), hide or purge, events create/list/edit/
delete, a new link must name an open episode of the same record
(422 symptoms.episode.notOpen). Error group symptoms.*.
- Sharing: reads at READ, logging an occurrence at WRITE, defining and
editing at MANAGE; C1/C3/C4 audit details; frozen lists, the handler
matrix and the activity verbs updated.
- Both encrypted columns registered and rotated; both models in the wipe
plan; both carried at both backup ends with occurrences nested in their
definition, plaintext in a portable file and ciphertext in a recovery
file, and a link to an episode the file lacks dropped and named
(symptomEpisodeReference).
- OpenAPI regenerated.
The summary and suggest routes accept `Prefer: respond-async`: after the
same quick refusals they answer 202 with a run id, and the background
document AI worker runs the read under two new run kinds
(DOCUMENT_SUMMARY, DOCUMENT_SUGGEST, migration 0370). Without the header
both routes answer synchronously as before; both paths run one shared
body, so they cannot drift.
The worker asks the capability for the record and re-checks the wire for
the provider it picks before every read. A summary that is to be stored
marks the document PENDING while queued, READY when it lands, and
UNAVAILABLE when the run fails or the write does not land. A second
request attaches to a live run only when it would produce the same thing.
The vault asks for both in the background and shows the same calm
waiting line the index uses, worded so a transient read never promises
that leaving the page keeps it. The egress guard and the route inventory
now hold every run kind to its dispatcher re-check and its capability.
The clinical category lived in a side table the app created at runtime,
after migration 0004 had dropped it as orphaned. It is now the
MedicationCategoryAssignment model. Migration 0368 creates the table
IF NOT EXISTS with the runtime DDL, so an existing table converges with
its rows untouched (verified with prisma migrate diff against a database
holding the runtime table and against a fresh one).
The helpers keep their names and run on the Prisma delegate; the runtime
CREATE TABLE and the pre-transaction ensure call in the restore are gone.
The backup plan judges the table as a schema model (backed up, two-ended,
seeded in the round trip) instead of under RAW_SQL_TABLES, which is now
empty; its guard proves its matcher on a fixture string instead.
The rollup tier and sleep resolve every (type, day) to the source the person's
ladder picks before they aggregate. The dense and raw reads of
/api/measurements/series filtered on user and type only, so with a second
provider a day's value blended the two, a reading present in both showed
twice, and the configured ladder had no effect on those charts.
Both reads now take the ladder-winning source per day through the shared
canonicalMeasurementsCte helper, as the other live readers do:
- the day and hour bucket read of the dense kinds (pulse, glucose, HRV, SpO2),
and the stats strip over its rows;
- the generic raw read (weight, body fat, steps, resting heart rate, ...).
The collapse is per day for the hour grain too, as everywhere else. The helper
gains an optional single-type scope so a series scans one metric, not every row
the person has in the window.
Only a type that has a ladder is collapsed. For a type without one (glucose,
body water, bone mass) the helper breaks the tie by source name, which would
silently hide one of two real sources, so those keep every source as before.
A single-source account reads exactly what is stored.
Tests: a real-database test covering the ladder in both directions, the
per-day fallback, a reading mirrored into two sources, the day-bucket, hour-
bucket and raw reads, a type with no ladder and a single-source account. The
route's unit test file reads its fixtures through a small adapter for the new
raw statements, and the dense-read guard drops the exception for the removed
findMany. The OpenAPI description states the rule.
Refs #1108
The integration-backfill drain handed its job budget only to the
Google Health backfill. The lab-biomarker backfill now takes it too,
asks it before each analyte group and, once it says stop, throws
without touching the rest. Finished groups are linked and drop out of
the next scan, so the retry resumes where the pass stopped instead of
running on past the job's expiry beside its own retry.
/api/dashboard/summary sent the glucose card's value in mg/dL with
unit null and a unitKey that reads mg/dL, so a reader on mmol/L saw
the mg/dL number. The card now carries latestValue, secondaryValue
and sparkline in the record owner's glucose unit and names it in
unit, the field that wins over unitKey, as the sleep card already
does. Applied after the summary cache read, so a unit change shows
on the next request.
The archive grouped a document without a stated date by the UTC day
of its upload, so an upload late on the last evening of a month west
of UTC opened next month's section (and early on the first morning
east of UTC, last month's). The upload day is now read in the
profile time zone for the month sections and the year filter.
Day keys anchored at noon UTC went through the zone-aware
dateShortSmart, which shows them as the next day in UTC+12..+14. A
dateShortSmartCalendar formatter reads the key in UTC with the same
year rule, and the predictions panel, the illness timeline, the
intraday pulse day label, the nutrient chart and the coach result
chart use it. The lab chart dates a date-only reading by its calendar
date and a timed one in the reader's zone, as the lab lists do. The
timezone formatter guard now fails on a noon anchor handed to a
zone-aware formatter.
The clinician view rounded numbers and printed them bare, so a German
record showed 24.5 where every other surface shows 24,5. Its number
formatter now goes through the locale formatter it already builds,
at most two decimals.
The range badge renders nothing for a reading without a reference range, so
that row had four cells instead of five and the range bar and the trend
moved one column to the left. The badge now sits in a cell of its own. The
chevron is hidden from lg up, where the row's hover already says it opens;
a phone keeps it.
A chart showing the last N days fixed its window end at the mount
instant. The query key kept that bound, so the refetch after a new
reading asked for the same old window and the reading stayed hidden
until the range tab was switched. The window now ends at the end of
the current day in the profile time zone (openEndedFetchWindow, keyed
on the day): every reading saved today falls inside it, the cache key
stays the same all day, and it moves on after midnight. The dashboard
batch shares the end-of-day helper.
Refs #1102
The sleep field asked for decimal hours only. A shared parser
(parse-duration.ts) now reads a bare number with point or comma,
H:MM, and hours and minutes with unit words (7h, 7h30, 7 h 30 min,
45m), answers in the field's unit, and refuses a negative, unknown or
longer-than-a-day entry. The form shows how the entry was read under
the field and puts a refusal on the field itself. The stored value and
the API are unchanged.
Refs #1101
The boot check decided on the first value it found under a key id, so
one damaged or foreign-sealed row could take a correctly keyed server
down with 503 on every request. The probe now samples up to five
values, one per column first. One value that opens clears the key; a
mismatch needs at least two values and none opening. A single value
that does not open is inconclusive: the server keeps serving, writes no
canary, logs a warning and probes again at the next start.
Integration setup truncates encryption_key_canaries between tests.
A server that issues Secure session cookies cannot sign anyone in on a
plain http:// page: the browser drops the cookie. The login request was
still verified, the MFA ticket minted and then claimed, and the next
attempt answered "Invalid or expired challenge".
The web client now sends an optional clientTransport on the browser
sign-in requests (password login, passkey start, both MFA completions).
When the server issues Secure cookies and the page is plain http on a
non-loopback host, the request is refused with 409
auth.session.insecure_transport before the password is checked, a
ticket is minted or claimed, a code is spent, or a passkey challenge is
issued. A request without the field is never refused. The SSO start
takes client_proto/client_host and sends such a page back to the login
page instead of to the provider.
GET /api/auth/registration-status publishes sessionCookieSecure, and the
login page shows the reason with both fixes and disables the sign-in
controls before anything is sent; on localhost it shows a note only.
SESSION_COOKIE_SECURE keeps its meaning; the header heuristic log line
stays.
Seven locales for the background-run copy: the calm line while a document
read or a lab scan runs in the worker, the hint when no worker has picked it
up, and the two failures only a background run can produce.
Readers label every linked reading with the marker's unit, so changing
it relabelled stored numbers without converting them. PUT
/api/biomarkers/{id} now answers 422 biomarkers.unit.locked with
meta.readingCount when the unit differs and any reading (tombstoned
ones included) is linked. The marker editor says so under the unit
field.
"Read with AI" and the lab scan used to run their model calls inside the
request. At the default response time a read of three calls outlasted the
100 s a proxy in front of the server commonly allows, and the person saw a
cut connection for a read the server was still doing.
Both now run as a background run (`DocumentAiRun`, migration 0366):
- `POST /api/documents/inbound/{id}/index` with `Prefer: respond-async`
answers 202 with a run id once every quick refusal has passed. Without
the header it answers exactly as before, which the iPhone app relies on.
A second request while a read of the same document is queued or running
attaches to that run.
- `POST /api/labs/ocr/extract` always answers 202. Only the web client
calls it.
- `GET /api/ai-runs/{id}` serves the run: the same body the synchronous
route answers with, or the failure code and status it would have given.
Scoped to the caller; another account's run is a 404.
The worker (`document-ai-run`, a long queue with a one-hour expiry, one
job per run under `lockedPass`) claims the run, asks the capability again
for the record and re-checks the wire for the provider it picks, sets the
run's deadline from the person's AI response time and refuses a read that
cannot fit the job's expiry. Sync route and worker run one body per kind,
so the two paths cannot drift. Budget is reserved at enqueue and settled
once by whoever ends the run.
Input and result are sealed with labelled AES-256-GCM, registered for key
rotation; the input is dropped when the run ends. A five-minute reaper
fails a run no worker took within fifteen minutes (and hands its
reservation back), fails a run past its deadline, and deletes a finished
run after an hour. Runs are outside backups and inside the data wipe.
The web client polls through the query-key factory and says, calmly, that
the read continues in the background, and when no worker has picked it up.
Every configured key id now has a canary in encryption_key_canaries,
checked at boot. A key that cannot open its canary, or existing data
written before the canary existed, puts the process in a refusal state:
every API route answers 503 encryption.key_mismatch, /api/health reports
reason encryption_key_mismatch, the pages render one explanation, and
neither the queue producer nor the worker starts. The process stays up
so the reason is visible instead of hiding in a crash loop.
The admin Encryption page gains a "Back up your encryption key" step:
key id and fingerprint (never the key), where the key lives per
platform, a check of a pasted copy that is compared and discarded, and
a confirmation bound to the active key id and fingerprint. The overview
gets a tile and admins get a dashboard banner while it is due.
HEALTHLOG_PLATFORM picks the platform the step opens on. Adds the
TrueNAS self-hosting page and key backup sections in the docs.
The in-memory memo of the last provider that answered reorders the
chain for up to an hour. Saving a new chain now drops it for that
account, so a provider the person removed or disabled no longer leads
the chain they just arranged.
The manual form sends one manual: id per opening and resends it after
an edit. The batch route now treats a MANUAL entry with a manual: id
as an overwrite, the way stats: measurements are: the stored row takes
the new values and the entry reports "updated", or "duplicate" when
nothing changed. Records keyed on the workout are removed under the
personal-records lock and re-derived. Every other id stays
first-write-wins. The status enum gains "updated".
The workout-slot detection reads the best workout and writes its record
inside one transaction under a per-user advisory lock, and the workout
DELETE takes the same lock before removing records and the workout. A
delete can no longer land between the read and the write and leave a
record for a workout that is gone.
The DELETE's match for a legacy record without an external id is scoped
to the slots of the deleted workout's sport, so a record another sport
set at the same instant stays.
The insight claim row is now the daily-cap ledger. Its workout_id is
nullable with ON DELETE SET NULL (migration 0367), so deleting a workout
no longer frees the day's slot. The cap counts claims that reached the
provider plus live leases, and drops the separate count of insight rows.
A daily workout-insight-claim-cleanup queue removes claim rows untouched
for seven days.
The round-trip fixture deleted its measurement on a fixed date. The restore
drops a measurement tombstone older than TOMBSTONE_RETENTION_DAYS (75) on
purpose, so on 2026-10-03 the fixture aged out and the suite failed on main
and on every open branch. The deletion is now ten days before the run.
A numeric lab reading was stored with its value as received and the marker's
unit stamped on it, so 5.6 mmol/L against a marker kept in mg/dL became
5.6 mg/dL and read as far below range. Both the manual/API write and the
scan commit did this; nothing compared the two units.
- POST /api/labs returns 422 labs.unit.mismatch, with the marker's unit and
the reading's unit in meta, and writes nothing. The unit is compared only
when one was sent: on the biomarkerId path it may be omitted, and the
marker's unit is then the stated one. A qualitative reading has no unit.
- The scan commit skips such a row with reason unit_mismatch, so the outcome
reads partial or failed instead of a clean save, and the toast names the
analytes concerned rather than saying "duplicates".
- The extract response carries the matched marker's unit. The review screen
shows a calm badge on a row in another unit, starts it unticked like a
duplicate, and treats it as a blocking error on the unit field if it is
ticked anyway.
- A small lab-specific normaliser forgives spelling only: spacing, the micro
prefix, the case of the litre, and a unit printed in capitals for a short
list where capitals cannot mean another unit. Case is otherwise significant,
so mIU/L and MIU/L, or g/L and G/L, are never merged.
The document-commit path already refuses a mismatch and is left as it is.
Readings stored wrongly before this cannot be identified, because the
original unit was never kept; they are not touched.
Adds EN and DE strings; the other locales are left to the maintainers. The
OpenAPI spec and the error-code catalogue carry the new code.
Fixes#1094
The form's start defaults to the minute it opened, so a workout logged
right after it finished was stored as ending up to a day ahead. The
duration now fails validation when start + duration lies past now, with
the same one-minute slack the start check uses.
The placeholder said "Default (~120)" while most surfaces fall back to
60 s and the briefing to 180 s. The placeholder and the help line now
interpolate both numbers from the constants the server applies
(PROVIDER_DEFAULT_TIMEOUT_MS, AI_BUDGETS.comprehensive.timeoutMs), in
all seven locales, and a test fails if a locale writes a number of its
own.
Refs #1090
Pins the four ways the setting could be skipped again: a wire client
reading params.timeoutMs itself instead of callTimeoutMs, an exported
resolver in provider.ts returning a provider it did not bind, a client
constructed outside provider.ts (the offline evaluation harness is the
named exception), and a surface ceiling not listed with its reason.
Each matcher asserts a non-zero match count.
Refs #1090
The browser gave up on its own: 120 s for the document routes, 90 s for
a lab scan, and the 15 s default on the lab text path, while a slow
self-hosted model with a raised response timeout was still allowed to
answer. A five-page report read by a local model failed on the client
side with the server still working.
Each request now sizes its abort from ai.provider.responseTimeoutMs on
the account payload, times the model calls the route can make, plus
30 s: one call for suggest and summary, three for Read with AI (the
transcription, then the lab staging read and its corrective retry), two
for a lab scan (the read and its retry). With the setting unset that is
90 s, 210 s and 150 s.
Refs #1090
The response-timeout setting reached the briefing, the status notes and
the Coach, which threaded it by hand. Document reads, document
extraction, lab OCR and lab staging called the provider without a
timeout, so every client fell back to its own 60 s literal and a slow
self-hosted model was cut off however high the setting stood.
The setting now rides on the provider instance. resolveProvider,
resolveProviderChain and resolveProviderForTest stamp the record's
aiResponseTimeoutSeconds on everything they return, and each wire
client reads its ceiling through callTimeoutMs: the setting, else the
surface's own timeoutMs, else 60 s. A call site no longer has to pass
it, so a new one cannot forget it. The connection test runs under the
same ceiling a read does.
The Coach nudge tick and the reaction line keep their short ceilings
(timeoutPolicy: "surface-ceiling"): the tick runs accounts one after
another under a shared budget, the reaction line's claim lease is
shorter than the largest setting, and both have deterministic copy
that stands in.
/api/auth/me publishes the effective value as
ai.provider.responseTimeoutMs, so a client can size its own request
timeout from it instead of guessing.
Refs #1090
The spec compared field tops within 8 px. A field label that wraps onto a
second line, which depends on the runner's font metrics, moves that
field's input down by one line while it stays in the same row, and the
560 px case failed on it. A field is now beside the value when it sits to
its right and starts above the value's bottom edge.
On a phone and a small tablet the review sheet slides up from the bottom;
its width settles before the slide ends, so waiting on width alone still
compared fields from different frames. The spec now waits for every
running animation to finish and for the surface to hold its position and
width across two frames.
Main measured 3718 KB gz after v1.39.7 and v1.39.8, almost all of it new
copy in the seven lazy locale catalogs; the workout form, its sheet, the
delete action and their copy add about 7 KB (3725). The cap moves from
3724 to 3749, the same 24 KB headroom as before; the eager routes stay
inside their drift allowances.
Refs #1085
The dashboard add menu asked only whether the record's section was
writable, so a switched-off mood or medications module still offered its
entry here while the bottom bar's picker hid it. The menu now renders from
the picker's own visibleCaptureKinds, so the two cannot disagree.
Refs #1085
The review stage widens the sheet and the row grid re-flows from the
container width; on a slow runner the 560 px case measured a half-laid-out
row and failed on the first attempt. The spec now waits until the surface
keeps its width across two frames before it measures.
A repeated submit that wrote nothing reads as a neutral notice rather
than a success. The delete control is entered in the destructive-control
registry, and its 409 code in the published error catalogue.
Refs #1085
Builds the entry the form sends, posts it through the batch route, reads
it back from the list, re-posts it as a duplicate, and deletes it; a
synced workout is refused.
Refs #1085
The dashboard add menu and the bottom bar's capture picker gain a
workout entry next to measurement, mood and medication. It belongs to
the workouts module (capture:workout) and is offered in one's own record
only, because the batch ingest resolves the caller and no grant reaches
it.
Refs #1085
A small sheet with sport, start, duration and optionally distance and
active energy, nothing else. The start is read in the profile timezone,
distance in the reader's unit and stored in metres. It saves through
POST /api/workouts/batch as source MANUAL with an external id minted once
per opened form, so a second tap lands as a duplicate, not a second row.
The workouts page carries the action in its header and in the empty
state; the detail page offers delete for a hand-entered workout.
Refs #1085
DELETE /api/workouts/{id} removes a MANUAL workout outright, with its
child rows and the personal records keyed on it, then queues a silent
detection pass so the previous best comes back. Any other source answers
409: the next sync would write it back. Own record only, 404 for another
user's row.
Refs #1085
The body fat tile was the one strip tile that led nowhere. Its href map
said there was no dedicated sub-page, which was true: body fat percentage
was tiled and charted on the dashboard but never got the Insights page
every body-composition sibling has.
Add /insights/body-fat on the generic metric-page scaffold (body group,
tab-strip pill, seven locales, manual-entry empty state) and link the
tile to it.
Move the tile href map out of the page into tile-destinations.ts, keyed by
the tile-capable widget id and typed against the sub-page slug union, so
a missing or non-existent destination fails to compile. The link passes
the destination's insights-page surface gate, so it never points at a
page a switched-off module hides.
A guard derives the tile set from TILE_CAPABLE_WIDGET_IDS and from the
ids the dashboard actually pushes into its strip, and fails when a tile's
metric has a sub-page the tile does not open, when a destination has no
route, or when a tile links to a page owned by another module. Tiles that
deliberately lead nowhere need a written reason; there are none today.
The reverse-lookup test pinned BODY_FAT as having no page; it now
resolves to body-fat.
Refs #1084
Same advisory, same reasoning as the pnpm audit entry: the affected
signature verification sits in node-apn's certificate credential path,
which token-authenticated APNs never reaches, and no fix is published.
GHSA-86w9-cpqp-85rv affects RSA PKCS#1 v1.5 signature verification in
node-forge up to 1.4.0, which arrives only through @parse/node-apn for
certificate-based APNs credentials. HealthLog uses token authentication,
so that path never runs, and no patched release exists yet.
The review stage is a list of readings to compare and correct, but it sat in
the default 448 px dialog, so with several readings it only grew taller.
- ResponsiveSheet gains an opt-in 6xl width tier (72rem). Nothing else
changes width; the Sheet branch on phones ignores the tier as before.
- The review stage uses it. The picking stage stays narrow.
- Each row lays out from the width of its container, not the window:
stacked in a narrow dialog, and once the dialog is wide enough the reading
sits on the left with its fields on the right.
- The field grid (value, unit, date) is a container of its own, so it takes
three columns when the fields have room for them, whatever the window
width. The result-text span follows the same query.
- The dialog-overflow e2e spec covers the review stage at 390, 560, 768,
1280, 1920 and 3440 px. 560 is wider than a phone's field grid needs but
below the window sm breakpoint, which is what tells a container query from
a window one.
Refs #1056
Recharts word-wraps a tick label that it measures wider than its axis,
and it measures in the page's body font rather than the tick's 11 px.
"62 bpm" on the pulse chart under a workout and "106 mg/dL" on the
glucose page therefore broke onto two lines at every width, and the
pulse chart's second line sat on the first x tick. Axis units are now
joined with a no-break space through `axisUnitSuffix`, so the label is
one word and stays on one line. A guard fails when a chart axis joins a
unit with a plain space.
The calendar and clock buttons in the date and time fields paint at
32 px; a `::before` halo now gives them a 44 px touch target without
changing the field.
A date-only value is stored at 12:00 UTC. The lab list and history, the
document cards and pickers, the document date suggestion, extracted
fact dates, source-picker rows and the compliance and mood heatmaps
formatted that instant in the reader's zone, which is the next day from
UTC+12 to UTC+14.
`useCalendarDate` formats such a value through the UTC-pinned calendar
formatter the vaccination list already uses, with the same field order
and locale as instant dates. Lab readings hold both kinds, so
`useLabDate` reads a noon-UTC anchor as its calendar date and a reading
typed with its time of day in the reader's zone. A document without a
filing date shows its upload day, an instant, in the reader's zone.
Range bars and the lab list, detail rows, chart tooltip and reference
ranges printed numbers with a plain `String()`, so a German reader saw
"1.8 mIU/L" and "61.3 kg" under a German interface. The same was true
of the mean, GMI, eA1C and the advanced indices on the glucose panel.
`RangeBar` now prints value, bounds and delta through the locale number
formatter and takes an optional `formatValue` for surfaces with their
own precision. Lab values keep their trim rule (whole numbers bare, at
most two decimals) in `formatLabValue`, which now hands the digits to a
caller-supplied locale formatter; `useLabNumber` wires it to the
reader's locale for every lab surface. Inputs are unchanged.
The time-in-range labels in the doctor report, the target caption on
the glucose panel, the glucose page description and the fasting
explainer printed fixed mg/dL figures, so a reader on mmol/L saw
"Low (< 70 mg/dL)" beside values in mmol/L.
The cut-points now live in one place (`GLUCOSE_TIR_CUT_POINTS`,
`GLUCOSE_FASTING_CUT_POINTS`) with the SI figure each guideline prints,
the analytics engine counts its bands from the same constants, and
every sentence takes the band edges and unit as parameters through
`glucoseBandParams`, formatted with the reader's number format.
The unit-preference guard now also reads the locale bundles: a string
that names mg/dL or mmol/L fails unless it is allowlisted with a
reason.
Refs #1067
A workouts:write token and the iPhone app shared one per-account bucket of
60 batches a minute, so a bridge stuck in a retry loop could use up the
allowance the phone needs to sync the same account. Scoped tokens now count
in a bucket of their own, shared by all bridge tokens of the account so
that minting more tokens does not multiply the allowance.
Refs #1054
The entry form offered pulse but not resting heart rate, which has its own
chart and series, so a value read off a watch or taken on waking could only
land among spot pulses. The form now offers it next to pulse. The server
already gates the type on the recovery module (its resting_hr domain), so
the form, the dashboard summary, the resting-pulse page, its trend slot and
its correlation channel now follow that module too.
Refs #1079
The iPhone app posts every ECG its sweep has not yet consumed and, on a
429, stops and restarts the same sweep on its next wake. With a limit of
60 per minute a sweep of 61 or more could never finish: the same last
recording was refused on every sync, so the newest ECG never arrived. The
limit is 600 per minute now, enough for more than a decade of weekly
strips in one sweep.
Refs #1060
The unit sweep covered the display trees and the insight library; the
prompt and snapshot builders, the text-writing jobs and the target and
digest producers were outside it, which is where a band in mg/dL, a
threshold in kg/week and a distance in metres reached the model. The
sweep now walks those trees too and matches prose units ("kg/week",
"5 metres") as well as symbols. The four allowlist entries for prompts
that stated weight in kilograms are gone with the literals; the new
entries each name why the literal is not a reader-facing unit.
A line scanner cannot see a canonical number printed with no unit at
all, so the guard also names every producer that carries a
preference-dependent figure into a prompt, a snapshot or a stored
sentence and requires each to call a reader-unit resolver. The canonical
weight signal of the day loses its kilogram label; the reader's unit is
attached where the signal is converted.
Refs #1067
The basal body temperature field, its chart and its tooltip were fixed
to degrees Celsius whatever the reader had chosen: the placeholder said
°C, the field accepted only 30-45, the chart plotted Celsius and the
tooltip printed "{temp} °C". Two chart titles carried a fixed unit as
well ("Wrist temperature (°C)", "Walking speed (km/h)") above an axis
already in the reader's unit.
The field now reads and writes in the reader's unit and stores Celsius;
a reading the person did not touch is saved back unchanged rather than
through a round trip of rounding. The chart plots, pads and labels in
the reader's unit, the two titles drop the unit the axis already
names, and the cycle contrast's fixed kg, °C and mg/dL labels, which
the transform had already replaced, leave the bundles.
Refs #1067
The reaction line read the arrived weight with its stored unit (kg) and
a workout distance in metres, whatever the reader had chosen, so the
sentence written about the new reading could quote kilograms to a
reader on pounds. The evidence now converts the reading and the
distance into the reader's units before the prompt is built.
Refs #1067
The weight status note had its own generator and never took the unit
preference: its signal, summary, graded series, latest-day figure,
target band and correlation pairs were all in kilograms, and the card
prompt stated the normal day-to-day swing, the plateau line and the
loss pace in kilograms too. A reader on pounds was handed both units.
The generator now converts every weight figure once after the canonical
arithmetic (the correlations are scale-free and stay as computed), the
card prompt states its thresholds in the reader's unit, and the unit
preference joins the input fingerprint so a switch is never answered
with the old note. The shared status-card examples and the briefing's
plateau pattern no longer show a weight in kilograms for the model to
copy.
Refs #1067
The cycle block's headline contrast labelled weight in kg and the
temperatures in °C, and left a glucose contrast without any unit. The
metric table, which the person sees and whose summary the model reads,
listed weight in kilograms, temperatures in degrees Celsius, walking
speed in metres per second and distance in metres.
The cycle contrast now converts both means and the gap into the
reader's units (the gap by the factor alone) and names the unit in its
sentence. The table resolves each column's unit and decimals from the
reader's preferences and converts every folded period value once; the
snapshot carries the preferences it was written in, so the table and
the snapshot of a turn always agree.
Refs #1067
The Coach snapshot carried weight, body composition, temperatures and
walking speed and distance as stored: kilograms, degrees Celsius, metres
per second and metres, with no unit on the block. The evidence-block
rules then listed kg and both glucose units as the units to cite. A
reader on pounds, Fahrenheit or miles was answered in the units they do
not use.
Every value block now converts its timeline (and the weight block its
aggregate and coarse tail) into the reader's units after aggregating on
the canonical value, and names the unit on the block; the grounding
scalars stay canonical. Workout distances arrive in kilometres or miles
with the unit named once. The prompt in every locale tells the model
that a block's figures are already in the reader's unit and takes the
evidence-block unit from the block instead of a fixed list.
Refs #1067
The daily briefing serialised the aggregated feature set as computed:
weight, grip strength and the weight target in kilograms, waist in
centimetres, glucose and the signals of the day in mg/dL (the glucose
signal carried no unit at all), workout distances in kilometres. A
reader on pounds or mmol/L got a briefing in units they do not use.
One step now converts the feature set after all canonical arithmetic,
on both the nightly and the on-demand path: absolute values through the
full transform, differences and slopes through the factor alone, every
converted block labelled, workout distances under unit-neutral keys.
The prompt, the stored fingerprint and the grounding check all read the
converted set, so a unit switch moves the fingerprint and the grounding
check compares the reply against the figures the model was shown. The
weight target sentence, the comparison block and the GLP-1 plateau
context quote weight in the reader's unit; the comparison block also
labelled sleep as hours while carrying minutes.
A unit switch now also re-warms a cached briefing alongside the status
notes and narratives.
Refs #1067
The reference grounding printed the fasting glucose bands in mg/dL and
the body-temperature band in °C, and the system prompt described time
in range as 70-180 mg/dL. For a reader on mmol/L or Fahrenheit the
model was handed one quantity in two units: the glucose block in
mmol/L, the band beside it in mg/dL.
The grounding block now takes both unit preferences and prints the
general, the diabetes goal and the temperature band in them, while the
placement is still classified on the canonical value. The glucose
block carries the time-in-range band in its own unit, and the prompt
points the model at it instead of naming a unit.
Refs #1067
The acute red-flag clause stated the glucose floors only in mg/dL and the
sustained-fever floor only in °C. The clause is matched against what the
person writes, so a reading of 2.9 mmol/L or 103 °F did not meet any of
its numbers. Each floor now carries both units, mg/dL and °C first, in
the English and German contracts.
Refs #1067
Fill every User column on the source account, export through the streaming
writer, recreate the account with credentials of its own, restore through the
route, and compare: each setting column must come back with the value it left
with, and every identity, credential and bookkeeping column must still hold
the target's value. The section in the file is checked to carry nothing but
setting columns.
Both backup purposes now carry an accountSettings section. The restore writes
it to the account being restored inside the same transaction, names every
refused value under the new accountSetting skip catalogue, and re-warms the
stored texts after the transaction when a unit preference changed, the way the
unit settings routes do. A file without the section leaves the account row as
it is, which is how every older backup restores. The instance settings
restore stays its own opt-in.
Every column of the account row now has a backup verdict in
USER_COLUMN_BACKUP_CLASS: SETTING, CREDENTIAL, IDENTITY or OPERATIONAL. A
schema-derived guard fails on an unclassified column, and the setting columns
each get a codec in account-settings-backup.ts, so a column classified as a
setting without a way to travel does not compile.
The restore end checks each value the way the settings route that writes it
does: AI endpoints against this host's private-origin policy, languages, units
and time zones against the known vocabularies, threshold bands against their
bounds, the avatar against the upload's sniff and limits. A refused value is
left out and named; credentials, identity and the host's own cursors are never
admitted.
The ingest route required integers for the average heart rate and the
sampling frequency. HealthKit reports both as doubles and a watch's average
heart rate is often fractional, so most new recordings were refused with
422 while older ones, whose value happened to be whole, went through. Both
are now accepted as numbers and rounded once before they are stored; the
columns stay integers. A refused body now logs which fields were refused
and why, never their values.
Refs #1060
The round-trip test proved that a row of each two-ended model comes back,
not that it comes back whole. A restore that writes the row with a column
defaulted returns the right count and loses the value; the medication
category went missing that way.
The new case lists each model's columns from the client's data model,
with optional/default/key attributes from schema.prisma (the two field
lists must agree). Columns the fixture left empty get a value that cannot
pass for a default, one column at a time so a refused value names its
column. Then it exports, deletes the account, restores, and compares each
column as a multiset of values: sealed columns by their plaintext, a
legacy plaintext column together with its sealed twin, declared foreign
keys by presence, primary keys, owner and @updatedAt excluded by rule.
A column added tomorrow is compared automatically: it comes back, or it
is named in COLUMN_EXCLUSIONS with a reason. Entries naming a column that
no longer exists fail. A pure case shows the comparison catches a column
it was never told about, and the run asserts at least 620 columns
compared (632 today). Removing the workout source marker, a schedule
label, the folded-cycle pointer or the nutrient createdAt from either end
each fails the run naming that column.
A column-level round trip over every two-ended model found four values
that left with the export and came back empty or re-stamped:
- MenstrualCycle.absorbedIntoId: the disaster-recovery file carried the
folded, soft-deleted start but not the start that absorbed it, so after
a restore removing the absorbing start could no longer bring the folded
one back. Carried in the disaster-recovery arm and written back only
when the file carries the start it names.
- Workout.externalSourceVersion: the source's version marker, stored so
the sync can replay it unchanged, was in neither end. Carried beside
metadata in the disaster-recovery arm.
- NutrientIntakeDay.externalSourceVersion and createdAt: neither was
selected, so a restored day lost its source marker and its first-arrival
time. Carried in both arms.
Files written before this change parse as before; every new field is
optional on the way in.
guard-floor-guard reads every *-guard(s).test file under src/ and every
test that walks a directory, parses it with the TypeScript compiler, and
fails unless it asserts a floor on what it found: requireFloor,
scanSourceMatches, toBeGreaterThan, an exact non-zero count, or an exact
non-empty set. A floor on the walk alone does not count; the Bearer-scope
guard had one and still matched nothing for weeks.
Files that are not scanners (unit tests of runtime functions named
"guard", fixed file lists with a positive match per file, temp-directory
listings) are named with a reason. An exemption that goes stale fails the
run. The reader's own shapes are pinned by probe sources, including the
ones it must not count: comments, strings, .not, zero floors, it.skip.
It answers per file, not per matcher, and says so.
A guard that compares a sweep against an allowlist, or against "none of
these may exist", passes when the sweep comes back empty. walkSourceFiles
already refuses a short walk; this adds the same refusal for what a guard
does with the walk.
- requireFloor(label, found, floor) throws below the floor and refuses a
floor of zero.
- scanSourceMatches(root, pattern, { fileFloor, matchFloor }) walks, matches
and floors both the files read and the hits found.
Floors added, pinned at the current counts:
- coach_usage owner columns: the clauses it actually examined (6).
- measurement reference index: the foreign keys it extracted (2).
- job outcome fact inventory: job files (130, floor 120) and fact keys
(399, floor 350).
- tone harness: assessment prompt modules (11).
- coach locale default: turn pipeline modules (15, floor 12).
- locale integrity: message bundles (7).
- settings card header coverage and settings anatomy: the walked cards.
- coach metric scope inventory: insight route sources.
- side-effect drift guard: both enums populated, since two empty keysets
are equal.
The pill-mapper guard walked with globSync, which skips dot-prefixed
directories; it now walks through walkSourceFiles like the rest.
A per-field registry of the answers the server resolves for the record on
screen (the blood-pressure target band, intakeActionable and courseStatus,
the AI capability, the module surfaces) and the ingredient shapes that
rebuild them in the browser: getBpTargets() on the viewer's age, a dose
offered from active and trackIntake, course state from endsOn against the
clock, the raw disableCoach column, server resolvers imported into client
code. A match in client code fails unless the file is allowlisted with the
reason it is not a rebuild; the switches that edit a stored value are the
allowlisted readers.
The walk carries a floor, every pattern is checked against a sample of the
shape it catches, a stale allowlist entry fails, and the total match count
must be non-zero. Putting back each of the three rebuilds this release
removed fails the guard.
- /api/auth/me and the AuthUser type described disableCoach as the gate
every Coach surface checks below a flags.coach short-circuit. Neither has
been true since the AI capability resolution: it is the account's stored
opt-out, and surfaces read ai.capabilities.coach or modules.coach.
- /api/mcp/tokens claimed /api/tokens mints medication:ingest; that mint was
removed, and the medication pair now comes from the per-medication
endpoint toggle.
- /api/meta/capabilities listed the token kinds as wildcard, medication
ingest or MCP, missing measurements:write, workouts:write and
documents:write. Comment only: the served scopeMintable answer for the
FHIR face is unchanged and still right.
A PDF longer than the pages a scan renders was read from its first ten
pages, and the cut was recorded only in the wide event. The person checking
the extracted rows had no way to know that values on later pages were never
read.
The rasterizer now returns the PDF's page count, the extract response
carries pageCoverage { read, total } when the cap applied, and the review
screen says how many of the pages were read.
A segment whose stage label was unknown (SLEEP_STAGE_TYPE_UNSPECIFIED, or a
label added upstream later) was skipped without a trace, so the night total
read short and nothing said why. The mapper now counts those segments and
keeps their labels, and the sleep sync puts both on its wide event.
The suite and the production image both sit near UTC, so a UTC day and a
local day agree for most of every day and day-boundary defects kept
passing. The harness sets the host zone in code to Los Angeles and to
Kiritimati, gives the account the same zone, and checks a schedule slot,
a course's last day, a mood entry, the dashboard summary's mood instant
and a document date round trip at the instant where the local and the
UTC day differ. Unit tests cover the new date-only and key helpers.
The noon-UTC rendering trick fixes the day west of UTC and moves it east
of UTC+12. A dose date is a calendar date, so it goes through the
UTC-pinned formatter the date fields already use, which is right in every
zone.
The grid change wrapped the heading in a cell div, which separated it
from the description paragraph the settings-score spec reads as its
following sibling. The heading is the grid item itself now.
An unknown or archived tag key, or a factor the catalog no longer carries,
was dropped on the way in while the entry still reported "inserted" (bulk)
or 201 (create, edit). A client had no way to tell that part of what it sent
never landed.
Each write now names those keys: the bulk result per entry, the create and
edit responses on the entry, as droppedTagKeys and droppedFactorKeys. Both
are present only when non-empty, so a response where everything landed is
unchanged. An archived tag an edit preserved is on the entry and is not
listed. The web shows a short notice after saving when a picked tag was left
off.
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC
midnight are the idioms behind every day-boundary fix this year: the
evening west of UTC and the small hours east of it land on the
neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in
src/ outside src/lib/tz and tests, and refuses a disable directive for
itself that is not next-line or gives no reason.
The time zone module now names every legitimate form: userDayKey for a
person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for
date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey /
isCalendarDateKey / statedDateKey for key arithmetic and parsing.
Of the 133 sites the rule found, 84 are gone. Most moved onto a named
helper with no change in behaviour; these changed behaviour:
- cycle calendar, insights, Coach snapshot and prediction refresh start
their lookbacks at the first instant of the user's day
- a model-transcribed date like "June 10, 2026" no longer lands a day
early in a process east of UTC (documents and lab scans)
- a custom report range covers exactly the picked days in the profile
zone instead of starting the evening before west of UTC
- date-of-birth pickers cap at the user's today, not the UTC one
- an allergy onset date is stored at noon UTC like other date-only values
- the report's cycle summary, the Coach's usual BP range and the intake
projector's rollup recompute use the user's day
- vaccination dates render as dates instead of the previous evening
49 remain, each with its reason on the line above: 39 UTC by design (15
file name stamps, rollup and object keys, the UTC-day score engines) and
10 marked baseline. A guard test requires a classified reason on every
directive and holds the baseline to a ceiling that only goes down.
Settings → Coach hid its preference and nudge cards on the raw disableCoach
column, which /me publishes for the signed-in person. Inside a managed or
shared record that answered for the wrong person: a guardian with the Coach
on saw the tuning for a record that has it off, and the other way round.
The cards now read the module map the server resolves for the record on
screen, the same map the navigation and the settings entry already follow.
The three-button footer fits a phone row in English, so the English
case never saw Cancel leave the sheet. A German case at every width
fails against the old footer (checked) and passes against the fix.
The intake pickers chose their default from every active medication, so a
course that had ended but was not archived could be the preselected dose.
The default now reads the server's intakeActionable answer; the medications
page dialog still lists ended courses for back-filling a past dose, they are
just never the default.
Both pickers also graded "due" against the fixed 120 / 240-minute defaults
while the cards beside them used the account's own late and missed
thresholds. They read the same thresholds now.
The blood-pressure chart rebuilt its target zones in the browser from the
signed-in account's date of birth. A band the person had set themselves never
reached the chart, and inside a shared or managed record the zones followed
the viewer's age instead of the record's.
The targets payload already resolves for the record on screen. Its blood-
pressure card now honours the user's own band too (labelled Custom, like the
weight card), and the chart reads its zones from that card, so the chart and
the reference panel under it name one band.
The lab list panned sideways on a phone while the overflow guard was
green, because /labs was not on its hand-kept list. The new sweep reads
the routes from src/app: static routes are visited as is, every dynamic
route needs a resolver to real paths, and routes that are not signed-in
surfaces are listed with their reason. A route added without either
fails the bookkeeping test by name.
It runs on an account of its own, filled once from globalSetup with one
or two of everything a route renders, in German, so the narrow-column
and long-string cases are present. Two lab tests assert at the elements
that broke: the marker name keeps a readable width, and the values
list's actions stay on screen. The dialog spec gains a 360px width,
where the labs sheet's three-button footer overflowed in English too.
Three cards put a full sentence into the SettingsCardHeader status slot
(mood reminder, low-stock warning, the app-log refresh button), which
does not wrap: the row grew to 1000px and /settings/notifications
panned sideways at every width. The sentence moves into the card body,
and the slot is capped at the row so a long status wraps instead.
Also on a phone: the provider-chain add control wraps, the report
picker's standard-template action drops under its sentence instead of
running past the card, and the dashboard layout rows put their switches
and arrows on a second line so the tile name is no longer cut to five
characters.
With three or four icon actions beside the title, the description was
left a ~130px column that wrapped five or six times. Below sm it now
spans the row under the title and actions; from sm up the actions span
both rows exactly as before.
A one-word German title wider than the column beside the actions ran
underneath them; the heading hyphenates by the page language and breaks
as a last resort. Notifications, whose two actions are text links,
moves them under the description on a phone (stackActionsOnPhone).
A reference window of 0-130 padded the axis to -13, a tick that reads
like a value for a concentration that cannot be negative. The pad only
goes below zero when the data already does.
The compact list was a five-column table at every width. Below the fixed
columns (badge, 12rem range bar, 72px trend, chevron) the name column
collapsed to nothing, the badge sat over the value and the row panned
sideways off the card. Below lg the same cells now fold into two lines
per row; the desktop subgrid is unchanged.
The values table needed ~510px, so on a phone its edit and delete
buttons sat in a sideways-scrolling strip and the range bar labels were
clipped. Below lg each reading is a stacked row with its actions beside
it.
The add sheet's three footer buttons did not fit a phone row in German
and pushed Cancel off the left edge; the secondary save takes a row of
its own there, and the footer wraps instead of overflowing on desktop.
The scan review's 'links to existing marker' chip truncates instead of
running past its row.
The travel and backfill pickers defaulted to the UTC day, which was
tomorrow east of UTC in the small hours and yesterday west of it in the
evening, and the backfill start cut the home's effective instant the same
way. Both now read the day in the account's zone.
The scan commit and the review screen looked for an existing reading in
the UTC day of the scanned date. A hand-entered morning draw east of UTC
sits on the previous UTC day, so scanning the same report wrote the
reading a second time. Both checks now compare calendar days: a date-only
reading's stated date, or the user's day of a hand-entered instant.
endsOn is a calendar date, and the storyline compared it with the instant
now, so a course dropped out on its final day. Compare it with the user's
own today as the column's date value instead.
The timeline grouped side effects by the UTC day of moodLoggedAt, so an
evening entry west of UTC (or a morning one east of it) showed on the
neighbouring day. The entry's stored date is already the writer's local
day; group by that.
A report's stated date, a document's filing date and the date a committed
lab reading or condition carries were written as UTC midnight, which is
the previous evening west of UTC: a reading dated the 1st showed on the
31st. Date-only values are now stored at noon UTC through one helper in
the time zone module, the convention the lab scan and the cycle engine
already use. Rows written at midnight still read back to the same date.
A document uploaded without a date files under the uploader's own day
rather than the UTC one.
The snapshot cut the last injection, the dose history and the projected
next injection on UTC days and compared getUTCDay() with the schedule's
local weekday. An evening injection west of UTC landed on the next day,
and east of UTC 'tomorrow' was today. Day keys now come from the user's
zone, which the Coach snapshot already resolves and now passes in.
The mood series carries local day keys, and the summary wrote the newest
one out as UTC midnight. West of UTC that instant is the previous evening,
so a client formatting it in its own zone showed today's entry as
yesterday's. Emit the first instant of the day in the account's zone
instead; the field keeps its shape and type.
Every queue bound with createAndWork is now declared in
src/lib/jobs/queue-runtime.ts as short (cannot come near the default
fifteen minutes, with the reason), budgeted (stops itself at three
quarters of the default and resumes) or long (an explicit expiry, a
stop on the job's budget, and a lock or claim per identity; or a
declared one-shot with no retries).
The guard reads every binding, every boss.send and every schedule tuple
in the tree: each names a queue in the table, every entry is bound, a
long queue's sends and schedules all carry its expiry, its handler
stops on jobBudget / jobDeadline where declared and holds lockedPass or
the named marker, and a short or budgeted queue sets no expiry of its
own. Floors pin the counts (112 bindings, 64 sends, 74 schedules), and
planted long queues missing each protection in turn are caught. Run
against the tree before the previous commit it reports 59 findings;
dropping lockedPass and the cron expiry from pr-detection reports both.
pg-boss gives a job fifteen minutes unless the send says otherwise,
then fails it and retries it while the handler keeps running. Several
passes can legitimately need longer and had no protection at all: the
seven nightly status passes and the pre-generation (a provider call per
account), the period-narrative warm, the record-detection cron (every
account's history, every thirty minutes), the key rotation (every
encrypted column), the content-index backfill (up to 200
transcriptions) and the on-host backup.
Each of them now carries an explicit expiry on every send and schedule,
stops between units once three quarters of it is spent (jobBudget), and
runs under a session advisory lock keyed on queue and identity
(lockedPass), so the retry pg-boss starts after the expiry does nothing
beside a run that is still going. The lock is the one the full-history
imports already held, moved into job-lock.ts.
Where a pass stops early it says so (stopped_early) and the next run
continues: the status passes and the narrative warm leave the rest to
the next night, the pre-generation and the narratives walk the stalest
accounts first, the on-host backup now backs up the oldest copies
first, the key rotation reports a partial run in its audit row and
skips rotated rows when run again, and the dose-history import fails
the delivery so its retry resumes from the committed chunk. The
off-host backup, which already stopped on its budget, gets a four-hour
expiry and the lock.
The narrative, the workout distance and the elevation chart converted
through the unrounded escape hatch, and the trend caption scaled its
noise floor off the raw factor. They now take the rounded helpers, so a
narrated pound or mmol/L figure reads at the grain every screen shows
it, and the caption's floors are declared canonically (the distance
floor in metres). The trends row joins the chart-feeding surfaces that
may hand a raw scale to a chart.
Refs #1067
The unit-preference guard covered the dashboard, the insights pages and
the target surfaces, and the class kept turning up beside them. It now
also walks src/components, src/app, src/lib/insights and the doctor
report and export modules for the glucose units anywhere in code and
for kg, km and °C in display strings, with comments stripped. Each file
that legitimately carries one is listed with its exact line budget and
the reason: the conversion and the pickers, input parsing, display-kind
tags, canonical wire and registry units, the SI policy for clinician
artefacts, and four prompt texts still to move. The sweep asserts a
match floor, so a broken walk cannot pass as a clean tree.
Refs #1067
The cycle-phase table printed the bundle's mg/dL label beside raw mg/dL
values for every reader. Weight, temperature and now glucose rows take
their unit and conversion from the one reading resolver; every other
row keeps its bundle label.
Refs #1067
Two more surfaces carried a fixed unit. The insights trends row labelled
weight in kg and body temperature in °C for every reader, and plotted
walking distance as raw metres under a km label; its trend caption
said the same. A single transformed series there now takes unit, scale
and offset from the preference, and the caption describes the converted
series against a converted noise floor.
The illness day sheet and timeline showed and took a fever in °C. The
fever is now seeded, typed and shown in the reader's temperature unit
and stored in °C as before.
Refs #1067
The workout list, the detail header, the stat tiles, the sport-average
line and the elevation chart printed kilometres, minutes per kilometre
and metres for every reader. They now read distance, pace and elevation
through the unit registry (km or mi, /km or /mi, m or ft). The splits
table keeps its kilometre rows, which are cut server-side, and says so.
A pace that rounded to sixty seconds now carries into the minute.
Refs #1067
The dense-read guard walked the worker and insight directories only,
and the same class kept turning up beside them: the targets page, the
cycle insights, the per-kind series and the doctor report all lived in
app/api or a helper next to it.
The findMany check now also covers app/api, import, export, the targets
and the doctor report. It reads the type from the where object rather
than the first type key in the call, accepts a list of sparse literals,
and counts a key-bounded where (an id, externalId or measuredAt list,
or an exact instant) as bounded. Every remaining unbounded read is
listed with its reason.
A second check covers every raw statement in the tree that reads from
measurements: it must GROUP BY, use DISTINCT or LIMIT, or select
aggregates only, or be listed. Both checks carry floors and run their
matchers over a planted offender. Restoring the old targets builder
fails the findMany check on the year of glucose and the heart-rate
month; dropping the GROUP BY from the resting-pulse fold fails the SQL
check.
The fasting-glucose pillar served its observed and reference labels as
"Fasting median 99 mg/dL" and "70-99 mg/dL fasting" whatever the
account's unit, and clients print those labels as they come. They are
now written in the record's glucose unit. The scored value, its unit
and the band edges stay canonical, so the score does not move and the
web, which converts from the value, cannot convert twice. The schema
now says which fields are canonical and which are display text.
Refs #1067
The sleep list capped its read to the last year only when no `from`
was sent. An explicit early `from` walked every per-stage row back to
that date, on a multi-source account several rows per night for years.
The read window is now at most a year ending at `to` (or now). `from`
narrows it but never widens it, older nights are reached by moving
`to` back, and `meta.windowFrom` names the instant a cut window
started, so a client can tell a cut from an empty history.
The settings threshold editor took the identity path for the glucose
thresholds, so an account on mmol/L was shown a mg/dL default and asked
for mg/dL figures against 40-400 bounds, while the insights sheet for
the same threshold already spoke mmol/L. The shared target adapter now
owns the glucose unit as it owns pounds: fields, inward-rounded bounds,
the default hint and the canonical write all go through it, and the
insights panel and its sheet use the same branch instead of their own
conversion.
Refs #1067
The report's dense exception covered pulse and glucose. Heart-rate
variability and blood oxygen were read raw for any window, and a watch
or ring writing them every minute puts six figures of readings into a
long report that draws them as one sparkline.
Their report sections are a statistics row and a series, the same as
pulse, so they take the same rule: beyond 90 days per local day, and
inside 90 days per local day once the window holds more than 10 000 of
their readings. One grouped count decides each type on its own. Count,
minimum, maximum and the latest reading stay exact; the mean is the
readings' mean. Glucose keeps its own rule because its clinical panel
needs the per-reading counts.
The bucket query now takes a list of types instead of two flags.
The period narrative pinned kilograms and mg/dL on its change list, and
its personal-range lines carried no unit at all. Every delta and band
edge is now stated in the reader's units (computed canonically, converted
at the end, percent left unit-free), and each band transition carries
its unit into the prompt. The sleep channel, a per-night total in
minutes, was labelled hours and is now converted to them.
Refs #1067
The route test mocked Prisma and asserted a raw glucose findMany. The
glucose and resting-pulse inputs now come from their SQL readers, so the
test mocks those, feeds the pinned response through the in-memory fold,
and asserts that neither stream is read as rows.
The per-metric status note quoted glucose in mg/dL, body composition in
kg, temperatures in °C and walking distance in metres, whatever the
reader had chosen; the deterministic floor without a provider did the
same. The generator now converts the series, the reference band and the
signal into the reader's units before they reach the prompt or the
floor, and prints the guideline band table in that unit while still
classifying on the canonical value.
A changed glucose unit or metric/imperial preference (own account or a
managed record) now re-warms the stored notes and narratives through
the hash-gated refill, so today's text does not keep the old unit.
Refs #1067
The dense rule in /api/measurements/series covered pulse and glucose
only. Heart-rate variability and blood oxygen read every raw row for
any window up to 3650 days, and a watch or ring that writes them every
minute makes that the same unbounded read pulse had.
Both kinds now fold per local day beyond 90 days and per local hour
inside 90 days once the window holds more than 10 000 readings. A
bucket keeps the point shape of its kind: only pulse carries
valueMin / valueMax. Statistics stay computed over every reading, and
a sparse series inside 90 days is unchanged. The OpenAPI description
names the four kinds.
GET /api/cycle/insights read a year of raw readings for its outcome
channels, glucose and heart-rate variability included. On a sensor
account that is over 100 000 objects per request.
The crosstab uses each local day's sum and count after picking one
source and device per day, so the route now reads readSourceDayAggregates
like the coach's cycle snapshot does, and each row carries its reading
count into metricDayMap.
The integration test runs the route over the fold and over every raw
reading handed in as a one-reading group, and compares the answers.
Metric/imperial and the glucose unit were two separate lookups, so every
server-side text producer had to remember both and most remembered
neither. display-transform.ts now carries a glucose branch keyed by the
glucose preference (getReadingTransform), the metre-valued workout
quantities (distance, elevation, pace), and a resolver for the two
stored columns together. loadUnitPreferences reads them in one query and
useUnitDisplay hands the glucose unit out beside the metric/imperial
branch.
Refs #1067
The targets page read a year of raw glucose readings to keep the newest
one per meal context, and thirty days of raw heart rate to derive one
resting figure per day. A sensor reporting every five minutes puts
105 000 glucose readings in that year and a watch recording once a
minute puts 43 000 heart-rate readings in the month, and the page is
read on every visit from the web and the app.
Glucose now reads one row per context (DISTINCT ON) for the newest
value and one row per context and local day for the thirty-day mean
and the consistency strip; the strip takes day sums directly through
rollupConsistencyFromDays. Heart rate reaches the resting-pulse card
through readRestingPulseProxy, the SQL fold the insights page already
uses, merged per day with the native resting rows exactly as before.
The cards are unchanged: the integration test rebuilds them from the
raw readings through the in-memory folds and compares.
The clinician view printed bare numbers for every measurement group and
the per-context glucose rows in raw mg/dL, so a recipient read glucose
without a unit and in the wrong one for an account on mmol/L. The PDF's
measurement table had the same gap for the raw glucose series.
Both artefacts now take the unit and the displayed number from one helper
(doctor-report/stat-display.ts): SI for mass, length and temperature as
before, hours for sleep, and the record owner's unit for glucose.
Refs #1067
A restore clears every row of the account, tombstones included, inside one
long transaction under the account's exclusive restore lock. The nightly
purge deleted expired tombstones across accounts, one statement per batch,
without that lock: on an account under restore it waited on the restore's
row locks for as long as the restore ran, past its statement timeout, or
deadlocked with it once each side held rows the other needed.
Each batch is now deleted one account at a time, in a transaction that
first takes the restore lock in shared mode, the same order the
consolidation passes keep. An account under restore is refused at once,
kept out of the rest of the run, and purged the next night. Still one
delete per account per batch, never one per row. The job reports how many
accounts it deferred.
The integration test holds a stand-in restore on one account while the
purge runs: the purge returns without waiting, leaves that account alone
and purges the other. With the lock call removed it waits out the hold.
Refs #1031
The shared admission queue sent its jobs without an expiry, so every one
took pg-boss's fifteen minutes. Against pg-boss 12.34, a handler that runs
past its expiry is not stopped: the job is failed, its abort signal fires,
the worker and group slots are freed, and the retry of the same job starts
in the same worker beside the run it replaced. A per-minute heart-rate
history is thousands of pages, so on a slow link one import turned into up
to four copies of itself, on the one queue meant to run a single import at
a time.
- The admission job now carries a sixteen-hour expiry, sized for the
longest legitimate import and under pg-boss's 24-hour ceiling.
- Each run holds a session advisory lock on its identity (kind, provider,
account) on a connection of its own. A delivery that finds it taken does
nothing and says so in the log; the running import stamps its marker or
is offered again at the next boot. Ending the connection or the process
releases the lock.
- The Google Health backfill runs under the job's budget and abort signal.
The page walk asks before each request, stops there, and reports the run
truncated, so it throws without the completion stamp and is retried.
- The drain moves out of the registrar into its own module so it can be
driven against a real pg-boss.
The integration test pins the pg-boss behaviour and runs the lane's
handler with a two-second expiry: without the lock the import ran twice.
Refs #1023
A sensor reading every five minutes puts about 26 000 glucose readings in
a 90-day window, and the per-kind series sent every one of them raw. The
hourly bucketing that already applies to a dense heart-rate stream now
applies to glucose too, with the same 10 000-reading cap: the hour's mean
converted once to the user's unit, statistics over every raw reading.
A fingerstick meter stays far under the cap and keeps its raw points.
The doctor report keeps glucose raw inside 90 days. Its clinical panel is
computed from the raw readings of every source, while the day path works
from canonical-source day buckets, so the two do not give the same figures
when a meter and a sensor overlap.
The dashboard and capture-picker intake form filtered the medication list
on `active && trackIntake`, so a course that had ended (still active,
nobody paused it) was offered for a dose while the cards, the table and
"take all" already followed the server's `intakeActionable`. The picker
now renders from that one answer as well.
When medications exist but none offers a dose today, the empty state no
longer claims there are no medications; it says nothing is due and
points to the medication history for a past dose.
The restore dialog asked GET /api/admin/backups/{id}/summary for the
contents of a copy, and the route read the whole stored copy to answer:
every piece opened, every measurement parsed, the rest validated. On an
account of 1.8 million readings on slow hardware that took about two
minutes. The dialog stopped waiting after 15 s and said the contents
could not be read, and its automatic retry made the server read the
whole copy a second time.
The copy passes through the process once anyway while it is written, so
the preview is worked out there and kept on the row (DataBackup.preview,
migration 0364): the summarizeBackup counts, the schema version, and
the inner key ids with the sections they sit in and one sample value
each. Every creation path goes through storeBackupBlob (the weekly and
manual pass, and an upload), so every new copy carries one. The column
is plain JSON: counts and dates only, and the samples are ciphertext
that stands for what is inside the copy, which a key rotation must not
re-seal.
The route answers from it without opening the copy. Both verdicts are
taken again on every read, from what the preview keeps: whether this
release restores the schema version, and whether this server holds the
keys the copy needs. The pieces are still counted against the row, so
a copy that lost one is refused as the restore and the download refuse
it. A preview names the copy it describes and is ignored once the copy
changes; converting a single stored value into pieces carries it over.
A copy written before this has no preview. It is read whole once, as
before, and the result is kept on the row, so it is slow only the first
time; concurrent requests for the same copy share the one read.
Only a file that fails the schema answers 422 "Backup payload failed
schema validation". Read refusals keep their codes; anything else is
the server failing and answers 500.
The dialog no longer retries the preview, waits up to 55 s, and when it
stops waiting on an older copy it says the copy is still being read and
to open the dialog again in a few minutes.
Refs #1031
The dependency alerts flag development copies only: @grpc/grpc-js under the
test containers, brace-expansion on three major lines, and undici 7 next to
the app's own undici 8. None ships in the image.
A bridge that pushes workouts from a watch vendor had no credential that
reached POST /api/workouts/batch: the route called requireAuth() with no
scope, so under the fail-closed default only a cookie session or a
wildcard token got through, and a measurements:write token was refused
403. The route's header still described the pre-fail-closed behaviour.
Add a separate workouts:write scope rather than widening
measurements:write, so a token already handed to a scale does not start
accepting workouts.
- POST /api/tokens/workouts mints it, mirroring the measurement and
document mints: cookie session plus a fresh proof, a literal permission
array, 10 mints a minute, at most 10 live tokens (409
tokens.workouts.ceiling_reached), a one-year default lifetime, audited.
- POST /api/workouts/batch names the scope. Sessions and wildcard tokens
behave as before. A scoped caller's workouts are stored as EXTERNAL,
resolved from the credential, and a batch in which any entry names a
source is refused 422 (workout.batch.source_not_permitted), the same
rule the measurement batch applies. EXTERNAL is on no source ladder, so
when the phone recorded the same session the phone's copy is shown.
- Settings > API offers a workout token card next to the measurement one,
and the endpoint list shows the batch route.
- Guards: the mint site, the declared scope vocabulary, the recent-proof
routes, and a new check that only the batch declares the scope.
- OpenAPI documents the mint, the batch's narrow-token behaviour and both
error codes; docs/api/README.md lists the workout and document mints.
Refs #1054
The production audit flags a critical advisory against next 16.2.0 to 16.3.5,
fixed in 16.3.6, and newer advisories against the transitive fast-uri (ajv),
ip-address (the MCP SDK's rate limiter) and dompurify (jspdf) copies. Next is
pinned to 16.3.6 rather than the newest patch so the install stays inside the
minimum release age without new exclusions.
The review dialog silently dropped any selected reading that could not be
written (no date, no unit, no value) and saved the rest. When nothing was
left it showed "Select at least one reading to save", which described a
different situation and said nothing about what was missing.
A save is now all-or-nothing over the selected readings. When one is
incomplete, nothing is written; the dialog states how many readings need
attention, marks each missing field (aria-invalid, inline message, red
border, required marker on the label) and moves focus to the first one.
Marks appear after a save attempt and follow the edits. The "nothing
selected" message is kept for that case only.
Validation is a pure function next to the payload mapping, so the rule that
blocks a save and the rule that builds the payload cannot drift apart.
Adds EN and DE strings; the other locales are left to the maintainers.
Refs #1055
A restore on a freshly booted instance failed its clearing step with a
deadlock (40P01) while the boot-time step consolidation was folding the
same account. A consolidation day updates or mints the day's total and
then soft-deletes the day's per-sample rows; the restore deletes every
reading of the account in one statement. When the restore reached a
day's samples before its total, each side held what the other needed
next.
The restore already took an advisory lock per account before its first
delete. Every day transaction of the passes built on runConsolidation
(step and mean consolidation, the per-sample cumulative drain, the dense
intraday retention) now takes the same lock in shared mode as its first
statement. A restore that starts mid-day waits for that day to commit;
a day that would start while a restore runs is refused at once, and the
pass leaves that account for its next run instead of folding rows the
restore is about to replace. The passes still share the lock with each
other.
A new integration test builds the lock order that deadlocked, with a
row trigger holding the consolidation on the day's total while the
restore starts; before this change the consolidation was cancelled with
a deadlock. A guard test requires every transaction in a pass on
runConsolidation to open with the shared lock.
Refs #1031
Prisma's debug logger keeps the arguments of its last hundred calls in
memory whether or not logging is on, and the client and the Postgres
adapter both log every statement with its parameters. A restore that
wrote 1 000 readings per statement therefore kept a copy of about the
last 100 000 readings alive for the whole measurement write: about
60 MB on 1.8 million Apple Health readings, next to a V8 heap limit of
524 MB in the default 1 GB container.
The bulk insert now splits whatever it is handed into statements of at
most 100 rows, which holds about 7 MB. The Apple Health export import
goes through the same helper and gets the same bound. The write costs a
little time: 94 s instead of 76 s for 1.8 million readings on a fast
host.
Refs #1031
A type served from day buckets (pulse or glucose past 90 days, and since
the previous change a dense pulse stream on any window) is drawn one point
per day, each the day's mean at the day's start. The summary line counted
those points as measurements, and the FHIR export stamped the last day's
mean as the latest observation: a value and a time nobody measured.
The day-bucket stats now carry when their newest reading was taken
(`latestAt`). `latestReading` returns that reading when it is there, and
the summary line counts each type's readings from its stats. Raw series
are unchanged: their stats count equals their points and their last point
is the newest reading.
Past 90 days every pulse reader already folded in SQL. Inside it, three
still read every raw heart-rate row, which on a watch that records a
reading a minute is 130 000 rows per read (#1023):
- The insights page's resting-pulse proxy, used when an account has no
resting heart rate, walked the whole window to take one percentile per
day and held the event loop for over a second per view. It now folds in
Postgres (`readRestingPulseProxy`) with the same percentile, rounding,
minimum-sample rule and day anchor.
- The per-kind series sent every reading: 14.7 MB for 90 days. A pulse
window with more than 10 000 readings is now bucketed per local hour,
mean plus low/high band, with stats still over the readings.
- The doctor report drew every reading into one sparkline. A pulse window
over the same cap now takes the per-day path longer reports already use.
Sparse pulse (a cuff, hourly imports, a watch sampling every few minutes)
stays under the cap and keeps its raw shape.
A full-history backfill read every heart-rate point of an account into one
array, mapped it into a second, and wrote the lot in one call; the cycle's
deferred-rollup ledger then kept one entry per written reading. On a watch
that records a reading a minute that is over a million points, and the
backfill ran a 1 GB heap out of memory within seconds, restarting the
instance it shares with the app (#1023).
The metric walk now hands each page to its write before requesting the
next, and both the Google Health and Fitbit ledgers collapse to one key per
touched type-day as they record. The heart-rate walk's page ceiling rises
from 1000 to 10000: at one point a minute the old ceiling is under two
years, a longer history always ended truncated, and the backfill retried
the whole walk on every attempt and every boot without completing.
The clinical category a person picks for a medication lives in a side table
created at runtime, outside the schema. The backup builder and the restore
only walk schema models, so no backup ever carried it: after a restore every
medication read as "Other".
Both backup formats now carry the category inside its medication, and the
restore writes it back in the same transaction as the medication. A file
from before this change restores as before; a category this server does not
know reads as Other instead of refusing the file.
The backup plan gains a list for tables created with raw SQL, and a check
that every such table in the source either maps to a schema model or has a
verdict there, so the next one cannot drop out of backups unnoticed.
A week or month point of a cumulative metric is the average daily total,
which keeps the axis comparable with the 7, 30 and 90 day tabs. The
caption said "Weekly avg", which reads as an average week's total. Steps,
energy, distance, flights and daylight now say "Daily avg per week" or
"per month" on the caption and in the tooltip, in all seven languages;
level metrics keep the plain average.
The All range is read from the WEEK or MONTH rollup tier once a history is
long enough, from DAY rows for a short one, and from the live table on a
coverage miss. The chart folds every path into the average day of each
week or month, but a tier row carried a cumulative metric's whole total
and a level's reading-weighted mean. A month of steps therefore read about
thirty times higher for a long history than for a short one, under a
caption that said average.
A tier row now carries the average day of its bucket on every tier: a
cumulative metric's total over the days that hold a reading, a level's
mean of its daily means. The rollup fold counts the bucket's days and adds
up their means for it. Only the web chart's series reads (the chart
endpoint and the dashboard batch) use these rows.
The add and edit wizard had no row for the thyroid, digestive, skin and
sleep-aid categories, so such a medication opened on "Other" and saving any
change, even a new dose time, rewrote its category to OTHER. The wizard
now offers a row for every category the app knows, translated in all seven
languages.
An edit also sends the category and treatment class only when the category
row was changed. A stored value the wizard cannot show, such as one a newer
server or another client wrote, now survives an unrelated edit.
Antidepressants, anxiolytics, mood stabilisers and ADHD medication had no
category of their own and ended up under "Other". MENTAL_HEALTH joins the
clinical category list: the add and edit wizard offers it as its own row
with a brain icon, the card badge names it, and it is translated in all
seven languages.
The category is a text value, so no migration is needed. Clients that
read the category as a string show the new value; an app that does not
know it yet falls back to its own label for it.
The release branch alone measured 3699 KB gz against the 3700 cap; the
review fixes added 1.3 KB gz across the whole build, nearly all of it new
copy in the seven lazy locale catalogs. The four eager routes measured
the same on both builds and stay inside their ceilings. The aggregate cap
moves to 3724, the same headroom as the earlier re-measurements.
A medication whose end date had passed still showed taken and skip in the
table and on its card, because the action row only looked at whether the
medication was active. The schedule engine already stops minting doses
after the end date, so reminders, the doses card and the today list were
quiet, but the medication itself kept inviting a dose that is no longer
part of the course.
The server now resolves one rule and publishes it on the list and detail
reads: `courseStatus` (UPCOMING, CURRENT or ENDED, in calendar days on the
user's clock, both ends inclusive) and `intakeActionable` (active, intake
tracked, course includes today). The table and both card variants render
their actions from it, the take-all flow skips such a medication, the card
and table name an ended course with an "Ended" badge, and no overdue
escalation is raised for it. A course that ends today keeps its actions
for the whole local day.
The detail header's "Ended" status now follows the same field. It compared
the end date's UTC midnight with the clock and so called the whole last
course day ended. Logging a past dose from the history is unchanged.
A restore writes rows back with their original updatedAt, behind every
paired client's watermarks, and removes rows without leaving tombstones.
A client that kept its cursor would miss both on its next incremental
pull. The same holds after the person deletes all their data.
`users.sync_reset_at` (migration 0363) records when the record was last
replaced: the restore stamps it as its last write and again right after
the commit, the per-account and the admin wipe stamp it inside their
transactions. Cursors now carry the time they were issued, and
/api/sync/changes answers its existing `cursorExpired` to a cursor issued
before the reset, or one without an issue time on an account that has
one. The comparison is on the issue time, because after a restore a
freshly drained cursor still carries old watermarks.
The persisted copy of a streamed reply used to mount as a new bubble, so
what the reader had done on the streamed one (the chart or table view, the
open steps list, the evidence panel) reset when the conversation refetch
landed. A turn now keeps one key from its first streamed frame to its
persisted copy, both render in the same wrapper, and the copy keeps the
live tables instead of refetching them, so React keeps the one bubble.
The wrapper stops being a live region once the turn is settled. The
150 ms twin-hiding window this replaces is gone.
"As a table" used to drop the table's chart, so the reply offered no way
back to it. The copied table now keeps its chart and carries view: table,
which the panel opens on the table (the chart one tap away on the toggle)
and the chips read to offer "as a chart". show_result with view table
does the same. The field is optional on the result metadata; a client
that ignores it shows the chart first as before.
The Coach's recent nights carried the wake instant in UTC as their date,
while the sleep table under the answer keys a night by the local day it
ends on. East of UTC the two named different days for the same night.
The snapshot now gives the night's own key.
The recheck and follow-up rules now ride the tool-mode prompt only once
the conversation holds a table, or from the round after the turn produced
its first one. The clarifying-question rules are shorter and English for
every locale, like the other instructions to the model; the German copy
of them and of the recheck rule is gone. A turn without tables sends
about 1 300 characters less on every round.
The golden transcripts change only in the system prompt's hash and
length (4 509 to 3 187 characters; 4 728 to 3 280 for the German turn).
Table refs, step ids, chip ids, a chip's anchor ref, clarification choice
ids and a reused table's source are now validated against their exact
server-minted patterns when a stored reply is read back. Some of them are
written into the model's context on a later turn (the earlier-tables list,
the "keep looking" fetched list), so a stored row edited by hand can no
longer carry text into a prompt through them.
A restore now leaves out measurement tombstones deleted longer ago than
the tombstone retention. The nightly purge removes exactly those rows,
the delta feed answers cursorExpired for every cursor that could still
be served one, and the consolidation repairs look only inside the same
horizon. Younger tombstones keep their readers (a lagging paired client,
the undo of a recent delete, the repairs, the duplicate guard on
re-uploaded samples) and are written as before. The backup file is
unchanged. On a seeded account of 1.89 million readings whose 1.68
million tombstones were past retention, the restore on a slowed database
took 5.4 min instead of 15.
The rollup fold after the commit rebuilt five years of buckets under the
60 s request statement limit and could be dropped on a slow disk, leaving
the charts empty until the next boot. `recomputeUserRollups` takes a
`statementTimeoutMs` that scopes each aggregate and persist transaction
with SET LOCAL, and the restore passes its after-commit allowance.
Fixture tombstones in two round-trip tests were dated in July 2026 and
had aged past the retention; they are now relative to the run.
The test diffed the shared system tmpdir before and after the extraction,
and other test files extracting in parallel stage their files there too,
so it failed on their files rather than its own. TMPDIR now points at a
directory of the test's own for the call.
show_result now passes the same scope gate a fresh read does before it
decrypts a stored table: a metric the person has since excluded from the
Coach, or one outside the conversation's scope, answers
unavailable_in_scope and nothing is sent. The list of earlier tables in
the context drops those metrics too, so the model is not told they exist.
The exclusion (the person's own list plus switched-off modules, and no
adherence when medications are excluded) is the snapshot builder's, now
one exported helper both paths use.
"Keep looking" no longer copies the unfinished question into the
server-written context lines. The question stays in the transcript as the
person's own turn; the line only points at it. Lifted into the system
role, whatever the question said would have carried the system's
authority.
A turn now reads its conversation's newest messages once and shares the
rows between the chip, clarification, "keep looking" and repeat-question
checks, instead of three identical reads.
Earlier tables keep their m<k> names once a conversation outgrows the
window a turn loads: the index counts the assistant messages before the
window too, so a name the model read on an earlier turn still points at
the same table.
The backup validation now reads the per-message table ceiling from
results/refs, which imports nothing but types. Reading it from
results/project pulled the tool executor and the message store into the
snapshot builder's import graph, and the free-text sanitiser guard then
walked files that never build a prompt.
A restore is refused before it deletes anything when its transaction limit
would run past three quarters of the job's expiry. At two hours that refused
a same-size replacement of about four million readings. The expiry is now six
hours, which admits ten million over ten million; a dead worker still frees
the account within minutes through the heartbeat, so the longer expiry does
not hold an account longer.
The refusal now names both counts, the readings in the backup and the ones the
account holds now (removed readings included until the nightly clean-up),
since clearing the latter costs time too.
The server offers no chips under a clarifying question. The second
streamed turn now carries the card alone, and axe runs once with the
chips under the first answer and once with the card.
run-case.ts now calls a provider for the live scenario run, and judge.ts
reads COACH_EVAL_SCENARIO_REPEATS. Both belong to the offline evaluation
harness, like the judge and its model knobs.
A turn never stores more than 128 KB of table JSON, but a restore accepted
any size in resultsJson or resultsEncrypted and sealed it into the database.
Both are now bounded: the readable form by its UTF-8 size, the sealed form at
twice that, above the ciphertext's measured expansion. A file this release
writes always fits; a larger value is refused like any other malformed field.
The grounding ledger derives differences only between figures of one
payload, so a correct "up 4 mmHg on last week" drawn from two separate
table reads was withheld as unverified. A table of the period before or of
a year ago now reads the current window too, and its summary carries the
current figures and the change (delta and percent change) beside the
earlier ones. A wrong delta is still flagged.
The live evaluation now captures the tool calls a generation makes, with
their arguments, and hands them to the judge. runScenarioLive puts a
scenario to the model in the chat turn's own context (system prompt,
tool-mode and dialog addenda, DATA INVENTORY, EARLIER TABLES, transcript)
over the real tool catalogue, answers each call with a stand-in result,
and reads the reply through the turn's clarification and chip parsers.
runScenarioJudge grades each scenario a few times with the same
evaluateScenario the deterministic suite uses and reports a pass rate per
scenario; the server-only scenarios and checks are left out.
Like the existing judge it runs only with COACH_EVAL_API_KEY, from the
nightly workflow's non-blocking step, and never throws.
A tapped follow-up chip goes away and a clarification choice disables as
the turn starts, which dropped focus to the page. Both now hand focus to
the composer, which stays focusable while a reply streams (read-only with
aria-disabled rather than disabled). The clarification question is
announced through a live region that is mounted before the question
arrives; the card itself no longer carries one.
The encryption backfill now also removes the two copies of a practitioner's
contact details that outlived the sealing. An appointment reminder's copy of
the address is cleared, since the reminder resolves it from the practitioner
when it fires. An address-book edit's audit row written before the sealing
kept the old phone number and address in its previous values; those values
leave the row and the changed fields stay named. Rows of a deleted account,
which no per-account job reaches, are scrubbed by the discovery pass itself.
A route whose readable geometry is a JSON null rather than SQL NULL had
nothing to seal but matched discovery every night. The pass now sets it to
SQL NULL so it drops out.
Sealing the practitioner's address left the appointment arm reading the old
readable column, which is now empty, so a booked visit's push lost the address
on booking, rescheduling and restoring a deleted visit.
The reminder no longer keeps its own copy of the address. The reminder tick
resolves it from the owning visit's practitioner when it builds the push and
decrypts it there, so an edit of the address also reaches a nudge that is
already booked. MeasurementReminder.location stays NULL on appointment rows:
booking writes none, a re-anchor clears one an earlier release wrote, and a
backup neither carries nor restores one. A checkup's own free-text location is
unchanged.
A withheld table now leaves a line saying why (module off, or the stored
tables could not be read) and a failed read shows an error row with a
retry, so the Data used count matches what is listed. A table copied from
an earlier answer names that answer's date under its title. The preview
shows the latest twelve periods of a time series, and the truncation note
says the latest rows are kept, which is what the server keeps. The title,
copy and view controls sit above the scrolling table with the title kept
as a hidden caption, and a reply with tables takes the full column width.
The step headers and the related-metric chip read as label: value in all
seven locales, so a metric name no longer sits capitalised mid-sentence.
The "compare with the period before" chip now needs a first reading
before the window's start, the same history check the other history chips
use, and is never offered on an all-time table, which has no period
before it: the table tool reads all time as the current period whatever
it is asked.
Tables are now projected outside the provider's failure path: a
projection defect costs the turn its tables and is annotated, instead of
refunding tokens already billed and dropping the reply.
The at-rest ceiling for a message's tables is applied once, when the
dialog is assembled, before anything streams. A table trimmed only at
write time used to show live and read back as withheld after a reload.
Ten scenarios, each in German and English, describe what a turn must do
with a question: which tool it calls and with which arguments, whether it
may read anything new, whether it asks back, declines a dose, offers
chips only for what the record holds, answers a chip without a provider,
offers to keep looking after a forced answer, and keeps an earlier
table's title out of the model's context. evaluateScenario grades a run
against them.
The deterministic suite drives POST /api/insights/chat with a scripted
provider; the tool loop, executor, table tool, chips, clarification
filter, method line, reply guards and stream run for real. Every check
also runs against a misbehaving model or a broken record and must fail
there.
route-followups.test.ts pins the front door for chips and answered
questions: validation, the rate limit and capability gate on a reuse
turn, the stored chip winning over anything the request adds, stale
chips and answers degrading to a plain message, and the continue line.
A total's weeks and months are now the sum of its days, labelled as a
total, and a level's stay a mean. All time is read as UTC calendar months
from the rollup tier's MONTH fold, or the same fold of the live table when
the tier has no rows, so a month no longer reads as a mean of daily totals
for a short history and a monthly total for a long one. Months are keyed
by their UTC start instead of being re-read in the user's zone, which
named every month one early west of UTC.
The chart's long ranges had the same shift: tier rows are now stamped at
noon UTC of the bucket's first day, which falls inside the bucket's own
week and month in every zone.
Step length and walking speed keep two decimals, loud-sound events count
as a total in whole events, and HRV counts a night measured by both
estimators once and charts the estimator the person mostly has. The model
summary names what a period's value is and gives a total's range total.
Three streamed turns against a stubbed chat stream: steps under the answer, a result table with its chart/table toggle and copy menu, a follow-up chip and a clarification choice that each send which one was tapped, and axe over the answer, chips and card in both themes.
The outbound screen matched "Erhöhe auf 2,4 mg" but not "Erhöhe deine
Dosis auf 2,4 mg": the German change verbs had no slot for an object
between the verb and the target, unlike the English "increase your dose
to". The raise and lower patterns now accept one.
Asking for an earlier table "as a chart" went through show_result, which
builds the requested view, but collecting the turn's tables then reset
every chart to null and only derived a new one for fresh tables. The table
came back without its chart. A table shown again now keeps the view it was
given.
A damaged or foreign-key route ciphertext threw out of readRouteGeometry, so
one bad track answered the workout detail with a 500 and failed the activity
insight job. It now reads as no track with a warning on the request event, and
still never falls back to the readable column.
The line and bar charts under a Coach reply let recharts place the y-axis
ticks over an auto domain, and after thinning the ones that would collide
a blood pressure line could read 75, 90, 105, 135. The chart now computes
a nice domain and hands explicit ticks one step apart (1, 2, 2.5 or 5
times a power of ten); bars keep their zero baseline.
A table the answer referenced opens under the prose as its chart; the
others sit under Data used in the evidence disclosure and open as
tables. The plot is one image whose name points at the table view, two
series carry legend buttons, and the chart loads through the shared
chart runtime. A metric chart token for a metric a shown table already
covers is dropped.
When the tool loop forces an answer at its round cap, the reply leads
its chips with "Keep looking". Tapping it runs a normal turn, under the
budget gate as ever, with two server-written context lines: the question
that was left unfinished (at most 1000 characters) and what the forced
turn already read, each table by the name show_result takes, so the
continued turn shows those again instead of fetching them twice.
An answer is continued at most once. The continued reply records the
message it continues in metricSource.continuationOf and offers no
further "Keep looking", even when it is forced again; a chip on a reply
that is itself a continuation is refused.
Up to three chips, derived on the server from what the turn read: the
other view of a table with a chart, the period before, a year earlier,
a wider window, and a metric a discovered pattern links to. A chip is
offered only when its read was done this turn and the record holds the
metric; the year-ago and wider-window chips also need the record's
first reading to reach back that far. The model may name chips in a
FOLLOWUPS block, but only to reorder ones the rules already allow; every
label is rendered from the catalog.
A tapped chip resolves against the chips stored on the conversation's
latest reply. "As a chart" and "as a table" are answered from the
stored table without a model call or a budget reservation: the rows are
copied unchanged and the message is tagged providerType "reuse". Other
chips run a normal turn with a server-written context line. A chip that
is no longer current falls back to a plain message.
A setting turns the chips off (absent means on).
A table over time is drawn as a line for a level metric and as bars
for a total, with at most two series in one unit. Category counts get
bars, horizontal above six categories, the top eight and one other.
Fewer than two or more than 400 rows, or no numeric column, stay table
only. show_result's view now counts: table drops the chart, chart turns
an earlier day table into counts per range with a histogram (fixed
widths for the common metrics, Freedman-Diaconis otherwise, none below
ten values).
The line that resolves an answered clarifying question was already
appended to the system prompt on both paths; nothing proved it for the
no-tools (local model) path. Three route-level cases now do: the local
prompt and the tool-mode prompt both carry the server-resolved line, and
a turn without an answer carries neither. Removing the line from the
local prompt fails the first case.
The golden harness gains a coachMessage.findMany mock for this; it
defaults to no rows, so no existing transcript changes and the golden
snapshots are untouched.
The result-table caption test used the regex dotAll flag, which the
TypeScript target rejects (TS1501); it matches across lines with
[\s\S] instead.
The method line merged a repeated read by keeping the larger count. With
show_result that could let an earlier answer's count stand over the read
this turn just made. A fresh read of the same source, window and period
now always wins, whichever order the steps ran in, and the two are never
added. An entry that rests only on an earlier table says so ("from an
earlier answer"), including a reused table no step accounts for.
The new catalog string is in all seven locales.
get_metric_table now gets a step like every other read: the domain is
its validated metric, and once the table is in, the window, period and
granularity are the ones the table was built with (after the executor's
defaults). The count is the readings the table folds, taken from the
summary the server computed, so a read past the sixth table still counts.
A step whose call produced a table now names it (resultRef), for the
projected tables of the older tools as well.
show_result gets its own step. It reads nothing new: while it runs it
names no domain, and once the stored table is found it takes the source
of the read that built it and the name the copy carries in this turn. A
table that could not be shown is a failed step with the reason, never an
empty read.
The property test now drives show_result too and checks resultRef; the
coverage test gives each tool the smallest arguments it accepts instead
of passing empty arguments to a tool that requires a metric.
The table shows its caption, scoped headers, right-aligned tabular
numbers and a dash for a missing reading, twelve rows before Show all.
Copy writes TSV and an HTML table together, falling back to TSV, with
formula starts neutralised and separators flattened inside cells.
get_metric_table reads one metric as days, weeks or months through the
chart reader, cut in the user's zone and collapsed to one source per day,
with null for a period without a reading. The model reads a bounded
summary; the full table rides the turn, encrypted at rest. show_result
shows a table an earlier reply of the same conversation fetched, resolved
only against that conversation's own messages. Workouts, labs, sleep and
adherence results project into tables, earlier tables are named in the
context by metadata alone, and result:rN marks are stripped from the reply.
The small chevron above the first nav entry becomes a full footer row that
heads the utility group: directly above Admin for an administrator, directly
above Settings for everyone else. PanelLeftClose / PanelLeftOpen mark the two
states; the label reads Collapse while expanded and stays for screen readers
only in the icon rail, where the tooltip names the action. aria-label switches
between collapse and expand; the stored choice keeps its storage key.
The width transition now honours reduced motion. Adds the expand and
sidebar-action labels in all seven locales.
The evidence disclosure under a Coach answer now carries one muted
line: which sources and windows the turn read, how many readings, and
how they were aggregated ("Blood pressure, last 90 days: 142 readings,
weekly averages · Sleep, last 30 days: no readings in this window").
The server builds it from the recorded steps and tables in the request
locale; it holds catalog strings and counts only, never a reading, and
a source that turned up nothing says so.
The tool-mode prompt gains a recheck rule: when the person questions a
figure, name the source and window, read it again (or reuse the stored
table), and correct the answer if the two differ.
While a turn runs, the header names the read in progress with a soft
shimmer; when it ends the list folds into "Looked at N sources". Each
row reads domain, window, and the count or the reason nothing was
found. Completed steps are announced through a polite status region at
most once every 1.5 s, and a reloaded conversation stays quiet. The
spinner and shimmer stop under reduced motion.
Each tool call now emits a running step as it starts and its final
status as it settles: found with a server-counted number of readings,
empty with the reason, or failed. The no-tools path shows one step for
the record summary. Steps carry catalog labels rendered in the request
language, closed-enum domains and windows, and integers only; a lab
analyte, a value or a model-written argument never reaches one. At most
twelve per turn. They persist on the provenance, so a reload shows the
same list.
The conversation window reaches the step so a call without a window
names the one it actually read. The no-tools transcripts gain the
summary step; a new transcript pins found, empty and failed steps ahead
of the first token.
A reply may end with a ---CLARIFY--- block (kind metric, window or
context, 512-byte cap). The server keeps only metric choices the record
holds, drops the question when fewer than two remain, takes window
choices from the presets, screens the question text like any reply, and
never offers a second question right after one. Choice labels come from
the catalog, never from the model.
An answer arrives as clarification { messageId, choiceId? }; it resolves
against the stored question on the conversation's latest message and
adds one server-written line to the next turn's context. The card sits
above the composer, announces politely and takes no focus.
A reply that is only the block, with no question before it, is now an
empty reply instead of falling back to the unstripped text.
A restore of an account with 1.9 million readings, most of them deleted
by the nightly consolidation and still awaiting the purge, was cancelled
in its clearing step: `DELETE FROM measurements` ran past the 60 s
statement timeout every pooled connection carries, and the whole restore
rolled back with a message that named no cause (#1031).
Most of that statement was not the delete. Two columns point into
`measurements` with ON DELETE SET NULL and had no index, so Postgres
scanned `personal_records` and `ecg_recordings` once per deleted reading:
38 of 40 s on a seeded account with 300 personal records. Migration 0362
indexes both, which also speeds up the tombstone purge and account
deletion.
The restore transaction now sets its own statement and idle-in-transaction
limits to the transaction budget with SET LOCAL, and the budget counts
the rows the account holds as well as the rows the file carries. A
rolled-back restore names what the database refused it over (timeout,
lock, constraint, storage, connection, other) on the job row, the
console, the audit row and the wide event.
The streaming hook handles the step, result, followUps and clarification
frames and sends followUp and clarification on the tool route only. The
bubble gains slots for the step list, the tables and the method line; the
thread offers chips under the latest assistant turn only; the conversation
shows the clarification card above the composer and marks a typed answer
as answering it. The tables of a persisted message load lazily when it
scrolls into view, seeded from the stream for the turn that produced them.
The new components render nothing yet.
Adds every string the dialog needs up front: step labels, table titles,
columns and controls, the method line, the follow-up chips and their
setting, the clarification card, and the caption of an answer reused from
a stored table. A key catalog shared by server and client names each one;
a test holds the catalog and the bundles equal in both directions.
GET /api/insights/chat/{id}/messages/{messageId}/results returns the
tables one assistant message read, narrowed to the conversation's owner
like the conversation read, with a table withheld as module_disabled when
its domain's module is off and as unavailable when it cannot be read. A
data read: no AI capability.
The published document now describes every stream frame as the
CoachStreamEvent union, including the suggestedAction frame it never
named, the new step, result, followUps and clarification frames, their
components, the provenance fields, the followUp and clarification request
fields, and the reuse provider type.
Each later piece of the dialog gets its own module, answering nothing for
now: the step builder, the table projection and chart choice, the result
ref marks, the follow-up derivation, sentinel, resolution and continue
chip, the reuse turn, the clarification parse and answer, the method line,
and the four prompt addenda. The turn pipeline calls them where they
belong, so the frames go out in the documented order (step, token,
provenance, result, cards, clarification, followUps, done) and a blocked
turn drops its tables, chips and clarification. With every piece empty
the stream and the prompt stay byte for byte as before.
The retrieval loop takes onCallStart and onCallSettled callbacks, fired
for every call with a turn-wide index; a throwing callback is swallowed.
The trace carries each call's schema-validated arguments (never the raw
string, never persisted), the result says whether the answer was forced
at the round cap, and a seedCalls parameter is accepted but not read yet.
The home location of the environment module was rounded to 2 decimals,
about 1 km, which places a home in a neighbourhood rather than the town the
schema comment promised. The weather archive it feeds is gridded at 9 to
25 km, so the extra precision bought nothing.
Every stored coordinate of the module now goes through the same 1-decimal
rounding (about 11 km north-south, 7 km east-west at 50° N): the home, the
travel periods, the geocoder's results, the per-day weather rows, and a
restore of a file written with finer values. Migration 0361 rounds the
existing users, travel periods and per-day rows; running it twice changes
nothing more. The answers keep their shape, plain numbers. The API
descriptions and the schema comment say 1 decimal now.
A new nullable column, coach_messages.results_encrypted, holds the tables
of values a turn read as AES-GCM ciphertext; only their metadata rides the
plaintext provenance. readMessageResults serves them to the owner, each
table whole or withheld with a reason. The provenance restore learns the
steps, method, table metadata, chips, clarification and the forced-answer
marker, entry by entry against the wire schema.
The column is registered for key rotation, carried through both backup
formats (ciphertext in a disaster-recovery file, readable JSON in a
portable one) and goes with its row on conversation delete and wipe.
Adds the TypeScript types for the four additive stream frames and their
provenance fields, a Zod mirror for the published document held equal to
the types by a type test, and a guard that no newer frame reuses a key the
shipped native client decodes. The chat request accepts followUp and
clarification, inert for now.
The practitioner entry of the backup payload gains the optional
locationEncrypted and phoneEncrypted fields. The rotation script names each
new column once, at its call, so a dropped call is noticed rather than
covered by a comment.
The route keeps the front door: auth, the coach capability, the body
read and schema, the rate limit, the locale and the inbound refusal
screen. Everything after it moves into one module per step:
conversation, context, chain, budget, model, reply-guards, cards,
provenance, persist, sse, errors and pipeline. The capability re-check
at the egress site stays owned by the route and is handed to the
pipeline, so the capability inventory still reads both calls there.
The stream producer now writes through a TurnEmitter created inside
createSseStream and passed to the model step, which is where live
progress frames will come from. Nothing writes to it early yet.
No behaviour change: the golden transcripts pass byte-identical, the
existing route tests pass unmodified, and every annotate, audit and
error-code name appears as often as before.
Guards follow the code. The fence call-site check reads turn/context.ts,
the German-default locale ban covers every file in turn/, and the
fenced module-graph test now asserts that turn/sse.ts and turn/errors.ts
reach neither the tool registry nor the snapshot builder. That test
named lib/ai/coach/tools.ts, which stopped existing when the registry
became a directory, so its negative check could not fail; it points at
tools/index.ts now, and a new sanity check fails if the tool route can
no longer reach the path it names.
The GPS track of an outdoor workout was stored readable, and its first and
last points are usually the person's front door. The phone number and address
in the address book of doctors and practices were readable too, while the
note beside them was already encrypted.
Migration 0361 adds workout_routes.geometry_encrypted (the geometry loses
its NOT NULL) and practitioners.phone_encrypted / location_encrypted, plus
partial indexes on the backfill's discovery predicates. A track is sealed as
one binary AES-256-GCM value bound to its purpose; the contact fields use the
same Bytes codec as the note. The practitioner's name, practice and specialty
stay readable, because the picker searches and sorts on them.
Every writer stores ciphertext only: workout batch ingest, practitioner
create and edit, and both restore formats. Readers prefer the ciphertext and
fall back to the old column for rows not yet migrated: the workout detail
(map, splits, GPX), the activity insight's climb figure, the practitioner
DTO behind the address book, visits and vaccinations. The wire shape does not
change. An edit's audit row now names a changed phone number or address
without recording either value.
free-text-encryption-backfill covers the three columns in the same pass,
finding a route through its workout. All three are registered for key
rotation (registry, CLI script, and the in-app rotation). A disaster-recovery
backup carries the contact fields as ciphertext only; a portable export
carries them readable. Workout routes are still not part of a backup, as
before. The old columns are marked for removal in a later release.
Drive POST /api/insights/chat through the real SSE stream and the real
reply guards for every branch a turn can take: tool mode with present,
drifted and missing figures, the no-tools path, inbound and replayed
refusals, the budget refusal, each provider failure class, a cancelled
turn, the outbound block, every sentinel in its valid and malformed
forms, learn-link scrubbing, the three no-provider paths, a workout
scope, the post-call defect path, and the refusals that answer before
the stream opens.
Each transcript records the frames the client receives (heartbeat
comments dropped) and the ordered side effects: persisted messages,
budget reserve and reconcile, audit rows, annotate names with their
meta keys, and the provider inputs as prompt fingerprints.
The delegated-writes spec checked for the invitation form's level control
right after page load, before the form had painted, found nothing and
skipped every test on every run, CI included.
- Give the journey its own account pair, seeded and reset in global setup,
so it no longer invites the same pair as the account-sharing journey
(a second live grant between two accounts is refused with 409).
- Wait for the form through its data-slot and fail when the WRITE level
control is missing instead of skipping.
- Revoke the journey's grant in afterAll.
- Keep it out of the mobile project in the config rather than through a
runtime skip.
Add two checks so a skip cannot hide a spec again:
- e2e-conditional-skip-guard.test.ts requires every conditional
test.skip/test.fixme in e2e/ to carry a reason and refuses a bare
test.skip().
- scripts/check-e2e-skipped-specs.mjs reads the Playwright JSON reports of
both shards in the e2e gate job and fails when a spec file skipped all of
its tests across every project, apart from an allowlist with reasons.
An admin page stacks independent cards, and a card that threw while
rendering replaced the whole page, so a field missing from the off-host
card's payload hid the snapshot table beside it. Every card in an admin
section now sits in its own boundary and fails as an error card with a
retry that remounts it; the rest of the page keeps working.
The invitation journeys, the backup journey (archive, snapshot download
and restores) and the delegated doctor report acted on jars signed in once
in global setup, so they passed or met the re-proof gate depending on how
far into the run they started. Each now acts from a session of its own,
made stale before every gated press, and answers the dialog with the
password; the delegated report asserts the 401 before re-proving over the
wire. The helper gains expireRecentProof, reproveWithPassword and
actWithReproof.
The off-host card reads pendingDeletions and lifecycle, which the route
always sends. The mock predated them, so the card threw and the page never
painted its rows. The mock now carries a refused deletion and a bucket
without an expiry rule, so the scan reaches both new lines.
A report for a past window lists a medication only if its course had begun
by the window's end. The fixture's drug was entered on the day of the run,
so it was rightly absent from a report for last year and the control that
expected it failed. The course now starts before that window. Generating the
report also meets the re-proof dialog from a stale session every time,
rather than only when the run has been going for five minutes.
Connecting an assistant and making a share link ask for a sign-in or
re-proof within five minutes. The shared jar is signed in once in global
setup, so the specs passed early in a run and failed later. They now start
from a session of their own signed in ten minutes ago: the consent page
withholds Allow and links to the confirm page, where the password re-proof
returns to the consent request; the share form opens the re-proof dialog
and creates the link after the password is given.
The address is what single sign-on links an existing account by, and
changing it took the password or a session younger than five minutes on
every account. A remembered browser skips the second factor at sign-in and
still makes a fresh session, so the password alone could move the address
of an account the second factor was meant to protect.
On an account with a second factor only a second factor or passkey
completed in the last five minutes counts now; the password is not
checked. The refusal names it (second_factor_required in rejectedFields,
or 401 profile.update.emailSecondFactorRequired when the address was all
that changed), and the profile form opens the re-proof dialog with the
factors the account holds, then saves again, no longer offering the
password field. Accounts without a second factor keep the password or a
recent sign-in. A token cannot change the address of such an account;
the app does not change it.
The purge requests and the off-host key-use ledger are not keyed to a
user, so truncating users never reached them. A file that reads them
whole, the purge job or the encryption status view, saw rows earlier
files had left and failed in a full serial run.
With TRUST_PROXY_HOPS=0 behind a proxy that sends only X-Real-IP, every
client address resolves to nothing and all anonymous sign-ins share one
rate-limit bucket, with nothing in the log to say why. The first request
that carries a forwarding header the setting does not let HealthLog read
now logs one warning naming the header and the setting to change.
X-Real-IP stood in for a missing X-Forwarded-For at any hop count. It is a
single address, so it is now read only with TRUST_PROXY_HOPS=1; with two
or more hops the chain the count describes is X-Forwarded-For.
The upload, import and native batch routes that bypass the proxy now carry
Cross-Origin-Resource-Policy and Cross-Origin-Embedder-Policy like every
other response, and so do the proxy's early exits.
The env examples described an unset TRUST_PROXY_HOPS as right for a
directly exposed host; unset means one hop, and that host wants 0.
The per-account password limit refused every attempt once ten failures
had landed in fifteen minutes, counted before the password was checked.
Ten guesses from two addresses therefore locked a password-only account,
the app's password sign-in included, and repeating them kept it locked
for as long as anyone cared to.
A place the account has signed in from before is now neither counted nor
held back: a remembered browser, a phone presenting the device id a login
of this account was issued to, or an address a session or device login of
this account came from in the last thirty days. Anywhere else gets five
free failures a day and then a wait that doubles with each failure, from
thirty seconds to at most fifteen minutes, instead of a wall. The per-IP
limit is unchanged, and an unknown identifier is limited the same way on
its hash.
Under an SSO-only policy, a password sign-in to an account other than the
one a pending link names is now refused before the password is checked,
so that exception no longer tells right passwords from wrong ones.
Allowing an AI assistant hands it a standing credential to the record
that outlives the session, the same class of act as minting a token, but
the consent took a live session alone. A stolen session could connect
its own assistant and keep reading after the owner signed everything out.
The consent page now shows the Allow form only when the session signed in
or re-proved within five minutes (with the second factor on an account
that has one). Otherwise it links to a new confirm page that runs the
same re-proof dialog as the rest of the app and returns to the exact
consent request. The allow decision checks again and mints nothing
without it; deny needs no proof. The confirm page only ever returns to
the consent endpoint.
Offering access to the record took a live session alone, and an
invitation outlives the session that sent it: whoever held a stolen
session could invite their own account and keep a way into the record
after the owner signed everything out. POST /api/account/grants now takes
the recent-proof gate at every level (a sign-in or re-proof within five
minutes on the browser, with the second factor on an account that has one;
a step-up elevation on a token, which no shipped client uses here), and
the invite form opens the re-proof dialog and sends again.
Sign out everywhere now withdraws every invitation the account offered
that nobody has accepted. Accepted grants are left standing on purpose:
each is a person the owner chose, often a carer who still needs the
record, and a sign-out button must not quietly cut them off. The response
lists them as grantsKept and the sessions card shows each with a one-click
End access.
The staging sweep removed any staged upload older than seven hours. An
import can wait in the queue behind another account's import for longer
than that, and when its turn came the file was gone and the import failed.
The sweep now keeps every upload a queued or running import still names,
and the extracted XML while an import is past the queue. When the queue
cannot be read, it removes nothing that tick.
GET /api/export with type=all (the default), POST /api/export/health-record
and GET /api/fhir/Patient/$everything hand over the whole record but took a
live session alone, so a stolen browser session could still walk off with
everything the full-backup gate was meant to protect. A browser session now
needs a sign-in or re-proof within five minutes, with the second factor on
an account that has one, and the doctor-report panel opens the re-proof
dialog and retries. When a manager builds the report for a record they
manage, their own session is the one asked.
On a token the shipped app keeps working: health-record and $everything
take the token as before, including a fhir:read token, and the recent-proof
guard names both. GET /api/export?type=all, which no shipped client calls
with a token, wants a step-up elevation there. Single-type exports are
unchanged.
The API reference now also describes the enrollment rule for accounts
with a second factor and the passkey refusal it adds.
A reminder's first-due date could only be sent as a date-time, and the
web form sent the browser's local midnight, so a browser set to a zone
other than the profile's could move the day. The API now also takes a
bare calendar date and stores it as that day's midnight in the user's
zone, and the web form sends the date itself. A date-time still means the
instant it names.
Since a snooze holds only the slots whose window had opened when it was
set, a snooze set ahead of a dose, such as "remind me later" in the app
before the dose is due, held nothing and the reminder came anyway. A
snooze set while no window of the medication is open now holds the next
slot to open. One set while a window is open still holds only that slot,
so a morning skip that snoozes to the end of the day leaves the evening
dose reminded.
On an account with a second factor, TOTP setup, security-key registration
and passkey registration no longer accept the password, a password re-proof
or a session younger than five minutes. A remembered browser skips the
second factor at sign-in and still creates a fresh session, so the password
alone could add a key that then satisfies every gate the factor protected.
Only a TOTP code, a security key, a passkey or a completed second factor
within five minutes opens enrollment there; the Bearer arm of TOTP setup and
security-key registration asks the same of its elevation. Accounts without
a second factor keep the previous rules.
The refusal names the proofs the account can give, and the enrollment
dialogs offer exactly those. A proof that verifies now gives its attempt
back to the shared re-proof budget, including the current password on a
password change.
FREQ=MONTHLY;BYDAY=MO means every Monday of the month, but the runway
counted it as one dose a month, so a Monday medication's supply read about
four times longer than it lasts and its low-supply notice came late. A
weekday without an ordinal now counts as every such day in the month
(yearly rules per BYMONTH month), a weekday with one (1MO, -1FR) as one
day, and BYSETPOS as the number of positions it picks.
Since the supply runway stopped counting an as-needed schedule as a daily
dose, a medication taken only as needed had no runway and never raised a
low-supply notice. It now warns once when two doses or fewer are left,
with its own line naming what is left, and re-arms after a refill like any
other medication.
A medication's start and end dates are calendar dates, but the report
compared them against the window's instants. West of UTC the window
begins hours after UTC midnight, so a course that ended on the first day
was left out, and one that started the day after the window was listed.
Both now compare against the window's first and last day in the report
zone.
The granularity router read only the coarsest tier the window cleared, and
a coarse bucket that starts before the window is left out. Data that sat
only in that leading partial bucket, such as three months at the start of
a 731-day window, read as no data at all. The router now steps one tier
finer until a tier has buckets, down to DAY.
A data wipe during the nightly upload could leave the pre-wipe copy in
the bucket: the purge the wipe started removed its request at once, and
the upload still in flight checked that request after finishing and found
nothing. A wipe's request now stays until a nightly run has walked the
account since the wipe. Until then each purge removes only the copies
dated up to the wipe's day, so the copy of what the account holds after
the wipe stays, and the admin card no longer counts the waiting request
as copies still in the bucket.
A re-import that moved a reading to another instant re-folded only the day
it moved to, so the day it left kept the reading in its rollups. And a
write chunk that failed part-way ended the request before the re-fold ran;
a retry then read the landed rows as duplicates and touched nothing, so
those days kept their pre-import rollups for good. The re-fold now covers
the old instant of a moved row and runs whether or not the writes finish.
The current half of a cumulative metric's range delta counted today's
still-growing total, so the comparison read lower every morning. Both
halves now end on the last completed day: yesterday on the user's
calendar, and never a UTC day bucket that has not ended yet.
Making `=true` grant nothing would stop a single-user self-host's local
model on a patch update. It is deprecated for one release cycle instead,
in a narrowed and safe form: it still grants a private host, but only to
the operator's own configurations (the instance-wide admin provider and
AI settings saved on an admin account), and only through the pinned
operator-approved dispatcher, so redirects stay forbidden and metadata,
link-local and unspecified addresses stay refused. A non-admin account's
own base URL needs an exact origin in AI_PRIVATE_ORIGINS.
Provider resolution passes the owning account's role to the client
(`operatorTrusted`); the save route and the connection test apply the
same rule to the account being configured. The boot summary now says the
setting is deprecated and names the replacement, and the admin AI
settings page shows the exact AI_PRIVATE_ORIGINS line for the private
origins saved on the instance (every account's Local and gateway URL and
the admin base URL, scheme, host and port only), so the operator can copy
it and drop the old setting.
The shortest value either codec writes is 40 base64 characters. A
shorter string under an ...Encrypted member is not ciphertext, and
reading it as a legacy value would have made a restore demand the v1
key for a field that holds none.
The admin wipe of every account is the operator clearing their own host,
and the bucket's copies are the documented way back from a wipe pressed
by mistake, so it writes no off-host deletion request. Deleting an
account, deleting a managed profile and "Delete all data" still do.
The backup runbook no longer says the worker never deletes an object: it
lists the grant the worker needs (DeleteObject included), when objects
are deleted, the version-4 envelope, the lifecycle check and how to
rotate the off-host key. The rotation runbook no longer says backups are
covered: rotation reaches a backup's envelope, not the content a restore
writes back, and a retired key stays needed until no backup lists it.
The privacy notice beside account deletion says the same about off-host
copies.
A failed import records its failure in a plain try block, and the
extraction test that inflates past the 64 MiB floor gets the time it
needs on a loaded runner.
The list of provider tags that send data out through a credential the
operator holds (admin-openai, admin-codex) existed four times: in the
consent gate, in the capability resolver, in the shared-provider health
read and in the admin provider-health ordering. A tag added to one and not
the others would be consented for in one place and not the other. The list
and the local-provider test now live in src/lib/ai/provider-egress.ts and
every reader imports them.
The guard meant to keep one copy matched a variable name, so the copy in
the resolver, under another name, never tripped it. It now matches the
list itself in either order and names the one file allowed to hold it.
A refresh token issued to a device is refused when presented under another
device id, but leaving the X-Device-Id header out skipped the comparison,
so a stolen token rotated simply by naming no device. A token whose row
carries a device id now rotates only for a caller naming that same id.
Tokens issued without one still rotate as before. The iOS app sends its
device id on every request, sign-in and refresh included.
The passkey sign-in, security-key sign-in and security-key registration
verifiers read the challenge, verified the response, and deleted the
challenge afterwards. Two requests carrying the same assertion could both
read it before either deleted it, and both be accepted.
A challenge is now claimed with one guarded DELETE ... RETURNING that also
checks its ceremony, its account and its expiry, before anything is
verified; a concurrent second request finds nothing. A passkey sign-in also
refuses a challenge that was begun for a different account than the passkey
belongs to.
Off-host objects are now written as envelope version 4: the header names
the off-host key by fingerprint, and the object's key in the bucket
(<date>/user-<id>.json.enc) is authenticated as GCM associated data, so
a copy moved to another account or another night no longer opens.
Objects of versions 1 to 3 still read. BACKUP_ENCRYPTION_PREVIOUS_KEYS
(comma-separated, on the compose whitelist and in the env manifest)
keeps retired off-host keys readable, which is the rotation path the
off-host key did not have.
The admin backup upload counted the bytes that arrived and none after
the gunzip, so a small gzip could expand without limit into the JSON
scanner and take the app down. The inflated bytes are now held to 100
times the compressed bytes read (at least 256 MB, at most 4 GB) and a
file past that is refused with 413 before anything is stored.
The extractor held the inflated export.xml to a flat 8 GiB whatever the
member occupied, and the header ratio check reads sizes the archive
writes itself: a member declaring an honest ratio could inflate at
1000:1, so an 8 MB upload could fill the temp directory. The streamed
byte count is now held to 200 times the member's compressed size on
disk (never less than 64 MiB, never more than 8 GiB), and a stored
member may not grow at all.
An account may have one Apple Health import queued or running. A
second upload with different bytes is refused with 409
import.apple_health.busy and the running job's id; the check and the
new row are one transaction under an advisory lock.
The staged upload is the person's export in plain text. It is now
removed on every exit that leaves no worker to read it: a refused or
broken multipart body, no worker running, a queue that takes no job
(the row is failed as well), the admin route's missing or unknown
target account, and an account deleted before the worker reached it.
The worker removes the files before recording a failure, so a row that
went with a deleted account no longer strands them. The 15-minute
import reconcile pass also sweeps staged files older than the longest
an import may run.
The log and error redactor missed the registration invite token (hlv_) and
the native sign-in handoff code (hlh_). The filter that keeps secrets out
of the idempotency replay cache and out of relayed upstream error bodies
missed the handoff code and the three MCP OAuth artifacts (hlac_, hlrt_,
hlc_). Both now cover every prefix the app issues: hlk_, hlr_, hls_, hle_,
hlv_, hlh_, hlac_, hlrt_ and hlc_.
Password sign-in was limited per client address only, so guesses spread
over many addresses reached one account without end. Each account now has
its own ceiling of ten failed attempts in fifteen minutes, whichever
addresses they come from. The attempt is counted before the password is
hashed and given back when the password is right, so the owner signing in
does not use it up. An identifier that matches no account is counted under
its hash with the same ceiling, so the refusal does not reveal whether the
account exists. Passkey and single sign-on remain open while the ceiling
is reached.
The client-IP resolver read X-Real-IP on every path: with
TRUST_PROXY_HOPS=0, which means no proxy in front and no forwarding header
to believe, and on the path where the X-Forwarded-For chain was shorter
than the declared hops, which means the proxies were bypassed. A caller
sending a new X-Real-IP with each request got a new per-IP rate-limit
bucket each time.
X-Real-IP now gets the trust X-Forwarded-For gets: it is read only when at
least one proxy hop is declared and the request carries no X-Forwarded-For.
A short chain resolves to no address. The reverse-proxy guide no longer
suggests TRUST_PROXY_HOPS=0 for a proxy that sets X-Real-IP; such a proxy
takes 1.
"Sign out everywhere" ended web sessions and device logins but left every
credential that works without signing in: a connected AI assistant kept its
OAuth connection and minted the next access token from it, a connector or
ingest token kept working, and a clinician share link kept opening the
record. A password change revoked the tokens but not the assistant's
connection or the share links. Anything a stolen session had minted
outlived the remedy.
- Sign out everywhere now ends every other sign-in, every AI-assistant
connection, every programmatic token except the caller's own, and every
clinician share link. Share links are revoked by default; the web offers
a checkbox to keep them (keepShareLinks=1), and a client that sends
nothing, such as the shipped app, gets the safe default.
- A password change and an admin password reset also end AI-assistant
connections, share links and step-up elevations.
- Turning on the first second factor (TOTP or a security key) ends every
other sign-in, as a password change does. Adding a further factor does
not.
destroyOtherSessions takes an explicit reach ("sign-ins" or "everything")
so each caller says how far it goes.
The login route's description named only the four reasons the login step
itself sends. The callback, which the identity provider drives and so has
no OpenAPI entry, ends the native leg at healthlog://oidc-callback with
eight more, and nothing written listed them. An app build matched against
hyphenated shorthand (identity-conflict) while the server has always sent
oidc_identity_conflict, so every SSO error fell through to the generic
message. The values do not change; the description now names the full
set, and a structural test binds it to the literals the two routes pass
to their redirect helpers in both directions.
Regenerates docs/api/openapi.yaml, including the CSV import description
from the previous commit.
Re-uploading a large export with an externalId column ran one
updateMany per row inside a single interactive transaction; on a
9 000-row file that outlived Prisma's 5 s timeout and the request ended
in a 500.
A re-imported externalId row whose stored value, unit, instant, glucose
context and note already match the line is now a duplicate: not written,
reported as skipped / duplicate. Changed rows still update, a tombstoned
one is still resurrected. The existence probes run per measurement type
in chunks of 1 000 keys, and the writes commit in chunks of 200 (creates
via createManyAndReturn, updates as one batch transaction per chunk)
instead of one transaction around the whole file. A failure part-way
leaves earlier chunks written, and re-uploading the same file completes
the rest.
The OpenAPI description now says what a re-upload does, including the
natural-key dedup for rows without an externalId, which it used to call
a duplicate.
Sealing a readable Coach title or custom-metric note while building a
disaster-recovery file gave each export a fresh IV, so the streaming
writer and the materialising builder no longer produced the same file.
A row the free-text backfill has not reached yet now rides as stored,
readable, inside the encrypted backup; the restore seals it, and a
migrated row carries its ciphertext verbatim as before.
Deleting an account, deleting a managed profile, "Delete all data" and the
admin wipe now write an off-host deletion request in the same transaction
as the deletion. A new job (offhost-backup-purge, kicked straight away and
run nightly as a backstop) lists the bucket once, deletes every
<date>/user-<id>.json.enc object of each pending account and removes the
request only when nothing of the account is left. A refused delete keeps
the request with the bucket's answer and is retried; the off-host card
shows how many deletions are outstanding and why. A copy the nightly run
was writing while the account was deleted is taken back after the upload.
The off-host card also reads the bucket's lifecycle rule and says plainly
when there is none (every nightly copy kept forever) or when the
credential may not read it.
Every stored backup now records the key ids its content was written
under, and every off-host upload records the same per key. The admin
encryption view lists, per key, the stored copies that need it with the
oldest date, and when the last off-host copy needing it expires. The
"safe to drop" badge now requires that no backup still needs a retired
key, instead of only that every row was rotated.
Migration 0358 adds data_backups.inner_key_ids, the key-use table and the
deletion requests.
Exporting the whole record, making a clinician share link, minting a token,
and the admin backup download, upload and restore, data wipe and password
reset ran on a live session alone. The plaintext full backup did, while the
encrypted one beside it asked for a second factor. Whoever held a stolen
session could take the record, or leave behind a credential that outlives
the session.
These now go through one gate, requireRecentProof (assertRecentCookieProof
for the routes that resolve their caller themselves). A browser session
passes after a sign-in or re-proof within five minutes: a second factor on
an account that has one, otherwise the password or a passkey. The refusal is
401 auth.reproof.required with meta.methods, and the web opens the re-proof
dialog, posts the proof to the new POST /api/auth/reproof and retries. A
password proof stamps the new Session.reproofAt (migration 0359), never
mfaVerifiedAt, so it cannot stand in for a second factor anywhere.
On Bearer, the full backup asks for an X-Step-Up elevation; the encrypted
export asks for a second-factor elevation on an account that has one. Share
links and connector tokens keep taking the token alone, because the shipped
app calls them without an elevation and reads a 401 there as a dead
session; the guard test names them.
Key rotation keeps its stricter second-factor gate.
ALLOW_LOCAL_AI_PRIVATE_HOSTS=true let any user of the instance point the
Local provider or the OpenAI-compatible gateway at any private address
the server could reach, the metadata range included, and a granted host
was dialled with no connect-time pin at all.
AI_PRIVATE_ORIGINS grants a private AI endpoint with the exact-origin
grammar NOTIFICATION_PRIVATE_ORIGINS and NIGHTSCOUT_PRIVATE_ORIGINS use.
A granted call goes through the pinned operator-approved dispatcher:
redirects forbidden, and metadata, link-local and unspecified answers
dropped at dial time even for a granted name. Everything else stays on
the public pin. The Local client (buffered and streaming) and the
gateway and admin-key tags of the OpenAI client all take their policy
from one function.
The host-list form of ALLOW_LOCAL_AI_PRIVATE_HOSTS keeps working with the
same pinning; a never-grantable literal or a host:port entry is refused
with a one-time warning. Its "true" form grants nothing any more and is
reported in the boot readiness summary with the replacement.
A failed lab-report scan no longer puts the endpoint's response body into
the wide event; status and model stay. The response to the caller never
carried it.
The title of a Coach conversation is the first 80 characters of the
opening message, and the note on a custom-metric reading is free text;
both were stored readable while every other note was encrypted.
Migration 0357 adds coach_conversations.title_encrypted and
custom_metric_entries.note_encrypted (the title loses its NOT NULL) plus
partial indexes on the backfill's discovery predicate. Every writer now
stores ciphertext only: conversation create, proactive nudge, reminder
surfacing, rename, entry create and edit, and both restore formats.
Readers prefer the ciphertext and fall back to the old column for rows
not yet migrated; a ciphertext that does not open throws instead of
falling back. The history title search decrypts in memory, since SQL can
no longer match the title.
free-text-encryption-backfill is a boot-time, idempotent pg-boss job
(staggered past the startup storm, daily discovery tick) that seals each
remaining readable value and nulls it in one transaction per row. A pass
that sees a row twice stops with an error instead of looping.
Both columns are registered for key rotation (registry, CLI script, and
the in-app rotation that walks the registry). A disaster-recovery backup
carries ciphertext only, sealing a not-yet-migrated row on the way out;
a portable export carries readable text. The old columns are marked for
removal in a later release.
A backup carries every encrypted column as the stored ciphertext, and a
restore writes it back verbatim. Key rotation re-seals the stored copy's
envelope but cannot reach inside it, so a copy taken before a rotation
still needs the retired key. Without it the restore went through and
left rows no reader can open.
The restore, the restore preview, the admin upload and the monthly
restore drill now collect the key ids of every inner ciphertext value,
hold them against ENCRYPTION_KEYS and decrypt one value per key. A
missing key, or an id configured with different key material, is
refused before anything is deleted, with the key id named. The restore
status and the preview say which key to put back.
The restore also checks, inside its transaction, that it added no
reference from the account's rows to rows another account owns. The
check is derived from the schema's foreign keys rather than written per
section, so the tag and category upserts keyed on a global key are
covered along with every link table. The illness parent update is
scoped to the owner as well.
The off-host listing now follows every page: the first page held only
the oldest dates, so the drill could read an old object and report the
chain as stale.
The seven integration clients now read their redirect URI and the app URL
through envValue/envOr instead of a local ?.trim() ||. The empty-env guard
drops its pending entry and floors its walk, and proves its matcher still
fires on a real client with the old read put back.
expandTodayIntakes had no caller left once the today projection moved onto
the recurrence engine and localHmAsUtc; it carried the last private
wall-clock solver in the medication code. The recurrence engine now takes
its offset and UTC-day helpers from the tz module instead of local copies.
The medication and measurement-reminder code now imports DEFAULT_TIMEZONE
from the tz module like every other fallback, so the lint rule no longer
exempts those paths.
Building the coarse buckets from the DAY tier read every day of the window
into the process: a five-year yearly read moved thousands of rows per type,
and the Coach's history bands read several such windows per request. The
per-day source pick and the fold now run in Postgres (DISTINCT ON the day,
ordered by the ladder rank and then the source name, the same order the
in-process pick uses), so a coarse read returns one row per bucket. The
long-range chart picks its tier from the span of the window's days and reads
that tier once. The Coach snapshot hands its already-loaded source order to
the history bands instead of each band reading it again.
The upload route is now outside the proxy matcher, so nothing bounded the
multipart parser, which holds the whole form. The bytes are counted first
and the form is parsed from what was kept, stopping at the 10 MB cap.
Next reads the body of every non-GET request the proxy matches into
memory before the route runs. The ceiling was 512 MB so the Apple Health
importer could receive whole archives, which let any anonymous POST to any
API path make the server hold that much.
The ceiling is now 1 MB (proxyClientMaxBodySize). The fifteen routes whose
real bodies are larger are left out of the proxy matcher and read their
own body after authenticating; apiHandler gives them the proxy's refusals
(worker-only, demo mode), security headers and a CSP. safeJson now counts
bytes while it reads and stops at its cap instead of reading everything
first, the CSV and dose-history imports do the same, and a multipart
backup upload is counted while it is read. A test compiles the matcher the way
Next does and checks it skips exactly the listed routes and every route
with a larger body is listed.
Local lab OCR runs its WebAssembly engine in a worker served from
/tesseract/. Those responses, and the /labs and /documents pages that start
the OCR, now carry 'wasm-unsafe-eval' in script-src; no other route does.
A report whose end date lies in the past listed medications that started
after it, the dose history up to today and the latest injection even when it
came later. It now lists a medication only if its course had begun by the
end date (or it had a dose scheduled inside the window) and had not ended
before the start, and dose history and the last injection stop at the end
date.
The status assessments folded their recent days, weeks, months and years on
Berlin days whatever the user's zone, the mood and cycle crosstabs keyed
their metric days in Berlin, the resting-pulse proxy defaulted to Berlin
days, the briefing's opener rotated at Berlin midnight, and every
assessment prompt labelled today's date as Berlin's. They now use the
user's zone. Rollup months and years are labelled by their UTC start,
which is how they are cut; read in a zone west of UTC the label named the
month before.
"Europe/Berlin" was written out in some eighty places. DEFAULT_TIMEZONE in
lib/tz/format is now the one definition: the second copy in mood/date-key,
DISPLAY_TIMEZONE, the Berlin-only day key helpers and the per-route copies
are gone. An in-repo lint rule rejects the literal outside lib/tz and
tests; four areas another change owns are listed as pending.
The shipped compose file passes TRUST_PROXY_HOPS as an empty string when
the operator never set it, and the parser rejected the empty string as an
invalid value, throwing wherever the client address was read. Empty now
means the default of one trusted hop.
The SSO callback bound a verified provider email to the local account with
the same address on the spot. The local address is not verified: anyone
could register with it, or type it into their profile, and the SSO user
then landed in an account the other person still held the password to.
The callback now links nothing. It records the link it would make in a
sealed, ten-minute, HttpOnly cookie and sends the browser to the sign-in
page, and the link is made only when that browser then signs in to that
same account with its password (and second factor) or passkey. Under an
SSO-only policy the password form opens for exactly that account. The
native sign-in answers oidc_link_required; the link is confirmed once on
the web.
Moving an account to a new email address now also needs a fresh proof: a
sign-in or second factor in the last five minutes, or the current password
sent alongside the change. Without it the address is left as it was and
reported in rejectedFields; the rest of the save still lands.
The compose file passes every whitelisted variable through, so an unset
redirect URI reaches the process as an empty string. Withings then sent
redirect_uri= to the provider, and Fitbit and Google Health refused to start
the connection as not configured although NEXT_PUBLIC_APP_URL was set. Every
integration now treats an empty or blank value as unset and derives the
callback from the app URL.
The MCP writes, the Telegram reply, the CSV and backup importers and the
Apple export import refreshed the rollups by hand and never re-warmed the
cached status assessments, so a reading logged there kept the day's old
assessment until the nightly pass. They now run the shared post-write tail,
and the export import re-warms every type it wrote. The WHOOP body sync
removed its legacy weight row without refolding that day; it does now.
A structural test lists every file that writes measurements and how it keeps
both readers current, so a new write site fails until it says.
The all-time weight, blood-pressure and pulse figures the insight features
carry averaged every raw row, so a device that samples densely outweighed the
readings the source ladder prefers on the same days. They now keep one source
per day, like the dashboard's all-time figures.
The period-over-period range delta averaged readings. For step-like totals
the current window still holds the last day and a half as many small
samples, so the average collapsed; it now compares average daily totals. For
sleep it averaged stage segments and in-bed envelopes; it now compares
average nights of time asleep. The API descriptions say which.
The export importer keeps the largest source subtotal per day for step-like
totals, and it told sources apart by name, version and device. On the day a
phone or watch updates, its records carry two versions, so the one device
became two sources and the day kept only the larger half. The identity is
now the source and its device without any version.
Withings, Fitbit, Google Health, Polar and Oura wrote each daily total and
daily summary at noon UTC of its date. From UTC+12 on (New Zealand all year,
Fiji, Tonga, Samoa) noon UTC is already the next local day, so every synced
step count, SpO2 or resting-pulse day showed up a day late. The rows now sit
at local noon of their date in the user's zone, and the fetch windows run
through the user's own today.
Fitbit weigh-ins carry a local wall clock without an offset and were read
as UTC; they are read in the user's zone now.
ExternalIds stay on the calendar date, so the next sync finds each row and
moves it in place instead of adding a second one, and the day a row leaves
is refolded. Rows inside each integration's re-read window heal on the next
sync; Fitbit and Google Health re-read the whole history on reconnect.
A browser session on its own could start TOTP enrollment or register a
security key. Combined with a pending secret counting as a factor, that let
a stolen session give itself a second factor, register a passkey with it,
and keep access after the owner changed the password.
TOTP setup and security-key registration now pass on the cookie path only
when the session signed in or completed a second factor in the last five
minutes, or when the body carries a proof in the step-up shape (password,
a confirmed authenticator code, or a passkey or security-key assertion).
The settings cards ask for that proof when the server answers
auth.reproof.required and retry the same call with it. The Bearer path is
unchanged: its elevation already is the proof.
Every route that checks a password or a factor as a re-proof now uses one
verifier and one per-account budget of five attempts per fifteen minutes,
shared with the step-up mint, and records each refused proof as
auth.mfa.failed. Passkey enrollment and password change had neither the
limit (passkey enrollment) nor the audit row (both). A structural test
freezes the set of credential-verifying routes and requires a throttle and
an audit call in each.
Today's key, each new record's day and the streak series were UTC days, so
through a New York evening a best set that afternoon counted as yesterday's
and dropped out of the digest. All three are now the user's own days; the
streak series is folded live on local days for this read.
Both heatmaps stepped back from now in 24-hour strides and cut each key in
UTC, so in the evening west of UTC, or the morning east of it, the newest
cell was a different day from the one the server keyed the readings on. The
days now come from today's key in the user's display zone, stepped on keys.
Local day bounds had three implementations. The day-key one read the zone
offset at UTC midnight, which on a transition day in zones far from UTC
(Pacific/Auckland, Australia/Sydney) is the offset after the change, so the
day started an hour off. The instant one converged on local 00:00, which in
zones that skip midnight (America/Santiago, America/Havana) does not exist,
and returned 23:00 of the previous day.
startOfLocalDayKey and localDayWindow in lib/tz/local-day are now the only
implementation: the first instant that reads the day, tried under every
offset in force around it. A skipped wall clock resolves to the moment the
clocks jump, the day-keyed copies in the measurement drains, the compliance
rollups and the dashboard batch window are gone, and the noon and mid-hour
anchors use the same converge.
A snooze lives on the medication row, and the reminder tick applied it to
every slot of that medication. A Telegram "skip" of the morning dose
snoozes to the end of the day, so the evening dose of the same medication
was neither reminded nor marked missed by the reminder path. A snooze now
holds only slots whose reminder window had opened when it was set, dated by
the row's updatedAt that the snooze write stamps (the same reading the skip
and pin suppression already uses). A later edit to the row can only widen
the hold back to the old behaviour.
The doctor report kept adherence in a map keyed by medication name, so two
medications with the same name (an ended course and a new one, two
strengths) collapsed into one row and the second overwrote the first. The
map is keyed by medication id now and carries the name as the label; the
report's medication list carries the id. The PDF, the FHIR adherence
observations, the MCP visit summary, the clinician view and the GLP-1
section read it that way.
The activity pillar averaged the 28 days ending today, so any score computed
during the day counted a step total that was still growing and pulled the
average down. The window is now the 28 completed local days ending
yesterday, cut on day keys.
The streak walked back from UTC midnight of today's key and read each
instant in the user's zone. West of UTC that instant is still yesterday, so
the walk started a day early and the streak came out one day short. It now
steps through the day keys themselves.
Rows older than the five-year rollup window were added to the all-time
figures without choosing a source per day, so a vital recorded by two
devices counted both on every one of those days. They now keep the
ladder-canonical source per day, like the rollup half and the live path.
The year-ago 30-day average read the monthly tier and kept whichever month
began inside the window, which could be a month off or leave the value
empty. It is now the count-weighted mean over exactly the 30 days that start
395 days ago.
Local lab OCR reads a report photo on the device with tesseract.js, whose
defaults load the worker, the WebAssembly core and the language data
from cdn.jsdelivr.net. The production CSP allows workers and fetches only
from this origin, so the engine could not load; where it did load (dev),
the browser of anyone scanning a report contacted a third-party CDN.
The worker, the three LSTM core builds and the deu and eng best_int
language data (about 17 MB) are now copied from pinned packages into
public/tesseract/ at prebuild and predev, and createWorker is pointed at
them with the worker started from its URL rather than a blob. A test
holds the options against the staged file list.
The rollup writer keeps one row per bucket and source. Collapsing the stored
WEEK / MONTH / YEAR rows picked one source for the whole bucket, so a month
of manual blood-pressure readings plus one synced reading reported the synced
day alone, and a step month that switched devices lost every day the losing
device carried. The live weekly and monthly chart fold made the same pick.
Every coarse bucket is now folded from the canonical DAY buckets: the source
ladder picks one source per day, then the week, month or year counts every
one of its days. Spread, slope and r² are composed from the summed day
accumulators. The live fold picks per local day before folding into weeks
and months. Ties between sources without a ladder rank break alphabetically
on the SQL paths too, matching the rollup reader.
The README said everything is kept encrypted at rest, that allergies and
family history are encrypted records, that the doctor report is
generated client-side, that there is no telemetry of any kind, and that
S3 backups are switched on in the admin panel. Free text, documents,
questionnaire answers, AI-written text and credentials are encrypted;
measurement values, lab values, medication names, mood scores and the
allergy substance or family condition are not. The PDF is rendered on
the server. Off-host backups are configured through the BACKUP_*
variables, and the admin panel only shows their status. The server's own
outbound requests are named along with their switches.
.env.example now says it is a starter and points at
.env.production.example as the complete reference, which is the file CI
checks against the env manifest. The Withings client id and secret it
listed are not read from the environment at all; they are entered in
Settings.
Two requests left the server without anything being connected: the
HaveIBeenPwned range query on every password set (registration, change,
admin reset) and the daily GitHub releases request behind the About page
and the admin version tile. Neither could be turned off, which made
"nothing leaves" untrue for an instance that wanted it to be.
PASSWORD_BREACH_CHECK_DISABLED and UPDATE_CHECK_DISABLED (1, true, yes or
on) stop each request. Both stay on by default. The breach check keeps
its fail-open contract: switched off, a password is simply not looked up.
The update route answers status "unknown" with reason "disabled" and
the admin tile says the check is off. The password routes reach the
check only through the switch, and a test holds that import line.
Both are on the compose whitelist, in the env manifest under a new
"Outbound requests" group with IP_GEO_LOOKUP_DISABLED, and documented in
both env examples and the getting-started guide.
The runway estimate read FREQ=MONTHLY as one dose every 30 days whatever
its INTERVAL, so a quarterly injection with two doses left showed about 60
days of supply instead of 180. FREQ=DAILY;INTERVAL=2 counted as daily, a
yearly rule ignored INTERVAL and BYMONTH, and a monthly rule ignored extra
BYMONTHDAY values. A CYCLIC schedule was counted as if it never paused,
and a PRN schedule as if it were due every day. The rate now honours
INTERVAL for every frequency, counts the listed days and months, scales a
cyclic schedule by on / (on + off) and leaves PRN out. The low-stock job
selects the schedule type and cycle lengths so it reads the same rate.
Whole-dose counts no longer lose one to binary floating point: 2.4 mL at
0.8 mL per dose is 2.9999999999999996 in IEEE 754 and used to read as two
doses. The stock summary, both runway estimators, the list and detail
routes, the GLP-1 route, the Coach snapshot and the inventory readout now
floor through one helper that treats anything within 1e-9 of the next whole
number as that number.
The "Send raw data" switch promised anonymised single readings from the
last 30 days. It sends bucket averages from the rollup table: per day for
the last 90 days, per week for the year before, per month back to five
years, each with its date and reading count. The label, both
descriptions and the warning now say that, and the "off" text no longer
claims the summaries carry no individual value (they include the latest
one).
The data-posture line and the Coach's own description of the app said
all health data is encrypted at rest and nothing leaves the instance
beyond the AI provider. Free text, documents, questionnaire answers,
AI-written text and tokens are encrypted; measurement values, lab
values, medication names and mood scores are not. Both now say so, and
the Coach is told not to claim otherwise. Two narrower absolutes are
scoped to what they cover: the local-model onboarding title and the
off-host backup card.
All seven locales.
A reminder with FREQ=DAILY;BYHOUR=7,19 (the morning-and-evening blood
pressure course) was only ever sent in the morning. The tick opened its
send window at notifyHour alone, so the 19:00 slot stayed due until 07:00
the next day, and the claim was keyed per local day, so a second send that
day would have been refused anyway.
The window is now the hour of the current slot: the local hour of
nextDueAt when it is one of the rule's BYHOUR values, else notifyHour. The
early skip for a reminder already sent for its slot uses the same hour, so
it stays consistent with the hold-open rule and lastNotifiedAt. A
multi-hour reminder claims per slot hour; a single-hour reminder keeps the
per-day key.
The password-reset runbook tells an operator to run
`docker compose exec app node scripts/reset-password.mjs`, and the
intake-repair runbook runs `healthlog-tsx scripts/repair-intake-anomalies.ts`
inside the container. The runner stage copied nothing from scripts/, so
both failed with "Cannot find module", the reset at the moment an admin
is locked out.
The runner stage now copies both scripts and the Argon2 parameter module
the reset imports, and proves at build time that each reaches its own
first check with every import resolved. A structural test holds every
in-container command in docs/ and the Dockerfile copies in step.
Key rotation is documented the way it works on the published image: the
Rotate now button under Admin > Encryption is the primary path. The
rotation script imports the application graph and runs from a source
checkout of the same release; the runbook, the admin hint in all seven
languages and the decrypt error now say so.
A medication's startsOn/endsOn are calendar dates stored as UTC midnight,
and the rrule library emits its day anchors the same way. The engine read
those midnights on the user's wall clock, which west of UTC is the evening
before. A FREQ=WEEKLY;BYDAY=MO plan therefore fired on Sundays in New York,
a daily course starting on the 14th produced a dose on the 13th, and an
evening dose on the last course day was cut off. One-shot doses, the first
dose of a rolling cadence and the legacy weekday walk (startsOn floor,
endsOn cap, every-N-weeks phase) carried the same shift.
The engine now keeps a calendar day as a calendar day end to end: startsOn
and rrule anchors are read from their UTC fields, instants (createdAt,
intake times) are converted to the local day they fall on, and only then is
the HH:mm materialised in the user's zone. Rolling cadences count calendar
days from the local day of the last intake, so a late-evening dose across a
DST change no longer slides a day. A nonexistent local time now always
moves forward, including in zones whose clocks skip midnight. RRULE anchors
with BYHOUR are collapsed per day so a twice-daily protocol cannot emit
duplicate slots.
CYCLIC on/off weeks are counted as seven-day blocks from the start day in
the local calendar. They used to snap to Sunday-rooted UTC weeks, so a
three-weeks-on course starting on a Wednesday lost its first days and the
off week began on the wrong day east and west of UTC.
Measurement reminders pass their anchor and course end as instants; the
adapter now converts them to the user's calendar day before handing them to
the engine.
The login challenge read its attempt count, verified the factor, and only
then incremented. Requests sent in parallel all read the same count below
the cap, so one ticket allowed more guesses than the cap. Each attempt is
now reserved by a single guarded UPDATE before the factor is evaluated,
and a failure only settles the reserved attempt.
docker-compose.yml forwards its whitelist as "${NAME:-}", so a variable
the operator never set reaches the process as the empty string. Reads
written as a nullish fallback kept that string: the weather client built
an empty base URL and every geocode failed with ERR_INVALID_URL, and the
IP geo lookup requested a relative path.
Reads with a fallback now go through src/lib/env.ts (envValue, envOr,
envFlag), which treats empty and whitespace as unset. IP_GEO_LOOKUP_DISABLED
accepts 1, true, yes and on instead of only "1". A structural test refuses
a ?? fallback written directly against process.env anywhere in src/, and
the compose whitelist guard counts the helper's call shape as a read.
A pending secret from an enrollment that was never confirmed counted as a
second factor wherever a code was verified: step-up minting, the passkey
enrollment re-proof, MFA disable and login completion. Anyone holding a
session can create a pending secret, so a code from one proves nothing
about the account owner. verifyMfaFactor now refuses both TOTP and
recovery codes until totpConfirmedAt is set.
Papra's date: filter matches the document date, so a row now shows that
date or none, and an undated document falls out of a date range in the
list as it does on the server. Import still files an undated document
under the day it was added.
Papra organization ids are checked against org_ plus 24 characters
before anything is sent. A 403 user.not_in_organization and a 400
server.invalid_request.params read as no such organization instead of a
refused key; the error body is read for its code and never passed on.
The connection test also reads the tags, so a key without tags:read
fails at save (permissionMissing) instead of on the first filter. Name
searches quote each word, so a leading dash is not a negation, and the
date range goes to Papra as date:>= / date:<= with the per-row check
kept as a net.
Keyed uploads and picker imports also check deleted, not yet purged
documents by content and answer deleted, remembering the new key on the
tombstone so the purge carries it into the ledger. A hand upload without
a key still stores a new copy. No content hash of a deleted document is
kept past the purge.
The settings card stays visible, read-only, while a connection is
stored but the operator list is gone or Documents is off, so the token
can still be removed; a new address asks for the token again. The
picker clears a selection when the search or a filter changes, says
when the 25-document limit is reached, steps back the rows a finished
run did not touch and offers Pick more, shows an empty state, and
announces each row's result to screen readers. Both system switches
are radio groups with one tab stop and arrow-key movement. Strings in
all seven languages.
- Source keys carry the normalised origin of the Paperless-ngx or Papra
they came from (source_instance on the document, the alias and the
purge ledger; unique NULLS NOT DISTINCT). A key stored without one
(v1.39.2 imports) matches any instance of its system, and a lookup
without one matches any stored instance. The upload route, the lookup
route and the import script accept or send it; backups carry it.
- Saving a connection reuses the stored token only for the same origin;
a new origin needs the token again (422 tokenRequired).
- Document ids are checked against what each system issues (Paperless
digits, Papra [A-Za-z0-9_-], never . or ..) before any URL is built;
the Papra organization id likewise.
- Paperless API 9 filters dates on created__gte / created__lte, in the
picker and the script. A search page the local re-check empties reads
on, up to five source pages. Tags are read past the first 250.
- The import's key pre-check uses the lookup allowance, a failed fetch
no longer charges the upload allowance, and one import per key runs at
a time under an advisory lock, so a double click downloads once.
- Reading and deleting one's own connection works with the documents
module off and the operator list unset.
97507-8 is the mean of interstitial glucose in mass/volume. A spot-reading
mean is neither, so it now goes out text-only in the account's unit. A
continuous-stream mean keeps the code and is sent in mg/dL, the unit it is
computed in, whatever unit the account displays.
A run of the HL7 validator against every code the export can emit found
terms that do not exist, are inactive, mean something else, or carry a
display the code system does not publish.
- Body water and bone mass use 101683-1 and 101685-6; the old codes are a
nasogastric fluid output and a calculated body-fat mass.
- Step length, walking speed, walking asymmetry and VO2 max ride their
HealthKit identifiers: LOINC has no fitting term, and two of the old
codes do not exist.
- Mood, estimated A1C, average cycle length and average period length are
text-only. The old codes did not exist or were questionnaire items.
- LDL, vitamin D and eGFR use the method-less and total terms (2089-1,
62292-8, 69405-9) a recorded value can honestly claim.
- Resting heart rate and pulse-oximetry SpO2 also carry the heart-rate and
oxygen-saturation codes the R4 vital-signs profiles require.
- Blood pressure, adherence, glucose-panel and injection-site codings carry
the published displays; ATC codings carry none, since the user's
medication name is not the ATC name. The name stays on the text.
- Mood is a survey finding, not a vital sign, so it no longer rides the
vital-signs panel.
Allergy used 106190000, inactive since 2013; it is now 473011001 Allergic
condition. Injury keeps 417163006 with the display SNOMED CT publishes,
Traumatic or non-traumatic injury; the old display was rejected.
A window without a vital sign emitted the panel with result: [], which R4
rejects. The DiagnosticReport now goes out only when it has a result, and
the vital-signs section drops an empty entry list rather than sending one.
The HL7 validator reports 419161000 and 419465000, the left and right
concepts of the R4 bodysite-laterality value set, as inactive. Use the
active qualifier values 7771000 Left and 24028007 Right, with 51440002
Right and left. The side word in bodySite.text stays English, the language
of every other generated text in the Bundle; the builder takes no locale.
Follow-ups to the clinical-record search, test first:
- Condition and vaccination notes are no longer searched or returned.
Both reads use an explicit column list without the note, as the Coach
illness snapshot and the doctor report already do. A structural guard
fails when any MCP read of either model names noteEncrypted or has no
explicit select.
- A document uploaded with AI reading deferred is not searched by its
text and returns no excerpt (metadata.reason "ai_read_deferred"); its
title, kind and links stay findable.
- Documents older than the newest 500 are found by title and file name
through a bounded SQL lookup on those plaintext columns.
- Query words shorter than four letters match whole words or word starts
only, so "arm" no longer finds "Darm".
- A deleted practitioner is no longer named or matched, in search, fetch,
get_visits and the appointments in get_preventive_care. The empty-query
listing shows visits that happened, not booked ones.
- get_labs, get_medication_schedule, get_medication_compliance and the
lab and medication resources (read, list, completion) answer
{ present: false, reason: "module_disabled" } when their module is off,
without reading. get_visits withholds condition labels when illness is
off and says so in conditionsReason.
- The consent screen and the tool descriptions name what a connection
can read, the indexed text of stored documents included.
A settings card under Integrations connects either system and tests it
before saving. An import button beside Upload in Documents, on a
condition's documents card, and beside Add in the visit and vaccination
document pickers opens a sheet that searches by name with tag and date
filters, shows what HealthLog already holds, and imports the ticked
documents one at a time with a result per row. The buttons appear only
for a person in their own record with a connection; strings in all
seven languages.
The ingest path takes the capability from its caller, so the upload and
the import route each read getAiCapability("documentAi") in their own
source, where the AI route inventory classifies them.
A person stores one connection per system (base address, Papra
organization id, API token encrypted at rest) and can search it by
document name with tag and date filters, then import picked documents
one per request through the same ingest path as an upload: source-key
answer before any download, content dedup, deleted stays deleted, quota,
provenance, and the person's own AI reading setting. An import can link
the document to the visit, condition or vaccination it was picked for.
The operator lists the instances in DOCUMENT_SOURCE_ORIGINS; unset, the
feature is off. Only listed origins are contacted, through safeFetch with
the origin pinned, redirects refused, timeouts and byte caps. The routes
are cookie-only and owner-only. The token is registered for rotation,
removed by Delete all data, and not carried in backups.
In its default mode ChatGPT can call only search and fetch, and until now
those two covered metric domains, medications and lab analytes. A question
about a knee operation or a discharge letter had nothing to land on.
search now also returns visit:<id> (procedures included), condition:<id>,
document:<id> and vaccination:<id>. Matching runs after the decrypt over
the account's own rows, bounded per kind, folded the way the body-sites
view folds sites, with German/English body-part and vault synonyms.
Documents match on title, file name, kind and whole words of the blind
content index; the body is never read for a search. Results rank by
matched query words, then score, kind, date and id, so the offset cursor
stays stable. Record results are capped at 50.
fetch returns each record's key fields and its one-hop links through the
link service, every link carrying an id fetch accepts. Free text is fenced;
short labels are scrubbed. A document returns metadata and at most a
1500-character excerpt of its indexed text, never the file or its stored
summary.
A switched-off module's records are neither read nor returned: illness,
inboundDocuments and vaccinations for the new kinds, and now medications,
labs and the owning module of a discovery signal for the older ones.
The upload route keeps authentication, its rate buckets, multipart parsing
and the response shape; the source-key answer, size and type checks,
pre-link narrowing, content-hash dedup with alias memory, the quota-gated
insert, the audit row and the background jobs move to
src/lib/documents/ingest.ts unchanged, so a second way in can store
through the same rules.
Condition.bodySite carries the free-text site as text. The side rides a
contained BodyStructure reached through the core bodySite extension, coded
with the SNOMED CT concepts of the R4 bodysite-laterality value set, and is
repeated in the text so a receiver that ignores extensions still reads it.
A snooze that had already run out before the send is history, not a
choice about the reminded slot, so it no longer keeps a row from being
repaired. The "no due date" arm matched nothing, because a reminder
cannot be created without a cadence; it now covers a rule whose last
occurrence was the one sent, which is open unless it was done or
skipped since, while a course whose window closed before the send stays
closed through the end-date guard. The start-page step reads a stored
item list only when it is a list, so a malformed layout can no longer
fail the migration.
Renaming an overdue booster check-up on the web still moved it to next
year. The edit sheet sends the first due date as the browser's local
midnight on every save, a booster row stores the dose instant plus N
days, and the route read the different instant as a new first due date.
The route now compares the first due date by calendar day in the
profile timezone and drops a resent same-day value from the write. The
edit sheet sends only the fields that changed. Re-enabling a reminder
that stays due after it is sent keeps the slot it was on instead of
scheduling from now; a short-cycle reminder is still scheduled from
now. Moving the notify hour later on the day a reminder was already
sent carries the last-sent cursor along, so the slot does not go out a
second time at the new hour.
The start page read every reminder due before the end of the day, so a
daily weigh-in at 20:00 or a twice-daily blood-pressure course showed
"check-up due" from the morning on and took a pinned place on the rail.
`holdsOpenAfterReminder` is now the one definition of a reminder that
stays due after it is sent: an interval longer than seven days, a rule
whose next gap is longer than seven days, a rule with no occurrence
left, or no cadence; never an appointment. The tick uses it to decide
whether a delivered reminder rolls on, and the start page uses it to
show such a reminder for its whole due day and keep it under the cap.
A short-cycle reminder shows only once its time has come and is not
pinned.
The tick also skips a reminder already sent for its current slot
outside its notify hour before the auto-resolve read. The eventful
satisfy worker still resolves it the moment a reading or lab result
lands, and the poll still runs for it once a day, in the notify hour;
the weekly hold stays after that poll so the safety net is daily.
The first cut kept only free-text check-ups due after their reminder
and let every reminder with a measurement type roll on. A fortnightly
PHQ-9 or GAD-7 reminder is a measurement reminder, and each one still
jumped two weeks forward the morning it was sent without the
questionnaire being filled.
The rule is now the cycle length. A reminder whose next slot is more
than seven days away, or that has no cadence, stays due after its
reminder until it is satisfied (a matching reading, a lab result or
"done"), skipped or snoozed, and is sent again at most once a week. A
reminder on a weekly or shorter cycle rolls on as before. Appointments
stay one-shot. A reading that lands on an open measurement reminder
still sets lastSatisfiedAt and schedules the next cycle from itself.
The 0355 repair drops its "no measurement type" condition for the same
cycle rule, so the fortnightly questionnaires in the reported shape
are put back on the day they were reminded.
The snoozed fixture sat inside a week of the send, so the one-week clause already excluded it and dropping the snooze guard from the migration left the suite green. Snooze it further out, a minute after the send, so only the snooze cursor tells it apart from a roll-on.
The start-page item choice stores the enabled kinds, so a list saved before a kind existed reads as that kind switched off. Say so next to the kind list, where the next kind will be added.
Every edit of a reminder recomputed its due date from now, so fixing
the label or the location of an open, overdue check-up moved it to its
next slot, the same "gone until next year" the reminder tick caused.
The due date is now recomputed only when the edit changes when the
reminder recurs: the interval, the rule, the first due date (compared
by value, since the edit forms resend every field), or switching a
disabled reminder back on. A notify-hour edit keeps the due day and
moves the hour on it, together with a snooze pinned to that slot.
The start page's short list left out things a reminder had just told
the person about. A check-up counted only once its due hour had
passed, a booked visit was read one at a time and dropped out at its
start time, which is when its reminder fires, and both sat below doses,
sync problems and the Coach check-in under a three-item cap, so a busy
morning pushed them off.
The check-up item now covers every check-up due today or overdue and
names up to three of them. Visits are read for the whole local day,
also once started, and for the next 48 hours: everything booked today
shares one item, the next day with a visit gets a second, and the
today / tomorrow / day-after wording follows the calendar rather than
an hour count. A due check-up and today's visits always keep a place
under the cap; the displayed order is unchanged. A Coach cadence label
is translated here as it is on the checkups page.
The digest's wire shape is unchanged: visits reach it only as
`upcoming_visit` items, which may now appear twice.
The reminder tick rolled every delivered reminder on to its next slot.
For a check-up (a reminder with no measurement type, made by the person
or suggested by the Coach) that filed an open task under next month or
next year the moment it was reminded, although nobody had done it. The
start page and the check-up card then showed a yearly check-up due
today as scheduled for next year, and a check-up with no cadence
dropped off every surface.
A check-up now keeps its due date after the reminder until it is
marked done, skipped or snoozed, and is reminded again at most once a
week while it stays open. A new `last_notified_at` column records the
last delivered reminder; a repeat is held while that send was for the
current slot and fewer than seven calendar days have passed, so a
snooze, a skip or a completion opens a fresh slot that fires on its own
day. The per-day claim stays the guard against two sends in one day.
Measurement reminders keep rolling on (the next reading satisfies them
and their next slot comes round soon), as do check-ups on a weekly or
shorter cadence, whose next slot is the next reminder anyway.
Appointment reminders stay one-shot.
Migration 0355 adds the column and puts back check-ups the old tick
rolled on: a live, enabled check-up whose most recent reminder claim is
followed within ten minutes by its last write, carrying a due date more
than a week past that send (or none, for a one-shot), not done, skipped
or snoozed since. Its due date returns to the instant of that send.
Anything else is left as it is. The same migration adds `upcoming_visit`
to start-page item choices stored before visits were one of them, which
otherwise read as visits switched off.
The due phrase on the check-up card and the checkups page counted a
check-up one day past its date as "Overdue by 1 days", and the same
counted form read wrongly in German. One day over now has its own
phrase in all seven languages. Both overdue phrases keep the warning
colour through a shared `isOverdueDue` helper, so a surface no longer
compares against a single key.
Every upload that sends a file counts towards the token's hourly limit,
workflow re-sends included, and a spent lookup allowance makes the
script wait rather than skip the wait. Note the 20 extra source ids a
document may collect, and name the settings section in the environment
example as the app shows it.
The runbook now says that key rotation converts copies still stored as one
value into pieces a slice at a time, that a backup skips an account whose
copy is being restored, that two backups of one account run one after the
other, what a reader answers when its copy is replaced mid-read, and that
migration 0352 cannot be undone by going back to v1.39.1.
Key rotation re-sealed a single-value backup in place, which holds the
stored string, its bytes, the plaintext and the new ciphertext at once; a
copy can be over a hundred megabytes and the in-app rotation walked 25 of
them per page, enough to run a 1 GB container out of memory. Rotation now
converts each such copy into pieces under the active key instead, one row
at a time. The ~hlgcm1. stream of v1.38.6 to v1.39.1 is read a megabyte
at a time with its tag checked at the end, inside one transaction, so a
copy that fails the check is left untouched; the copy keeps its date. The
scan reads only the first characters of each value. Measured on a copy of
a 2.6 million-reading account (126 MB stored): converted in 25 s with the
heap flat at 57 MB under a 200 MB limit, where re-sealing in place ran out
of heap.
The weekly and manual backup leave an account alone while a restore of
its current copy is queued or running, and count it as skipped, not
failed. A reader whose copy is replaced mid-read answers 409
backup_changed, "replaced by a newer one while it was being read", rather
than the undecryptable error; a restore rolls back with the same code.
Pieces are sealed with a fixed associated-data label, so a value the same
key sealed for another purpose never opens as a piece; rotation re-seals
under the same label. A row holding both a single value and pieces, which
only an older release writing after a downgrade produces, is refused with
a message saying so, and the next weekly run replaces it. Rotation treats a
row deleted between its read and its write as gone rather than an error.
The drill section said the drill needs nothing beyond the uploader's
GetObject and ListBucket, but the documented uploader grant is PutObject,
GetObject and AbortMultipartUpload. The drill lists the bucket to find the
newest object, so ListBucket is an addition, not something already granted.
The weekly-backup section still described one stored value capped at a
fifth of the heap and advised more memory. It now says how a copy is stored
and checked, that its size no longer depends on memory, what
BACKUP_MAX_STORED_MB limits and how to raise it, how older copies are read,
and how key rotation handles both forms. It also states that a 512 MB
container is below the app's own working set during a backup.
A stored backup was one value in data_backups.data. v1.39.1 wrote it a
piece at a time but still joined the pieces into that one value, and one
value has to pass through the app whole, so a copy was capped at a fifth of
the heap: 105 MB in the default 1 GB container. An account with 1.75
million readings is past that and could not be backed up at all (#1031).
A copy is now kept as ordered pieces of about a megabyte in the new
data_backup_chunks table (migration 0352). Each piece is sealed on its own
with AES-256-GCM, and its sealed header names the copy (a random stream id
kept on the row), its position and whether it is the last. A reader checks
every piece before it releases a byte, so a piece that is missing, moved,
altered, taken from another copy, or a copy cut short is refused with
backup.payload.undecryptable before a restore deletes anything, and the
second read during the restore checks each piece again.
The write is one transaction, so the previous copy stays whole and readable
until the new one commits. Pieces are written through the model API: a raw
query with a large parameter is kept alive by Prisma's query plan cache,
which left 160 MB of heap behind per hundred one-megabyte inserts.
Restore, preview, download, the restore job and the admin list read both
forms; copies written before this change keep their single value and still
restore. The heap-derived limit is gone. The one limit left is on storage,
BACKUP_MAX_STORED_MB (default 2048), and its message names the setting. Key
rotation covers the pieces, the wipe and account deletion take them with
their backup, and the backup plan lists the table as not carried.
Measured on an account of 2.6 million readings in a 1 GB container with web
and worker in one process: backup 87 s, 97 MiB in 96 pieces, preview 20 s,
download 25 s, restore 264 s with every reading back exactly, peak 567 MiB,
no restart.
A 429 on the lookup no longer waits for the Retry-After; the keyed upload
answers the same question. The source key goes in the query string and the
form. The closing summary and the guide point to Read with AI for deferred
documents instead of Index all.
Refs #1038
A document uploaded with aiRead=defer is now indexed locally by any
automatic path, because the indexer reads the marker from the row rather
than from the job. Index all for search leaves such documents out and no
longer counts them. Only an explicit Read with AI or Generate summary ends
the hold.
Uploads that send a body keep their slot of the hourly limit, duplicates
included; handing it back let a token send the same bytes under id after
id for free. One document takes at most 20 further source ids, beyond
which the upload answers 409 documents.inbound.sourceAliasLimit. The
query-string key check is metered on the lookup allowance it shares with
GET /api/documents/inbound/source.
Refs #1038
Name the Paperless-ngx webhook fields as the workflow editor shows them,
note that Paperless does not follow redirects, and date the double-brace
placeholders correctly: only {{doc_id}} needs 3.0. Say what a dry run
does not check, which re-sends count against which allowance, that Index
all leaves held-back documents alone, and that a workflow can hold AI
reading back with aiRead=defer.
A weekly or uploaded backup written by v1.39.x is a single ~hlgcm1. stream
in data_backups.data. Its key id sits behind the stream marker, which the
string codec's parser does not read, so key rotation filed every such row
as legacy, failed to decrypt it and counted it as an error. On a host that
ran v1.39.x that was every weekly copy, and the rotation never finished
clean.
Rotation now reads the key id of a streamed value and re-seals it as a
stream under the active key, without touching what it holds.
A current disaster-recovery backup writes aggregationProvenance for every
measurement, null included. The restore treated null like a missing field
and marked every Apple Health stats: row without a provenance as
LEGACY_UNKNOWN, which is meant only for backups too old to carry the field.
The nightly daily-mean fold writes exactly such rows, so on a large Apple
Health account a restore changed tens of thousands of readings it was
supposed to put back as they were (#1031).
Only an absent field now takes the legacy path.
Ask HealthLog whether a document is already held before downloading it
from Paperless-ngx or Papra, and send the source key in the query string.
Pin Paperless API version 9 (Paperless-ngx 2.16 and later), apply --since
locally for Paperless too, fall back to the file name for a blank title,
stop with a clear message when the HealthLog address redirects, and say
only that storage is full when the figures are withheld. The guide names
the minimum versions and the shared OTHER namespace.
Refs #1038
A keyed upload that matched a stored document by its bytes never recorded
its key, so deleting that document let the next import run store it again.
Such keys are now kept as aliases of the document, the source-key check
reads them, and the purge carries them into the ledger with the row's own.
A source key may ride the query string and is then answered before the
body is read or the hourly allowance charged; an upload answered as a
duplicate hands its slot back. GET /api/documents/inbound/source answers
whether a key is held, under the same documents:write scope, so an
importer can skip a download.
aiRead=defer is stored on the row. The summary catch-up, the summary job
and lab auto-staging skip such documents until an explicit Read with AI
or Generate summary clears the marker; backup and restore carry it.
A documents:write caller no longer sees the quota figures on a full vault.
Refs #1038
A delegate who may keep the illness journal but not read the visits
would be refused by GET /api/body-sites on every open of the condition
form, and each refusal writes an access-denied row. The field now asks
only when the grant covers the visits' section, and the condition page
shows the site as text rather than a link in that case.
Refs #1025
A condition (illness episode) can carry where on the body it sits, in the
person's own words and encrypted at rest, plus an optional side (LEFT,
RIGHT, BOTH). Same enum, codec and reasoning as the procedure's site.
- migration 0354: two nullable columns on illness_episodes, reusing the
laterality type from 0349
- create and edit accept bodySite and laterality; on PATCH an absent key
keeps the stored value, so an edit from the shipped iPhone app, which
never sends either field, leaves them untouched
- GET /api/body-sites: the sites the record holds across visits and
conditions, folded after the decrypt and bounded per kind; with site
(and laterality) the visits and conditions there, one hop to their
documents, lab results and visits. Read in the profile section;
conditions only under an illness grant with the module on, and links
into ungranted sections come back as unlabelled placeholders
- the link service reads document and visit links from the condition's
end of the two existing tables
- /checkups gains a Body sites tab; the visit and condition forms share
one body-site field pair that suggests sites already used; a
condition's page links its site into the view
- the doctor report, the clinician page and the MCP doctor-visit summary
name the site on conditions; the Coach illness block carries it
sanitised, and MCP get_illness_recovery returns it fenced
- backup and restore carry both fields in both file kinds; the site is
registered as ciphertext and rotates with the key
- i18n in all seven locales, OpenAPI regenerated
Refs #1025
Document tokens, the Paperless-ngx workflow recipe, the import script for
Paperless-ngx and Papra, scheduling for Papra, storage, and
DOCUMENT_UPLOAD_LIMIT_PER_HOUR.
Refs #1038
A single dependency-free Node 22 script that copies tagged documents from
Paperless-ngx (REST, original files) or Papra (API key, /file) into the
vault through a document token. Serial, one file in memory, waits on 429
Retry-After, and resumable through the server's source-key dedup. Supports
--since, --kind / --kind-map, --dry-run with sizes, and holds back automatic
AI reading unless --ai-read is given.
Refs #1038
A narrow `documents:write` token, minted cookie-only at
`POST /api/tokens/documents` and accepted by the vault upload route alone.
A scoped caller draws on its own hourly bucket per token
(`DOCUMENT_UPLOAD_LIMIT_PER_HOUR`, default 120) and gets a receipt
`{ id, duplicate }` instead of the stored row.
Uploads take optional `sourceSystem` / `sourceId` fields. The source key is
unique per account across live and deleted documents, and the tombstone
purge carries it to a small ledger, so re-sending a document the owner
deleted answers 200 `deleted: true` and stores nothing. `aiRead=defer`
indexes locally and skips the summary and lab staging.
The detail sheet shows where an imported document came from; backup and
restore carry both fields; Delete All Data clears the ledger.
Refs #1038, #938
A commit that reaches main without a pull request, such as an asset
uploaded through the web UI, was never analysed until the weekly run.
Run on push to main as well. Pull-request runs still cancel their
superseded heads; push runs are grouped per commit and never cancelled,
so back-to-back merges each keep their own analysis.
Bump @simplewebauthn/server 13.3.3 -> 14.0.2 and @simplewebauthn/browser
13.3.0 -> 14.0.0, and keep React on an exact pin at 19.3.0 with react-dom
and both type packages moved alongside.
SimpleWebAuthn 14 changes one default the server relied on: the verifier
used to accept every algorithm the library knew (EdDSA, ES256, ES512,
PS256/384/512, RS256/384/512, RS1) and now accepts only the three it
offers. Pass both lists explicitly so a registration that succeeded
before still succeeds, and so the offered list stays EdDSA/ES256/RS256
on a runtime that would otherwise prepend ML-DSA-44. The option JSON of
all four ceremonies, and verification against stored credentials, match
the 13.x snapshots unchanged.
Every existing passkey test mocks @simplewebauthn/server, so a library
upgrade could change the options a browser or the iOS app receives, or
the algorithms the verifier accepts, without any test noticing. Drive
the real library with a software authenticator: snapshot the option
JSON of all four ceremonies, register and authenticate with each of the
ten algorithms the verifier has accepted, and verify an assertion
against a credential row stored the way earlier releases wrote it.
On the rollup path the all-time count, min, max and mean are composed
from DAY buckets plus one raw aggregate of the readings older than the
fold window. The bucket half read every DAY bucket the account had, the
raw half every reading before now minus five years, so a reading on any
day that had a bucket before that instant was counted in both. Such
buckets are common: the per-write hook folds whatever day it touches,
the Apple Health import folded whole spans until this release
clamped it, every fold snaps its start down to a whole day, and
nothing removes a bucket once the moving window has passed it. The
count came out too high and the mean leaned towards the doubled days,
while the live fallback reported the true figures for the same account.
Both halves now meet at the start of the window's first UTC day:
buckets from there on, raw readings before it. A reading lands in
exactly one half whichever buckets exist, so buckets already written
before the window need no repair.
The snapshot's second phase (blood pressure in target, the per-context
glucose tiles, the Health Score) waits until weight, both blood-pressure
halves and steps have DAY buckets. The fold writes buckets only inside
the five-year window, so a type whose every reading is older than that
never gets one and the phase stayed null for good. Since the Apple Health
import now folds only inside the window too, an account that imported
years-old blood pressure or steps and nothing since lost those tiles and
its score with no explanation.
An uncovered gate type now holds the phase back only when it has a
reading inside the window, which is the case the backfill will still
reach. The extra query runs on the miss path alone and asks one indexed
EXISTS per uncovered type.
The restore route answers 202 with a job id and leaves the work to the
backup-restore queue. The journey still expected the synchronous 200 and
read the record straight after the click. It now asserts the queued
answer and its status route, polls that route until the job reports
succeeded (a failed job ends the wait with its code), and only then
checks what the restore put back and dropped. The job runs on the
pg-boss worker inside the server under test, as the manual backup pass
earlier in the same file already relies on.
The projector floored today's slots only on an empty archived era, the
reminder worker and the next-due reads on the newest revision boundary,
and the auto-miss pass on the creation instant alone. Switching tracking
back on and then moving a dose time the same afternoon therefore put the
morning dose on the card, and the next day's pass stamped it a miss; a
same-day times edit on any medication did the same.
One helper now names the live era start (the newest active revision's
boundary) and every path floors on it: the projector, the worker, the
doses card, the medication list, the MCP schedule tool and the auto-miss
pass, which leaves a placeholder minted after the switch for a slot
before it.
Three readers compute a rate straight from intake rows without reading a
schedule: the weekly compliance nudge, the adherence series behind the
general status, and the BP-versus-compliance correlation. They now read
tracked medications only. The structural guard covers intake-row readers
as well as schedule readers, with a whitespace-tolerant matcher, a floor
on the match count and a reviewed allowlist.
A medication's units per dose, a schedule's override and a restored
backup now accept any value above 0 and at most 100 with at most four
decimal places, which is exactly what the Decimal(10,4) column stores.
Before, only whole numbers 1-100 and the fractions 1/4, 1/3, 1/2, 2/3,
3/4 passed, so one and a half tablets could not be recorded. A restore
now refuses a value no write route would accept.
The medication editor keeps its buttons and adds an Other field that
reads 1.5, 1,5, 1 1/2 and 1½, holds the step while the text does not
read, and previews the value it will save. The supply tab's packaging
dialog uses the same reader; it refused every non-whole value before,
so a medication at half a tablet could not have its packaging saved.
Cards and the supply tab show a whole number plus a fraction as 1½.
The rule, the input reader and the display format live in one module
without imports so the editor and the validators share them.
Refs #1034
The shipped iPhone app cannot see the schedule of a medication kept as a
record, because it is served with an empty schedules list. Its editor opens
such a medication as daily at 08:00 and, once any schedule field is touched,
sends a full replacement built from that default, plus asNeeded and oneShot,
and never trackIntake. On a record-only medication the update route now
drops schedules, asNeeded and oneShot from a write that does not name
trackIntake and applies the rest. The web schedule editor and the wizard
name the flag on every save.
The corpus walk and the rotation script paged with Prisma's cursor and skip.
Prisma resolves that cursor by reading the page's last row again, so when
that row was hard-deleted before the next page (a backup replaced, a cached
response expired), the next page came back empty and the walk ended early.
The scan then counted fewer rows than the database holds, which could read
as nothing left on the old key while rows it never reached still were.
Both walks now ask for the rows whose id is greater than the last one read,
which needs no row to still exist.
A per-medication trackIntake flag (migration 0351, default on for every
existing and new row). With it off the dose, dates and schedule stay stored,
but nothing derives a due dose from them: the reminder worker, the manual
reminder check, the today projector, the auto-miss pass, the low-stock pass,
the doses card, the today list, the outstanding-dose badge, compliance and
its heatmap, the doctor report, the Coach, the BP-status gate, insights,
targets, achievements, the efficacy view and the MCP schedule tool all skip
it.
The medication wire serves such a medication with schedules: [] and the
stored rows in an additive recordedSchedules field, so a client that arms
local reminders from schedules stays silent without an update. Switching
tracking off or back on archives a schedule revision at that instant, the
untracked stretch with an empty payload, so it never counts as missed and
the projector does not put a dose from before the switch back on the card.
A structural guard freezes the set of schedule readers: each one filters on
the flag, derives from dueSchedules, shapes the wire with scheduleWireFields,
or is allowlisted with the reason it derives nothing due. The flag travels
through backup, restore and the medication CSV export.
The detail page leads the schedule tab with the switch and a one-line
explanation of each state; with tracking off it drops the reminder card,
the next-dose and adherence rows, and shows the stored schedule as the
record. The wizard summary carries the same switch. Cards, the table and
the detail header mark a record-only medication, which leaves the take and
skip buttons, the take-all set, the log-dose dialog and the dashboard quick
add. Strings in all seven locales.
Portable files exported before v1.39.1 carry no creation date for a
medication, so the restore stamped the restore time. The dose history reads
that date as the day the medication began to exist, and every miss before
it, including the day before the restore, dropped out of the record.
When the file has no creation date, the restore now uses the earliest thing
the file records for the medication: its first dose (scheduled, taken or
written), the start of its first schedule revision, its course start, its
first pause, dose change or side effect, and never a date after the restore.
A file that records nothing about the medication keeps the old default.
An uploaded file whose encrypted copy passes the size one stored backup may
have on this host (a fifth of the heap limit) stopped the store with
BackupBlobTooLargeError, which the route let through as a 500 and reported
to the error tracker. It is now a 413 with the numbers and the remedy in the
message and meta.errorCode backup.upload.too_large, and the refusal is
audited like the others. The store rolls back, so nothing is kept.
A restore commits its transaction and then rebuilds the chart tiers. A
worker that stopped during that rebuild left the job running with a stale
heartbeat, and the boot sweep and the admission check treated it like any
interrupted job: the sweep queued it again, so the second run deleted and
re-wrote the account and lost whatever was written in between, and a second
interruption closed it with "rolled back, nothing was changed", which was
false.
The job now records `committedAt` (added to migration 0347, which has not
shipped) and awaits that write before the rebuild starts. The sweep and the
admission check close a committed job as `failed_after_commit`, never queue
it again, and evict the account's caches. The rebuild itself is left to the
nightly and boot passes that already rebuild missing tiers.
The module comment now says what an interrupted restore does (restarted
once, then failed) and what happens to other writes while a restore runs:
new rows are written at once, updates to rows the restore already deleted
wait on their lock until it commits or rolls back.
In the procedure history every row carried the kind as its heading and
again as its badge. A procedure now leads with its reason, and the
practice moves to its own line beneath the body site.
Refs #1025
New structured leaf SURGICAL_HISTORY in the history group and in the
standard template: every procedure that took place, oldest first, with
its date, what was done (the visit's reason), the body site with its
side and the outcome. Lifetime reference data like the immunization
record, so it ignores the report window. The PDF prints it as a table
and the clinician share page renders it; saved selections do not widen.
The FHIR bundle carries no Procedure resource, on the same terms as
visits: no REST route or capability entry exists for it.
Refs #1025
A visit can be filed as a procedure or surgery (new EncounterKind
PROCEDURE) and carry a body site in the person's own words, encrypted at
rest, plus an optional side (LEFT, RIGHT, BOTH). An existing visit
switches to the new kind by an ordinary edit, keeping its date, reason,
outcome and links.
- migration 0349: enum value, laterality type, two nullable columns
- GET /api/encounters/procedures: the DONE procedures, newest first,
searched after the decrypt over site, side and reason, with body-site
choices and a total computed over the whole history
- create and edit accept bodySite and laterality on any kind
- /checkups gains a Procedures tab with a body-site filter; the visit
form offers the site for a procedure and keeps it in view on any
visit that carries one
- backup and restore carry both fields; the body site is registered as
ciphertext and rotates with the key
- MCP get_visits narrows by kind and reads the whole record for
procedures when no window is named; the Coach visit block names a
procedure's site
- i18n in all seven locales, OpenAPI regenerated
Refs #1025
The named-stage arm took the body's source and so wrote MANUAL under a
scoped credential, while every other arm of the route resolves such rows
to EXTERNAL. It now resolves the source from the credential as well, and
a bridge segment stays apart from the same reading from Apple Health.
Decoders written against the shapes the shipped iPhone builds read still decode every payload this release changes, and the old shapes still satisfy the new document: the new batch-entry fields and the error code stay optional, the profile time zone stays a required string, and the named sleep-stage answer sits beside the old create response rather than in place of it.
A bridge such as Tasker or Home Assistant could only send sleep stages
through the batch route, in HealthKit's terms: the category identifier,
a stage codepoint from 0 to 5, and a value it had to work out in
minutes. It can now post one segment to `POST /api/measurements` with
`type: "SLEEP_DURATION"`, a `sleepStage` of `IN_BED`, `ASLEEP`, `AWAKE`,
`CORE`, `DEEP` or `REM`, and either `startDate` + `endDate` or
`measuredAt` (the end) + `value` (minutes).
The row is the one the batch route writes for the same segment, and the
dedupe is the batch route's: the opposite-source same-reading check,
then one reconcile over both unique identities. Without an `externalId`
the server derives one from the stage and both instants, so a re-post
answers 200 `duplicate`, a corrected start answers 200 `updated`, and a
new segment answers 201 `inserted`. The narrow `measurements:write`
token is accepted and still writes only MANUAL rows. Every other body
on this route behaves as before.
Requested in #972.
`/api/auth/me` has reported the resolved zone, the stored one or the
instance default when the stored value is unusable, while
`/api/user/profile` returned the raw column. A client reading the
profile could cut days differently from the server, and sending the
value back in a PATCH was refused.
Both reads, and the profile PATCH answer, now go through one
`resolveStoredTimezone`. The field stays a string and always holds a
valid IANA name. The web account editor already reads the resolved
zone from `/api/auth/me` and saves through the validating timezone
route, so it shows the zone in use and can only write a valid one.
The nutrient batch docs told a client to log and drop an `upsert_failed`
entry. The route means the opposite: a write group failed on the
server's side and nothing was stored. The docs now say to send the
entry again on a later sync, and keep the three validation skips
terminal.
A retry under the same Idempotency-Key used to replay the cached
failure, because the 200 carrying `upsert_failed` was cached like any
other. Both the nutrient and the cycle bulk routes now mark that
response `no-store`, as the measurement batch already does for
`failed`, so the retry writes again.
The cycle bulk route already answered its validation refusal with
`cycle.bulk.invalid` and an over-cap batch with `cycle.bulk.too_large`.
Both are now pinned by tests and named in the route's 422 response.
The validation 422 from `POST /api/measurements/batch` now carries
`meta.errorCode: "measurement.batch.invalid"`, beside the existing
`too_large` and `source_not_permitted` codes, so a client can file the
rows as permanently refused instead of holding its anchor on a guess.
A `value_out_of_range` entry now also carries `convertedValue`, the
reading after conversion into the stored unit, and `range`
(`min`, `max`, `unit`), the band it was checked against. Both appear
only on that skip. A fraction sent where a percent was expected is now
visible in the response instead of looking like a sensor glitch.
Status codes, the envelope and every existing field are unchanged.
Each `createManyAndReturn` flush of the export import left about 5 MB of
heap behind until some two dozen calls had piled up. A 300 000-record
synthetic import under a 524 MB heap settled at 53 MB retained; a run of
back-to-back 600-row calls with nothing between them reached 212 MB.
Spot rows now go through the column-array `INSERT … SELECT FROM unnest`
the restore already uses, with `ON CONFLICT DO NOTHING RETURNING`, which
is what `createMany({ skipDuplicates })` emits. The same import holds
11.5 MB and finishes in 35 s instead of 55 s. Ids are minted in the
cuid() shape, defaults are written as the client would write them, and
integration tests compare the returned rows and the resulting table with
`createManyAndReturn` for fresh rows, conflicts on either unique
identity, and duplicates inside one call.
Restoring a large account was one request: 94 s for 1.25 million readings.
A reverse proxy that cuts a request at 60 s (nginx's default) showed the
operator an error for a restore that went on to finish on the server.
The restore request now checks the typed confirmation, that the backup
exists and that its stored copy opens, admits one job for the account and
answers 202 with its id. The `backup-restore` queue runs the restore and
records its phase, counts and outcome on a `backup_restore_jobs` row
(migration 0347), which `GET /api/admin/backups/restores` and
`GET /api/admin/backups/restores/{id}` read back.
What a restore checks and writes is unchanged. The body moved from the
route into `restoreBackup` (src/lib/export/restore-backup.ts): every
refusal still happens before the first delete and the replacement is still
one transaction, so a failure part-way leaves the account as it was. While
it runs the account keeps showing its current data, and other writes are
neither refused nor held, as before.
- One restore per account: a partial unique index admits one queued or
running job, and a second request gets 409 `backup.restore.active` naming
the running job. A per-account advisory lock inside the transaction holds
even if a worker thought gone is still writing.
- Time budget: the job's expiry is two hours and a file whose transaction
limit would run past three quarters of it is refused before anything is
deleted (`jobDeadline`, beside `jobBudget`).
- Resuming: the job writes a heartbeat every two seconds. A boot sweep
queues a job whose worker stopped again from the start, at most twice,
then fails it as interrupted. The admission check releases an abandoned
job so it never holds the account's slot.
- The staged file is the stored `DataBackup` row, already encrypted at
rest. It stays whatever the outcome, so a retry needs no new upload. The
job records a digest of the copy and refuses one that was replaced while
it waited.
- Failures carry a stable code and a sentence that says what to do.
- The console follows the job: phase and counts while it runs, the outcome
after, and after a reload it finds the job again from the server. A poll
that fails on the network is never shown as a failed restore. A web
process separate from the worker evicts the account's caches when it
first reports the restore finished. The success line goes through the
outcome module, so an empty file is not drawn as a tick.
The model is registered in the backup plan (not carried) and the wipe plan
(wiped with the account); the queue in the maintenance registrar, the
subsystem surface and the job-fact allowlist; the new code in the error
catalogue; the routes in the OpenAPI contract. The tests that pin restore
semantics now drive the real route and the real job through one helper, and
`backup-restore-job.test.ts` pins the asynchronous contract itself.
The picker lives inside a modal sheet, and a toast sits outside it where
its button cannot be pressed while the sheet is open. The undo is now a
line under the chips that stays until the next change. The calendar's
intercourse diamond also grows a step so it reads as a diamond on a
phone rather than a dot.
0346 is taken by the external-source migration that reached main first.
The repair touches only intake rows and depends on no other migration of
this release.
A dose opened from the list carried no document links, since only the
detail read has them. The edit sheet showed an empty picker, and saving
any change sent that empty set, which the route reads as unlink
everything. The sheet now reads the dose's own links before it shows
them, and a save leaves the links alone unless they were changed.
A link to one dose or visit (/vaccinations?dose=, /checkups?visit=) now
scrolls to it and opens it for someone who may edit it.
A link chip was one button with an X drawn inside it, so tapping the
chip to open the linked record removed the link. The label now opens the
record (a dose, a visit, a condition or a document) and a separate X,
labelled with what it removes, unlinks, with an undo in the toast. Each
chip also carries its record's date, so several doses of one vaccine no
longer read as identical chips. The fix sits in the shared picker, which
is every chip list with this shape.
An intercourse entry left no trace on the month grid and the legend had
no entry for it. The day now carries a diamond marker, and anything else
logged that day with no marker of its own (a test, contraception,
spotting, a temperature, a mucus or cervix reading, a note) shows a
hollow ring, so a day with one of those logged no longer looks empty.
Both are named in the legend and in the cell's accessible label.
The calendar grid carried flow, symptoms, BBT, the ovulation test and the
mucus reading, and dropped the rest of the day log: intercourse, spotting,
the pregnancy and progesterone tests, contraception and whether a note
exists. The published contract also promised the three cervix signs while
the route never sent them.
The intent fields resolve through the same reader the day-log read uses,
so an encrypted envelope reads the same on both, and the grid is served
under the same cycle grant as the day log. Additive fields; the contract
test pins the wire keys to the published schema.
The text is unchanged and waits with the other release drafts; keeping it
in the branch made CHANGELOG.md collide with every release that shipped
while this waited.
Review caught a behaviour change this branch causes but never wrote down.
`pickCanonicalSourceRows` takes the first ladder source present in a day's
bucket and filters the day to that source. Before, a typed reading and a
bridged one were both MANUAL, so both survived. Now MANUAL ranks, EXTERNAL
does not, and the bridged row leaves the canonical set on any day that also
has a typed one.
That reaches every surface needing one source per day — the daily totals
behind the charts, the health score, the doctor report, correlations, mood
crosstabs and personal-record detection. A day with only bridged readings is
untouched: nothing ranked is present, so the picker's pass-through keeps
everything.
The changelog says so in the user's terms, including the part that is a fix
rather than a change: two indistinguishable MANUAL rows were both counted,
which inflated a running total on a day somebody did both.
Four cases pin it, because it is emergent from a rank ladder and stated in
no single place: the contested day, the bridged-only day, the both-MANUAL
shape the change moves away from, and a stored ladder that names EXTERNAL
being honoured. Ranking EXTERNAL turns the first two red, which is the
review anybody adding it to a ladder should have to pass.
The measurements list already badges any row whose source is not MANUAL and
already builds its source filter from the shared enum, so both pick the new
value up on their own. What it needs is a name: `EXTERNAL` reads as a leaked
enum constant, and it is the only new value a user is expected to operate,
since it is an option in the filter and a badge on every bridged row.
Both label catalogues gain an entry. They are keyed by the enum, so a new
source without a label is a type error and the coverage guard fails the
suite — which is exactly what happened here, and is the reason this carries
a settings label too even though EXTERNAL sits on no priority ladder and so
never renders there.
The settings card's scope note and the Home Assistant guide both claimed
readings arrive "as if you had entered them by hand", which is no longer
true. Both now say the rows are marked as coming from an external device and
are still yours to correct, and the guide spells out that the payload must
not name a source at all.
The changelog entries go under Unreleased as Changed: v1.38.1 shipped the
token, so these describe a change to something people already have. They say
what that means in practice — rows written before this keep the old label
and cannot be sorted out afterwards, a batch re-push can duplicate because
the dedup key now carries the source, and an automation sending an explicit
`source` field has to drop it.
The write-smoke suite is `satisfies Record<MeasurementSource, SourceCase>`,
so growing the enum is a compile error until a case exists; the new one
goes through the ordinary single-entry POST, which is the production path.
It carries the trap this suite sets for you written down: `beforeEach`
seeds a session cookie and the caller resolver is cookie-first, so arming
only the authorization header resolves as that session and quietly writes
MANUAL — the case would then pass without testing anything. Both hardcoded
totals move 14 → 15.
In the bearer-scope suite the two rows that asserted MANUAL now assert
EXTERNAL, and three cases join them: a body naming MANUAL is refused on
each route (the widening from "names APPLE_HEALTH" to "names anything",
which nothing else covered), and a bridge-written row is still value-
editable by its owner through PUT. That last one guards the split between
the write allowlist and the correctable set — folding the two constants
back together value-locks every bridge row behind a 409, and this is what
notices.
Under a `measurements:write` credential the row's source is now decided by
the server on both routes, and the body may not name one at all.
The 422 widens from "names APPLE_HEALTH" to "names any source". It was
narrow only because the forced value was MANUAL, so a body saying MANUAL
was never actually overridden. Now it would be — and overriding a client's
explicit assertion quietly is the thing both routes' comments rule out,
while honouring it would defeat the change: a bridge that kept sending
MANUAL would stay indistinguishable from a typed reading. Status and
errorCode are unchanged, and the documented Home Assistant payload sends no
source at all.
The single-POST predicate keeps reading the raw body: the create schema
defaults `source` to MANUAL, so after parsing "said MANUAL" and "said
nothing" are the same value. Both of that route's create sites resolve the
source, the array arm included — that is the combined BP + Pulse path a
bridge uses, and missing it would leave exactly those rows unbadged.
Two consequences worth naming. Bridge rows no longer join the
MANUAL/APPLE_HEALTH cross-source merge, so they can't be swallowed by a
rule written for the iOS adopt-on-pair case. And a bridge posting at the
same instant as a hand entry now lands both rows instead of a 409, which is
honest and does not double-count: the rollup collapse is per
(type, day, source) and MANUAL outranks the unlisted EXTERNAL.
Adds the `EXTERNAL_SOURCE` leaf beside `scopes.ts` and mirrors the value in
`measurementSourceEnum` so the read shapes and the sync decoder see it. It
stays out of `WRITABLE_MEASUREMENT_SOURCES`: a source a client could assert
would prove nothing about where a reading came from, and that constant is
published to clients as `ingest.writeAllowlist`.
The value-edit gate in `PUT /api/measurements/[id]` needs the other question
answered, though — whether the number is the user's own to fix — so it now
reads a separate `USER_CORRECTABLE_MEASUREMENT_SOURCES`. The 409 that
protects a Withings row exists because the value is the provider's reading;
the hardware behind an ingest token is the user's own scale, and locking
those rows would contradict what the settings card and the Home Assistant
guide promise. That is the only site reading the wider set.
Rows pushed in through a `measurements:write` Bearer token are attributed
`MANUAL` today, so afterwards nobody can tell which readings came off the
scale and which were typed by hand. Give that write surface its own
`MeasurementSource` value, the way 0189 did for TELEGRAM and 0207 did for
MCP.
Enum extension only: purely additive, no row touched, `IF NOT EXISTS` makes
a rerun safe, and the value is not USED in the migration so Postgres'
same-transaction restriction does not bite. Declared last in the datamodel
so the Prisma member order and the physical order stay identical.
No backfill, and none is possible — `measurements` carries no actor or
token column, so a bridge-written MANUAL row is byte-identical to a typed
one. Existing rows stay MANUAL.
The one-shot hourly rebuild of already-folded heart-rate, HRV and SpO2
days walks one account's whole folded history in one job with the
default fifteen-minute expiry, about fifty statements per day. Like the
drains it now stops between days once 75 % of the expiry has passed or
pg-boss aborts the job, and fails so pg-boss retries it (the boot send
allows three retries); a rebuilt day leaves the candidate set, so the
retry starts at the first day not yet rebuilt.
The off-host backup walked every account in one job with the default
fifteen-minute expiry, and a retry started again from the first account.
On an instance with one large record next to others, an attempt that
ran out of time spent its retry uploading the accounts it had already
done (#1031).
The run now stops between accounts once the job's time budget is spent
and fails, so pg-boss retries it. Every attempt of a job carries the same
creation time; an attempt skips the accounts whose ledger shows a success
since then, and keys its objects on that day, so a retry after midnight
still writes under the run's date. A new job uploads every account again,
and a retry whose accounts were all done passes.
After an Apple Health import the worker folded the rollup table from the
account's first measurement to its last, whatever the import carried, and
so also folded history older than the five-year window that the boot
backfill, its discovery and every read path keep to.
The import now records the oldest and newest instant it wrote
(`measuredSpan` on the job result), and the fold covers that span,
clamped to the fold window. On the seeded account of 1.25 million rows the
full fold took 12 s, the slowest statement 2.7 s against the 60 s
statement limit, so this is about the window rather than time.
Key rotation and the rotation status read every column that was not
flagged as a blob with one `findMany` over the whole table.
`Measurement.notesEncrypted` and `MoodEntry.noteEncrypted` are such
columns, so rotation loaded every measurement of the instance: 1.25
million rows on one large account (#1031), and the rotation status page
did the same on every view.
Every column is now walked with the id-cursor pages the blob columns
already used: 25 rows a page for blobs, 5 000 for everything else. The
maintenance script's own per-column walks page the same way. On the seeded
account with 1 460 notes to rotate, peak heap fell from 272 MB to 153 MB,
and it no longer grows with the table.
`scanned` now counts the rows that hold ciphertext for every column, as
it already did for the blob columns, rather than every row of the table
for some columns and only the ciphertext rows for others. The runbook
says so.
The repair for the inflated cumulative records deleted the suspect rows,
committed, and then re-ran the detector. When the detector failed, which
a timeout on a large account or a restart can cause, the records stayed
deleted, and since discovery looks for the suspect rows the account was
never repaired again (#1031).
The delete and the re-derivation now run in one transaction, with the
detector reading through it, so they commit together or not at all. The
transaction gets five minutes; the detector took 19.8 s on the largest
seeded account.
The personal-record pass, every half hour for every account, read every
live row of each cumulative type into memory to find the best day. With
784 000 active-energy rows on one account (the per-sample rows pile up
when the nightly drain cannot finish, #1031) it ran a 524 MB heap out of
memory; the old code needed 1.1 GB of heap for that account.
It now reads the type in bounded keyset pages and handles one local day
at a time. The canonical-source pick it applies is per day, so the day
totals and the winner are the same; a tie still keeps the earliest day.
Measured on the same account: 189 MB peak heap, the same record. The
warm-up gate counts only up to its threshold instead of counting a whole
history.
`iterateSourcePages` and `iterateDayBuckets` in the consolidation base
take the row type as a parameter, and the day grouping can keep every row.
An account of 1.25 million measurements could not export its data or be
restored from any backup (#1031). Measured in a 1 GB container:
- The full export and the passphrase export built the whole payload and
its JSON string in memory; the app ran out of heap and restarted.
- The weekly backup's JSON is 662 MB, longer than any string V8 can hold,
so `unpackBackupBlob` threw on every read. Restore, download and the
restore preview all answered "could not be decrypted".
- Upload accepted 10 MB, a fraction of a large export.
Every reader and writer of a backup file now streams it:
- `scanBackupJson` reads a backup's bytes once, keeps every section but
the named bulk arrays, and hands their elements out one at a time.
`readStreamedBackup` uses it twice: a first pass that validates each
measurement against the element schema and keeps the rest of the file,
and a second pass that hands the measurements to the writer in batches.
Stored copies and off-host objects are authenticated whole on their
compressed ciphertext before any plaintext is read; only the
decompression is streamed.
- Restore writes the measurements with one `INSERT … FROM unnest` per
batch. Prisma caches a query plan per `createMany` row count, and one
1 000-row plan kept about 145 MB alive, which alone put the restore past
the heap limit. The transaction budget grows with the measurement count.
The awards and ECG sections are handed the ids they reference that were
written, not every written id.
- The export routes write the file into the response as it is produced.
The passphrase export spools its ciphertext to a temporary file because
the HLX1 format puts the tag first; the format is unchanged, so existing
archives open as before.
- Upload accepts the raw body, JSON or gzip, up to the 512 MB request
ceiling, checks it as it arrives and stores it in the same pass; the admin
page compresses the file in the browser before sending it. Multipart stays
for small files.
- Download streams the stored JSON; the preview counts measurements from
the stream. The monthly restore drill and `scripts/restore-backup.ts`
read the off-host object the same way; the script can write `.json.gz`.
On the seeded account in a 1 GB container: export 25 s at 389 MB peak,
passphrase export 25 s, both uploads 19 s, restore of the weekly backup
94 s at 554 MB peak, and export, upload and restore round-trip to exactly
the same rows. The encrypted-column guard now also recognises the raw SQL
statement that writes `DataBackup.data`.
`scripts/seed-large-account.ts` inserts about 1.25 million measurements
for one user, shaped like an Apple Health export plus the iOS sync: dense
heart rate, HRV and SpO2, the high-frequency mean types, export `stats:`
daily rows, recent per-sample cumulative rows and nightly sleep stages.
Everything is generated in Postgres, so a run takes seconds, and it ends
with ANALYZE. It is how the maintenance jobs in #1031 were reproduced and
measured; never point it at a database holding real data.
The backup streamed its JSON through gzip and the cipher, but kept the
stored copy as one string and handed that to Postgres in one parameter.
On a seeded account of 1.25 million measurements the copy is 64 MB of
base64, and the pieces, the joined string and the driver's copies of it
grew the process from 309 MB to 946 MB resident. A bare process needed a
heap limit above 400 MB to finish it (the new path finishes under 220 MB).
With the dense retention running beside it in a 1 GB container the
process died of heap exhaustion, and the backup job sat "active" until its
two-hour limit marked it "job timed out" (#1031).
`packBackupBlobInto` hands the stored copy on in pieces of up to 4 MB as
it is produced, and `storeBackupBlob` writes them into a temporary table
on its own transaction and assembles the row with one `string_agg`. The
process never holds the whole copy. The account's previous copy stays in
place until the new one is complete: a failure rolls back to it.
Measured on the seeded account in a 1 GB container, web and worker in one
process: the backup completes in about 30 s with the dense retention
running beside it, where v1.39.0 crashed within 30 s.
The streamed JSON also reports its progress to the job every 50 000 rows.
pg-boss marks a job "job timed out" at its expiry and fires its abort
signal, but does not stop the handler. A drain that could not finish kept
writing after its job was declared dead, and the retry then ran beside it
over the same days.
The nightly drain tick and the per-user dense-retention and daily-mean
jobs now stop between days once 75 % of the job's expiry has passed, or
as soon as pg-boss aborts the job, and complete with `stopped_early`
instead of failing. What they folded is committed. A nightly tick that
stopped early after folding something sends itself again a minute later,
up to 24 times, so a first-time backlog after a large import drains the
same night; a run that folded nothing does not hand on, so a pass that
cannot move does not loop.
The expiries themselves stay at their defaults: with the passes bounded
per day and budgeted, a longer limit would only delay the signal.
The consolidation drains (cumulative, daily mean, dense intra-day
retention, legacy steps) gathered a type's whole history into one array
before folding the first day. On an account of 1.25 million rows the
heart-rate array alone held about 230 MB, and the dense retention peaked
at 460 MB of heap against a 524 MB limit; in a 1 GB container running the
web server and the worker together the process died of heap exhaustion
within a minute and the job was later marked "job timed out" (#1031).
- Pages are now handed on one at a time and each local day is folded as
soon as its last row has been read, so a pass holds one page and one
day.
- The keyset cursor gains a redundant `measuredAt >= cursor` bound.
Without it Postgres could not use the cursor as an index condition and
re-read every earlier row on each page (47 ms and 248 000 filtered rows
for one page halfway through the heart-rate history; 5 ms now).
- The dense fold resolves a day's hourly rows with one lookup and one
insert instead of two lookups and a write per hour. On the seeded
account the pass went from about 290 000 statements to 54 000.
- `shouldStop` lets a caller end a pass between days; the result says
`stoppedEarly`. Days commit one by one and a folded day leaves the
scan, so the next run continues at the first day not reached.
A job that ran into its pg-boss expiry left no trace in the app log. Most
handlers annotate a wide event nobody emits, pg-boss records an expiry only
in its own table, and a worker that dies of heap exhaustion writes one
FATAL line that names no queue. On a large account three queues failed
with "job timed out" for days and the log said nothing about them (#1031).
Every handler now runs inside `observeJob` (through `runJob`), which writes
`job.progress` every two minutes while a handler runs (elapsed time, heap,
RSS and the last progress the pass reported), `job.expired` when the job
passes its expiry while the handler is still running, and `job.finished`
when a long or expired run settles. At worker boot, `worker.boot.jobs_cut_off`
names every job a previous process was still running when it stopped.
The admin card's "job timed out" now says what the limit was and that the
cause is either a run too slow for it or a restart while it ran.
Automatic matching counts a dose taken up to an hour before a slot's
on-time window as on time. A dose pinned to the slot was judged from the
window's start instead, so the same dose read on time unpinned and late
once pinned. A pinned dose now uses the same early grace, and the
confirmation says so plainly: the dose counts as if it had been logged for
the slot, on time or late depending on when it was taken.
Refs #1028
The history view treated every auto-miss anchored before the medication's
creation as the today projector's placeholder and dropped it. That is true
only on the creation day. History that arrives from before the creation, a
restore of an older file for one, can carry real misses, and those vanished
from the history and the rates.
The creation stays the boundary of expected doses: an earlier one, the
first recorded dose or the course start date, would read every day between
it and the creation that no row names as missed, and an import carries
takes and skips only, so those misses would be invented. Before it, every
recorded take, skip and miss claims its slot. Only rows that record nothing
are dropped: a pending row, and an auto-miss within the day before the
creation, which is where the projector's placeholders sit.
Refs #1028
The today projector mints a pending placeholder for every slot of the
current day, so a medication added in the afternoon carries placeholders
for that day's earlier slots. The hourly auto-miss pass stamped them as
forgotten doses a day later, and every reader that counts raw rows saw a
miss on a day before the medication existed: the miss-free streak broke on
the creation day. The dose history, the rates and the Coach already ignore
such a placeholder; the streak read the row as it was.
The pass now stamps a miss only on slots from the medication's creation on.
A placeholder before it stays pending, so the web and the app can still
offer the slot for logging and a dose recorded for it converges onto it. A
migration returns the placeholders an earlier pass already stamped. It
touches only rows that can be nothing else: server-minted, never acted on,
anchored before the creation but within the day before it, which is where
the projector's placeholders sit. An older auto-miss carried in with
restored history keeps its state.
Refs #1028
A portable export left the medication's creation instant out, so a restore
stamped the restore time instead. The creation instant is where a
medication's expected doses start, so the whole restored history then read
as predating the medication: its missed doses dropped out of every
adherence rate. The portable file now carries the creation instant as the
disaster-recovery file already did, and the restore, which already honoured
it, brings it back. A round trip pins both file purposes.
Refs #1028
The adherence calendar counted every off-schedule ledger row as a dose taken
on time. An orphaned skip or auto-miss, a row on an instant that is no slot
of the schedule, carries no taken time, yet coloured its day as taken. Such
a row now counts as taken only with a taken time; an orphaned skip reads as
skipped, and an orphaned auto-miss counts nothing, as it already does in the
rate.
Refs #1028
The confirmation promised that an attributed dose always counts as taken
late. It counts as on time when it falls inside the slot's on-time window,
which also covers a dose taken shortly before the slot's clock time, and
as late otherwise. The copy now says so in every locale, and a test pins
the split it describes.
Refs #1028
A medication added at 16:05 with doses at 09:00, 14:00 and 21:00 showed its
creation day wrongly in "Last 90 days". The 14:00 dose, recorded afterwards
on its own slot, read as an off-schedule take "due" at 21:00, and a 09:00
row with no time appeared as off-schedule too.
The history route minted slots only from the creation instant, so the 14:00
slot never existed for the dose to land on, while the write path, which
mints the whole day, had filed it there. The 09:00 row was the pending
placeholder the today projector mints for every slot of the day, which the
auto-miss pass stamped a day later; with no slot to bind to it surfaced as
an off-schedule row.
The ledger now mints slots over the requested window and takes the
creation instant as the floor of expected slots: a slot before it appears
only when a recorded dose claims it (a take by its window or its pin, or a
skip), and a pending or auto-missed placeholder on it is dropped. The first
archived schedule era also covers the stretch before the creation, so an
edited schedule behaves the same.
Every adherence surface tallies the same ledger with the same floor, so the
detail rates, the calendar and the ledger bar agree on that day. The doctor
report, the Coach and the adherence chips previously read the slots before
a medication existed as missed whenever their window started earlier; they
now read nothing there.
Refs #1028
The cycle delete now runs the shared removal, which finds live starts only.
A second delete of the same cycle read nothing to remove; the route now says
so explicitly and answers 204 before the transaction, and the tombstone the
first delete left stays as it was.
The live daily fold ranked every row by source before folding, which sorted
the whole window twice. A year of per-minute heart rate (525,600 rows) took
about 0.55 s per series on Postgres 16, with both sorts spilling to disk.
The fold now aggregates per source and bucket in one hashed pass and then
keeps each bucket's ladder-canonical source. Folding the canonical source's
rows is the same as folding that source's group, so the numbers do not
change; the same year reads in about 0.29 s. A new integration case pins the
canonical pick, mean, count and spread for a day with three sources.
A start logged within one period before an existing start folds that start
into itself and soft-deletes it, since the two are one period entered out of
order. Nothing recorded which start was folded, so taking the earlier start
back could not bring the later one back: a mis-tap on the 5th with a real
start on the 10th, then deleted, left the 10th gone and the cycle before it
reopened.
Migration 0345 adds menstrual_cycles.absorbed_into_id. The folded row names
the start that absorbed it; removing that start restores it, re-derives its
own end and hands its days back to it. The column is needed because a
soft-deleted row alone cannot tell a folded start from one the person
deleted, and the audit trail is not state a removal can read. Deleting a
cycle directly now runs the same shared removal as deleting its day-log.
The measurement form, and with it the add menu's Measurement entry,
listed sleep and glucose with those modules switched off. It now filters
its types through the surface map's type ownership, which a test holds
equal to the ownership the server gates measurement types on, and a
default or last-used type that is switched off falls back to the first
offered one.
The runtime accepts "+05:30" as a zone id, so isValidTimezone let it through,
but Postgres reads a bare offset in AT TIME ZONE with the POSIX sign (east
of UTC is negative), and the day-grain SQL aggregates would cut every day on
the wrong side of UTC.
isValidTimezone now accepts named zones only: whatever the runtime resolves
to a bare offset is refused, while named zones, their aliases, UTC and the
Etc/GMT family stay accepted. Every write that already validated through it
refuses offsets now, and the screener and mood linked-context request schemas
validate their zone too. A value stored before this change can still sit in
the database, so the SQL call sites that read the stored zone directly (the
dashboard summary, achievements, the doctor report, the tiered AI context)
fall back rather than splice it, as the resolver and the day aggregates
already did.
For a cycle-tracking account the strip painted Strain first and swapped
it for the cycle ring once the calendar read landed, moving the row
under the reader. The ring's decision now reports while it is pending,
and the strip keeps its skeleton until then.
Settings → Coach's sources pointer and the About-me follow-up questions
linked to /coach whatever the Coach's state. Without a provider or
consent the Coach page sends the visitor straight back to Insights, so
the link only bounced. Both now follow the Coach capability; the
questions themselves stay readable.
The confirmation said the AI features stop straight away, and the
withdrawn line that no health data goes to an AI provider. The Coach and
Insights need the receipt only when they use the AI access the server
provides, so on an own key or a local model they keep working, and the
model-written notes are kept while another consent still covers
Insights. Both strings now say so in every locale, and a guard pins the
copy next to the resolver rule it describes.
The aboutMeQuestions capability admits an ai_insights_only receipt, but the
question generator ran its own gate that demanded a Coach receipt, so a
person whose capability read available still got the fallback questions on
an operator-held chain.
The generator now runs the shared wire re-check (aiEgressRefusal) for the
aboutMeQuestions key, so the receipt kinds and the rule for when one is
needed come from the capability table alone.
Migration 0343 moved the operator's Coach answer from module availability
into the Coach switch and folded every live row. Restoring the instance
settings from an older file wrote the dead coach key back into module
availability and left the Coach switch on, so a Coach the operator had off
came back.
The restore now applies the same fold: coach false in the file's module
availability switches the Coach off, and the key is dropped either way.
The visit form's lab and episode pickers fetched with their own page
sizes under the same keys as the Labs list and the illness pages, which
fetch with others. Whichever read landed first served the other: the
picker could see 50 episodes instead of 100, or the Labs list a 200-row
page as the whole list. The picker reads now have keys of their own,
under the lab and illness roots so writes still evict them.
Three assertions in the AI-optional overview spec named slots that no
longer exist anywhere in the tree, so they held whatever the page did.
They now name the error primitives the overview paints on a refused read
and the Coach hand-offs that paint only while the Coach is available, and
the no-provider journey checks that /coach goes back to the overview.
A consent withdrawal purges the stored status notes, briefing, reaction
lines and workout paragraphs in its own transaction, but a provider call
that was already in flight still persisted its reply afterwards, bringing
the text back under a consent that no longer existed.
The consent rule now runs once more after the reply arrives, against the
provider that actually answered and with a fresh receipt read: in
runStatusCompletion (status notes, workout paragraphs, narratives, derived
assessments), before the briefing commit, and before the reaction-line
commit. A refused reply is dropped; the spent tokens stay on the ledger.
A dose or a visit can link documents from its own form, and a
document's sheet can link doses and visits; both ends write the same
table, and the sheet writes the whole set on every tap. Dose and visit
writes left the document reads alone, and the sheet seeded its pickers
from a detail cached for five minutes, so the first tap there after
linking from the dose's form wrote a set without the new link and
removed it.
Dose and visit writes now evict the document root, the sheet reads the
document again on every open, and its pickers wait for that read before
they offer a change.
With no provider set up, a fresh self-host's default, the Coach module
is on but /coach redirects to /insights because the Coach is not
available. The tour read the raw module map, kept its Coach stop, pushed
/coach, was sent back to /insights and pushed /coach again on the
pathname change: a loop.
The tour now reads the same capability-folded map as the navigation, so
the Coach stop is not in the list. And a stop whose page redirects is
dropped after one navigation, in the direction the person was going,
instead of being pushed again.
The daily sweep and the measurement-ingest hook surfaced due reminders into
a Coach conversation whatever the record's coach capability said, and the nag
cap then dismissed them after three ticks although no badge ever showed. The
app-open path already waited on the capability.
surfaceCoachReminders now holds every row while the coach capability is
unavailable (the same answer that keeps the badge dark), and the nag cap
neither charges nor dismisses a held user's surfaced rows. They surface on
the first tick after the Coach is back. The integration fixture gives its
users a provider and consent, and a held case covers the sweep, the nag cap
and the ingest hook.
Every AI surface reads its capability from the ai block on
/api/auth/me, but setting up or removing a provider, signing in to or out
of Codex, saving the fallback chain, the central-Codex opt-in, the
local-OCR switch, a minted consent receipt, the admin's server key and
central Codex, and a guardian's save inside a managed record evicted only
their own reads. The surfaces stayed on the old answer until a reload.
All of these writes, and the ones that already refreshed the payload
(consent, modules, Hide Coach, the operator's switches), now evict one
bundle, aiInputDependentKeys. A structural test holds every client write
to those endpoints to it.
A background job opens one wide event for its whole run, and the per-request
memo cached the operator switches, module availability, module preferences,
provider presence and consent kinds on it. The nightly status passes loop
over every candidate inside that one event, so a switch turned off mid-run
did not stop the remaining users, and the wire re-check read the same cached
answer.
memoizePerRequest takes a freshInBackground option; every input that decides
whether AI work may run sets it, so a background caller reads them on every
call while a request still reads them once.
The page gate matched /coach/plans and /coach/conversations to the Coach
module by the nav prefix, so with the Coach switched off by the operator
or hidden by the person both pages were replaced by the module notice and
never reached their read-only mode. Both are now stored-record pages the
gate leaves alone.
With the Coach off nothing in the navigation led there either. Settings
now carries a plans card, shown when plans exist, and the conversations
card links to the full list.
Reminders are the person's own record, like the stored facts,
conversations and plans. The list and delete routes no longer ask the
Coach module; creating, confirming, resolving or rescheduling one stays
Coach use and is refused by POST and PATCH as before.
Settings -> Coach now always mounts the reminders card (shown with Hide
Coach on only when reminders exist), and Settings -> AI lists them through
the stored-memory block while Settings -> Coach is out of reach. With the
Coach unavailable the card is read-only: every reminder can be read and
deleted, while keep / done / dismiss are not offered.
Plans are the person's own record, like the stored facts and
conversations. The list and delete routes no longer ask the Coach
module, and the plans page turns read-only instead of redirecting to
Insights: every plan can be read and removed, while confirming or moving
one stays Coach use and is refused by the PATCH route as before.
Adds the AI-optional journeys: the overview with AI switched off by the
operator and with no provider (no AI requests, no refusals, one setup
hint, remembered dismissal), and on an account of its own, withdrawing AI
consent deletes the stored briefing, and a hidden Coach's conversation is
read and deleted from Settings, AI.
Specs that drive AI surfaces against mocked routes now serve an available
capability map through /api/auth/me, since the web no longer assumes AI
is on until told otherwise.
The AI consent card turned any failed read of the receipt (a rate limit, a
network drop) into "Withdrawn" and offered a grant. It now says the read
failed, with a retry. The Coach history note is one sentence.
The scores strip reserved a cell for the cycle ring, and dropped Strain to
make room, whenever the cycle module was on. Without an active cycle the
ring renders nothing, so the row showed a gap where Strain used to be. The
overview now asks the same calendar read whether there is a dial today.
The web no longer reads GET /api/feature-flags: useFeatureFlags and its
query key are gone, and use-ai-capability gains the two readers the Coach
pages and the operator notice need. A structural guard fails when web code
reads the retired projection, and when a module requests an AI route
without being registered with the capability that gates it.
Adds the copy for every new state in all seven locales and drops the
per-card no-provider strings.
Settings, AI says which AI features the server's operator switched off and
shows the one setup hint while no provider is configured. The consent
withdrawal says what it deletes and what stays, and a document-reading
consent on file gets its own withdrawal. Regenerate follows the briefing
capability and the connection test names an operator refusal.
The Coach memory (facts and stored conversations) is never gated on the
Coach: Settings, Coach always shows it, and Settings, AI shows it while
the Coach page is out of reach, whenever rows exist.
Onboarding offers AI only where it can be set up here: the done-screen
panel and the getting-started row go away while the operator has AI off
or inside somebody else's record.
Reading a document, scanning a lab report and turning a medication
description into a schedule follow documentAi, labsOcr and
medicationExtract. Unavailable, the action is not offered. With only the
document-reading consent missing, the place that reads asks for it and
grants exactly that (ai_extraction), nothing wider.
Refusals are read by meta.errorCode: a 403 is no longer always called a
missing consent, and a spent daily budget no longer shows the rate-limit
sentence.
The daily briefing, the hero's briefing paragraph, the trend sentences and
the status notes follow the briefing and statusText capabilities: hidden
while unavailable, never an error card or a per-card "connect a provider"
state. The advisor and status reads only fire while their capability is
available; a 403 settles as an outcome instead of throwing.
A failed comprehensive read no longer replaces the overview, and every
overview section is fenced so a section that throws becomes one error row.
The period review always renders its composed narrative. Cycle, breathing
and lab-change sections go through the surface map. One calm setup hint
appears on the overview while no provider is configured and one could be.
Signals, rhythm events and the mood patterns card say a failed read with a
retry, the score anatomy no longer calls a failed read insufficient data,
the catalog marks only the rows whose read failed, and workouts are not
probed while the module is off.
The floating button, the drawer, the launch pills, the nav entry and the
Coach pages read the coach capability from /api/auth/me instead of
combining the operator switch with Hide Coach on their own. A missing
provider or consent now hides the Coach too, instead of opening one that
can only answer with an error.
Stored conversations stay reachable: while the Coach is unavailable the
conversation list turns read-only, opens a transcript in place and keeps
delete. Refusals before the stream opens are read by meta.errorCode and
each code has its own copy.
The connect and sign-in hand-offs moved from window.location writes onto
the loadDocument helper, and the guard stopped seeing them. Its reference
floor caught the drop. Watched red with a fenced thumbnail route handed to
loadDocument.
The provider mock lacked `probeProviderChain`, so the capability loader
threw on every wire and read `check_failed`. It now answers "no
provider" like the rest of the mock, and the derived payload pins
`no_provider` so a starved loader fails the file.
The bulk-delete, restore and by-external-ids suites mocked
`invalidateStatusInsightsForTypes` on the comprehensive-generate barrel,
while the shared post-mutation tail imports it from status-invalidation.
The mock was never called, the real re-warm ran the capability loader
against a stub database, and the "no invalidate" assertion could not
fail. The mock now sits on the imported module; the positive cases
assert the re-warm with the distinct types, the zero-row cases assert
its absence.
A structural check over src/ and tests/: every `vi.mock` / `vi.doMock`
of `@/lib/feature-flags` either uses the shared switches factory or
names `loadAssistantSwitches`. The matcher is whitespace-tolerant,
asserts a non-zero match count, and checks itself against planted
one-line, split, automock and `import()` forms.
Eight files mocked `@/lib/feature-flags` with `getAssistantFlags` alone.
The capability loader reads `loadAssistantSwitches`, so wherever it ran
it threw and every capability resolved to `check_failed`. A shared
factory now points both readers at the function the test drives.
Only the config-change chain reached the loader. Its fixture means AI
switched off by the operator; it now supplies the loader's other inputs
and asserts `operator_disabled` on the digest, so a starved loader turns
it red instead of passing on the failure path.
Nine places assigned window.location.href directly: OAuth connects and
sign-in hand-offs that land on API routes answering with a redirect, and
the reload after account deletion. The newer Next lint rule reads each as
an in-app page change. They now go through one helper that says why a
document load is the right tool there.
The file stubbed the old switch reader only, so the capability loader
failed and every capability resolved closed. The owner's positive control
then enqueued nothing, and the delegate's negative passed without a
provider on the record, proving nothing. The record and the owner now
carry a provider and consent, so the delegate case refuses for the
reason it names.
/settings/layout/<module> is a URL a person can land on, so with its
module off it now shows the module notice in place, like every module
page, instead of bouncing to the Layout hub without saying why.
The Coach correlations tool, the per-metric card and the period
narrative scanned every channel, so a switched-off module still fed
their statistics and the text a model was grounded on. The matrix's
modules option is now required, each caller resolves the record's map,
and the Coach skips the lab pass with labs off. A registry guard holds
every caller to resolving the map itself.
The mask's doc comment now states what it does to the false discovery
correction: fewer channels is a smaller family, so q-values of the
remaining pairs move and a borderline pair can cross the threshold.
Serving stored text and starting model work are separate questions. A
delegate may not start model work on somebody else's record, but reads
the owner's stored briefing, period narrative, status notes, workout
notes and Coach unread signal exactly when the owner would: those
readers now resolve the record's own capability state, not the
viewer's. Generation, warms and enqueues still ask as the viewer, so a
delegate's read of a stale narrative queues nothing. A hidden
model-written narrative is no longer overwritten by a deterministic
warm while AI is unavailable.
The sharing front door reads the digest's briefing lead again, and an
integration case pins both halves.
Refs #975
The push refused outright while the `insights` module was off, which
now means only "AI analysis". The digest it reads already drops the
model-written lead and top signal in that case, so the push goes out
with the deterministic line (the score, or the day's items) and a
digest with nothing to say is still skipped. The `module-off` outcome
and its counter are gone.
Refs #975
Brings in the jobs, stored-text and consent package and the extraction
routes. Conflicts resolved:
- The digest keeps its capability-driven reaction line and check-in,
now through the shared `aiCapabilityForRecord`; the local copy of it
is removed.
- The route inventories classify the extraction routes (documents,
labs OCR, medication text, fenced chat and attachments) as actions;
a capability call counts as the gate for the module inventory.
- With every caller moved, `requireAssistantSurface` and
`AssistantDisabledError` are deleted. The `assistant.disabled.*`
codes stay on the capability refusal, and the guard now asserts the
retired gate is gone.
- `aiCapabilityToServe` answers whether stored model text may be shown
from the record's own state, apart from who is reading.
Refs #975
A journey on its own account switches mood and medications on, proves
the Mood nav entry, the Mood and Medications pills, the mood trends slot
and both add-menu entries are there, switches both off through the
module route and proves each is gone, the trends row fills from pulse,
/insights/mood shows the module notice at the same URL, and no chart on
the way paints its error state.
The hero rings read their owner from the surface map instead of their
own map, and the readiness, recovery and sleep score rings no longer
wait on the AI analysis switch: they are computed scores, not model
text. The dose ring still follows the medications module.
Each family of ids must name a surface that exists (nav hrefs, sub-page
directories, section ids, widget ids, derived ids, Settings slugs,
correlation channels). Sub-pages, trend slots and correlation channels
must follow the owner of the types they plot, joined against the
dashboard's summary owners, and a pinned table covers the owners no
join can derive. The replaced per-surface keys and the redirecting page
guard are searched for in source, and the derived route's gate is
compared with the map entry by entry.
Broken on purpose before committing: dropping the sleep sub-page entry,
the mood trend entry or a nav entry, drifting a derived owner, and
reintroducing a per-destination key each fail by name.
The correlation scan still paired mood, sleep, glucose, adherence,
symptoms and weather with everything else after their modules were
switched off. The discovery matrix now drops every channel whose module
is off, read through the surface map, and the correlations route passes
the record's module map, skips the lab draws with labs off, and keys its
cache on the switched-off set.
Settings sections and Layout groups no longer declare their own module
keys; they ask the surface map. The Insights overview arrange list marks
the breathing, labs changes and cycle blocks unavailable when their
modules are off, as the overview itself would not show them. The rows
stay in the list so a save keeps each block's place.
selectTrendCharts takes the record's module map and skips every slot
whose module is off, on the briefing path and on the fallback row,
through the surface map. Sleep was never covered before, and the
fallback row shrank to two charts with mood off; it now reaches for
pulse. The row reads the module map itself.
The tab strip's slug map is gone; every pill asks the surface map by its
slug, the same entry the page notice reads. That adds the Medications
pill and the cardio recovery, six-minute walk and stair speed pills,
which stayed visible with their modules off.
Navigation destinations, the bottom bar's fixed slots and the tour stops
no longer carry their own module keys; each asks the surface map by its
href. Insights has no owner there, so switching AI analysis off leaves
the Insights entry, slot and tour stop in place.
The shell's page gate now resolves Insights sub-pages by slug as well
as top-level pages, so /insights/mood, /insights/medications and the
recovery metric pages show the module notice when their module is off,
worded from the access reason, with the Settings link only for the
record's own switch. The per-page redirect guard, the achievements
redirect and the nutrients page's own notice are gone; nothing
redirects any more.
Whether a vaccination is still current was known only on the checkups
page. The vaccination log now shows it on each antigen's heading:
the booster the person planned when they logged a dose, marked as
overdue, due soon (within 30 days) or scheduled, tinted only in the
first two cases.
The state is resolved on the server from the person's own confirmed
booster reminder and published on the list response as renewals, so
the web page and the native client read the same answer. Nothing is
worked out from age, history or the catalogue: an antigen without a
planned booster shows no state. Renewals are null for a grant that does
not reach the measurements section the reminders live in.
Refs #1005
The dashboard widget and summary maps are now views of the surface map.
The legacy dashboard feed painted the stored layout as saved, so the
medications card stayed with the module off; the mask the snapshot
applies now runs on that feed too, in the browser, from the same map.
The widgets route keeps returning the stored layout unmasked, because
Settings saves that value back.
The Today digest is the day's data and no longer waits on the AI
analysis switch, on the server prefetch or in the browser.
SURFACE_MODULE names each surface a toggleable module owns (nav entries,
Insights pages, overview blocks, trend slots, add-menu entries, dashboard
widgets and summaries, derived scores, Settings sections, correlation
channels) as <kind>:<id>. isSurfaceVisible answers from it on the server
and in the browser, useSurfaceVisible reads it against the account.
The insights key owns no surface: it means AI analysis, and the Insights
area is data.
A document could show its visits read-only and its vaccinations not at
all, so a childhood record covering a dozen doses had to be attached
from a dozen dose forms. The document's detail sheet now lists the
visits and vaccinations it is filed against and, for someone who may
manage the vault, links and unlinks them in one place, with the records
dated near the document on top. Both ends write the same link tables
through the link service, so the dose's form and the document's sheet
cannot disagree about what is filed where.
The document detail response carries the linked doses, named by
catalogue slug or the person's own wording. It is null rather than
empty for a grant that opens the vault but not the health background,
so a shared page never claims to record no doses. The update route
takes a replace-set of vaccination ids, up to 100 so one record can
cover a whole primary schedule; ownership is re-checked, a foreign id
answers 404 and the change lands in the audit log beside the other link
fields. Linking from the dose suggestion now refreshes the sheet.
Refs #1024, #1005
The status family (the five metric cards, medication compliance, the
generic metric status and the biomarker assessment) asked the retired
switch and the `insights` module and refused with 403. The note is model
text and the card around it is data, so they are mixed reads now: while
the `statusText` capability is available the note is served and warmed
as before, with `ai` beside it; otherwise the answer is 200 with the
note null, `preparing: false`, `hasProvider` as provider presence only
and `ai` naming the reason, and neither the cache nor the warm queue is
touched. The generic metric status still reports `insufficient` from the
data. The mood card keeps its mood module gate.
Kept apart so it can be held back while the shipped iPhone app's
rendering of a 200 without a note is confirmed.
Refs #975
The status chokepoint, the comprehensive briefing, reaction lines and
AI-composed nudges now ask `aiEgressRefusal` about exactly the chain they
are about to send to, instead of each pairing the operator-key check
with a surface receipt lookup of its own. The consent kinds come from the
capability table, so the wire and the published capability answer from
the same rule.
The document summary, its catch-up pass and the index backfill stop on a
closed switch or module before anything is picked, and leave "no
provider" and "consent needed" to the document pick, which re-checks the
wire for the provider it chose. A closed switch still only heals PENDING
to NONE; a withheld pick records UNAVAILABLE. The per-pick document
consent helper is removed, since nothing calls it any more.
The auto-read toggle mints the shared `AI_EXTRACTION_CONSENT_KIND`, and
the web grant helper goes back to minting the master grant only.
Refs #975
The visit and vaccination forms asked the document list for a page of
200 while the route accepts at most 100. The refusal read as an empty
list, so the picker said "No documents yet." to anyone with a full
vault, and the only way a scan reached a dose was the seven-day
suggestion offered at upload. The visit form's condition picker had the
same problem against the episode list.
The document pickers now walk the vault page by page at the route's own
limit, so any document can be linked from a vaccination or a visit, and
one document can sit on as many records as it covers. Documents dated
within seven days of the record stay on top under their own heading,
the rest follow by month and are searchable. A failed read now shows an
error with a retry instead of passing for an empty list.
Refs #1024, #1005
Brings in the extraction routes and the capability re-check at the wire
(`aiEgressRefusal`), so the jobs and stored-text work can share it.
Conflicts in the document routes, the fenced chat, the auto-read toggle
and its contract text resolve to the extraction branch: those files no
longer call the per-pick document consent helper.
The weight status note's snapshot now carries the person's own target
band and which way is progress, and its comparison signal is framed as
higher-better below the band and lower-better above it instead of the
neutral target-band framing. The weight prompt tells the model to judge
changes against that target and keeps the loss pace and milestone
guidance for when losing is the goal. The target is folded into the
input fingerprint, so editing it regenerates the note even when no
weight reading moved.
Refs #1006
The weight tile's arrow, 7-day delta and comparison caption were wired
as "up is bad", so a person below their own target who was gaining saw
every step toward it coloured as a setback. Movement is now judged
against the target they entered: below the band a gain is progress,
above it a loss is, and inside it holding steady is, with a move either
way shown neutral rather than orange. Without a stored target the tile
reads exactly as before; the height-derived band is not treated as a
target because nobody chose it.
The direction is resolved once on the server and published on the
dashboard snapshot as tiles.weightTrend, so the web tile and the native
client colour the same reading the same way. The personal-record
direction reads the same placement helper. The feature payload behind
the briefing, the overview analysis and the Coach now carries the
target band and which way is progress, so a narrative no longer calls a
gain a regression when gaining is the goal. The insights vitals grid
drops weight from its fixed "up is bad" set.
Refs #1006
Three files walk the AI state matrix through the real handlers: every
data route answers the baseline's data under each state, and an ECG
upload is stored with AI off; mixed reads null the model text, name the
reason and enqueue nothing, and the dashboard hides a cached briefing on
the next read after a switch flip; the briefing and Coach actions refuse
with the exact code, capability and reason before any provider or cache
write. Existing files that seeded provider-less accounts now give them a
provider where the briefing or the Coach is under test, and the sharing
front door marks the digest by a due checkup, since model text is not
served inside somebody else's record.
Refs #975
Data never depends on AI. The Insights data reads no longer ask an AI
switch or the `insights` module (now only the AI analysis opt-out): ECG
list, detail and upload, rhythm events, derived scores, rule cards,
correlations and patterns, the baseline strip, health status, intraday
pulse, and the Today digest with its dismiss. Breathing screening moves
to the sleep module and lab changes to the labs module. Stored Coach
facts, conversations, message ratings and the seen stamp stay readable
and erasable while the Coach is unavailable. Cards now say
`provider: "rules"`.
Reads that mix data with model text answer 200, null the model text
and publish an `ai` state from the capability resolver, without warming
anything: comprehensive (`ai.briefing`, `ai.statusText`, `hasProvider`
from the presence probe), the derived assessment override, the period
narrative, nudge status, the seeded opener, about-me questions, the
workout paragraph, the pregenerate warm (`queued: false`), the Today
digest (lead, reaction line, check-in) and the dashboard briefing,
which is now applied per read after the snapshot cache. Hiding the
Coach no longer hides the briefing.
`insights/generate` GET and POST ask the `briefing` capability and
`insights/chat` POST the `coach` capability, with the typed refusal
envelope; a missing provider keeps the chat's `coach.provider.none`
frame and adds `reason`. The /coach page, the MCP ECG and intraday
tools and `aiAvailable` on the provider route follow the same answer.
An inventory of AI and data routes replaces the Coach gate inventory:
every route that asks a capability names it, and no data route asks.
Refs #975
Every AI job now asks its capability for the user before it builds a
snapshot, and skips with a `.skipped` annotation naming the reason: the
nightly pregenerate (briefing and statusText, each half on its own), the
status batch and the per-metric crons, the on-demand status worker, the
narrative cron, Coach nudges and their AI composer, the Coach memory
refresh, reaction lines, workout paragraphs, and the document summary,
catch-up and index jobs. Seven of these read no assistant switch at all
before, so the master switch now stops them too.
The cron candidate queries drop people who switched AI analysis off in
SQL, and no longer read `disableCoach` outside the Coach: hiding the
Coach hides the Coach. Coach nudges use the one presence probe, so a
central Codex account is no longer skipped; a missing consent still gets
the deterministic template. The arrival spine asks only the
provider-free half (switches and modules) before it enqueues, keeping
its module graph free of provider code.
Document reading needs an extraction receipt (ai_extraction or ai_full)
for any external provider; the auto-read toggle is the trigger, no longer
a consent shortcut, so a revoked receipt wins over a toggle left on. Lab
report scans get the same rule for a whole chain.
Two structural guards: every file that calls a provider is named with
the reason its call is admitted, and the chokepoints re-check a
capability; every AI job references its capability gate. An integration
matrix runs each worker under the refusing states with a provider spy.
Refs #975
Every status note reader and writer moves to InsightStatusCache: the
generators, the unchanged-data gates, the stale-while-revalidate read,
the ingest debounce, the previous-note prompt block and the
medication-compliance card with its per-medication lines. A stall now
opens a negative-cache window next to the note instead of a stub row
that shadowed it.
Stored model text is never served while its capability is unavailable,
whatever the reason: the status readers and gates check statusText, the
period narrative hides a model-written row while periodNarrative is
unavailable (the deterministic narrative is data and always shown), and
the daily digest drops reaction lines while reactionLines is
unavailable. A read-only miss now reports why (`unavailable` with the
capability's reason) and keeps hasProvider meaning provider presence, so
a withdrawn consent no longer reads as "no provider".
runStatusCompletion takes the capability as a required argument and
re-checks it before the provider chain is resolved, so a job queued
before a switch flipped still stops at the wire. The comprehensive
briefing re-checks briefing the same way. hasUsableStatusProvider is the
one presence probe.
Refs #975
A new receipt kind, ai_extraction, covers reading documents, lab report
scans and typed medication text with an external model. Switching the
document auto-read on now mints ai_extraction instead of ai_full, so it
no longer grants the Coach or the analysis as a side effect. Existing
ai_full receipts keep covering everything.
DELETE /api/consent/ai/latest now runs through withdrawConsent: the
revoke and, when it leaves no receipt covering the analysis, the
deletion of the text a model wrote under it commit in one transaction.
That is the status notes, the cached briefing, model-written period
narratives, reaction lines and workout paragraphs. Coach conversations,
facts and plans and document summaries stay. The purge is audit-logged
as consent.ai.purge with counts only.
Refs #975
Status notes (the per-metric text a model writes) move out of audit_logs
into InsightStatusCache: one row per user, metric and locale, the note
encrypted at rest, upserted in place, with the input and snapshot
fingerprints and a separate negative-cache window.
Migration 0344 creates the table and deletes every audit_logs row whose
action matches insights.%-status.%. The notes are a regenerable cache and
the old rows are plaintext, so nothing is copied; the first nightly run
after the upgrade writes them again. Take a backup before applying.
The table is registered for key rotation, excluded from backups as a
regenerable cache, and deleted by Delete All Data.
Refs #975
Each refusal is checked twice, at the response and at a spy standing in
for every model call, beside an all-open twin in which the spy is called.
Two background cases run the index and summary job handlers directly and
show that a job enqueued before document reading was turned off finds no
provider when it runs.
The document, labs-scan, medication-extraction, fenced-chat, attachment,
connection-test and auto-read operations document the capability they
answer under, the refusal envelope and codes, and the consent rule. The
two capability probes publish the capability beside the provider answer.
Turning document auto-read on now mints an ai_extraction receipt instead
of ai_full, so it covers reading documents, lab reports and medication
text and no longer opens the Coach or the AI analysis. Receipts minted as
ai_full by earlier releases stay valid. The catch-up over stored
documents is only scheduled while documentAi is open for the record.
The AI connection test is an AI call, so it is refused with
assistant.disabled.enabled while the operator's master switch is off, and
fails closed with ai.unavailable when the switches cannot be read (403,
since the route never answers 5xx). A missing provider carries
ai.provider.none and a spent daily budget ai.budget.exceeded.
Reading a stored document (suggest, summary, extract, index, chat,
reindex), scanning a lab report and turning medication text into a
schedule now answer under documentAi, labsOcr and medicationExtract. The
operator's master switch and the new reading-documents switch stop the
work and the egress; a module off for the record, a delegate inside
somebody else's record, and a missing receipt refuse with the capability
envelope. Existing no-provider codes keep their status.
The document pick and the labs pick re-check the capability for the
provider they picked, immediately before anything is sent, so a job
enqueued before a switch flipped finds no provider and falls back to the
provider-free text layer. Consent follows the document rule for all
three: any provider that leaves the machine needs an ai_extraction or
ai_full receipt, answered for the provider actually picked.
The fenced Coach chat and attachments answer under coach and documentAi.
Uploads are always stored and indexed; the background summary is only
enqueued while documentAi is open. The capability probes report the
capability beside the provider answer and keep a read offered when only
the receipt is missing. medications/extract keeps its 503 for no provider
and adds ai.provider.none, and its budget refusal gains the
coach.budget.exceeded code.
requireAiCapability answers at the top of a route, before a provider is
picked, and a job enqueued before an operator switch flipped runs long
after any route answered. aiEgressRefusal asks again about the providers a
caller is about to send to: the capability for the record (the request's
own resolution, or the job's for the record it names) and, for exactly
those providers, whether a consent receipt of the kinds the capability
table declares is active.
A pickDecides gate option leaves no_provider and consent_required to that
pick, for routes that read the chain in their own order.
Several chart mounts (the medications page, the coach chat, the
recovery section) pass no zone, and the chart then fell back to a fixed
Europe/Berlin, so those charts cut and labelled days in Berlin for
every user whatever their profile said.
The chart now falls back to the signed-in account's zone, and only
before that has loaded to the shared DEFAULT_TIMEZONE the server's own
resolver ends on. /api/auth/me reports the zone the server resolves:
the stored one, or the instance default when the stored one is
unusable, so the client cuts days where the server does.
Refs #1026
The dose-history response reported the slot window: `from` clamped
to the medication's creation, `to` the request's end. Recorded doses
are now read from the requested start and, for a dose recorded ahead
of its slot, up to that slot, so the response reported a narrower
window than the rows it carried.
`from` and `to` now report the window recorded doses were read over.
Both fields keep their shape; the OpenAPI descriptions say what they
mean.
Refs #1028
The earlier rule could leave a one-day cycle in front of a real start
when day two of a period was logged before day one. Tapping "My period
started this day" on the real first day folds the later start in, but
the re-tapped row kept the end date and length its vanished successor
had given it, so the repaired cycle stayed closed after one day.
A start with no cycle after it is open, so opening one now clears a
stale end and length. The repair of such a history is covered end to
end: the broken state is seeded as the old rule left it, the one tap
is made through the period route, and the result is a single open
cycle that owns every day of the period.
The coach's cycle snapshot read a year of raw readings for the phase
crosstab, including steps, heart-rate variability and CGM glucose. On a
CGM account that is over 100 000 objects per snapshot.
The crosstab only uses each day's sum and count after picking one
source and device per day, and the pick depends only on which sources
and devices are present. readSourceDayAggregates returns one row per
type, day, source and device, and the day map now carries each row's
reading count, so the day values are the ones the raw readings give.
The 30-day clinical glucose reads stay raw: time in range, SD, J-index
and the risk indices are functions of individual readings, and a
sensor's rate bounds the window below 45 000 rows.
Refs #1023
On a per-minute heart-rate stream the 7-day chart showed only the first
half of the week. The range read raw rows oldest-first under a
5000-row limit, and seven days of per-minute samples are 10 080 rows,
so the newest days never arrived.
The chart folds every range into days anyway, so the 7-day range now
asks for one row per local day like the longer ranges. The whole week
arrives whatever the sampling rate, and the 7-day and 30-day ranges
share one day boundary and one source pick per day. The 7-day range
still draws no spread band, so its picture is unchanged.
Refs #1026
The manual form keeps a hand-written type list, and the server's grew
past it: visceral fat, fat mass, fat-free mass, lean body mass and
muscle mass all had units and plausibility ranges on the server and
arrived from scales, but had no row to type a reading into. They have
rows now, with the scale's kg and lb units and a "rating" unit for
visceral fat in every language.
A new test derives the body-composition family from the server's own
categories and fails when a type in it is neither offered by the form
nor named with the reason it is left out (BMI, which is computed from
weight and height). It also checks each row's unit and placeholder
against the server.
Listing the four side by side exposed labels that named two different
readings with one word in French, Italian and Polish (body fat and fat
mass, fat-free and lean mass). Each now has its own name, which also
makes them resolvable by name for the MCP reads that used to refuse the
ambiguous words.
The reset after "Save and add another" cleared the biomarker and passed
the picker `undefined`, which switched it to its own internal state. It
went on showing the marker it last held while the form value was empty,
so the next save refused with "Pick a biomarker first" under a visibly
picked marker, and a later pick could land on the first catalogue entry.
The picker is always controlled now (an empty pick shows the
placeholder), and "Save and add another" keeps the marker, the draw date
and the visit, clearing only the reading, its note and its printed
range. Focus moves to the value field for the next entry.
Selecting a symptom grew its chip inline with the 1-4 intensity
selector, which reflowed the wrapping row and moved the chips after it,
often out from under the finger reaching for the intensity. Each symptom
is now its own row: the toggle on the left, the intensity on the right.
The intensity's space is laid out whether or not it is shown, so a
selection changes colours and reveals it in place and nothing moves.
The intensity stays optional and out of the tab order until the symptom
is picked. Long names wrap instead of truncating.
The log sheet took its "Day N" header and its "period ended" button
from the verdict, which is the answer about today. A January date opened
in September read "Day 2", and a back-dated period could only be closed
on dates where today happened to be a bleeding day, so the button came
and went as unrelated months were filled in.
The calendar read now carries, per date, the cycle day of the logged
cycle that date belongs to (null before the first start, after today, or
past the verdict's ceiling for an open cycle) and whether a one-tap
period end can land there. The sheet reads both off the grid day for the
date it opened on.
Saving a new entry with nothing picked used to write an empty row: the
request succeeded, the sheet closed and the calendar had nothing to
draw, which read as a save that did nothing. Save now needs something to
store and says what, and an emptied existing entry still saves. The
sheet also stops writing until the opened date's own entry has loaded,
because until then it held the previous date's row id and a quick Save
patched the wrong day.
On the server, a start recorded a few days after an earlier one is the
same period entered in the other order: opening the earlier start now
folds the later one into it instead of leaving a one-day cycle in front
of it, and a bleed too far before the next start to be its first day and
too close to be a cycle of its own no longer infers a start. Opening or
removing a start re-attributes the day-logs whose cycle changed.
The Coach gate inventory lists the correlations route with the routes
no assistant switch owns. The seeded-question test gives the switch
read a settings row, since a read that fails no longer opens every
switch.
A single account syncing heart rate from a watch took the worker down
within half an hour. The status cards' graded series read the last 91
days of raw rows, bounded in time but not in rows, so a stream sampled
every minute or faster materialised six or seven figures of objects on
every refresh and the read ran into the statement timeout.
Add readDayAggregates, which folds one type into per-day sum, count, min
and max in Postgres (optionally split into age segments and filtered to
the metric's plausibility domain), and move the readers that folded raw
rows into days in memory onto it: the graded series (recent, weekly and
the cold-tier fallback, for every type), the vitals day-mean fallback
behind the personal band, the tiered context's recent days and the plan
review. Recent, weekly and monthly buckets are identical to the raw
fold; the cold-tier yearly slope now runs over day means.
On 91 days of per-minute pulse the graded series now allocates 1.6 MB
where it allocated 62 MB, while the raw read alone holds 22 MB.
A structural guard freezes the remaining untaken measurement reads in
the worker and insight directories: each must name a sparse literal
type or carry a listed reason.
Refs #1023
Switching a chart from 7 to 30 days moved steps, active energy,
flights and distance totals onto neighbouring days. The 7-day range
buckets raw rows by the user's calendar day; the 30-day range read the
DAY rollup tier, whose buckets are UTC days, and the chart labelled each
bucket's UTC-midnight start in the user's zone. East of UTC a sample
shortly after local midnight counted on the previous day; west of UTC
every bucket read one day early.
The daily series (the chart read and the batched dashboard read) and the
aggregate=daily|weekly|monthly list now fold live in the user's zone and
return the instant of local midnight, so every range and every summed
metric shares one day boundary. The fold runs in Postgres and returns
one row per day; about 0.1 s for 90 days of per-minute samples. Windows
longer than the DAY cap still step up to the coarser rollup tiers.
Refs #1026
The Coach availability fold and the AI analysis carry-over for people
who hid the Coach are read out of the migration and run twice against
rows shaped like an upgrading instance, including a malformed
preference blob and the concurrency token.
Each state changes one layer against a fully set-up account and checks
the capabilities it owns: master switch, Reading documents, no
provider, the operator's key with and without consent, hiding the
Coach, the AI analysis opt-out, the workouts module, the single Coach
switch, a delegate, and a guardian limited to the operator's key.
AccountPayload gains ai with the AiCapabilities, AiCapabilityState,
AiUnavailableReason and AiProviderState components. The feature-flags
route is marked deprecated, drops correlations and documents
documentAi. The error envelope's meta documents capability, reason and
module, lists the assistant.disabled switches, and the catalogue
carries the three new ai codes.
GET /api/auth/me gains ai: the state of every capability and the
provider block, resolved for the active record and masked to the
grant like moduleAccess. The authority comes from the switch the
payload already resolved, so a delegate reads not_permitted_for_record
and a guardian is limited to the operator's key.
useAiCapability is the web reader and fails closed until the payload
arrives. GET /api/feature-flags becomes a deprecated projection of the
operator switches with a Deprecation header and a link to its
successor.
loadAiCapabilityInputs reads the switches, module access, provider
presence and active consent kinds for one record in parallel, memoised
per request, and fails closed to check_failed. requireAiCapability,
getAiCapability and aiCapabilityForJob resolve for the request's
record, masked to the active grant like moduleAccess, or for a job's
record under the worker's authority.
apiHandler renders AiUnavailableError as one envelope with errorCode,
capability, reason and module. Existing codes stay: the operator
family keeps assistant.disabled.<switch>, modules keep module.disabled,
consent keeps consent.ai.required. New codes are ai.record.notPermitted,
ai.provider.none and ai.unavailable.
probeProviderChain describes the chain that would serve a record
without decrypting, building a client or refreshing a token: which
entries hold a credential, in chain order, whether each reads images,
the in-browser OCR opt-in, and where the credential comes from. It
applies the provider-work authority, appends the central Codex only
behind the opt-in, and falls back to the legacy provider only for an
empty chain. hasAnyConfiguredProvider and resolveProviderAvailability
now answer through it.
The operator switches become master, Coach, Daily Briefing, Status
notes and Reading documents. Correlations goes: it gated statistics,
not anything a model writes. Reading documents covers the document
vault, lab scans and medication extraction.
The Coach module's operator availability now follows the Coach
switch, so there is one place to turn the Coach off server-wide. The
migration folds a stored Coach availability of false into the switch,
and gives everyone who hid the Coach the AI analysis opt-out, since
hiding the Coach no longer stops nightly briefing and status notes.
A switch read that fails now fails closed. The insights module is
described as AI analysis in every locale, and the switch guard now
keeps both retired switches out and freezes the callers of the old
operator-only gate.
A pure resolver turns operator switches, module access, provider-work
authority, provider presence and consent receipts into one state per
capability, reporting the outermost reason. The document provider rank
moves to its own module so the resolver and the document routes read
the same order.
One frozen table names every AI capability with the operator switch,
owning modules, consent rule, modality and provider order that decide
it. The reason enum is listed in precedence order. No I/O, so the
resolver, gates, contract and guards can all depend on it.
The Verlauf ledger dropped doses the full history showed. Three windows
ended too early or started too late:
- The server clamped the window floor to the medication's creation
instant and filtered recorded doses by the same floor, so a dose
logged for earlier the same day on a medication added afterwards
fell out of "Last 90 days". The creation floor now bounds minted
slots only; recorded doses use the requested window.
- A dose taken inside its window before the slot's own time is stored
on the slot anchor, which still lay past the window end. The read
horizon now reaches the latest recorded anchor within a day, without
minting later upcoming slots.
- The client pinned from/to at mount and reused them on every refetch,
so a dose added after the tab opened stayed hidden until a reload.
The window is now resolved when the request fires and only "from"
travels; the query key carries the window length instead of instants.
Refs #1028
The iOS client left public beta. The badge row, the navigation lines and
the iOS section named TestFlight only, which now reads as the single way
in. Both routes stay, with the price and the free-code offer stated
rather than left for someone to discover on the store page.
The archive member was matched against the English lowercase spelling
alone. iOS writes that file name in the phone's language and some
versions capitalise it, so an ordinary export could arrive as
Export.xml or a translated name and was refused with a message telling
the reader their export was invalid.
The basename now matches in any capitalisation, and a single XML member
at the top of the archive is taken as the translated spelling. Apple's
clinical-document file never qualifies: it sits beside the real export
and would parse to nothing. When no member qualifies the error lists the
XML members the archive does hold, so the next report arrives with the
answer in it.
Refs #1008
Picking a symptom without tapping an intensity is how most of them are
logged. The sheet sends severity null for those, the schema took a number
or nothing at all, and the day log came back 422 with two issues for two
symptoms. All the sheet could say was "Could not save. Try again.", and
the cycle saved fine as soon as the symptoms were removed, which sent
people looking in the wrong place.
Nothing else disagreed: the link row has always stored an unrated symptom
as a plain presence, and the writer already maps a non-number to NULL.
Only the gate at the door refused it, on both the create and the patch
path, and no test touched either schema.
Refs #1001
Shipped iOS builds multiply oxygen saturation and body fat by 100 before
upload. The importer scaled them again, so 0.97 was stored as 9700, the
plausibility guard rejected the row as out of range inside an HTTP 200,
and the client treats that skip as final and moves its HealthKit anchor
past the sample. Every reading of those two types was lost, silently, on
every install that syncs them.
The contract says values travel raw and the server scales, and that is
what the OpenAPI description publishes. One comment in the schema said
the opposite, that the client must multiply by 100 first, and that is
the line the app was built against. It is corrected here.
The five percent-shaped HealthKit identifiers now take either spelling:
a value above the fraction ceiling is already a percent and passes
through, anything at or below it is scaled. The domains do not overlap,
and the per-type range guard still rejects what is impossible either
way. Current app builds stop losing readings without an app release.
The tests pin both spellings, the range after conversion, and that the
tolerant converter covers the percent class and nothing else: waist
circumference uses the same factor to turn metres into centimetres, and
a blanket change made a 1.02 m waist into 1.02 cm.
Refs MBombeck/healthlog-iOS#7
nodemailer 10 is a TypeScript rewrite and ships its own declarations, so
the hand-added @types/nodemailer package now shadows them with the types
of a release two majors old. The email sender keeps its default import
and its Transporter type from the package itself.
The new floor is Node 20; the image and the engines field are on 22.
The Trivy install step piped a script fetched from a tag straight into a
shell. A tag can be moved to other content, so the job ran whatever the
ref pointed at, with the runner's privileges, and the Scorecard check
flagged it as an unpinned download-then-run.
The script now comes from the commit the tag points at, is checked
against its SHA-256 before it runs, and the binary version stays what it
was.
The compose ecosystem added last change did its job on the first run and
immediately proposed 16.15 to 18.6. A PostgreSQL major changes the
on-disk format, so merging that would hand self-hosters a container that
cannot read their data directory without pg_upgrade or a dump and
restore. Minor releases keep arriving as reviewable PRs; a major is a
project decision with a migration path behind it, not a weekly bump.
The guard grows a case for the ignore rule, so dropping it fails rather
than quietly re-opening the door.
The tab strip already hides the Mood pill when the mood module is off, but
the trends row still charted mood, both from a briefing finding and in its
blood pressure / weight / mood fallback. selectTrendCharts now takes the
metrics to leave out, and the overview passes mood when the module is off.
Refs #975
With the assistant switched off on the server, the Insights overview did
not load. Its main read, /api/insights/comprehensive, was gated on the
Coach surface, and the derived tiles, the ECG list, strip and live ingest,
and the rhythm events were gated on the status-card surface. The master
flag forces every surface off, so all of them answered 403 and the page
replaced the whole overview with an error.
None of these handlers calls a provider or returns assistant prose. They
now gate on the insights module only, like health-status, labs-changes and
patterns already do. comprehensive had no module gate of its own, only the
Coach gate, so it gains the insights module gate the other five already
call. Routes that serve generated text keep their gate.
The route-gate inventory moves the six files onto the not-Coach-owned
list, four OpenAPI descriptions drop the gate they no longer have, and a
comprehensive test pins a 200 with the master flag off.
Refs #975
The bundled PostgreSQL was pinned to 16.14 while 16.15 had been out for
five weeks. The pin itself is deliberate, a database change should be
reviewable rather than whatever the registry serves on pull day, but
nothing watched it: Dependabot was configured with the docker ecosystem,
which reads the Dockerfile and never opens a compose file, so no update
PR was ever raised. CI and the integration suite meanwhile run against
the floating 16-alpine, so the shipped database was older than the
tested one.
Adds the docker-compose ecosystem so each PostgreSQL minor release
arrives as a reviewable PR, moves the pin to 16.15, and pins both halves
with a test that fails if the exact minor or the watch is dropped.
Refs #987
A self-hoster scanning a stack of lab reports ran into a fixed six scans
an hour with no way to raise it. LABS_OCR_LIMIT_PER_HOUR now sets the
per-user ceiling (default 6, clamped to 1-1000, falling back to the
default when unset or not a number), the same shape as
DOCUMENT_AI_LIMIT_PER_HOUR. It is on the compose whitelist, in the
production example, the env manifest and the AI provider guide.
The route also charged the slot before it had looked at the request, so
a malformed body, an unsupported or oversized file, a PDF that could not
be rendered or a scan the daily budget refused still used up the hour.
Those failures hand the slot back now; a scan that reached the provider
keeps it. The 429 carries meta.retryAt. Refs #985
The MCP consent journey navigates to /api/version only to give the page
an origin, then registers the OAuth client with the page's own fetch.
That evaluate reads no DOM, the same shape as the allowlisted vaccination
seed, and the consent page after it is gated on the Allow button.
After Allow on the MCP OAuth consent screen, Chromium-based browsers
stayed on the page although the grant was issued. The page carried the
app-wide CSP form-action 'self', and Chromium applies form-action to the
redirect that follows the form post to the client's redirect_uri.
The authorize route now owns the CSP of its responses and the proxy
leaves that exact path alone. The consent page allows form-action for
its own origin plus the origin of the redirect URI the route has just
validated against the resolved client, and only https or http origins
qualify. Every other response from the route keeps form-action 'self'
with default-src 'none'. The redirect after the decision is now 303,
which tells every browser to follow a form POST with a GET. Refs #983
A connector signing in through the MCP OAuth consent screen gets stuck
after Allow in Chromium: the grant is issued, but the browser never
reaches the client's callback. Only a real browser enforces the page's
CSP on that redirect, so this is a browser journey. It registers a
client with a local loopback callback, clicks Allow and requires the
callback to receive a code, state and issuer. It runs against the second
server, which now carries APP_URL because the MCP surface fails closed
without a configured origin. Refs #983
The stub port was drawn from 49152-65535. Linux assigns local ports for
outgoing connections from 32768-60999, so the drawn number could already
be held by a client socket the runner had opened, and the notification
journey failed in beforeAll with EADDRINUSE although no other stub was
listening. The port now comes from 20000-32767, below the Linux and
macOS ephemeral ranges.
The mobile smoke test pressed Escape on the capture picker and tapped
the More button straight away. While the picker's close animation still
covers the bottom nav, that tap lands on the overlay and the hub never
opens, which failed the lucide-react update once. The test now waits
until the picker is hidden.
The malformed-payload journey installs its /api/auth/me route before it
clicks the switch. When the owner session read its own payload again in
that window, the helper threw inside the route handler: the throw came
before fulfill, so its catch never saw it, the test failed, and the
teardown's unroute then hit a closed page. It showed up on the Playwright
1.63 branch, whose timing opens that window more often. An unswitched
payload now passes through unchanged, and the test asserts that at least
one switched response was actually corrupted before trusting the refusal.
The setup flow specs answer questions through these helpers before their
first real navigation. That case still went through page.request, which
shares the runner's keep-alive agent, and a stale socket failed an answer
with read ECONNRESET on the dependency update branches. The page now opens
the public version endpoint first and sends the call with its own fetch.
The bump raised the CLI to 7.10.0 but left @prisma/client at 7.8.0 and
the migration CLI the image installs under /opt/prisma-cli at 7.8.0, so
the generated client, the one that ships and the one that migrates would
have come from two releases. All three are 7.10.0 now.
The setup flow helpers read the account, the dashboard layout and wrote
answers through page.request, which shares the runner's keep-alive agent
with every other API context. A socket the server had already closed on
its idle timeout failed one answer with read ECONNRESET, and CI counts
the passing retry as a failure. The calls go through the page's fetch
now, as the record session specs already do; before the first navigation
there is no origin, so that case keeps the API context.
The webhook, ntfy, email and Telegram test routes send through the
saved channel config. After a field changed, the card kept showing the
Saved note from the last save and Test still ran, so it reported on the
old settings: a webhook switched to the Gotify format was tested with
the generic body and Gotify refused it. The note now disappears on a
change, and the test button stays off with a save-first hint while the
form differs from what is saved. Refs #947
The ntfy and webhook 502 descriptions shared one list of refused credentials that neither channel fully has. Each now names its own, including the userinfo and long path segments of a webhook URL. The email description names the TLS, protocol and envelope cases.
Refs #947
The admin test response carried a code and status per channel that nothing rendered. The toast now lists each failing channel with the translated cause and the HTTP status or SMTP code. The relay reply is no longer part of that response, and the ntfy error no longer echoes the topic, which the sender treats as a secret.
Refs #947
Userinfo and each query value are decoded on their own, so one malformed escape no longer discards every other secret in the URL. A reply is compared with each secret in literal, JSON-escaped and percent-encoded form, and URL path segments of 16 or more characters count as secrets, which covers the token in a Discord or Slack webhook URL and an n8n webhook id.
Refs #947
A STARTTLS failure (port 465 set up as 587) and a protocol error now read as connection_failed, and an envelope or message refused without a reply code as upstream_rejected. They used to fall through to the 500 for an unnamed failure.
Refs #947
The fetch and envelope reading move into runConnectionTest, which the button calls. A test stubs the fetch with the 502 envelopes the notification test routes send and asserts the rendered code, HTTP status, SMTP code and relay reply, so dropping the meta extraction fails it.
Refs #947
A 502 from a channel test reports the user's relay or mail setup, not a fault on this server, yet the status alone raised every press to an error-level event. The event builder gains a ceiling for the status-derived level, and the test routes cap it at warn on the 502 path. The 500 for an unnamed failure stays at error, and explicit elevations are unaffected.
The notifications guide notes that a reverse proxy which replaces 5xx pages hides the detail, so the card falls back to Test failed.
Refs #947
The anamnesis page writes through requireRecordAuth("manage",
"profile"), which refuses a grant whose sections do not reach profile.
The Settings list and the navigation entry checked only the level and
the record kind, so a MANAGE grant scoped away from profile would be
offered a page whose saves are refused.
Record-content destinations now name the section they write, and are
listed only when the active record's manageable sections include it.
A destination without a named section is never listed. The shell and
the navigation read the same predicate. MANAGE grants are whole-record
today, so managed profiles and adult MANAGE shares list what they did
before.
Refs #939
A same-origin auth gateway that answers an expired session with a 200
sign-in page returns HTML for both the navigation and the /api/version
probe, which read as another app. The worker then unregistered and
destroyed the Web Push subscription, which nothing re-creates, so
reminders stopped without notice.
The worker no longer unregisters while the registration holds a push
subscription, or when the lookup is unavailable. It deletes its caches
and passes requests to the network for the rest of its life instead.
The probe now sends same-origin credentials, so a valid gateway cookie
reaches HealthLog.
Refs #847
A notification target on a private network has been reachable since the
operator allowlist for private origins arrived; the hint under the URL
field and the guide still said the address had to be public.
The guard read src/lib/idempotency.ts for the hle_ prefix, which moved to src/lib/secret-shape.ts with the matcher. It now asserts that the shared matcher refuses an hle_ token and that the idempotency cache imports and calls it, so a private copy of the pattern would fail it too.
The outbound payload was documented nowhere. The webhook section now shows the generic body and the Gotify body as the Test button sends them, what each field carries, and how to point the webhook at Gotify: a URL ending in /message, the application token in an X-Gotify-Key header, and the Gotify format.
Refs #947
Gotify's POST /message types priority as an integer, so the generic body, which sends the word, was refused with a 400 before the token was ever the question. The webhook card now offers a payload format: generic, which stays byte for byte what it was, and gotify, which sends title, message, an integer priority and extras.
Priorities follow the Gotify Android client's channel thresholds: 5 for routine events (audible), 8 for medication reminders (the lowest heads-up value), 10 for urgent events. The extras declare plain text and carry the event name, masked to reminder in discreet mode as the generic body does.
The choice lives in the encrypted channel config, so no migration: an absent format is generic, only gotify is written, and a save that omits the field keeps the stored choice. GET returns the resolved value. The sender comment no longer suggests an Authorization Bearer header for Gotify, which takes X-Gotify-Key or a token query parameter.
Refs #947
A reverse proxy with a response-header allowlist strips
X-HealthLog-Page from every real HealthLog page, and the worker then
unregistered on each load and dropped Web Push subscriptions.
A successful page without the marker now only sends requests to the
network while the worker asks /api/version with no-store and a manual
redirect. It deletes its caches and unregisters only when the answer
positively is not HealthLog: a 404, a non-JSON 2xx, or JSON without
the version shape. A failed probe, any other status or the HealthLog
shape resumes normal service. buildSha may be null on a self-built
image, so only its presence is checked.
Refs #847
The test-connection callout now appends the HTTP status or SMTP reply code the route reports, and shows what the relay answered on a line beneath it. The reply is rendered as a React text child, so markup in it is shown as characters.
Adds the upstream_rejected message in all seven locales.
Refs #947
The More hub carries feature destinations only; Settings and
Notifications sit in the top-bar menu. The browser-only refusal for a
manage invitation comes from the transport check, which runs before and
independently of the second-factor step-up.
The webhook, ntfy and email test routes turned every delivery failure into a bare 500, so the settings card could only say "Test failed" while the server knew the relay had answered 400. A failure at the other end, or on the way to it, is now a 502 with meta.errorCode, the upstream HTTP status or SMTP reply code, and for webhook and ntfy what the relay answered when that is short and holds no credential. The codes reuse the test card's vocabulary; upstream_rejected is new, for a relay that accepted the connection and refused the payload. The private-origin 422 is unchanged, and only a failure the server cannot name stays a 500.
The guard that pinned the bare 500 now asserts the opposite, with the reason in its name.
The admin fan-out test reports the same code and status per channel, and tests the webhook and email channels, which it used to list as an unknown channel type.
Refs #947
The webhook and ntfy senders now carry up to 200 characters of a non-2xx response body on the send outcome, with control and formatting characters removed. The body is dropped when it looks like a credential or echoes the header value, auth token, topic or a URL token. Nothing stores it; the ledger row and channel state keep the reason code as before.
Transport faults without a status now say whether they were a timeout or a refused connection, and the email sender maps SMTP failures into the same vocabulary with the reply code. Both exist so the test routes can answer with the cause instead of a bare 500.
Refs #947
Offering manage access is refused outside a browser session, and the
second factor is asked only when the account has one. The note under
the manage option and the browser-only error said the factor was
always asked. Reworded in all seven locales.
Switch into the managed record, follow the sidebar Settings entry to
the profile card and on to the Modules card, then repeat from the
phone-width user menu. Asserts the entry's target, the absent
Notifications entry and the record id on each card, after the shell
has loaded.
Refs #939
A self-hoster looking after a managed profile could not find Settings
after switching into it: the utility entries vanished for every shared
record, while the Settings shell lists the profile's own configuration
and the section gate admits it.
The utility tail now answers for the record on screen. Settings is
offered when the shell lists at least one section for it and links to
the first one: the profile card for a managed profile, the anamnesis
page for an adult share at manage. Read and write shares, and a
refused or pending switch, get no utility. Notifications stays hidden
under every switch. The desktop sidebar footer and the mobile user
menu read the same list; the footer identity block stays a plain
block.
Refs #939
Move the rule that decides which Settings sections a shared record
lists out of the settings shell and into the record-settings
classification module, next to the inventory it reads. The shell
filters through it unchanged; the navigation reads the same answer
in the next change, so the two cannot disagree about whether a record
has Settings pages and where they start.
Refs #939
The worker is registered with scope / and serves /_next/static/*
cache-first. A self-hoster who stopped HealthLog and started another
app on the same port kept getting HealthLog's cached chunks until they
unregistered the worker by hand.
The proxy now marks every page response with a presence-only
X-HealthLog-Page header. When a successful navigation arrives without
it, the worker stops handling requests for the rest of its life,
deletes its own caches and unregisters, and hands the page through
untouched. A network failure keeps the offline fallback, and a
redirect or error status is never read as another app, so a gateway
in front of a stopped HealthLog does not drop the Web Push
subscription.
Refs #847
LOKI_ENDPOINT is read only from the server environment, so the push now
dials its exact origin through the pinned operator-approved resolver
instead of the public-host pin that refused a Loki on the operator's
own network. Link-local, metadata and unspecified addresses stay
refused.
A thrown push or a non-2xx answer used to vanish in an empty catch. It
now writes one line to stderr per reason at most every five minutes,
naming the Loki origin, the reason and the dropped event count, without
credentials. The endpoint accepts both the base URL and the full push
URL; the second form used to double the path into a silent 404.
Refs #947
The idempotency cache refuses to persist a body carrying a HealthLog token or an AI provider key. The notification test routes are about to refuse echoing such text too, so the pattern moves to src/lib/secret-shape.ts instead of being written a second time. Behaviour of the cache is unchanged.
The update runbook ran migrations with a plain docker run and an
.env.production file. No setup guide creates that file, and a plain
docker run starts outside the Compose network, where the database's
service name does not resolve, so the step waited thirty seconds and
gave up with a connection error.
It uses docker compose run now, which starts the one-off container in
the stack with the app's own environment. Refs #961
The first run tells you what is actually true about your account, an invitation link behaves, and the AI you ask a question to has budget left when you ask it.
v1.38.19 was cut before this change and still answers on the path, so
naming it as the version that removed the route would be wrong in the
published contract from the day it appears.
A refused address change returned before the update was built, so every
other field in the same request went with it. The settings form posts
the whole profile at once, so somebody who had spent the budget could
not save a timezone or a height for an hour and was told it was about
email addresses. The address is now dropped and the rest of the save
lands, reported through the existing rejectedFields key with the code
`rate_limited`; the 429 is kept for a request that asked for nothing
else, where a partial success would be a lie.
The comment claiming an audit row beside each question was inverted:
both refusals returned before the row is written, so only the answers
that told the caller nothing were being recorded. The two answers worth
detecting write their own rows now.
The stored address is normalised before the "has it changed" comparison.
Registration writes it as typed, so an account created with capitals did
not match its own address and paid for its first ordinary save.
The path stood in the published contract, so a caller outside this
repository can hold a reference to it whether or not we can name one. A
bare 404 reads as a broken deploy or a misrouted proxy, which is exactly
the reading that kept a client retrying a removed path for twelve days
once before. Registering the retirement makes the edge answer 410 with
the removing version and no replacement, and republishes the path as
gone rather than erasing it from the document.
The dummy hash was awaited as an argument to the verifier, so a rejected
mint threw before the verifier was reached and before the catch on it
could apply. The promise was created once at import, and a rejected
promise stays rejected, so one failed mint under memory pressure would
have made every unknown identifier answer 500 while a real account
answered 401 — a cleaner oracle than the timing gap this closes, and
passkey-only accounts locked out of the password form until restart.
The mint is now awaited inside the guarded region, is lazy rather than
an import-time cost in every process that touches this module, and is
dropped on failure so the next refusal mints again instead of inheriting
a cached rejection. A failure leaves a log line; the answer is false
either way. The tests cover a rejecting mint, not only a rejecting
verify.
With the discovery endpoint gone, this is the remaining anonymous path
that answers whether an address is registered. The 409 already gives
that away, so the work asymmetry ahead of it costs nothing today. It
would become the whole answer the moment somebody unified the status
code without moving the check after the hash, so the ordering now says
it is load-bearing.
Background insights can no longer use up the day before somebody asks a question; a fifth of the daily limit is held back for what a person does by hand.
Changing the profile address asks whether another account already holds
it, and the answer is 409 versus 200 — an existence check over every
address on the instance, open to any signed-in caller. Neither route
that reaches it had a ceiling of any kind and the API wrapper supplies
no default, so the only bound on a sweep was the network.
Both routes now share one per-account bucket, ten an hour, charged
before the question is asked so a refused caller learns nothing. Only a
request whose address actually differs from the one on file is counted,
so re-saving a settings form that carries the unchanged address stays
free. The refusal is the standard 429 with Retry-After and the
X-RateLimit triple, carries `profile.update.emailRateLimited`, and is
published on both operations.
Sign-in returned the moment it found no account, or an account carrying
no password hash, before it reached the verifier. An account that did
have a password paid a full Argon2id verification at 19 MiB and t=2. The
three refusals answered with the identical 401 body and three very
different durations, so the clock told an anonymous caller whether an
address is registered and whether it carries a password.
`verifyPasswordOrDummy` verifies against a stand-in hash minted once per
process at the same cost parameters real hashes use, and returns false
whenever there is nothing to verify, so the passkey-only account takes
the same path as one with a password. The dummy arm discards its own
verdict and swallows a throw, so no outcome there can be read as a
success or answer with a different status.
`POST /api/auth/check-user` answered, for any typed identifier and with
no credential at all, whether an account exists and whether it carries a
passkey or a password. Its header called that the intended contract with
the native client; it is not one any more. The native client never calls
it, and no web surface in this repository does either.
Removed without a replacement: the route, its two test files, the
OpenAPI operation and its two schemas, the surface's mention in the
rate-limiter doc comment, the bucket expectation in the auth-surface
rate-limit test, and the login route's comment pointing at it. No stub,
no deprecation shim, no 410 arm — there is no caller to keep.
An invitation link no longer offers to register while you are signed in, the connect step says what is true about a provider, and the AI offer waits until the provider has actually answered.
The delivery ledger is written without awaiting the result, so the row
appears some time after the dispatch returns. The test slept a flat fifty
milliseconds and then read, which holds on a warm machine and loses on a
loaded runner: the assertion reported an empty ledger as though the
dispatch had not recorded the skip.
It polls for the rows now, up to five seconds, and fails with a message
naming how many it found. The claim is that the row is written, which is
what the code promises; that it is written within fifty milliseconds is
not.
Brings in the truncate retry, the socket fix for the record-fence spec that
failed this branch's second e2e shard, and the corrected absence assertions.
The protected shell renders its hydration gate for every child until
/api/auth/me resolves, so after a goto, a reload or an exit click nothing
exists yet: no nav, no top bar, no banner, no page body. Fifteen absence
assertions across twelve specs were asserted inside that window. Each was
true because the surface had not rendered, and each would have stayed green
if the thing it forbids came back.
Every site now waits for a positive marker of the loaded state first: the
document sheet's footer download action, the settled integrations card
rather than the family attribute its skeleton shares, the top bar after an
exit, the page's own heading, the delegate's own marker, the Coach FAB. Two
skeleton-absence waits are replaced by waits on the content that follows.
delegated-writes had a different cause: it asserted the bulk selection bar
absent although no row was ever selected, so it passed for an owner too. The
gate is one level earlier - below MANAGE no row carries a checkbox at all -
so the test asserts that instead, with the bar and the row delete kept as
backstops.
No sleeps added, no timeouts raised, no claim weakened.
The connect step's new attention and credentials tiles link to the
integrations page with a label that names a path — "Open Settings →
Integrations". The button base is `whitespace-nowrap` with a fixed height and
`shrink-0`, so at 390 px the German and French renderings could neither shrink
nor wrap: the control ran 27 px past the viewport. The pre-existing connect
card was unaffected, its label being a short verb phrase; the path label and
the tiles that carry it are new, which is why the sweep passed before.
The CTA is bounded to its card, allowed to break between the words of the
path, and allowed to grow for the second line, with the 44 px tap target kept.
No copy moved, so the seven catalogues stay as they are.
The background ceiling applied only when the operator funded the chain, so on a
chain the operator does not pay for the automatic generators and the chat shared
one undivided ceiling and the first could still take all of it. That is the
configuration the recommended remedy for the original lockout produces — put the
personal Codex account first — so the guarantee was missing on exactly the setup
it was written for.
The ceiling is now expressed as what it is meant to protect. Background work may
run until the day's spend would leave less than a fixed reserve for whoever is
waiting on an answer, on both cost owners. A reserve and a quota look alike at
the boundary and mean opposite things in the middle: a quota stops useful
generation on a day nobody was ever going to interrupt, while a reserve costs it
nothing until the moment it would take the last of the day. An account
generating 1.45 million tokens of insights on its own plan keeps every one of
them and the chat still holds 400 000.
INTERACTIVE_RESERVE_SHARE replaces JOB_SURFACE_SHARE. The old name described a
quota over the jobs rather than a floor under the person waiting, and a name
that states the opposite of the intent is what put the wrong comparison in this
ledger twice already.
The fraction is a default an operator will want to change. The setting and its
documentation are separate work.
The offline fence spec already sends its account switch with the page's
own fetch, because every Playwright API context in the runner shares one
keep-alive agent and can be handed a socket the server closed on its idle
timeout. The read on the next line still went through that agent, and it
failed with read ECONNRESET on all three attempts, which fails the run
because CI treats a flaky test as a failure on purpose.
It reads through the page now, like the write above it. The two remaining
calls on the shared context sit in finally blocks that swallow their
errors, so they cannot fail a test.
The suite wipes the database between files with one TRUNCATE, which needs
the strictest lock on every table at the same time. The server writes in
thirty-nine places without awaiting the result, so one of those can land
after the test that caused it has returned, and Postgres then picks the
truncate as the deadlock victim. It failed an unrelated file twice in two
days and passed on the rerun both times.
The truncate is retried up to four times, on that one error and no other,
with a short back-off. Three cases hold it there: a deadlock that clears,
an error that is not a deadlock and must not be retried at all, and a
deadlock that never clears and must still fail. All three go red without
the retry.
CLAUDE.md forbids planning shorthand in committed artefacts, and this branch
introduced 74 lines of it across source, tests, a schema field and two
migrations. Someone reading the migration SQL in a year has no way to resolve a
wave letter or a round number, and a comment that points at something outside
the repository names no reason at all.
Three passes. A version followed by a parenthetical keeps the version and loses
the parenthetical, because the version says since when something holds. A
sentence that leaned on the vocabulary to carry its meaning is rewritten to
state the reason directly: the arrival reservation now says it was refused over
money the operator never spent, rather than naming the defect a wave existed to
remove. Three test titles carried the marker in the name itself and are renamed
to what they pin.
Comments only. No behaviour, no renames beyond those three titles.
Allowlisting `PUT /api/auth/profile` so the setup flow's baseline step could
finish also handed every visitor the shared demo account's contact email, its
display name, its full name and its insurer fields: the allowlist admits a
path and a method, and every field the handler behind it writes comes with
them. The justification beside the entry named three fields; the entry
admitted eleven.
The write is narrowed on the server, in `applyProfileUpdate`, to height, date
of birth and sex while `DEMO_MODE` is set — not in the client, which is the
party the allowlist exists to distrust. It happens ahead of the email conflict
check, which would otherwise answer "is this address registered here?" to
anybody who can open the demo. The display-name box goes read-only there, as
the anamnesis card already is, so nothing accepts input it will discard.
The entry's comment now lists what the handler writes and what is admitted.
The consent branch of the done screen's AI panel painted "insights work for
you right now" on the strength of the receipt alone, reading no health at all.
A person who had consented — including one who had just tapped the offer — was
told a dead provider was serving them. That is the claim-nobody-measured
defect closed at the offer end of the same panel, still open at the other.
The positive sentence now needs a healthy verdict as well. A consented account
on a provider that failed reads the same "not answering" note as anybody else,
and on an unmeasured one the panel claims nothing. The panel's four variants
are pinned across all three health states in every arm.
Six operation descriptions in the API document opened with a version and
a bracketed letter-number code from the notes the work was planned in.
A client author reading the document has no way to know what B1 or B6
mean, and they mean nothing outside those notes. The version stays,
since it says when the operation appeared.
The panel paints before its status query answers, and every variant inside it
is conditional, so an assertion that a variant is absent could pass on a
question that had simply not been answered yet — which is how the journey for
the unmeasured-provider case passed on a retry against the very defect it was
written to catch.
The panel now carries the variant it settled on, absent until it has settled,
and the three journeys wait on that instead of racing it. The third journey
covers the state a freshly deployed instance is in before its first AI call:
no offer, and no claim of an outage either.
The provider-health route answered a spendToday payload that nothing rendered,
which is the pattern the two-ended rule exists to stop. It is also the screen
the operator opens when a surface refuses on budget, and the one mixed number
could not answer the question that raises: a day at 1.24 million tokens is
alarming until the card says that 151 200 of them were the instance's own key
and the rest ran on the users' own plans. The operator ceiling is enforced
against the second figure. Copy lands in all seven locales.
The arrival-reaction claim carried its own copy of the reservation upsert so
the write could ride the claim transaction. The copy drifted: it learned to
write operator_tokens and kept gating on the day's mixed total, at the halved
background ceiling. On the row the wave was written for, 1.2 million tokens
served by the user's own plan, the reaction was refused all day at half the old
ceiling, reproducing the defect the wave exists to remove on the one surface it
forgot to convert. reserveBudget now takes the transaction client, so both
writes still roll back together and there is no second copy left to drift.
The row also remembers who paid for its reservation. The supersede refund in
data-arrival has no chain to ask and could only reverse the total, so the
operator share of every superseded reservation stayed on the row forever,
walking the operator's ceiling upward against spend that never happened. A
supersede is routine, not an edge case: any second measurement of the same kind
on the same local date before the worker runs. The resume path re-derived the
owner from a chain the health ledger may have reordered since, which leaks the
same way or drains another request's balance.
A structural guard makes the invariant executable. Every INSERT column list,
SET clause and RETURNING list that names one counter must name the other, and
the set of files holding raw ledger SQL is pinned. Both defects above were a
grep away and both would have failed this test.
The owner split left an operator-funded chain with no ceiling at all. Its arm
compared operator_tokens, and that counter returns to roughly zero on every
reconcile the user's own plan settled: on the 2026-09-11 evidence the shared
key answered 500 and codex served, so the counter never grew, every job
reservation was admitted forever, and a runaway client loop could write an
unbounded row. A pre-existing concurrency contract caught it from the other
side, two reservations that must not both pass one cap both passing.
reserveBudget now refuses when the owner's counter is over its cap OR the day's
mixed total is over the abuse ceiling, and a background surface gets its share
of that ceiling too, so background spend is bounded on the total and not only
under a counter the operator's key never fills. The surface is a required
argument, so the type checker enumerated every reservation site.
The hop guard learns the same lesson: an operator-funded fallback hop is now
rationed at the calling surface's ceiling rather than the full operator cap. A
job admitted under the job share could previously spend the whole 200 000 once
its chain fell back onto the shared key, which is the original defect reached
by the fallback path.
The job share applies only where the operator pays for the chain. Multiplying
whichever ceiling the chain resolved halved background generation for a
self-hoster on a local model, who costs the operator nothing.
Refusals now name the ceiling they hit and whose it was. A refusal used to emit
the day's mixed total and nothing else, which on an operator refusal is not the
counter that tripped.
Thirty-one files carried internal build vocabulary and anchors pointing at
notes that are not in the repository: a reader of `checklist.ts` in a year
would find its rationale keyed to a research id they cannot open, inside a
comment that also names an internal build stage. Two test titles carried the
same anchors.
The version tags and the whole of the reasoning stay; only the vocabulary and
the dangling references go, with the few sentences that were built around an
anchor rewritten to stand on their own.
`onboarding-ai-offer.spec.ts` seeds the operator's AI key into `app_settings`
and replaces every `provider_health` row for both shared provider tags, then
restores both. Its own header says the inputs are instance-wide and it
configures serial mode, but serial mode only orders tests within a file: the
spec was absent from the project filter, so the mobile project ran it in a
second worker concurrently with the desktop one, each holding and clearing
state the other was asserting on — and any other spec reading AI availability
alongside them.
`invite-registration.spec.ts` joins the list for the smaller reason its
neighbours carry: two projects registering from one address share the same
five-per-fifteen-minutes budget.
The done screen's one-tap button posts `POST /api/consent/ai/web`, a path the
demo instance's edge allowlist refuses — correctly, since the demo is a single
shared account and a receipt one visitor minted would turn the operator's
provider on for everyone after them. The button was offered anyway, so a
visitor who took it got the generic "give it another try in a moment" toast on
the one screen whose whole purpose is not to say things it cannot back.
The route withholds the offer under `DEMO_MODE`, which leaves the panel in its
neutral form, claiming nothing either way.
The demo allowlist had grown two entries it should not carry. The demo is one
published account every visitor signs into, so `PUT /api/coach/about-me` was
an anonymous write of arbitrary text into the Coach prompt, persisted for
every later visitor until the next reseed; it is gone, and the baseline step's
anamnesis card renders read-only under `DEMO_MODE` so no visitor meets the
refusal as a failed confirm. `POST /api/managed-profiles` bought nothing: the
route opens with a fresh-MFA requirement the demo account cannot satisfy, so
the guardian arm was limited to finishing without the profile either way.
The baseline profile write stays, and the block's preamble now describes what
is actually admitted and why the two refusals are refusals.
`isAuthenticated` holds the last-known state off a localStorage marker when
`/api/auth/me` fails at the transport level or with a 5xx, so the shell does
not sign people out on a blip. The register page gated its "already signed in"
panel on that flag, and on that path there is no account payload: an invited
household member opening the link during a 502 window was shown "You are
signed in as ." and a sign-out posting to the failing endpoint, with no way to
register at all.
The gate is now the payload itself, which is the only positive evidence of a
session and is equally absent while the probe is in flight.
The managed-record integration projection mapped the ledger's new `unknown`
state to `disconnected`, but that mapping runs after the connection map has
already established a real credential. A guardian who connected Withings on a
child's record saw "Not connected" until the first hourly sync landed, beside
a row whose own `connected` field said true — an invitation to revoke and redo
the grant they had just made.
`state` on this projection reports that a pipe exists, not that it is live;
liveness is the sync verdict the card beside the pill already reads. With no
ledger row and a credential present, the pipe exists.
The confirm screen derived "Out of the navigation: …" from the acting
account's module map and rendered it in every arm. In the "someone I look
after" arm the completion never touches that account: it applies the
derivation to the managed record, or, when none was created, to nothing at
all. So a parent answering for a child was told Workouts, Cycle and Documents
were leaving their own navigation when nothing was going to move.
The arm now derives the list from an empty map, which makes it empty, and the
sentence disappears with it.
The done screen folded the `unknown` provider verdict into `unhealthy` and
painted "This instance's provider is not answering right now." `unknown` is
an empty `provider_health` table, which is the state of every instance until
its first AI call — so the most common path through the screen made a claim
about something nobody had measured. It now falls to the neutral panel, which
says nothing either way; the offer is withheld in both cases as before.
The projection itself drew the same unearned conclusion from staleness alone:
a quiet instance whose last success was two days old read `unhealthy` without
any failure behind it. A verdict now needs an observed one — a row whose last
outcome was a failure — and a merely old success reads `unknown`.
The entry claimed the setup screens leave room for longer translations
and pass the accessibility check, which describes how the work was
verified rather than anything a reader gets, and it mentioned the demo
instance, which is ours and not theirs. What remains is that the setup
is available in every language the app has.
The branch numbered the coach_usage column migration 0341 over a 0340 that
exists on no branch. Prisma orders lexicographically, so the gap is inert for
migrate deploy, but a later 0340 merged after this ships applies out of order
and makes prisma migrate dev complain on every developer machine. The series
now runs 0339, 0340 with no hole.
The entries for every released version since 1.35.2 were written from the
inside of the release process. They counted routes and modules, named
helpers and test files, and explained which check would catch a
regression. A person who uses the app, or hosts it for a family, could
not tell from them what had changed for them.
Each of those entries is rewritten: what someone saw, why it happened,
what happens now, and what they have to do, in that order, with the
paragraphs sorted by who they are for. Nothing is dropped. Every issue
number, contributor, environment variable, route, field, error code and
figure a reader can check is still there, in fewer and plainer words.
Headings, their order and their dates are untouched.
Entries between 1.27 and 1.34 keep their shape; only the sentences that
described a test or a sweep rather than a behaviour are restated. Older
history is left as it stands.
The done screen's assistant panel stops claiming that the instance's
provider already serves the person. A tri-state read over the operator's
own provider ledger decides between a one-tap offer that mints the
consent receipt, a plain "not answering right now" note, and today's
setup link; anything unknown, any read error, a managed profile and an
operator who has switched the assistant off all fall to no offer.
# Conflicts:
# e2e/setup/global-setup.ts
A provider with no sync history reports "unknown" instead of "connected",
so a fresh account is no longer told a device it never linked is already
delivering; the connect step resolves the sync verdict into four honest
tiles, records the flow's result only for a connection that is actually
serving, points an unconfigured provider at its credentials, and steps
over a source the account already has.
The getting-started checklist's data queries follow the same setup rule as
its visibility, so a settled account stops being told to do what it has
done; a failed completion leaves the confirm screen usable; an empty
multi-select is no longer recorded as an answer; the confirm screen names
the modules a run would take out of the navigation; the settings links it
offers work before the first completion; and demo mode can fill the
baseline it is shown.
The invite landing is public in the client shell as well as the proxy, the
edge answers the redirect itself so no shell mounts, and registration
refuses to run over a live session instead of silently swapping the
caller's cookie for the new account's.
The provider-health endpoint showed delivery health only. When a surface
refuses on budget the operator's next question is which ceiling he hit,
and one mixed token figure cannot answer it: a day at 1.24 M tokens looks
alarming until you see that 151 200 of them ran on the instance's own key
and the rest on the users' plans.
`spendToday` carries the day's key and both counters, aggregated across
accounts. The operator ceiling is enforced against `operatorTokens`, so
that is the figure the readout puts beside the total.
The automatic generators — status and reference cards, the briefing, the
arrival reaction, the nudge, document summaries and the index backfill —
reserved against the same ceiling as the chat. On the operator's account
that meant 150-330 ledger rows a day, 172 metric generations before
06:42Z, and a ceiling already spent when he opened the chat.
Every background surface now reserves through
`resolveDailyCapFor("job", chain)`: half the cost owner's ceiling, all
job surfaces together. The interactive routes keep the whole ceiling, so
a day of background work can no longer take the user's own away.
The reservation compared the day's TOTAL against whichever ceiling the
chain resolved, and the chain walker's hop guard did the same. On an
account whose background jobs run on the user's own ChatGPT plan, both
saw a total the operator had not paid for: the chat refused at 06:42Z
with 513 539 tokens against the 200 000 operator ceiling, and the
fallback hop to the shared key was closed on the same arithmetic.
An operator-funded chain is now measured against `operator_tokens` and a
user-plan chain keeps its abuse ceiling on the total; the hop guard reads
the operator share too. A day of turns the user's own plan paid for can
no longer lock anyone out of the operator's ceiling.
Two structural guards caught the new gate. The error-code catalogue is derived
from the route tree on every run and fails in both directions, so a code the
server emits and does not publish is a client that cannot branch on it; the
session-surface guard freezes the short list of files under `src/app` that
resolve a caller without `requireAuth`, and the register route now belongs on
it — `requireAuth` is the wrong gate there twice over, since it would throw at
the anonymous caller the route exists for and this route wants the ABSENCE of a
session.
The code is `auth.already_authenticated` rather than the bare
`already_authenticated`: the catalogue's own header rules that new codes are
dot.case, and it sits beside `auth.mfa.code_invalid` in the same family. The
OpenAPI document renders the catalogue into the ErrorEnvelope description, so
it is regenerated.
Running the two cases in parallel let each walk the other's account state, so
they go serial like the sibling journeys. The second case also asserted the
wrong thing: with a Q2 area answered as well, an already-delivering source
correctly falls through to `log-reading` — that IS the fix. Split into three:
nothing connected, a connection with somewhere to fall to, and a connection
that is the only task the answers name.
`coach_usage` counted one number per (user, day). A turn served by the
user's own ChatGPT plan after the operator's shared key answered 500 was
therefore charged to the operator-cost cap, and the operator's chat was
locked out every morning by background work he pays for himself.
The ledger gains `operator_tokens`: the share of `total_tokens` served by
an operator-funded provider. `reserveBudget` books a reservation to the
owner of the chain's primary and `reconcileSpend` settles it against the
provider that actually served, both inside the same statements as the
total so concurrent requests can never see one counter without the other.
Every call site names its cost owner and its serving hop; the argument is
required, so a new surface cannot inherit the old blind accounting.
Every layer of the invite chain had unit coverage and the chain was broken
anyway: the proxy test proved the edge admits the landing, the page test proved
it redirects onto the registration form, and neither could see that the
redirect arrives in an RSC flight payload after the shell has already flushed.
The operator's path — mint an invitation, open the URL in a private window,
reach a usable form — was exercised by no spec at any level.
The new spec mints a real invitation in the admin UI and opens it twice: once
in a genuinely cookie-less context, asserting both where the visitor lands and
that they never passed through the sign-in page, and once in the admin's own
window, asserting the explaining panel and that the admin's session survives. A
third case pins the server half through the wire. Against `origin/main` all
three fail — the anonymous visitor lands on `/auth/login`, the panel does not
exist, and the registration POST answers 201 over a live session.
The minted URL gains a test id so the spec reads it from its own element
rather than from the first paragraph in the ticket.
Two runs on one account, and the only difference between them is a row in
provider_health: a success inside the freshness window brings the offer and
the tap leaves an active ai_full receipt; a dead credential inside its
cooldown brings 'not answering' and no button at all.
The seed is instance-wide because the projection is - a fresh account's own
ledger is empty by construction - so the spec runs serially and puts the
operator key and the ledger rows back afterwards. The offer path asserts
that no provider was called: the flow reads a projection and never probes.
Verified as a real check, not a tautology: moving the seeded success from 30
minutes to 25 hours ago makes the offer case fail.
The demo allowlist names a path and a method as data, which reads to the
submitter matcher exactly like a request expression. proxy.ts submits
nothing and has no answer to read, so it is excluded by name.
No journey reached the connect arm: bp answers `manual`, visit answers
`file`, and the child path ends at confirm. The a11y sweep opened the screen
and asserted the connect card was visible — which it was, for the few hundred
milliseconds before the status envelope resolved, so the sweep passed through
the defect rather than catching it.
The new journey answers Q4 with a wearable and asserts only after
`GET /api/integrations/status` has settled: nothing is claimed for an account
with no connection and no result is stamped on its behalf, and a connection
seeded as delivering is acknowledged rather than offered. The a11y sweep now
waits for the tile the screen settled on instead of the frame before it.
A demo visitor who typed a height on the confirm screen got a 403 and a
generic toast, and the "someone I look after" arm could only take
"Finish without the profile": the profile PUT, the self-context PUT and
the managed-profile POST were missing from the demo allowlist, so only
the empty-form path completed.
All three are added, pinned per method as the rest of the list is. None
of them creates health data.
Both Settings links on the confirm screen are page routes, and the
onboarding cookie that sends every page route back into the flow is
cleared by the completion this very screen calls. On a first run they
could not go anywhere.
They now render as plain text until the flow has been completed once.
The words stay, because where a setting lives is true either way.
The screen listed what the answers switch on and said everything else
stays one click away under Settings, Modules. That is true of
reachability and false of the navigation entry: a module the answers do
not ask for is written off by the completion's derivation.
It now names those modules, read through the registry's own merge rule
and intersected with the map the navigation shows today, so nothing is
claimed about a module the account payload says nothing about.
`chooseFirstResultTask` read the answers alone, so an account whose WHOOP had
been feeding the dashboard for months was sent to go and connect WHOOP. It
takes the delivering sources now; the server still picks from the answers,
having no status in hand, and the screen re-picks once the envelope resolves.
The fall-through stops where there is nowhere to fall to: a person whose one
answer is an already-connected source keeps the step and is told what was
found, rather than ending the flow on no result at all.
A sharing invitation carries no link and nothing is e-mailed — it is addressed
to an account that already exists on this instance, and the person accepts it
from inside their own session. The card's input hint already named the account
requirement and left the rest unsaid, so an inviter looking for a link to send
had nothing to find and no reason to stop looking. The card now names where the
invitation turns up, in all seven locales.
The step decided "connected" from the ledger's `state` and had one piece of
copy for every outcome: "the first sync is in progress". That sentence was
false for a connection delivering for months, and worse for one the person
has to repair — the screen said everything was fine while the grant was gone.
`connectSourceView` resolves the envelope entry into one of four tiles and
says whether the flow may record its result. Only `pending_first_sync` and
`fresh` may; `stale` is acknowledged without being claimed as new; `stalled`,
`failing`, `reauth_required` and `parked` name the state and link at the page
that can change it. An unresolved envelope paints no tile at all.
A source the instance cannot connect — a BYO provider with no client pair, a
shared-app provider with no app — now points at the credentials instead of
offering a "Connect" button onto a card that has none.
`POST /api/auth/register` read no session at all, so a request carrying a valid
`healthlog_session` was handled exactly like an anonymous one and the
unconditional `createSession` at the end replaced the caller's cookie with the
new account's. An admin opening his own invite link to check it was signed out
of his admin account and into a stranger's onboarding, his previous session row
still valid server-side and no longer reachable.
The route now resolves the optional session first and refuses with 409
`already_authenticated`, before the rate-limit bucket is spent and before the
invitation is consumed, so a signed-in visitor neither burns a use nor locks
the door for the person the invitation is for. The register page renders an
explaining panel with a sign-out button in place of the form, so nobody has to
fill three inputs to discover the refusal; signing out returns to the same
invitation rather than to the login screen. Copy lands in all seven locales.
Q2, Q4 and Q6 let "Weiter" write an answer with nothing ticked. On Q2
that answered "no areas", which is what switched every optional module
off for somebody whose click meant "no preference"; on Q6 it marked the
step done while recording no unit at all.
An empty selection now yields no body, so the primary button is dead
until something is chosen. Skip stays the way past, and Skip is the
conservative derivation the completion route already applies.
The card read one field and made two claims from it: that insights work,
and that no setup is needed. The first was a presence read - it knew a key
existed, never that it answered - and the second skipped the consent
receipt the egress actually needs, so the person who believed the note met
a consent.ai.required refusal on their first briefing.
Four branches now, one button. The offer appears only when the server says
the shared provider is known to work and nothing forbids it, and the tap
posts the affirmative web intent. An unhealthy or unknown verdict paints
'not answering' with no button. When the offer is refused for a reason that
is not health, the panel stays neutral rather than blaming the provider.
The note survives for the one state it was ever true of: consent on file.
The sample, the ladder, the 'fully useful without' line and the setup link
are identical in every branch. Copy in all seven locales.
The baseline form raised its pending flag and lowered it on only two of
three exits; the success path went through the confirm screen's finish(),
which caught its own error and returned normally. A failed completion
therefore disabled both buttons until the page was reloaded.
The reset now lives in a finally, finish() rethrows so the form sees the
failure, and the two managed-arm buttons report it themselves. The step
passes before the completion run off a ledger ref, so a retry after a
failure midway no longer re-sends a step the ledger already carries.
The proxy now answers `/invite/<token>` with a real 307 onto
`/auth/register?invite=…` (or a bare `/auth/register` when the segment fails
the `hlv_` shape gate), carrying the baseline transport headers plus the
no-store / noindex / no-referrer hardening the token-in-path surface has had
since v1.17.0.
Before this the redirect came from the landing page, which Next delivers in the
RSC flight payload rather than as a server 307 — the shell mounted first and
raced it. At the edge there is no HTML and no hydration, so there is nothing to
race. The page stays in place as the fallback for any path that bypasses the
proxy matcher.
The ledger's synthetic "no row" snapshot returned `state: "connected"`, so
the field's default meant the opposite of its name: every account that had
never connected a provider published `connected` on the status envelope.
Settings survived it by never reading `state`; the setup flow's connect step
did read it, and told brand-new accounts their wearable was connected.
The snapshot now reads `unknown`. `syncHealth.verdict` is unchanged and stays
the only liveness truth; the managed-record projection maps `unknown` onto
`disconnected` the way it already maps a missing connection.
`/invite/<token>` has been public in `proxy.ts` since v1.17.0 and was never
added to the client-side gate in `auth-shell.tsx`. Next streams the shell
before the landing page's RSC redirect resolves, so the shell classified the
route as protected, `/api/auth/me` answered 401, and `router.replace(
"/auth/login")` won the race — the operator's invite link landed on the sign-in
page it was meant to bypass.
The shell now lists `/invite/`, and lists the `/auth/` family as the prefix the
edge already admits rather than as two individual pages, so a future auth page
cannot inherit the same defect. `PUBLIC_PATHS` is exported from `proxy.ts` so a
new parameterised guard can render the shell at every page prefix the edge lets
through and fail when the two lists drift apart again.
GET /api/user/ai-provider gains three additive fields: the instance-wide
health tri-state, whether this caller already holds a receipt, and the one
boolean that answers 'may the shared provider be offered in one tap'.
The offer is the conjunction of five preconditions and every one of them
can only take it away: the operator's provider is the one that would serve
the caller, the health is demonstrably healthy, the assistant master and
coach flags are on, the acting record holds its own credentials, and no
receipt is on file yet. A managed profile is excluded by the positive form
of the policy test - 'not deny' would have let a child's record through.
Nothing here probes a provider; a probe would bill the operator for every
registration on the instance. docs/api/openapi.yaml is regenerated from the
schema source.
The card's visibility rule gained a "ran the setup flow" disjunct; the
component's query gate kept restating the older half of it. A wizard-run
account past five readings therefore saw a permanent checklist whose
medication, data-source, notification and insight rows were frozen at
"not done", because every supporting query was disabled.
Both places now read one exported predicate, isStillInSetup, which also
keeps the rows live for a restarted run nobody finished.
The done screen promised working insights on the strength of a presence
read. A fresh account's own provider-health ledger is empty by
construction (the table is unique on user + provider), so the evidence has
to be an instance-wide fold. It folds across accounts and returns three
words: no counts, no timestamps, no account can be inferred from it.
Fails closed against the grain of the ledger it reads. There the fail-open
protects a generation already running; here the output is a promise made to
a person, so an empty table, a timeout or a read error is 'unknown', and
'unknown' buys nothing.
The string-headroom sweep went red on CI for French at phone width by
two pixels the Mac never showed: the runner's fallback fonts render a
touch wider. The box was the sex select on the confirm screen — its
value is whitespace-nowrap, and a grid item's automatic minimum is its
min-content, so the track was pinned at the padded option's width and
the shell scrolled sideways. Both cells of that row and the trigger
carry min-w-0 now: the track takes the free space and the value
ellipsises, which is the select primitive's own design. Reproduced and
verified locally by narrowing the viewport to 372 px, which stands in
for the wider fonts (336 px of scroll before, none after).
The sweep now names what overflowed: the boxes past the shell's edge,
with tag, slot, classes, text prefix and edges, ride the failure
message and the outside list, so the next red run says what rather
than only that.
A module page whose switch is off answers with the shared notice that
names the switch and offers the Modules settings, so a bookmark or a
report link explains itself instead of bouncing home. The vaccinations
journey pinned the old redirect and went red for the behaviour the setup
flow introduces on purpose.
A webhook or ntfy target on your own network can be reached again with the operator's say-so, the test button says why a private target was refused, and the second of two close doses can be logged from the card again.
The nearest test enumerated only the schedule under test, and production
rows carry no schedule identity. On a medication composed of sibling
schedule rows (08:00 daily plus 12:00 daily, the wizard's compose shape) a
row snapped exactly to A's 08:00 was also asked about B's 12:00, and B's
own occurrences alone never showed that A's 08:00 is nearer: next-due, the
display due and the compliance cycle all answered tomorrow's 08:00 at 09:00
local, the same swallow one level up.
The neighbour set is now the union of every schedule's occurrences within
the radius, memoised per row, since it depends on the row alone and a
predicate asks about many slots. The rolling short-circuit still looks at
the schedule under test only. The era filter in the next-due predicate
compared a field no caller ever populated, so it is gone along with the
test that pinned it; the era floor lives in the overdue search's band
range and in the forward walk's start at now, as it always did in
production.
Tests: sibling schedules in both next-due and the compliance cycle, the
exact tie between two slots, the 25-hour fall-back day (a pair across the
repeated hour, and a row anchored in the old offset), and the ad-hoc
epsilon case now stands on the ad-hoc row alone.
A taken, skipped or auto-missed row counted as resolving every occurrence
within six hours of its anchor. The radius was sized for the twice-daily
12 h gap, so on any schedule whose neighbouring doses sit closer than that
(19:49 and 23:59, 08:00 and 12:00, every four hours) the first dose's row
resolved the second occurrence too: the next-due walk jumped to tomorrow,
the card offered tomorrow's first dose, and a skip pressed next landed
there. The second dose could not be logged from the card at all.
Reproduced with a Europe/Berlin account on 19:49 + 23:59: the 19:49 take
left the 23:59 row pending and the skip was written to tomorrow's 19:49.
The compliance-journey e2e spec exposed the same thing in every run that
started after noon, when its twin slot is 23:59.
Rule, in slot-resolution.ts and shared by the list's next-due, the card's
overdue search and the compliance current-cycle descriptor: a row resolves
exactly one occurrence, the one nearest its anchor, and only within the
drift radius. An exact match short-circuits; a drifted anchor is measured
against every occurrence the schedule emits within the radius and loses to
any nearer one (ties go to the earlier occurrence), so the effective reach
is min(radius, half the gap to the neighbour) and a sibling can never be
swallowed. Rolling cadences skip the nearest test, their occurrences are
at least a day apart. Ad-hoc rows keep the one-minute epsilon, now in the
cycle descriptor as well, so the card's due slot and its cycle state agree.
The drift tolerance itself stays: it covers a row whose anchor sits off the
canonical instant, a pre-snap row or an instant that moved under it across
a DST shift, which is what the radius was introduced for in v1.15.10.
A Nightscout self-host on host networking with
NIGHTSCOUT_PRIVATE_ORIGINS=http://localhost:1337 worked before and must
keep working unchanged; an operator who lists an exact loopback origin
with its port has made the same deliberate decision as one who lists a
LAN origin. The never-grantable floor is now the unspecified address,
link-local and the metadata endpoint only. Loopback (127/8, ::1,
localhost) is grantable when listed exactly, still resolved and pinned
at connect time with redirects forbidden, and still refused when not
listed.
::1 is answered before the embedded-IPv4 unwrap, which read it as the
IPv4-compatible spelling of 0.0.0.1 and refused it as unspecified. The
pinned Nightscout transport test runs the real floor again. Each refused
range keeps its own test; loopback moves to the grantable-when-listed
set with a test that an unlisted loopback is still refused, and a
Nightscout grant of http://localhost:1337 is asserted to parse.
Refs #947
"Finish without the profile" completed the flow with no record id, and
the route then derived the child's answers onto the guardian's own
record. The server now decides: answers given for somebody else with no
record to apply them to complete and latch the flow without deriving or
seeding anything, whatever the client sent. The checklist carries the
profile instead and reads none of those answers as the guardian's own.
A record id the caller does not guard answers 404 like every
id-addressed managed-profile route, one answer for missing, not managed
and not theirs alike; the contract documents the field, the 404 and the
422 text. Pinned by two integration cases and a second child journey.
A loopback, link-local or metadata target answered
private_origin_not_approved with a message telling the user to have the
operator list it, and the parser refuses such an entry forever: a dead
end the route test even pinned. Those targets now answer
private_origin_not_grantable (catalogued, on all four routes, translated
in seven locales) with a message that says no grant can open them and
to use the relay's LAN address. Both senders carry the reason into the
outcome so the test button forwards the same code.
The malformed-entry warning printed the entry verbatim, which is where
a pasted token or userinfo sits; the parser now hands callers scheme
and host only, plus the reason in words. The Nightscout list throws a
named error with that redacted entry and reason, and the connect route
answers it as a 422 with the message instead of a bare 500, so a
self-host that listed http://localhost:1337 reads what to change.
The two settings cards translate a coded save refusal like the test
button does; the docs and the changelog name the sub-host and sibling-
port narrowing; the dispatcher floor tests assert the policy marker so
a real NXDOMAIN cannot pass for a refusal.
Refs #947
"Someone I look after" derived the child's answers onto the guardian's
own record. The completion now takes the managed record the profile
step just created, checks the same access rule the guardian's modules
route applies, and lands the same derivation and dashboard seed on that
record through the same record-keyed write; the guardian's own flow is
stamped complete and latched without touching its map. Three
integration cases pin it, and the child journey reads the child's map
through a real switch — it asserted the guardian's map before, which
pinned the wrong record.
Also from the review: the checklist's visit row read a meta the visits
list never publishes and now counts its body; the first-result tile
read the measurements list as an array and now shows the reading; the
demo allowlist admits the disclaimer and tour writes, with a structural
test over every onboarding route file; restart clears the old first
result, and the screen trusts the ledger before painting a result as
done; the headroom sweep's raw-key check matches the flow's own key
shapes; the demo migration script stops copying the dropped column; the
questions-to-pass rule and the shell's onboarding-path rule are pure and
pinned; the baseline form composes the shared action row; the schema
diff loses the unrelated formatting churn.
The mark named its action unconditionally, so a job that had already
named its own (the low-stock underflow branch) or a settings route
whose test then failed at the relay lost that name on the wide event.
Set the action only when the event has none, and always land the
channel and origin meta pair, through a small hasAction() on the
builder. Note the parse-time floor on the Nightscout page, which shares
the grammar.
Refs #947
At 390 px with labels a third longer the action row pushed "Confirm and
continue" past the viewport; both rows now wrap. The string-headroom
sweep lengthens every word by 30% rather than hanging the whole surplus
on the last one — that manufactured an unbreakable run no locale
produces — leaves screen-reader-only boxes alone, and accepts a select
value's ellipsis, which is the primitive's design app-wide.
Put the variable on the compose environment whitelist (without it a
value in .env never reaches the process), on both example files, and in
the env manifest so check-env reports it. The self-hosting notifications
page gains a section on notification targets on a private network: the
exact-origin grammar, what stays refused even when listed, and a table
of which target is refused where so an operator knows which lever to
pull.
Refs #947
The save routes evaluate the same policy the senders do. A target on a
private network the operator has not listed is a 422 with
meta.errorCode = private_origin_not_approved and a message that names
NOTIFICATION_PRIVATE_ORIGINS, instead of the generic "Invalid data"; a
listed private address saves although the plain public floor would
have refused it. The settings schemas take the target predicate as a
parameter so the validator module never reads the environment; the
exported constants keep the public floor for the OpenAPI contract.
The test routes forward the sender's refusal as the same 422 and code
instead of a bare 500, so the card can say why the test failed rather
than sending the operator to the wide-event log. A delivery fault is
still a 500. Both test routes and the dispatcher reach the same sender
function, and a structural test holds them there.
The OpenAPI descriptions name the code on all four routes; the test
button's error map gains the code in every locale.
Refs #947
A self-hoster's Gotify or ntfy on a private network was refused at
dial time with no operator override: both senders pinned the public
resolver, and the two grants that exist for other surfaces were never
read here. Add the third grant, NOTIFICATION_PRIVATE_ORIGINS, a comma-
separated list of exact origins read from the server environment.
A listed origin runs under the operator-approved pin: still resolved
and pinned inside the connector, redirects forbidden, loopback and
metadata answers refused. An origin not listed keeps the public pin it
always had. A malformed entry is logged once and grants nothing, and
the valid entries beside it keep working, so a typo in one relay's
origin cannot take every public webhook down with it. There is no
`=true`, CIDR, wildcard or suffix form.
One function, evaluateNotificationTarget, gives the verdict for both
senders, so the test button and the scheduled reminder cannot disagree.
A refusal now carries its own reason (`*_private_origin_refused`) and
an errorCode instead of collapsing into a network error, and a send
that used the grant marks the wide event with
`notification.egress.private_origin` and the channel plus origin.
The pin inventory keeps both senders in the conditional shape and
refuses a bare true or false in either.
Refs #947
Run against the standalone build, the four journeys and the axe sweep
surfaced five things the unit tier could not see. The completion route
derives only once every question is settled, and Q6 stays pending for
somebody with no unit-bearing area — the confirm screen now passes the
questions the flow never showed before it completes. The dashboard seed
matched a JSON null where the unset column is a SQL NULL, so it never
landed; the retired step route carried the same filter. The auth shell's
onboarding branch matched only the front door, so every question screen
rendered inside the full app chrome with the sidebar. Inline links
relied on colour alone, the step list sat outside any landmark, and the
baseline form brought a second h1 onto the confirm screen. The two
sweeps run serially, since each drives one account through the API.
The integration file gains the seed case that would have caught the
null filter.
Lift the Nightscout allowlist parser and verdict into
src/lib/private-origin-policy.ts so a second operator grant can reuse
one grammar instead of a second parser. The Nightscout validator keeps
its exported names and behaviour; its tests are unchanged.
Add the floor both precedents lacked: an entry whose host is a literal
address in 127/8, 0/8, 169.254/16, ::, ::1 or fe80::/10 is not a grant,
and the operator-approved pinned dispatcher drops an answer in those
ranges even when the name is listed. RFC1918, CGNAT and IPv6 unique-
local stay grantable; that is what the list is for. Loopback inside the
container is the app itself, and on host networking every admin port of
the host; link-local is the cloud-metadata endpoint. No relay lives on
either.
The pinned Nightscout transport test resolves its listed name to a
loopback server on purpose, so it stubs the grantable-address predicate
for that harness only.
Refs #947
The journey anchors one dose at the clock and its twin twelve hours on, and
the twin wrapped past midnight for every run that started after noon. The
skip then landed on tomorrow's slot and the dashboard tile read one taken
over two expected where the skip-parity figure is a hundred; the green runs
on main were all morning runs. The twin is twelve hours on before noon and
the last minute of the day after it, so the skip lands on the day the tile
reads whenever the suite runs.
The catalogue lists the answers and restart codes and no longer the
retired step ones, and the generated document quotes that list. The
skippable-step id type nothing read goes with it.
The done screen moves focus to its heading like every other screen; the
confirm screen's preference list holds only dt/dd pairs and the settings
link stands beside it; Back from Q1 is not offered once the flow has
been entered, because the front door would resume past the welcome and
bounce straight back; the medication task records no target on the
ledger, as the contract says, and keeps the id on the screen; and the
two success affordances go through the outcome module rather than a
hand-rolled tick. The error-code catalogue lists the answers and restart
codes and no longer the retired step ones, and the session-surface guard
learns the one new deriving helper the onboarding pages read through.
The destructive tone as 12px badge text on its own wash measures 4.08:1 in
the light theme and 3.99:1 in the dark one; the muted foreground on the
muted wash 3.6:1 in the dark one. The badge takes the shape the status rows
on the same console already use: the token paints a small indicator and the
wording stays in the foreground colour. The word states the condition, so
nothing hangs on the colour alone.
Four journeys, one account each, from the welcome screen to the
dashboard: blood pressure and weight typed in, a daily medication added
inline, a visit within a month with labs, and a child's managed profile
created on the confirm screen. Each asserts what the walk wrote — the
module map, the tile order, the checklist, the settled ledger — and the
first proves that leaving after Q3 resumes at Q4.
Two sweeps on accounts of their own drive the state through the answers
route and visit every screen by URL: axe at both widths plus one
keyboard-only answer, and every locale with every text node lengthened
by 30% before the page, the shell, every clipping box and every control
are measured. All six run in one project because each mutates the
account it walks.
The medication wizard learns to stay where it was mounted after a
create, which the first-result screen needs and the medications list
does not.
Every key the flow, the checklist rows, the Settings card and the
module-off action added to the English bundle now exists in German,
Spanish, French, Italian, Polish and Korean, with the same placeholders.
The forty-seven keys the five-step wizard left behind — its carousel,
goal picker, source grid and step counter — leave all seven bundles,
and the English-leak guard drops the one entry that named them.
The delivery-state reader in the error-reporting module had no caller: the
card reads the three columns itself. The skippable-step id type was exported
for a consumer that never appeared. The dead-code gate names both.
The getting-started checklist reads the answers: a visit named within a
month adds a "prepare the visit" row that is done once one is on the
calendar, and the data-source row names the wearable that was chosen.
Its per-browser state moves to one module so the done screen, the skip
and Settings write the same keys.
Settings → Account gains the two ways back in: "Set up again" through
the restart route, which resets the ledger, keeps the answers as
prefill and touches no module; and "Show the checklist again", which
forgets what this browser hid.
A page whose module is off now answers with the shared notice and the
one action that helps, in one place for every module page: the shell
maps the route to the module through the navigation model's own gate,
so a page and its nav entry cannot disagree about which switch they
follow. And the tour's two dead stops get their anchors on the
integrations and health-record group headings.
The route's description told a native client only that phase never leaves
the cycle routes. It now says what a grant scoped to the cycle alone
receives: the phase rows with no measurement or mood outcome columns and no
lagged discovery, the same as a record with nothing logged. The owner and a
whole-record grant are unchanged.
The catalogue's header read as a current count; it is history and now says
so.
The guardian's modules branch no longer invalidates the health score in the
route; the record-keyed write it shares with the setup flow does. The guard
pinned the old call site and went red for a behaviour that had not changed.
It now asserts the route goes through the shared write with the record id
and that the helper is still the place that invalidates.
The needs-based flow is the one setup flow now. POST /api/onboarding/step
and the six goal slugs go, and with them the two account columns the
wizard kept — its checkpoint and its goal list — through a migration
that drops them; onboarding_completed_at stays as the first-run
redirect's gate. The one durable effect the goals had, the dashboard
tile order, moves to the completion route: the answers now carry both
halves of Q2, which modules are on and what sits on top, and the seed
keeps the wizard's one contract of writing only while the layout column
is still unset.
The OpenAPI table loses the step path and the completion's description
says what the route does today; the demo-allowlist test names a
mutation that still exists.
The dashboard is one click away, already ordered, and the checklist
opens on arrival. The tour is an offer here rather than an automatic
launch: pressing it sets the referrer the shell's launcher reads, and
nothing else does. Two record-shaped exits follow the answer to Q1: an
account that said "both" is offered the profile for the person it looks
after, and one that set up a record for somebody else is offered that
record through the same switch the account menu uses, so the first
entry lands where it belongs. The step is marked done on arrival, once.
Exactly one task, by the step machine's fixed priority: connect the
wearable named in Q4, add the first medication, or log one reading for
the first area. The measurement form and the medication wizard render
inline; a connection goes through the integrations page, because the
OAuth handshake cannot run inside this screen, and the screen watches
the status envelope until it comes back connected. On completion the
screen shows what the task produced — the reading on its tile, the
medication with its next dose, the connection with its first sync — and
records the result on the ledger with the server's own instant.
The offer is not recorded on arrival: somebody who leaves mid-task
resumes here rather than on the done screen.
What the answers switch on is read off the registry's derivation and
never computed here, so the list the person confirms is the list the
completion route writes. Units, language and time zone are shown once
beside it, changeable in Settings, never asked again.
The profile half keeps the baseline form and its per-field refusals:
the fields are seeded from the account so a value it holds is confirmed
rather than typed again, and a refused field still stops the flow. For
"someone I look after" the managed-profile form takes the profile's
place, because that record is created here, after the questions; an
account without a second factor can finish without it and create it
later under Settings.
Confirming posts the completion, passes the first-result step outright
when there is no task to offer, and goes where the step machine says.
One question per screen, Q1 to Q6, each a chip set with a real radio or
checkbox inside every chip so the browser owns grouping and keyboard
movement, and a focus ring the five-step picker's hidden inputs never
had. The selection is pre-filled from the stored answers, "Next"
persists it through the answers route and navigates from the state the
server hands back, and Q1 is the one question without a skip.
The questions are data (which step, which answers, one or many, may it
be passed) with two pure translations beside them — stored answers into
a selection, a selection into the strict body — so the wire never sees
a chip this file did not offer. Q6 asks only about the units the ticked
areas need and sends nothing for a value the account already had.
The step page guards every URL with the step machine and stays under
/onboarding/, which is what the first-run redirect lets through.
The front door reads the same ledger every screen reads: a record that
never entered the flow sees the welcome, one mid-way is sent to the step
it owes, a finished one is offered the questions again. The shell prints
the counter and an ordered step list off the step machine, so it shows
exactly the screens this flow has, and every screen moves focus to its
heading on arrival.
"Skip for now" is a real exit — it stamps the account past the first-run
redirect and opens the checklist on the dashboard — and the one-time
medical disclaimer now gates it as well as "Set up", because a skip that
reached the dashboard without it would be a way round the acknowledgment.
The five-step wizard's welcome carousel, goal picker and source grid go
with it; the numbered step route follows in the next commit.
Which screen is next, where a returning person resumes, whether a URL
may be visited, and what the counter says are all one function of the
published ledger and answers, so the page guard, the screens and the
shell cannot disagree. Q6 is in the order only while an area needs a
unit and the account does not already answer it; the first-result task
follows the spec's fixed priority (a connection the browser can finish,
then the first medication for a daily schedule, then one reading for
the first area) and is left out for a record somebody else will run,
because the three tasks write the caller's own record.
Tested before the screens exist; the two rules a screen would most
easily get wrong were broken on purpose and the tests went red.
The needs-based onboarding has no read of its own — its state rides
GET /api/auth/me — but every screen that persists an answer, the confirm
screen, the disclaimer acknowledgment and the Settings restart card are
mutations, and the queryKey-factory rule allows no bare key at a call
site. One module names them, and one read-back key for the reading the
first-result step logs, under the measurements prefix so a later write
evicts it.
Two branches each added an onboarding surface to the catalogue; the object literal carried the key twice and the second entry hid the first from the guard.
The write-level row listed "a vaccination dose and the booster reminder it
suggests" three paragraphs under a section table that puts the vaccination
record in Health background. Read together, that tells a self-hoster a
health-background WRITE grant can plan a booster — which the route beside it
now refuses, because the reminder it mints lives under Readings. The row lists
the dose alone, and the exceptions paragraph, which is where a reader looks for
exactly this, carries the two-section case and says the dose logs either way.
With it, the small things the same pass turned up:
The cycle-insights fixture asserted a property it could not show. Its docblock
claims the cycle-native answer survives the fence, and it seeded no symptom at
all, so `symptomPatterns` was empty in every leg and a regression that emptied
it along with the foreign columns would have passed green. One catalogue
symptom on the luteal days, asserted on the owner leg and on the cycle-only
one.
The immunization rows matched on a bare `role="button"`, which passes vacuously
the day the card becomes a real button and fails for the wrong reason if
anything else in the subtree grows one. They carry the same stable marker the
visit card took.
`/api/cycle/calendar` reads the same wrist-temperature channel the crosstab
does and is deliberately not fenced, and it was the only one of the pair with
nothing saying why: no value from it reaches the response.
The encrypted export's new 403 documented the switch refusal and not the
narrow-scope one it answers first, unlike the two settings operations beside
it. Both sheets say at the top that their Save and Delete are gated at the
caller, so a third mount reads the constraint before it violates it. And one
docblock line ran past the wrap.
Two cases in the integration file answered one or two questions and posted
the confirm, which the gate now refuses; they answer or pass all six. The
audit assertion reads the details column as the JSON string it is.
Four of them. The confirm re-stamped its completion instant on every call, so
a replay quietly moved the moment the setup finished at while the derivation
beside it was carefully latched. The two list answers were stored exactly as
sent, duplicates included, so the column could hold something the reader would
never return — the reader de-duplicates on the way out and now so does the
write. The wide-event name for saving an answer used a different noun from its
two siblings, which makes a dashboard filter awkward for one feature. And the
contract said the restart body was required while the route accepts none.
Six surfaces were named and fixed; nine carried the expression. `/labs`,
`/medications` and `/mood` still read `inSharedRecord || user?.modules?.X`, and
every precondition holds for them too: all three are presentable to their own
scoped grants, all three module keys map to a real section so the masked map
answers correctly for a delegate, and none of the three routes behind them
carries a module gate of its own — so a typed URL rendered a working page for a
record whose owner had switched the module off.
The guard is a discovered sweep now rather than a six-entry list. It walks
every component under `src/app` and `src/components` through the shared walker,
keeps the ones that read the module map, and asserts the SHAPE: no boolean `||`
joins the delegate flag to a module read. The search window cannot cross a `;`,
a `{` or a `}`, so it sees one expression at a time and a delegate check
elsewhere in the same file is none of its business. A tenth surface fails on
arrival.
Writing it turned up why the first version could not have found the three: the
comment stripper every structural guard in this directory re-spells removes
block comments first, and cannot tell a real opener from a `/*` inside a line
comment. `mood/page-client.tsx` explains its gate with "Every
`/api/mood-entries/*` route also enforces the gate", and that `/*` opened a
fake block that swallowed the next 2.2 kB of real code — the gate three lines
below it included. A guard reading that file was searching a source with a hole
in it and reporting a clean sweep. The shared walker now carries a stripper that
walks each line left to right and lets whichever opener comes first win; the
published-coverage sweep beside it moved onto the same one.
Three things, all in the same pair. The reader that orders the list from the
answers shipped without the proof that it reads correctly: every existing case
took the early return, so `checklistOrderFromNeeds` and the two new arms were
never executed by a test. They are covered now — medication first for somebody
who takes one daily, the named connection ahead of the untouched rows, profile
always first, and no answer combination that drops or duplicates a row.
A first-result task that was offered and then passed left the setup reading as
permanently unfinished, because the check asked only whether the offered task
had produced anything. A deliberate pass is an answer. And the published state
is additive — every record carries the field — so nine pending steps and empty
answers must read as "never entered the flow" rather than as an unfinished
one, or every account that predates the flow would look half set up.
The list itself now follows the design: for a record that ran the flow it goes
when its rows are done or the person hides it, and the five-reading rule stays
only for records that never entered.
The account payload resolved the setup state for the actor while its module
map, its cycle flag and every count the checklist reads beside it answer for
the active record. A guardian looking at a child's dashboard therefore got the
child's rows ordered by their own answers, and the child's reading count
tested against their own flow state. The two halves of that component have to
agree about whose record they describe, so the field is record-scoped now,
like the module map next to it.
Under a grant narrowed to one section the answers come back empty, for the
reason the module map is masked: they name the domains the record tracks, and
a grant that opens readings does not open that. The shape of the flow still
comes through.
The three write routes stay actor-only and say why: a delegable declaration is
a reviewed entry in the sharing surface guard, and a read that is wider than
its write is the honest shape while that entry is still to come.
The setup flow writes a record's module map, its glucose unit and its unit
preference. All three have a dedicated settings route that records an audit
row, and the flow recorded none, so "why did my modules change" had no answer
in the activity panel. Restarting the flow did write a row, which made the gap
look like an oversight rather than a decision, because it was one.
Same event names as the dedicated routes, since it is the same column and the
panel should group them. The module row names the keys that actually moved and
the domains kept for their content.
The guardian's modules route and the setup flow's confirm both decide a
record's modules, and each had its own copy of what that means: write the
preference blob, write the delegated cycle column, invalidate the health
score. Two copies of a three-step write drift, and the second one was added by
this branch. They share one function now, keyed on the record, which is the
carrier the managed-profile work needs.
Authentication stays at the routes and stays different on purpose — the
guardian route acts on a managed record, the setup routes refuse outright
under a switch until a guardian's route into a record's setup lands with the
rest of that record's configuration. So does the merge: what the answers imply
and what somebody just ticked are different questions about what may be
overwritten. The actor's own modules route keeps its own write, because that
one is guarded on a concurrency token the record-keyed callers do not carry.
It named three of them and the booster mint made four the same day. A comment
that enumerates its own callers goes stale on the next one, so it states the
shape instead and keeps the note about the two verbatim copies it replaced.
The derivation wrote an explicit off for every module the answers did not
name, which is right for a record being set up and wrong for one with years of
rows behind it. An established self-hoster has, for most modules, no stored
preference at all — they were default-on and nobody ever touched the toggle —
so the first confirm took away the navigation entry, the dashboard widget and
the settings entry for domains full of content. The rows survived; the
surfaces did not.
A derived off now asks first, and only for the modules it would actually
retract. Ownership is not re-answered: the measurement domains come from the
same map the MCP reads and the coach snapshot gates off, and each remaining
module names the one table its content lives in, exhaustively, so a module
added later fails to compile until somebody says where its content is. A
module whose domain holds rows keeps whatever the record already had, because
absence is what "on" looks like in the stored map.
The only gate on the derivation was Q1 being set, so one answer was enough to
turn the whole questionnaire into a module map. Two ways that landed wrong and
they compound. Somebody answers who the record is for, closes the tab, and
later finishes the legacy four-step wizard, which posts to this same endpoint
and knows nothing about these questions: the five unanswered ones each read as
their conservative default, eleven surfaces are written off, and the
once-only marker makes it unrepeatable. Or any client patches one answer and
posts the confirm straight back.
The gate now asks whether every question has an answer or a deliberate pass.
A half-answered flow derives nothing, is not stamped complete, and keeps its
confirm step pending, so finishing the questions later still does the work.
The legacy arm is untouched: a caller with no setup row still answers the
fixed acknowledgement it always did.
The spec's sixth question is skipped when the account already carries the
preferences, and the flow had no way to know: `User.unitPreference` is
nullable with null meaning "default metric", and the account payload coerces
that away, so nothing downstream could tell a chosen metric from an unset
column. The two columns are read raw on the server now, into the same
vocabulary the wire publishes, and a record whose account holds both reads its
`units` step as done — the question answers itself and is never put again.
Read-side only: the stored ledger keeps saying pending, because the person
really has not answered it, and an answer given later still lands.
The two writes the units answer makes — the display columns and the record of
the answer — go in one transaction, so a failure between them can no longer
leave the columns changed with no record of what changed them.
Measured against the route declarations on this tree:
The health-background section names allergies, family history and the profile
facts, and omits visits, the practices behind them and the vaccination record —
all three of which `scope.ts` puts in that section, so a grant is narrower on
the page than it is in the product.
"Aggregate surfaces appear only on a whole-record grant" is true and
incomplete: a whole-record grant does not open them equally either. The daily
digest and the dashboard's snapshot answer at read, the health score, the
dashboard's summary and the insights need manage.
The write level lists seven creates against twelve WRITE declarations. A visit,
a practice, a vaccination dose and the booster reminder it suggests were
missing, and the guide said nothing about the creates that need manage inside a
section a write grant can otherwise add to, or about the vault taking no
delegated write at any level.
"Exports, the health-record archive" sat under what a delegate cannot reach,
while `POST /api/export/health-record` answers at manage on a whole-record
grant and its own comment says why. The row is about the full exports and the
encrypted archive; the doctor report is named as the exception it is.
The sharing-denial guard's docblock carried the same class of stale claim — 116
fenced modules, 26 never published — and that gap is closed on this tree. The
caveat becomes an assertion: a new leg recomputes the fenced modules from the
route sources and requires every one to be published, and it names no number,
because a hard-coded figure is what turned the paragraph into a false statement
about the contract. It fails when a published path is withheld from it.
The entry carried "Non-delegable in v1, server-side and here". That was true at
v1.36.0 and is not true now: twenty-seven `/api/insights/*` reads plus
`/api/dashboard/summary` and `/api/export/health-record` declare
`("manage", "record")`, and the AI-egress objection they were closed for is
answered by construction rather than by the flag — the record resolver stamps
`setDelegatedGenerationSuppressed`, so a delegate reads what the owner's own
account generated and causes no egress.
What keeps the door shut is the overview's own reads rather than the tiles':
`/api/insights/layout` and `/api/insights/generate` both resolve `requireAuth()`
and refuse any switch at any level. Opening the entry without first deciding
what those two mean for a delegate — whose saved layout, and whether the
briefing reads or regenerates — would hand a whole-record MANAGE delegate a page
whose chrome 403s while its tiles answer, which is the failure the presentation
inventory exists to prevent. That is a decision about two more routes, not a
flag on this one, so the comment now says what is actually load-bearing and the
Coach's own reason is stated separately.
Four claims the unit suite cannot reach, because each is about what is in the
database afterwards: the answers persist and a replay writes nothing new, the
module derivation happens once and leaves a later Settings change alone, a
restart resets the steps and not the modules, and a delegate with a live
accepted grant and a switched session is refused on all three writes — with
neither record acquiring a row, which is the quiet failure a fall-back to the
caller's own record would produce.
The demo guard gains the three paths and, beside them, the case that matters
more: admitting one verb on a path must not open a sibling.
`writableDomains` answers "is there at least one delegated write route in this
section this grant satisfies". Its docblock promised a per-section answer the
table cannot give: within a section the split runs by verb, and three of the
four sections marked writable carry MANAGE-only creates beside their WRITE ones
— an allergy and a family-history entry under `profile`, a water entry and a
checkup reminder under `measurements`, a snooze and a re-recorded dose under
`medications`.
No shipped control is wrong because of it: the allergy and family-history forms
already ask the manage question, and so does the water entry. But the name and
the sentence beside it invite a control to read "my section is writable,
therefore my create is", and that reading is wrong in three sections out of
four.
Documentation only, in the three places the claim is made — the published
field, the function that derives it, and the guard that holds it against the
routes, whose green run now says what it does not prove. A per-verb table would
be a second place the route declarations live, and the declarations are what
decides; the control keeps asking the hook for its own verb class.
`POST /api/mental-health/assessments` is fenced on `mind` and writes one row
into `measurements`: the instrument's total, minted `COMPUTED` and keyed on the
administration it came from, so the trend reads through the chart and rollup
tier without the encrypted item answers going anywhere. A grant scoped to mood
and mind therefore adds a point to the owner's measurement series, and nothing
said so.
Left in place rather than fenced. Withholding the projection the way the
booster mint is now fenced would file a screener whose own total is missing
from the record — an inconsistency inside the section the grant does open, to
avoid a derived point in one it does not. So it is documented instead, in the
three places it is decided or agreed to: the declaration, the section's own
definition, and the consent copy in all seven locales, which is where a
widening belongs. The published operation says it too.
`POST /api/vaccinations/{id}/booster` is declared `("write", "profile")`, and
that is right for the act — filing the Impfpass and arming the booster it
suggests are one thing to the person doing them. The row it writes is not: a
`MeasurementReminder` belongs to the measurements section, every direct route
over the model declares it there, and creating one needs MANAGE. So a WRITE
grant scoped to the health background alone produced a reminder on the owner's
checkup list that the delegate could neither read nor change through the
section that owns it.
The seam is fenced the way the visits service fences a checkup closure, with
one difference stated at the call site: this refuses rather than skips. There
the closure is one effect of a larger save and reporting it as not done keeps
the save honest; here the reminder is the whole request, and a 201 over a row
that was never written would tell the person their booster is planned while
nothing rings. The prompt that raises the call asks the same question client-
side, so a delegate outside the section is not offered it in the first place;
the dose itself still logs either way.
`vaccination-booster-scoped-delegate.test.ts` drives the owner, a delegate
holding both sections, a profile-only delegate and a whole-record one. The
scoped-refusal leg fails against the previous handler, which answered 201 and
left the row.
Six page gates read `inSharedRecord || user?.modules?.X`, with a comment saying
the delegate's own module map does not govern the owner's record. That was true
when it was written and stopped being true in v1.38.14: `GET /api/auth/me` now
publishes the RECORD's map, masked to what the grant opens, so the prefix that
protected against reading the wrong map now widens the gate instead. The
navigation drops the entry correctly while a typed URL still renders the page
for a record whose owner switched the module off, and `/api/vaccinations`
carries no module gate of its own, so it renders and works.
Deleting the prefix answers both cases from the one map, which is what it is
for. A structural guard holds the six expressions: it locates each gate, proves
it is a module gate, and then asserts it does not widen on the sharing
question. It finds nothing to assert on if the expression moves, and says so,
rather than passing on an empty match. All six legs fail against the previous
gates.
Nothing recomputes an answer. The module map they produced survives on the
account row, but the map says what is switched on, not that the person told the
flow they take medication daily and have a visit next month — so a restore
without the row opens a blank questionnaire for somebody who already answered
it, and leaves the checklist ordering itself from nothing.
Both ends live in one file, the arrangement the visits and the score already
use, because a reader who greps only the restore route gets a false negative
when the route delegates.
`modulesDerivedAt` is the field that makes this more than history and it is
asserted rather than defaulted: it is the once-only latch on the module
derivation, so a restore that let it default to null would hand the next confirm
permission to re-apply the questionnaire over every module decision taken since,
silently. The round trip seeds it non-null and reads it back.
The file's schema validates the three answer objects loosely and the restore
narrows them through the same fail-soft readers the account payload uses. Being
stricter there would refuse a whole account's restore over one retired chip.
The wipe takes the row for the reason the reset already takes the wizard's own
columns: the record the answers were about is gone, so the answers describe
nothing.
Additive: `onboarding` carries the nine steps with their status, the answers as
given, the flow's own completion stamp and the one task it offered. Always
present — a record that never entered the flow reads as empty answers with nine
pending steps, so a client branches on the statuses rather than on whether the
field exists, and an unknown step id is skipped rather than refused.
It is resolved for the CALLER's own record rather than for the active one,
unlike the two module fields beside it. The three write routes are actor-only,
and publishing a setup state the browser cannot write would be a read it could
act on and never complete.
Both ends land here, because a field with no reader is the failure this
repository keeps rediscovering. The getting-started checklist orders its rows
from the answers — medication first for somebody who takes one daily, the
data-source row first for somebody who named a wearable — and it stays on screen
while the flow is unfinished. That is the second half: five readings were never
evidence that the setup finished, and now a pending step, an unconfirmed set of
answers or an offered task that produced nothing keeps the list where it is.
The ordering only applies once the answers are confirmed. Re-ordering the
dashboard under somebody who is still mid-question would be movement they did
not ask for.
Three writes, all on the caller's own record.
`PATCH /api/onboarding/answers` saves one step the moment it is answered, so
leaving the flow and coming back resumes where it stopped. It is idempotent
because the whole computation is a pure function of the row and the body,
including the first-result instant, which is kept rather than re-taken. There is
no ordering contract at all: a back button has to work.
`POST /api/onboarding/complete` keeps everything it did and gains the confirm
screen's job. For a record that answered the questions it derives the module map
once and applies it, and the once-only latch is the point: a second confirm
would re-apply a questionnaire over decisions taken in Settings since. Cycle is
switched on through its own column and never off, because the gate already
derives an unanswered cycle from recorded sex and an untick is not a request to
retract that.
`POST /api/onboarding/restart` puts the steps back and clears the latch. It
writes no module state whatever: a re-run never turns off something switched on
by hand, and the safest way to hold that is for this route to have no opinion
about modules at all. It also leaves the first-run redirect's own stamp alone,
so asking the questions again does not push anybody back through the old wizard.
All three refuse outright while the session is acting on somebody else's record,
at every grant level, so a delegate can never write another record's setup. The
route into a managed profile's flow belongs with the rest of that record's
configuration and is not smuggled in here.
The demo allowlist gains the three: the flow has to be completable there with
preset answers, and none of them creates health data.
The setup questionnaire the design spec asks for needs somewhere to put its
answers. `OnboardingRecord` is that place: one row per RECORD rather than per
person, so a guardian running a managed profile's setup writes the child's row
and not their own, and the three JSON columns hold the answers, the nine
ordered steps with their status, and the one task the flow ends on.
Nothing here is encrypted, and the schema comment says why: the columns hold
closed enum vocabularies, not free text, which is the same class of value as
the module map and the goal slugs sitting in plaintext beside them.
The needs to modules map lives next to the module keys rather than in the flow,
because a module renamed or retired has to break the map at compile time rather
than leave a chip that quietly changes nothing. The derivation is total — every
directly-owned key gets an explicit value — and the merge beside it is
one-directional: what the answers name is switched on, what they do not name is
switched off only where the record does not already carry a deliberate on. The
two delegated keys are absent by construction: `cycle` is reported separately
because its real column is on the cycle profile, and the Coach is not offered by
this flow at all.
Readers are fail-soft. They sit on the payload that rides every app boot, and a
value written by a release that named an area this one retired must not take the
shell down with it.
Nothing is retired: the four-step wizard, its goal slugs and the one-time tile
seed keep working and keep their meaning until the new surface replaces them.
The two states are disjoint — the slugs decide tile order, these answers decide
which modules are on, and neither reads the other.
`canWrite` is the grant's LEVEL and carries no scope term. Its docblock said
"May this caller ADD to that record", and the client hook republished it as
`canAdd`, so fourteen controls across measurements, medications, labs, illness
and the dashboard gated a create on a boolean that answers `true` for a WRITE
grant scoped to the document vault — a grant whose writable-section list is
empty, because the vault takes no delegated write at any level.
Nothing shipped was visibly wrong before the capture picker, because the nav
drops a section a grant does not open. That is a property of one surface, not
of the answer, and the answer is the thing many controls read.
So the coarse answer is gone from the hook, and every consumer asks
`canWriteDomain` for its own section instead: `measurements` on the two
measurement surfaces and the dashboard's empty state, `medications` on the
medication list, table, ledger, card menu and side effects, `labs` on the lab
list and biomarker detail, `illness` on the episode list. Each of those is the
section its own route declares. `canWrite` keeps its name and its wire shape —
native clients read it — and its docblock now says what it is: whether the
grant is a reading one, with the per-section lists beside it for the question a
control actually has.
The hook table gained the vault-scoped WRITE grant as its own leg: level true,
every section false. The take-all-due fixture described a grant the server
never mints — `canWrite: true` over an empty writable list — and now derives
its list from the same table the server derives it from.
The three routes that act on the whole of an account — `DELETE
/api/settings/data`, `DELETE /api/settings/account`, `POST
/api/export/encrypted` — name no record. They resolve the caller and act on
whatever the caller owns, so the question a switched session puts to them is
what happens when the browser believes it is somewhere else.
They already refuse: all three resolve through `requireFreshMfaIfEnrolled` or
its erasure sibling, and both open with `requireAuth()`, which reads the acting
carrier and throws `sharing.not_permitted` for anything but `none`. What was
missing is that the contract never said so. `/api/settings/{data,account}`
published a 403 about narrow Bearer scope alone, and the encrypted export
published no 403 at all, so a client that switches records had no way to learn
which of its calls stop working short of trying one.
One sentence, spliced onto the three, byte-identical for the same reason the
delegable refusal is one string. `switched-session-destructive-refusal.test.ts`
drives each route with a live whole-record MANAGE switch and asserts both
halves: the status, the code and the wording, and that neither record moved —
a refusal raised after the transaction would satisfy the first alone. The
export leg additionally asserts no archive rode along with the 403.
The capture FAB rides the bottom bar on every authenticated page, and its
fallback arm was section-blind:
return caps.canAdd && DELEGABLE_CAPTURE_KINDS.has(kind);
`canAdd` is bound straight to the grant's LEVEL — `grantAllows(grant, "write")`,
no scope term at all. So a WRITE grant scoped to `["labs"]` published
`canAdd: true` with `writableDomains: ["labs"]`, and a delegate standing on
`/labs` was offered a weight form and a dose that the server refuses at
`grantCoversDomain`. The worst case is a WRITE grant scoped to `["documents"]`:
the vault takes no delegated write at any level, the writable list is empty, and
both kinds were still offered.
`writableDomains` is already the level × scope × route-table intersection, so
one question per kind, asked of that kind's own section, answers the scoped
case, the vault case and the mood entry's MANAGE-only create together. The
closed kind list and the separate manage arm are both dead once that lands and
are gone. The dashboard quick-entry sheets and the header menu carried the same
shape — unreachable today, because the dashboard opens only for a whole-record
grant — and move with it rather than waiting to become reachable.
The picker's legs in the affordances suite now build their capabilities from
the same table the server publishes, and four scoped cases join them: labs-only
offers nothing, measurements-only offers the reading, documents-only offers
nothing at all, and a mind-scoped manage offers the mood entry alone.
`VisitsSection` asked the grant about its add button and nothing else. Every row
underneath stayed tappable into the edit sheet, whose Save is `PATCH
/api/encounters/{id}` and whose Delete is `DELETE` on the same path — both
`("manage", "profile")`. So a READ delegate opened a form that could not be
saved, and a WRITE delegate, who may file a new visit, opened one that could not
be changed. Reachable wherever the checkups page is, which is every
`profile`-scoped grant.
The row now takes its open callback only when `canManageDomain("profile")`
answers, and the card renders its content as content rather than as a button
when it does not. The add button keeps asking the write question, because that
is a different question with a different answer.
Four legs in the affordances suite over the same scoped fixtures the
immunization log uses: the owner opens the sheet, a READ and a WRITE delegate
see the row and cannot open it, a WRITE delegate still gets the add button, and
a MANAGE delegate opens it only while holding the section. Two of them fail
against the previous section.
The immunization log carried no capability check at all: not one reference to
the record hook anywhere under `src/components/vaccinations`. The page is
presentable to every `profile`-scoped grant and to every whole-record one, so a
READ delegate was shown the add button, a clickable row that opens the edit
sheet, and the delete inside it — three controls the server refuses with a 403
each. A WRITE delegate was shown two of them.
`POST /api/vaccinations` answers at WRITE; the edit, the delete and the restore
behind it are `("manage", "profile")`. The two add buttons now ask
`canWriteDomain("profile")` and the row's edit affordance asks
`canManageDomain("profile")`, which is the split the address book next door
already gets right. The list already renders a non-interactive card when it is
handed no edit callback, and the sheet is only reachable through one of the two
gated entries, so nothing needs a second check that could drift from the first.
`delegated-write-affordances.test.tsx` gained a scoped-grant fixture — every
fixture in it was `sections: null`, which is why nothing noticed — and six legs
over the log: the owner, a READ, a WRITE and a MANAGE delegate holding
`profile`, a MANAGE delegate holding another section, and the empty state's own
add. Four of them fail against the previous view.
`GET /api/cycle/insights` is declared `("read", "cycle")`, which is right for
the phase labels and wrong for what it compares them against. The crosstab rows
carry `lutealAvg`, `follicularAvg` and `delta` in display units for resting
heart rate, HRV, sleep duration, steps, weight, body/wrist/skin temperature and
blood glucose, plus the mood score read straight off `MoodEntry` — measurement
and mind data, published in full to a grant scoped to the cycle alone, with the
lagged discovery's narrated interpretation on top.
The visits service already had the right shape for this: a predicate over the
grant's sections, asked before the foreign rows are read. Both sections are now
fenced at the query rather than in the response, so a caller outside them never
loads the values in the first place and the rows read exactly as they do for a
record with nothing logged. The wrist-temperature read that sharpens the
prediction stays: it never reaches the response.
The predicate had two verbatim copies, one in the visits service and one in the
vaccination service, with a comment on the second saying a second spelling would
be a second thing to keep in step. It is one module now, `sharing/acting-domains`,
and the third consumer imports it rather than a feature service.
`cycle-insights-scoped-delegate.test.ts` drives four grants against a fixture
whose separation is large enough that the FDR gate cannot be the reason a row is
missing: the owner and an entire-record delegate get both figures, a cycle-only
delegate gets neither and no lagged row, and a cycle+mind delegate gets the mood
column and not the weight. Both scoped legs fail against the previous handler.
Four smaller things around the same card.
The configured flag came from the environment of whichever process serves the
request. A hand-rolled split that gives BACKUP_S3_* to the worker and not to
the web process would have shown an operator "nothing leaves this host" over a
ledger full of fresh rows. The ledger is queried either way now, and a row in
it outranks an environment read: a row is proof an upload happened.
Rows sorted by username put the one account that needs attention wherever the
alphabet leaves it, which on a hundred-account cohort is the crowd problem the
card exists to end, moved from a count into a list. Worst verdict first,
alphabetical within a verdict, decided server-side so the tests and the
accessibility fixture see the order the operator does.
The card's docblock claimed the worker holds PutObject and GetObject. The
runbook is right and the multipart path needs AbortMultipartUpload too, so the
prose no longer documents a grant that would break the uploader.
And the byte formatter moved to lib. The off-host card was importing it out of
a thousand-line client component, which costs nothing while both cards render
on one route and becomes a cost the moment either moves.
The badge read the enabled flag plus a DSN that parses. Neither says anything
about the target accepting a report: a wrong public key, a wrong project id and
a host that stopped answering all parse, and every one of them showed green
with a line naming the host. That is the false reassurance an operator finds
out about when they go looking for a crash that was never reported.
Nothing in the tree recorded an outcome, so there was nothing to read. The
sender now stamps one next to the DSN it used — instant on a success, a short
classification on a failure. Never the response body: that is text from a
remote host and has no business in this database or on this page.
The badge is green only while a report has left inside the last fortnight,
amber while a target is set and nothing has come back, and it names the reason
underneath. The clock lives with the ledger, so the verdict is resolved in the
route and the card paints what it is handed. A failure that a later success
superseded is not shown at all. Pressing Test records like any other send, so
the button proves the target on demand.
On the release that adds the ledger the table is empty, so on a host that has
been uploading nightly for months every account read Never, under a sentence
saying no copy had ever reached the bucket. That sentence is untrue on exactly
the morning an operator opens the new card for the first time, and it is a
claim the host cannot make: what is empty is the record, not the bucket.
The run now records every account it walks, not only the ones an object came
of. A walk that produced nothing moves the attempt instant and leaves the
success columns alone, so the two cases separate: no row at all is no record
yet, a row without a success is never. Never now means what the word says —
a run looked at this account and has never put anything there.
The card names the last check on the never row, so the state is a fact with a
timestamp rather than an assertion. Runbook and all seven bundles carry the
same two sentences. The integration file proves both arms against real
Postgres, including that a failed run leaves the previous good copy on the
row.
The threshold was the schedule period with no budget on top of it. Two things
spend that budget on a host where nothing is wrong. The cron runs 02:30
Europe/Berlin, so on the DST fall-back Sunday the gap between two runs is 25
hours and every account on every host would cross one period on the same
morning. And the ledger instant is when that account's object landed, by
design, so on a cohort walked one account at a time an account reached an hour
later tonight than last night drifts past 24 with two clean runs behind it.
The schedule period and the verdict threshold are now separate numbers. The
period is still what the card quotes; the threshold is the period plus six
hours, applied to both steps, so due still means one run produced nothing and
stale still means two. The fall-back night is pinned in the unit file by its
real dates rather than by an arithmetic 25, and a genuinely missed night at 31
hours still reads due. The runbook says why the slack is there.
The column was INTEGER. A single object may occupy 8 MiB across 10 000
multipart parts, which is the 80 GB the uploader explicitly permits, and
Postgres INTEGER stops at 2.1 GB. An account between the two uploads fine and
then cannot be recorded, so the biggest account on the host is the one the
console would have to report on and could not.
BIGINT in the table, BigInt in the model, Number() where the route builds the
wire row so the payload type stays a plain number. The migration is edited in
place rather than followed by a second one: the table has not shipped, and a
column widened before its first release is one column, not a history.
The comparison with the in-database backup does not carry over, for the
record: that size is octet_length over a bytea Postgres caps at 1 GB, while
this object never touches the database at all.
The per-account ledger row shared the upload's try. A pool timeout or a
connection reset on that upsert landed in the same catch as a refused upload:
the account was counted as failed, its reason pushed into the run's failure
list, and the console would go on to read never or stale for an account whose
encrypted copy was sitting in the bucket the whole time. That is the same
inversion the ledger was added to remove, on the write side.
The upload's boundary now ends where the object is durable. uploadEncryptedBackup
resolves only after the multipart completes, so the count and the largest-object
figure are taken there, and the row is written afterwards under its own boundary.
A write that fails warns with the account it belongs to and adds a run-level
count, which is a different fact from a failed upload and reads as one.
Two arms that were correct by construction and unasserted now have tests: a
failed upload writes no success row, so the previous timestamp stands, and a
run whose ledger write rejects after the object landed still reports it
uploaded.
The unit tests either side of this mock Prisma, so neither proves the
migration applies or that the writer and the reader agree on a shape. The
integration file runs the real nightly job against real Postgres with an
in-memory bucket, then reads the row back and asks the console's own
classifier what it says: fresh after a run, one row across two runs, stale
once it ages past two nightly periods, gone when the account is.
The component test pins the case an operator most needs stated: a host with
no bucket configured says so, rather than painting an empty list that reads
as "no problems". The accessibility scan's admin-backups response gains one
row in each state, because a card that paints only its header proves nothing
about the rows anybody reads.
Two small corrections ride along, both from writing the tests. The freshness
badge composes its classes through `cn` like every other surface, and the
error-reporting line waits for the settings to arrive instead of asserting
"nothing is sent" while the answer is still loading.
There is no default error-reporting DSN in this tree and there should not be:
a self-hoster's crash reports must not leave their host because nobody told
them not to. That part was already right, so nothing here adds a default.
What was wrong is what the console said about it. The GlitchTip card showed a
green "Configured" badge as soon as a DSN existed, whether or not the switch
that actually sends anything was on, and it never said where reports go. An
operator reading that badge has been told their errors are being collected
somewhere, which may be true of neither half.
The badge now tracks reports actually leaving, and one line under the switch
states the case: the host they go to, or that they stay here, or that tracking
is on with no target. The host only — the public key in the DSN answers a
different question. The deploy runbook says the same thing for a
maintainer-operated instance.
The nightly off-host job reports counts — uploaded, failed, total — and a
count cannot name the account it is failing for. A cohort where one object
has been refused every night for a month reads as "99 uploaded, 1 failed"
each time, which looks like weather rather than like an account with no
disaster-recovery copy at all.
The worker now writes a ledger row when an object lands (account, instant,
size), and Admin → Backups gains a second card that rates every account
against the nightly schedule: fresh inside one run, due when one run
produced nothing, stale when two did not, never when no object has ever
landed. On a host without BACKUP_S3_* the card says off-host backup is not
configured and stops there.
The verdicts are computed from the ledger, never from a bucket listing: the
worker's grant is deliberately PutObject + GetObject, and an admin page must
not put the operator's credentials on its render path to state a number the
database already holds.
The 1.38.15 entry said PUT where the record-settings route answers PATCH, and promised a rejectedFields field on the managed-profile edit that only the two profile routes carry; the edit refuses with a 422 and the issue list.
The eleven single-record writes answered their shared 429 with prose and
nothing else, while the envelope's own description tells a client to
branch on `meta.errorCode`. On `POST /api/medications/[id]/intake` and
`POST /api/cycle/day-logs` a second, narrower bucket can answer 429 as
well, so the presence of the rate-limit headers was the only thing
telling the two refusals apart.
All eleven now carry `record_write.rate_limited`, the catalogue lists
it, the published 429 names it, and the contract test fails if a site
stops emitting it or the sentence stops publishing it.
The same description now states what a client could not predict from
"300 requests per 60 seconds": refused requests count too, because the
check runs before the body is read.
The single-record write ceiling justified itself with "every one of these
routes is already wrapped in `withIdempotency`". Ten of the eleven are.
`POST /api/medications/intake` is not, and it is the one of the eleven a
native client actually loops — it is replay-tolerant for its own reason,
being an update of a named `intakeId` rather than a create. The comment
now says which is which, and adds the clause it was missing: the check
runs before the body is read, so a refused request spends the allowance
too.
The custom-symptom wrapper said a replay "answered the 409 that
uniqueness raises". There is no such 409. The row's key is minted fresh
per request and the label lives in an AES-GCM column with a per-write
IV, which can carry no unique index, so an un-keyed replay wrote a
second row with the same label — a silent duplicate in the person's own
vocabulary, up to the fifty-row cap. The wrapper is worth more than the
old sentence claimed, not less.
`PUT /api/auth/me/report-selection` threw a bare 422 whose whole `error`
string was `report-selection.leaves.unknown` — a machine token in the
field a client shows a person, with nothing beside it to branch on and
no way to learn which leaves were refused. It now answers a sentence,
with the token in `meta.errorCode` and the refused ids in
`meta.unknownLeaves`, and the operation's description says so.
The site was invisible to the refusal guard because the refusal is
decided by `if (!minted.ok)` rather than by a parse result, so the guard
now reads the refused string as well as the deciding block: every 422
under `src/app/api` whose `error` is a dotted token is inventoried with
the reason it still is one. Eleven remain, each with its reason, and the
inventory may only shrink. Removing the fix above turns it red naming
that route.
The four sibling shape refusals keep their token in `error` on purpose,
and the comment now says why that is not the same decision as the seven
`invalid_json` sites: those moved their string because their status
moved to 400 anyway, while a shape refusal that keeps its 422 would be
changing the wire for nothing. Both halves publish `meta.errorCode`.
Two loose ends in the same guard: a block calling `returnAllZodIssues`
or `apiValidationError` without spelling `422` is now judged (their
default status is 422, so a future `refuse422(err)` helper cannot slip
past), and the site floor moves from 40 to 200 against the 248 the tree
carries — the old floor would have survived losing five matches in six.
The catalogue guard read the route tree plus a hand-kept list of library
modules. The dose-history import builds its file-level codes in a module
that list never named, so `empty_file`, `missing_required_columns`,
`unreadable_json`, `json_not_an_array` and `json_carries_no_intake_time`
shipped on `POST /api/medications/intake/dose-history-import` while the
envelope's own description promised every code was enumerated below, and
the guard was green because it had not read the file.
The sweep now walks the import graph from every route under `src/app`:
1423 modules, resolved through the same `@/…` alias the app uses, with
the generated client never opened. A code is read where it is written,
however many hops from the route that answers with it, and nothing is
hand-listed any more. Seeding from the whole app tree rather than
`src/app/api` also reached the clinician share-link document route,
whose three codes were missing for the same reason.
A tuple of codes is read as a family, like the object form already was,
which is what makes the import's union visible; the constant holding it
is renamed to the `*_ERROR_CODES` spelling the sweep reads, since a list
named for anything else is as likely to be nutrients as error codes. The
two sync-ledger writers are cut out of the text first — `errorCode` on
`IntegrationStatus` is a different field with the same name.
Proven by breaking it both ways: dropping `empty_file` from the
catalogue turns the guard red, and a code planted in
`src/lib/analytics/compliance.ts`, a module no list ever named, turns it
red naming that file.
The adherence journey pinned the tile at the figure its engine produced before a skip left the denominator; with both engines held to one number the tile reads what the card reads.
`meta.errorCode` is the mechanism this API chose for machine-readable
errors, and the envelope's own description tells a client to branch on
it rather than on the prose. Of the two hundred and forty-two codes in
the tree, sixteen appeared anywhere in the contract. A client had to
discover each one by triggering the error in testing, and had no way at
all to learn that a new one had appeared.
They are enumerated now, grouped by the surface that emits them, in the
description of the field they belong to. The list is generated from the
code and committed as a literal so it reads in a diff, and the sentence
in the document is rendered from it rather than written beside it.
Nothing is renamed. Four naming conventions coexist — dot.case, bare
snake_case, a camelCase segment and a hyphen — and a code is a wire
value a shipped client branches on, so they stay. New codes are
dot.case, and the guard is what stops a fifth convention arriving
unnoticed.
Three families stay outside the list and say why: the assistant refusal
builds its last segment from a template, the integration probes are
enumerated per operation where the per-provider differences can be
stated, and a route checking a credential of its own may answer 401
with no code.
The guard re-derives the set from the tree and fails both ways — a code
emitted and not listed, and a code listed and no longer emitted, which
is the worse of the two because a client can write a branch for it and
wait forever.
The batch endpoints have been capped at 60 calls a minute since they
were written. The per-record siblings a naive or older client loops
over — the measurement create, the mood entry, both medication-intake
writes, a lab result, a custom-metric entry, a cycle day-log, a
vaccination, an allergy, a biomarker and an encounter — had no ceiling
at all. That is backwards from the risk: the batch endpoint is the one
a well-behaved client uses, and the unlimited path is the one a broken
loop finds.
All eleven share one generous per-account bucket, `record-write`, at
300 requests a minute. Five writes a second sustained for a full minute
is beyond any hand-driven session and any sync burst, and every one of
these routes is already wrapped in `withIdempotency`, so an honest
retry storm is deduplicated before it is counted — the bucket exists to
cut off the pathological loop, not to shape traffic. It keys on the
ACTING account, the precedent the delegable medication routes set, so a
delegate burns their own allowance rather than locking the record's
owner out of their own log.
The eleven operations publish the bucket name and the cap instead of
the generic refusal, on the shared 429 that already declares
`Retry-After` and the rate-limit triple. A test pulls the key and the
window out of the query the limiter runs and holds the published
sentence to them, so the paragraph cannot drift away from the bucket it
describes.
Two more creates the client replays out of its offline outbox ran the
handler again on the replay. A titration step carries no natural key at
all, so a lost success response left the medication with two dose
changes on the same day and the runway estimate reading off the wrong
one. A custom cycle symptom is unique on its label, so the replay did
not duplicate — it answered the 409 that uniqueness raises, reporting a
conflict the client cannot act on for a row it has no record of
creating.
Both wrap in `withIdempotency`, the composition their siblings already
ship, and both operations publish the header and the in-flight 409 so a
client generated against the contract can tell a retry is safe.
The integration file that drives the medication creates twice against
real Postgres gains the titration case: one dose-change row after the
replay, and a replayed 201 carrying `X-Idempotent-Replay: true`.
`POST /api/mcp/tokens` is deliberately not on this list. Its response
carries a freshly minted `hlk_` credential, and the wrapper refuses to
persist a secret-shaped body — publishing the header there would
promise a replay the mechanism will never perform.
`safeJson` refuses a body that will not parse with 400, and so does
`apiHandler`'s own SyntaxError branch — about two hundred and twenty
routes inherit that. Seven `/api/auth/me/*` writes hand-rolled the
parse in a try/catch and threw 422 for the identical condition, so a
client's "my serializer produced garbage" branch had to accept two
statuses on a subset of routes it had no way to predict.
All seven answer 400 now, with the sentence `safeJson` already sends
and the dotted token each one carried moved to `meta.errorCode`. The
400 is published on those seven operations through a shared response;
it is deliberately not folded into the standard set, which is spread
onto reads as well and a GET has no body to refuse.
`/api/auth/me/health-score-config` keeps its 422: the operation's own
description names that status and its exact message, so moving it is a
contract decision rather than a cleanup. The guard carries it as an
allowlist entry with that reason rather than quietly skipping it.
Three route tests pinned the old status; they assert the new one and
say why. The guard reads the hand-rolled parses out of the tree and
asserts it still finds them, so it cannot pass on a matcher that
stopped matching.
Fifty-nine 422s raised off a Zod `safeParse` answered with one opaque
sentence and nothing a client could map onto a field, while the other
166 sent `details.issues` — the multi-issue envelope that exists so a
caller with three bad fields fixes all three in one round-trip. Twelve
of the fifty-nine put a dotted machine token in `error` where the
sentence belongs, so `error` was sometimes text to show a person and
sometimes a token that must not be shown, with nothing in the payload
to tell them apart.
Every one of them now answers through `apiValidationError`, which keeps
the existing `error` string and the existing `meta` byte for byte and
adds the issue list beside them. The dotted tokens keep their place in
`error` for the clients that already match on them and are published in
`meta.errorCode` as well, which is the field a machine code belongs in
and the one a new client should read.
A guard reads the route tree per refusal site rather than per file —
per file is what let this grow, since a route that used the helper once
looked compliant to a `grep -l` sweep — and asserts it still finds the
sites it judges, so a matcher that stops matching fails instead of
reporting a clean zero.
The admin snapshot paints its worker row in `--destructive` whenever the
process serving the page is not the worker: a split deployment running
`HEALTHLOG_PROCESS_TYPE=web`, or a worker that stopped answering. The token
is AA on a card (4.85:1 in the light theme) but the stat tile lays a
`bg-muted/50` wash over that card, and axe measured the tinted wording at
3.97:1 there, under the 4.5:1 AA floor. `e2e/a11y.spec.ts` went red in
exactly that state and green in every other one, which is why the console
could ship it.
The status grids now name a tone instead of spelling a class at every call
site. Success and warning keep their tint, both clear AA on the tile.
Destructive takes the shape the other status rows already use, the
medication detail row and the invite chips: the token paints a small
indicator and the wording stays in `text-foreground`. The word itself
("Stopped", "Error") states the condition, so nothing hangs on the colour
alone. The database, Umami and GlitchTip rows sit on the same surface with
the same tint and move with it.
The success-affordance guard's two pinned entries for the status grids went
stale in the process: the tint they carried now lives once in the tone
table, so the pins follow it there.
Checked against a standalone build on a throwaway database: the whole a11y
spec green in both themes and both viewports with `HEALTHLOG_PROCESS_TYPE`
at `web` (the failing state) and at `all`. jsdom cannot measure a contrast
ratio, so the new unit test pins the token choice for both arms instead.
The determinism guard counted two Playwright servers and would have refused a third by deletion; the notification journey's mail-configured, scheduler-free process is the third. Pin it by its two distinguishing variables and assert the shared servers carry no mail configuration.
The journey's isolation went one way only. `test.describe.configure` orders
the tests inside the file; the suite is fullyParallel with two workers, so
everything else runs beside it — and its own trigger, the admin reminder
sweep, walked the whole instance and dispatched for every account that had
an overdue dose. With the mail env pointed at the local responder, that
would have started sending real messages for accounts this file has never
heard of, and the exact-equality assertion on the accepted recipients would
have been reading them. The sweep now names the account it then counts, and
the comments that defended the opposite direction say what the exposure
actually is.
Its own server, too, and for two reasons. The `SMTP_*` vars decide whether
the Email card renders at all, so on the shared server they put a card the
axe scan and the card-anatomy sweep had never measured onto
`/settings/integrations` for every spec that opens the route. And unset,
`HEALTHLOG_PROCESS_TYPE` starts the pg-boss reminder scheduler inside the
process serving requests, whose every-fifteen-minutes tick either mints the
missed-dose row that makes the sweep skip its own dispatch or dispatches
beside it. A third server carries both: mail configured, `web`, and only
this spec talks to it.
The shared server keeps `all` on measured grounds rather than taste. Worker
status is per-process and in-memory, so a `web` process reports the worker
stopped, `/admin` renders that row destructive-red, and axe fails it at
3.97:1 against the muted tile — a real contrast defect any operator running
the documented web/worker split already sees, and not this branch's to fix.
That limit is written where the server is defined.
Also here: the stub draws its port per run instead of pinning one, and says
so by name when the port is taken; teardown ends its sockets and survives a
setup that never got as far as starting it; the ledger reset stops deleting
rows whose subject is this account but whose recipient is somebody else,
and clears intake events explicitly rather than through a cascade; the
attempt poll refuses a window larger than the twenty rows the diagnostic
returns; and the header names the precondition the file cannot enforce and
the internal reasons it pins on purpose.
Verified against a production build and a throwaway Postgres: the journey
three times over at one worker, then the a11y and settings geometry specs
at two. Both assertions broken once to see them fail.
The medication detail's notification section decides between the
server-side reminder switch and the client-managed chip on
`notificationPrefs.medication.clientManaged`, read off `GET /api/auth/me`.
That payload never carried the field — the preferences live on their own
endpoint, which is not on the app-boot path — so the flag was `undefined`
for every account: the chip never rendered, and a person whose phone owns
the reminders was shown a switch that no longer decides anything. The
write path and the dispatch gate were correct the whole time; only the
web reflection was missing its end.
The payload now carries the fully-resolved block from the same resolver
the dedicated endpoint uses, so the two cannot disagree, and an account
that never opted in reads `false` rather than absent. No extra query: the
session already loads the row the column sits on.
The pair guard over this payload gained the direction that could not see
it. It asked that every published field has a reader; a reader with no
field was invisible. It now also reads the response types clients declare
for this endpoint and holds them against what the route publishes. A
typed guard was tried first and does not work here — each consumer
declares its own local interface, so the compiler checks every file
against its own private claim and agrees with all of them.
`POST /api/admin/notifications/reminder-check` walked every active
medication on the instance and dispatched a reminder for each overdue
slot it found. That is the operator button's intended behaviour and it
stays the default, but it left any other caller — a browser journey, a
smoke check — writing into every account's delivery ledger at once with
no way to say otherwise.
The body now accepts an optional `userId` that narrows the sweep. It can
only narrow: the route is `requireAdmin()`, cookie-only by construction,
so a Bearer caller never reaches it however wide its scope, and the
selector feeds the Prisma `where` rather than the authorisation. A
bodyless POST — what the admin panel sends — is unchanged. An unknown key
is a 422 instead of a silently instance-wide run.
Published in the contract alongside the notification diagnostic, so the
scope is readable rather than folded into the console-only exemption.
The file claimed it stayed inside its own account. "Backup now" enqueues the
pass for every account on the instance, so the header says that now, and says
what is confined instead: the restore's transaction, and the instance-wide
settings the dialog's opt-in leaves alone — which the journey asserts is clear
before it confirms, because a default that flipped would hand this file the
power to rewrite the document size cap for every spec sharing the server.
The run's own answer is asserted where it happens. Without it a missing worker
cost two minutes of polling and then blamed the backup pass for a 503 about
something else; the failure now names the endpoint in two seconds.
The rest are the assertions that pinned incidentals rather than promises: the
Argon2 cost comes from the constant the writer exports, so a legitimate cost
bump is not a red spec; the document is found by id rather than by position;
the poll compares the row against the row it saw before the click rather than
against the runner's clock; the non-admin bounce is pinned by pathname; the
empty-state CTA has its own test id, so nothing has to pick between two
buttons that share one; the deliberately corrupted copy is deleted when the
control is done with it instead of sitting in the console's list; and the
fixture orders by id after the timestamp so a tie cannot present as a change.
One browser journey for the web arm of notification delivery: an account
configures its channels on the settings surface, a due medication reminder
runs through the product's own sweep, and the dispatch decision is read back
off that account's `push_attempts` ledger via the admin diagnostic.
What it pins:
- the enabled channel delivers (`result: ok`) and the message really leaves
the process, asserted against a local SMTP responder rather than against
the ledger row alone;
- a channel that is configured and switched OFF is never attempted, and its
own test control is inert on the surface;
- the APNs arm is attempted and skipped, and the ledger's reason
distinguishes the transport refusing (`apns_not_configured`) from the
client-managed opt-in refusing (`client_managed`) — with email still
delivering under the opt-in, which is the published "APNs leg only"
contract;
- an ntfy relay URL inside a private range is refused with 422, leaves the
stored config untouched, and writes no attempt.
Email is the channel exercised for real because it is the only one that can
be: every account-supplied delivery target crosses the SSRF floor
(`isPublicUrl` plus `safeFetch({ requirePublicHost: true })` with a
connect-time address pin), so ntfy, the generic webhook and Web Push refuse a
local stub by design, and Telegram and APNs address hard-coded third parties.
Email's transport is operator configuration, so `e2e/setup/smtp-stub.ts` can
stand in for it end to end. The config points the e2e server at that stub.
The journey carries one skipped test at full strength: the medication detail
surface decides between the reminder switch and the client-managed chip on
`notificationPrefs.medication.clientManaged` read out of `/api/auth/me`, and
that payload does not carry the field. The flag therefore reads as false for
every account and the chip never renders, so a person whose phone owns the
reminders is still shown a server-side switch that no longer decides anything.
The write path and the dispatch decision are both correct; only the web
reflection is missing its end.
The account and its jar are seeded in global setup, and its channels, devices,
ledger and preferences are reset before every test so the counts are about the
run in front of them.
Two gaps in `backup-restore.md`. It said the restore "replaces the account's
data tables" only in a paragraph about grants, so nothing stated the
consequence for the account's own record: the rows it gained after the snapshot
was taken are deleted with the rest of their class. And the console restore was
named once in passing — `## Restore` documents only the off-host script — so an
operator restoring from the browser had no procedure to read.
Both are written down now, including which button does what, that the restore
targets the snapshot's owner rather than the admin running it, that
instance-wide settings ride along only when the dialog's opt-in is ticked, and
what a copy this instance can no longer decrypt answers.
Two places claimed a stored payload cannot leave the page: the card's own
description ("payloads are not downloadable from this page") and the listing
route's comment ("admins can trigger a re-snapshot but not download another
user's payload from the UI"). Both have been false since the download button
shipped — every row carries one, and the route hands back the decrypted JSON.
The description now says what happens instead: sizes are the encrypted blob,
Download decrypts the snapshot for the admin who asks, the ciphertext stays in
the database. Seven catalogues, and the route comment points at the route that
owns the payload.
A `DataBackup` row that fails to decrypt answered 500 on the restore, the
download and the preview. It is not a server fault: either the key that wrote
the copy is no longer in `ENCRYPTION_KEYS` — a rotation that dropped the legacy
entry before the script reported zero rows, the case the rotation runbook warns
about — or the stored bytes are not the ones that were written. Every other
bad-input arm on those routes already answers 4xx, and the 500 also paged the
error reporter as if the process had broken.
All three now answer 422 with `meta.errorCode = backup.payload.undecryptable`
and one shared sentence, so the three routes cannot drift into three wordings
for one condition. The download's second 500 stays: a payload this instance
wrote and cannot parse really is a fault here.
The two routes that open a stored copy move from the unpublished list into the
contract, because the refusal is now a promise an operator can look up rather
than whatever the handler happened to answer. The restore publishes its
`Idempotency-Key` header with them.
A disaster-recovery snapshot carries the singleton `app_settings` row —
registration, the MFA requirement, the default locale and timezone, module
availability, the notification and AI configuration, the document size cap and
quota — and the restore wrote it back as a side effect of restoring one
account. An operator putting a single record back on Wednesday reconfigured the
whole host from Monday's file, silently, with nothing on screen saying so.
Rebuilding a host from a snapshot is a real case, so the write stays: it is now
`restoreInstanceSettings: true` on the request and a checkbox beside the typed
gate, off unless the operator ticks it. Restoring an account and reconfiguring
the installation are two decisions, so they are two answers.
The 422 confirmation sentence and the dialog copy drop the instance-wide claim;
the opt-in carries it instead, in all seven catalogues. Integration coverage
runs both arms: without the flag the settings row keeps its own values, with it
the file's values land.
Three consent-surface tests mocked the shared AI-consent bucket at 60 where it
is 20, and the analytics-read and MCP mocks named 60 against real caps of 120.
Nothing asserts the number today, so nothing was wrong on the wire — but the
mocked verdict now carries `limit` all the way to `X-RateLimit-Limit`, and the
first test that reads that header off a mock would pin the fiction.
The response-shape file's header pointed at a document that is not in the
repo; it says what it means directly instead. It also now records the one
thing the gate cannot see: `meta` is a loose object on purpose, so undeclared
keys inside it survive the parse and the strip comparison does not reach them.
The shared 429 named `Retry-After` and the `X-RateLimit` triple in prose, so a
generated client got no typed accessor for the field the same sentence tells
it to back off on. They are a `headers` block now: `Retry-After` and the cap
and remainder as integers, the reset instant as a date-time. The text also
says what a 429 from a ceiling that is not one of these buckets carries —
nothing, because there is no bucket to describe.
Two route descriptions still listed the pre-change header set: the ingest 429
named only `X-RateLimit-Remaining` / `X-RateLimit-Reset`, and the document
suggest 429 named only the reset instant beside `meta.retryAt`. Both are short
of the wire in exactly the direction this work exists to close.
The comment on the measurements list justified the tiebreaker with an Apple
Health import stamping long runs with one instant, and said the unique index
forbids two live measurements of the same type at that instant. The index is
`(userId, type, measuredAt, source, sleepStage)`, so the named example is the
one case it does block — while ties survive across `type` (the `stats:` daily
rollups), across `source` (two writers landing together) and across
`sleepStage`. Same conclusion, honest premise.
The per-day drill-down on the same route still sorted on `measuredAt` alone.
It slices in memory rather than with `skip`, so nothing drifts between pages,
but two identical requests were free to render a tied block in different
orders, and a list that reshuffles on refresh reads as data changing.
Three of the tiebreakered routes had nothing pinning the order: custom-metric
entries, the per-medication intake history (both sort arms, so the NULLS LAST
pin cannot be lost to the tiebreaker) and the mental-health assessment
history. Each now asserts the `orderBy` the way the illness day-log test does.
Three refusals still answered 401 with prose alone: the Bearer re-read on
`/api/auth/me/sessions`, and the wrong-second-factor arm on both MFA step-up
routes. The shared 401 description told clients the code is the field to
branch on, so a client that took that advice landed in its default arm on all
three — and on the MFA pair that is the difference between re-prompting for a
digit and signing the user out over a typo.
The sessions re-read is the same missing-credential fact the gate already
names, so it reuses `auth.missing`. The MFA pair gets a code of its own,
`auth.mfa.code_invalid`: none of the existing ones fit, because every other
401 code asks the client to discard something, and this one asks it to keep
the session and try another code.
The description narrows to match what is actually true. The generic gates
name a reason; a route that checks a credential of its own — sign-in, the MFA
challenge exchange, a password confirmation — still answers 401 with prose,
and the text now says so instead of promising a field those responses do not
carry.
`resetAt` is written by Postgres as `NOW() + interval` and subtracted from the
app process's clock, so a row that still refuses can read as already expired
here. The old floor let that render as `Retry-After: 0`, which tells a client
to retry immediately into the ceiling that just turned it away — a contradiction
of the status it rides on, and a hot loop for anything that honours the header.
One second is the honest minimum on a 429. The rounding-up rule and the reason
for it are unchanged; only the floor moves.
The request-scoped capture kept the last verdict of any kind and fell back
to it when nothing had refused. That fallback fires in exactly one state —
no limiter refused this request — which is the definition of "this 429 came
from somewhere else". So a daily AI budget that is spent until midnight, an
hourly generation quota, and a provider-side 429 relayed onward all went out
carrying our own bucket's numbers: a remainder that says there is room left
and a Retry-After measured against an unrelated window. On the AI paths the
captured window can also expire while the provider call runs, so the dressed
delay could land at zero and tell the client to retry immediately into the
ceiling that had just refused it.
The cell now keeps refusals only. A request every bucket let through reports
nothing, and a 429 from another cause leaves undressed, which is correct:
there is no bucket to describe. The two-limiter rule is unchanged — the last
refusal wins, and a refusal still outranks a pass.
The unit case that pinned the fallback is turned round, and both directions
now run through the real wrapper: a limiter refusal gets the headers, a 429
raised without one gets none. The integration file gains the two shapes it
was missing — a route that consults two buckets must report the one that
refused, and a route whose 429 comes from the daily budget must carry no
rate-limit headers at all.
One journey across the whole path: an account writes readings in three
domains and files a PDF, downloads its own record as the passphrase-sealed
archive, an admin snapshots it from the backups console, and the snapshot is
restored over an account that has since lost a reading and a dose and gained
one the snapshot never saw.
The assertions address what the documentation promises rather than what the
implementation happens to do. The archive is checked byte by byte against the
HLX1 header in src/lib/export/passphrase-archive.ts — magic, version, KDF id
and the Argon2 cost the file carries — because "a file arrived" is equally
true of plaintext JSON under a new extension. The restored reading and dose
are checked by their ORIGINAL ids, because a restore that re-created them
under fresh ones would have written a different record that reads the same.
The reading created after the snapshot is asserted GONE: the runbook says the
restore replaces the account's data tables, and replacing is not merging.
Two refusing controls. A stored copy with one flipped ciphertext byte is
refused with the route's decrypt failure and leaves the record untouched — the
envelope is AES-256-GCM and its tag covers every byte, so the flip is caught
before the transaction opens. A delegate holding whole-record READ inside the
account gets the not-covered panel instead of the export card, never reaches
the console, and is refused 403 by the list, the manual run and the restore,
the last of them naming the real snapshot so the refusal cannot be mistaken
for a missing row.
Waiting is on the job's own evidence: the console's endpoint is polled until
this account's stored copy is newer than the click, so nothing in the file
guesses at a queue. The journey has its own account, its own delegate and its
own cookie jars; only the reset between repetitions and the deliberate
corruption reach Postgres directly, because a browser cannot express either.
The backups console gains data-testid hooks and a data-backup-id on each row
so the spec addresses controls rather than translated labels.
The spec re-declared the server's algorithm, digit count and period as
literals, so a change to any of them would leave the spec generating codes the
server no longer verifies and blaming the product. It imports the three
exported constants instead; the recovery-batch size stays a literal, because
its module pulls in the database client and would drag the generated client
into the Playwright process.
The two credential fields on the login form are addressed by test id now, like
the submit button beside them, and the recovery toggle at the challenge is
renamed to the house's noun-verb order.
The journey emptied every `auth:%` bucket before each sign-in, which is wider
than it needs and takes the register, passkey and reset buckets with it — a
future spec measuring one of those throttles would find its evidence deleted by
an unrelated sign-in. The clear now names the two surfaces this file spends.
It also left the throttle as the one auth control the journey rides on and
never sees: no spec in the suite asserted a 429, and this is the only one that
posts a real login at runtime. A closing stage spends the cap from an empty
bucket and asserts the answer that goes over — 429, with the bucket's own
remaining and reset headers. It costs nothing: the account is dropped
immediately after and nothing else signs in from there.
The bucket is keyed per IP and every spec on the machine is that IP, the
sibling browser project included, so the measurement is taken in one project
and a round disturbed by the sibling is retried rather than asserted. Two
probes emptying and filling one bucket beside each other were seen to measure
their own interference instead of the limiter.
The replay control submitted the enrolment code at the login challenge and
asserted a 401. That 401 is the same answer the route gives a code that has
simply drifted out of its ±1-step window, and by the time the journey reached
the challenge — a rotation, a refused rotation, a sign-out, a page load and an
Argon2id verify later — the enrolment code usually had. Out of window, the
refusal happens before the monotonic floor is ever consulted, so the control
could pass with the floor deleted.
Both TOTP refusals now assert the verdict the route records itself,
auth.mfa.failed with details.replay, written and awaited before it answers:
false for a code that never matched the secret, true for the replay. Flipping
that verdict while leaving the refusal in place turns the positive half red,
which is what the old assertion could not do.
The replayed code is the one the previous sign-in just spent, so the window is
a property of the journey rather than of the wall clock: the accept is taken at
a step boundary when the current step has too little left, which bounds the
wait by one period and leaves the pair at least two.
The rotation stages no longer press Escape to get the confirmation out of the
way. They assert the card closes it, by test id.
The confirming click on both alert dialogs in the TOTP card calls
preventDefault(), which is what keeps the dialog up and its button disabled
while the request is in flight. Nothing took the dialog down again, so a
refused rotation left the confirmation open over the card, and the card is the
only place the refusal explains itself: the step-up message painted behind the
overlay, inside the aria-hidden region the modal creates, with the confirm
button live again. A success hid it, because the fresh-codes panel replaces the
branch the dialog lives in and it unmounted anyway.
Both dialogs are controlled now, and each mutation closes its own when the
request settles, whichever way it went. The disable arm carried the same shape
and a mistyped six-digit code there is routine, so it is fixed with it.
The card test pins the sentence a step-up refusal shows and the wiring that
takes the dialog down; what a person sees is asserted against a real browser in
e2e/mfa-step-up-journey.spec.ts.
`GET /api/auth/me` publishes a boolean per module, and three different
situations collapse into the same `false`: the record has the module switched
off, the active grant's sections do not open it, or the operator switched it
off for the whole instance. A client holding the boolean has to guess between
them, so the empty state either says nothing or says something that may be
untrue -- offering a delegate a switch that is not theirs to reach, or telling
an account holder to turn on a module the operator removed.
`moduleAccess` carries the reason beside the booleans, which keep their exact
meaning: `enabled`, `disabled`, `not_granted`, `unavailable`, resolved
outside-in so the reason published is the one nothing further in can change.
`modules[key]` stays exactly `moduleAccess[key] === "enabled"` for every key,
and the two halves are built independently -- the boolean still runs through
`maskModulesToSections` -- so the test of that pairing is an assertion rather
than a restatement of a derivation. It is checked on the pure builder across a
matrix of grants, records and operator blobs, and again on the real route for a
scoped delegate, an unscoped one, and an operator-disabled module.
Both ends in one change, because a payload field with no reader is the failure
this repository keeps rediscovering. The reader is `ModuleDisabledNotice`, the
shared module-off empty state the empty-states audit asks for: it names the
reason in the reader's own language and offers the enable control only when the
record's own switch is what is off, since neither of the other two is fixable
from that page. The nutrients page adopts it and drops its bespoke block, which
had been handing every delegate a button that would refuse them.
Registered in the OpenAPI registry as `ModuleAccessMap` and carried on the
account payload; the native contract gains a field and loses nothing, since a
Bearer request carries no switch and never reads `not_granted`.
`requireAuth`, `requireAdmin`, `requireCookieAuth`, `requireFreshMfa` and
`requireBearerAuth` all refused with a bare prose string and no `meta`.
So the single most important branch a native client makes — refresh the
token and retry, drop the session and show the login screen, or stop
retrying because this credential will never work — could only be made by
string-matching the English sentence "Token expired". Any rewording of
that string, or a localisation pass, silently turns a recoverable
refresh into a forced logout for every already-shipped build.
The mechanism was already here and these gates were the ones skipped:
the assistant, consent, step-up, module and sharing refusals all carry
`meta.errorCode`, and `/api/auth/refresh` emits `auth.refresh.*`. So
`HttpError` takes an optional code and the wrapper puts it in the same
field. Five values, dot.case like the majority: `auth.missing`,
`auth.token.expired`, `auth.token.invalid`, `auth.scope.insufficient`,
`auth.admin.required`. A revoked token reads invalid, not expired —
refreshing a revoked token loops forever.
Additive: an HttpError thrown without a code serialises byte-identically
to before, which is most of them. The published 401 description now
enumerates the codes and says what a client should do with each.
Both asserted the single-column `orderBy` the offset lists used to build,
so they held the defect in place: a test that pins the shape a fix has to
change is not evidence, it is a lock. Turned round to assert the total
order, with the reason at the call site.
Response drift was structurally undetectable. `openapi:check` proves the
YAML matches the registry and the route-coverage guard proves the verb is
listed; both pass when the registered schema disagrees with what
`apiSuccess()` sends, and the coverage guard says so in its own doc
comment. That is how `/api/sync/changes` shipped five domains against a
three-domain contract and how the error envelope went years without
declaring the field the multi-issue 422 was built for.
Two bodies, the two that were broken: a full `/api/sync/changes` page
with live and soft-deleted rows in all five domains, its `cursorExpired`
short-circuit (a second shape under the same schema), and a 422 carrying
more than one issue.
`safeParse` alone would not have caught either finding, because Zod
strips undeclared keys instead of refusing them — a schema short of the
wire parses cleanly. So the parsed value is compared back to the raw
body; a strip means the contract is missing something the server sends.
Removing `cycleDays` / `cycles` from the sync schema, or `details` from
the envelope, fails all three cases.
`GET /api/sync/changes` returns five domains — measurements, mood,
intakes, cycleDays, cycles — and has since the cycle module landed. The
registered schema declared three, closed the object and required only
those three, so `cycleDays` and `cycles` did not exist in the contract:
full day-log and cycle payloads, both upserts and tombstones, computed
and serialised on every single pull, invisible to any client generated
from the spec. The body was also invalid against its own schema, since
the object was closed against the two keys it always sends.
The day-log and cycle DTOs were already published for the cycle routes,
so the two upsert arrays reuse them and only the tombstone shapes are
new. The module's own description and the operation summary were stale
in the same direction and now name all five.
This is the two-ended change the contributor notes describe: the writer
shipped, the contract half did not, and no gate noticed because every
gate proves the other end.
No wire change.
Every documented 422, 401, 403, 409, 410 and 429 points at one
`ErrorEnvelope`, and that schema declared `data`, `error` and a `meta`
closed to `requestId` + `errorCode`. Two things were wrong with it.
`returnAllZodIssues` has sent a third top-level key since v1.4.42 —
`details.issues`, one entry per rejected field — from 166 route files.
It exists so a client can fix three bad fields in one round-trip instead
of three, and the doc comment says so, yet no component schema anywhere
declared it. A client generated from the contract had no field to read
it from, so the feature was invisible to exactly the consumer it was
built for. It is now a schema, with the `unrecognized_keys` list and the
promise that no rejected value is echoed back.
And `meta` was closed while real refusals put more in it: `removedIn` /
`replacedBy` on a 410, `module` beside `module.disabled`, the
per-integration context on a failed connection test. Closed plus those
keys means the response was invalid against its own published schema and
a strict decoder is entitled to reject it. It is open now.
No wire change; both halves describe what the server already sends.
The two compliance engines disagreed about a deliberate skip. The dose-history
ledger (`tallyComplianceFromLedger`, behind the medication card, the dose
history, the doctor report and the Health Score pillar) has excluded a skipped
dose from the denominator since v1.15.9 — a skip is a pause, not a miss. The
schedule-anchored engine behind the dashboard tile and the adherence storyline
built its denominator from the raw expected-slot count with no skip term, so it
counted the same slot as expected-and-missed. A twice-daily day with one dose
taken and one skipped read 100 % on the card and 50 % on the tile.
The skip term now lands in the engine, not at the call sites: the day's
expected count drops the doses the user skipped, capped at the minted slots,
and a day whose every expected dose was skipped falls out of the buckets the
same way it falls out of the ledger denominator. The schedule anchoring, the
per-day buckets and the duplicate-log cap are untouched — only the skip
treatment moved. `skipped` outranks `autoMissed`, mirroring
`reconstructDoseHistory`, so a dose the auto-miss cron marked forgotten still
counts against the rate.
A new parity test feeds one fixture (twice daily, one taken, one skipped, one
missed) to both engines and pins them to the same figure, so the class cannot
come back through either side; both new cases were confirmed to fail against
the previous behaviour. The tile-engine suite gains the corrected figure.
`/api/measurements` and `/api/labs` sort on one non-unique column and
page with `skip`/`take`. When the sort key ties, Postgres does not
promise the same order for the query that fetches page N and the one
that fetches page N+1, so a client paging its own history can receive a
row twice and never receive another — silently, on both sides.
Ties are not hypothetical here. The partial unique index forbids two
live measurements of the same type at one instant, but the tie that
occurs in production is the cross-type one: a daily `stats:` rollup
posts steps, distance, flights and active energy at the identical
timestamp, and an unfiltered history list pages straight through the
block. A lab panel drawn in one sitting shares `takenAt` across every
analyte in it.
Appending `{ id: <same direction> }` to the `orderBy` makes the order
total. Same fix on the five siblings that page the same way — medication
intake history, illness day-logs, custom-metric entries, reminder
history, mental-health assessments — because the defect is the shape,
not the two routes it was found on. No wire change; `/api/sync/changes`
already ordered this way.
The fixtures seed ids in an order unrelated to insertion order, so the
assertion on order inside the tied block can only hold if the secondary
key is doing the work: with the tiebreaker removed the new file fails on
all three cases.
A refused client had nothing to back off against. `rateLimitHeaders()`
emitted `X-RateLimit-Remaining` and an ISO `X-RateLimit-Reset`, no
`Retry-After` and no cap, and only about 70 of the 199 route files that
consult a limiter attached even those two. The rest answered with a bare
prose envelope, including every endpoint an offline replay queue hits:
the four batch ingests, `/api/sync/changes` and `/api/auth/refresh`. A
first-run Apple Health import trips the 60-per-minute batch ceiling
reliably, and the only strategy left to the client was a blind backoff.
Threading headers through 129 handlers is 129 edits the next new route
forgets, so the verdict is carried instead: `checkRateLimit` records it
in a request-scoped cell and `apiHandler` dresses any 429 that leaves
without headers. A route that already built its own is left alone —
`Retry-After` is the presence test, and it comes from the same builder.
`Retry-After` is whole seconds rounding UP (RFC 9110 §10.2.3), floored
at zero. `X-RateLimit-Limit` is new and needs the cap, so the limiter
result carries it. `X-RateLimit-Reset` keeps its ISO form: it is the
wrong convention for the name, but it is on the wire today and changing
it is a decision of its own.
The cell lives in its own leaf module because 177 test files replace
`@/lib/rate-limit` wholesale with a mock factory, and `apiHandler` must
not depend on anything those factories stand in for.
Enrol TOTP from the settings surface, sign out, sign back in through the
challenge, and step up for a recovery-code rotation — in a browser, against
a real server, on a throwaway account the journey seeds and drops itself.
Four refusals ride alongside the four positives: a wrong code at the
challenge (401, no session cookie, and the ticket's attempt counter moved),
the enrolment code replayed (401, refused by the monotonic totpLastStep
floor), the rotation once the session's mfaVerifiedAt has aged past the
five-minute window (401 with meta.errorCode auth.stepup.required), and a
recovery code presented a second time (401). Each was proved to fail by
removing the control it stands on and watching that one assertion break.
Codes are computed from the enrolment secret with the otpauth dependency the
server verifies against, at explicit timestamps, so nothing waits on a step
boundary: the sign-in code is generated one step ahead of now, inside the
±1 drift window and necessarily above the replay floor.
The fixture reaches Postgres for the two things that have no endpoint by
design — ageing the step-up stamp, and emptying the per-IP auth buckets
this journey would otherwise exhaust. The account carries the Playwright
project name so the two browser projects never drive one factor state.
Adds data-testid hooks on the TOTP card, the login step, and the password
form so the spec asserts on stable attributes instead of rendered text.
The mobile exclusion carried an inherited second reason — that mobile
emulation emits no download event for the panel's anchor click. The
precedent it came from contradicts itself: the JSON example-download
test in `settings-export.spec.ts` asserts a real download event in the
mobile project, and that spec also measures this very panel's tap
targets there. The honest statement is that the panel's mobile layout is
covered elsewhere and this journey is about the artefact.
The bytes are read off the download because the saved file is what the
person ends up with, not because a consumed body reads back empty —
Playwright buffers the response independently of the page.
And the filename assertion covers the name the client anchor invents,
not the route's `Content-Disposition`; the two differ by a word, so it
is not contract coverage of the header.
The period assertion hardcoded a slash-separated month-first rendering.
The field order comes from the account's date-format preference, so a
DMY account prints dots and fails with a message claiming the report
carries no period at all, while an en-GB rendering matches, swaps the
two fields and computes a nonsense span in silence.
The order is now read off the account and the separator is captured once
and back-referenced, so all three renderings parse and none can be read
as another. `AUTO` is refused rather than guessed at: the fixture pins
an explicit order, and an account that lost it should say so.
The empty-window control also asserts the span it got. It had two silent
fallbacks under it — the panel sends a day count when a date field is
empty, and the route falls back to its 90-day default on a bound it
cannot parse, neither a 422 — and both landed on a window that holds the
seeded readings, so the missing-section assertion below was passing on
the fixture's luck.
The delegate spec asserted the absence of the export card inside a
READ-level record and presented that as the UI half of the MANAGE
boundary. It is not. `/settings/gesundheitsakte` is a personal
destination, and the shell answers it with the "not part of shared
access" panel at every adult grant level — read, write and manage
alike — so the same two absences would pass unchanged inside the record
the delegate manages. The assertion could not fail for the reason it
named.
Both arms now open the page. Inside the managed record, where the route
answers 200 for the same request, the card is still absent: that is what
makes the destination the cause. The level-sensitive statement stays
where it belongs, on the route's 403, and the file header now says which
half proves what.
Generating a report writes: the route remembers the scope and the
practice name on the account row, and the export panel a later visit
opens is therefore the collapsed "repeat run" state. The journey was
generating on the shared fixture account, which is the account
`settings-export.spec.ts` asserts the first-run state against in nine
places — an expanded picker, an empty summary, a disabled button. The
two files run in an unconstrained order under two workers, so the first
symptom was an order-dependent red in CI; against a persistent local
database the flip never cleared and the red was permanent.
The journey now seeds, generates and reads back on `e2e-report-owner`,
with its own cookie jar. The shared account and the whole-record
fixtures also get `report_selection_json` and `last_report_practice_name`
cleared on every seed, which repairs a database an earlier run already
dirtied and keeps a delegate's scope choice from being left on a record
owner's row.
The account's date order is pinned rather than left to the locale, and
its export bucket is purged beside the share-link and login buckets: the
10-per-hour ceiling is shared by every export route, keys on the actor
and outlives a run by an hour, so repeated local runs would otherwise
meet a 429 the spec reports as a missing artefact.
`data-latest-rate` is a production attribute whose only reader was the browser
suite, so a change to the rate's shape would have surfaced three minutes and a
whole server away from the gate that should own it. One unit test now renders
the chart over a loaded window and asserts the root carries the last day's
rate.
Two comments were wrong. The card's take / skip buttons do not collapse to
icons at narrow widths — both render their label unconditionally beside the
icon — so only the true half of the reason for addressing them by `data-slot`
survives: the labels are translated copy.
And the Enter press in the wizard's times-of-day helper is not decoration.
The field's overlay input holds what is being typed and hands it to the form
only on Enter or on blur (`src/components/ui/time-field.tsx:175-184`), while
the add button is disabled on an empty draft — so a fill followed straight by
a click waits out the actionability timeout on a button that never enables.
Proven by removing it: the flow died at exactly that click. The line stays,
with the reason written beside it.
The dashboard assertion called itself "the assertion that they cannot drift
apart silently" and then pinned 50. They have already drifted. The tile's
denominator is the day's expected slots, which a deliberate skip does not
leave, and its numerator counts only rows carrying a `takenAt`
(`src/lib/analytics/schedule-anchored-compliance.ts:141-164`). The card's
engine — `src/lib/analytics/compliance.ts`, the documented single source of
the percentage — drops a skip from the denominator outright
(`src/lib/analytics/compliance/ledger.ts:43-47`), which the counts nine lines
earlier already assert. One afternoon, two answers. The note now says that,
names which engine is authoritative, and the number stays pinned as what the
server's daily buckets really produce rather than as agreement.
The refusal flow was named for a schedule that does not exist and proved
something else: the 404 comes from the ownership guard, before the body is
read and before any schedule code runs. `expect(refused.data).toBeNull()`
proved nothing either — the error envelope always carries a null `data`. So
the test is renamed for what it does prove, the empty assertion is dropped,
and the case the old title promised is added: a pin onto an instant that is
not one of the medication's slots, refused with the documented 422.
Two separate ways the flows could not deliver what they promised.
The card read polled for up to seventy seconds and escaped a missing figure by
reloading the page — which re-mounts the list and issues the very batched
compliance call it was waiting out, so each poll spent one more of the
allowance it was short of. Meanwhile the budget is thirty seconds tripled by
`test.slow()`, so one ride-out consumed the whole test and the run died on a
Playwright timeout rather than on the "the card never painted an adherence
figure" message the note promised. The inner poll was also wrapped in a second
one with a thirty-second budget, and `expect.poll` does not abort an in-flight
callback, so a seventy-second call ran to completion inside it.
With the journey on its own account there is nothing to ride out: the read now
waits twenty seconds for the attribute and fails with the diagnosis attached.
The nesting is gone too — the step that waits for the card to re-read its
schedule polls the attribute directly.
The other one is the day boundary. A clock-anchored slot is inside its own
on-time window by construction, so the TAKE is always today's; the dashboard
assertion is not, because it reads the latest day and a day's expected count
carries no now-cap. Cross midnight between the take and the final read and the
latest day is a fresh one with two doses scheduled and none taken, which
answers 0 where 50 is asserted. The flow is a couple of minutes long, so it
declines to start in the last ten minutes of the day and says why.
The batched compliance read allows thirty calls a minute per ACTOR, and the
journey spends that allowance twice over: it writes doses and then reads the
resulting rates back off the same cabinet. On the shared account two siblings
spend it as well — `refetch-nav.spec.ts` mounts `/medications` unstubbed and
drives a client-side round trip back to it, and `delegated-writes.spec.ts`
opens `/medications?new=1` on a SECOND session row of the same account, which
is a different session but the same actor and therefore the same bucket. Add a
serial group's retries and thirty reads inside one minute is reachable rather
than theoretical.
The cabinet has the same shape of problem in the other direction: the clear
before each test deletes every medication row of the account it runs on, and
that is today the account every other authenticated spec holds.
So the journey gets its own account and its own jar. The rate-limit bucket,
the medication cabinet and the dashboard tile's denominator become private at
once.
Two fixture repairs ride along, because they are the same helper:
- The clear now goes out as the app's own DELETE per row instead of one raw
`DELETE FROM medications`. Three fifteen-minute per-user cells hold
medication state and a raw statement evicts none of them; the file survived
only because every test happened to write through the API afterwards, which
is an accident of ordering rather than a property. The rows are still
enumerated in SQL, because the list route serves a memoised shape and a row
it has not caught up with would hide from it.
- `ageMedication` takes both stamps from its caller instead of deriving them
from `CURRENT_DATE`. That resolves in the database session's zone — UTC in
CI — while the plan dates the spec sends are computed in the browser's
pinned Europe/Berlin, so between midnight and 02:00 the row silently started
a day earlier than the caller asked for. The docstring now says `starts_on`
is overwritten, not adjusted.
The doctor report had unit coverage for the renderer and route coverage for
the envelope, and nothing that pressed the button. Two browser journeys close
that.
`doctor-report.spec.ts` seeds a few weeks of blood pressure and weight, one
allergy and one medication through the page's own `fetch`, picks a window in
the export panel and generates. The saved download is asserted as a real
artefact — `%PDF` magic, a `Content-Length` that agrees with the bytes, a
length no empty document reaches — and then parsed back to text, so the
period line, the blood-pressure section and the drug list are read out of the
document rather than inferred from a 200. The period is checked as a span, so
the assertion survives the account's timezone. Its refusing control is a
window with no readings: still a valid, readable report that names the period
and carries no blood-pressure section, with the reference data that is not
window-scoped as the control against a blank page passing.
`doctor-report-delegate.spec.ts` covers the access boundary the route draws at
MANAGE. A delegate inside a record it may only read is not offered the export
card at all, and the route refuses the request that card would have sent with
`sharing.access.denied`. The positive control is the same delegate, the same
payload and the same switched-record path against a record it manages, so the
one variable between pass and refusal is the access level. Both requests carry
the record-context headers the shipped client sends: without them the fence
refuses with the same status and the same error code, which would make the
refusal pass for the wrong reason.
Seeding is idempotent — a re-posted reading collides on its natural key and
answers 409 — so a second local run and a repeated run both stay green. The
delegate journey takes its own cookie jar because it moves the session's
record selector, and both specs run in one project.
The catch-all angle-bracket pattern read as an HTML sanitiser to static analysis. The annotation Apple writes is a number, so the pattern now matches exactly that, and a unit with anything else between the brackets stays unknown instead of being silently trimmed into a lookup.
`PATCH /api/managed-profiles/{id}` had none, while creating a record and
inviting a Guardian have both been capped at ten an hour since they shipped —
one family answering the same abuse three ways. It matters more here than the
count suggests: `updateManagedProfile` opens a transaction and takes an
advisory lock on the id it was HANDED before it decides the caller is not a
Guardian of it, so an unthrottled caller can name any id and make the database
hold a lock about it.
The refusal now lands before the id is read, on the same bucket convention and
with the same ceiling as creation. The GET beside it stays unlimited, matching
the guardian roster read next to it: both are cheap identity reads a Guardian
may already make. The 429 is documented on the contract and the edit form's
message mapping grew the arm creation already had.
A managed record with no recorded language opens the edit form's language
control on the actor's, which is the same starting point the creation form
uses. The diff then compared that control against the record's NULL column, so
every save of such a record carried `locale` — a guardian correcting only the
timezone silently recorded their own language as the record's, and the audit
row said the language changed.
The comparison is against the value the control opened on, frozen at mount.
Nothing is sent unless somebody moves it.
The fields component was keyed on the query's `dataUpdatedAt` under a comment
saying the key stopped a refetch landing mid-edit from replacing what somebody
is typing. It did the reverse: the timestamp moves every time the query
resolves, so invalidating the `managed-profiles` prefix from anywhere — the
guardian list on another row is enough — remounted the fields and threw the
half-typed name away.
Keyed on the record's id instead, which is stable for as long as the form is
about the same record.
The `AccountPayload` description still claimed that under an active switch the
payload describes the caller "their preferences, their modules, their
identity", which stopped being true when the module map and the cycle flag
started answering for the active record — `ModuleMap` on the same spec said
the opposite. The iOS client reads this file.
Both descriptions now say the same thing: identity and preferences are the
caller's, `modules` and `cycleTrackingEnabled` are the active record's, and
both are masked to the sections the active grant opens. Spec regenerated.
The Modules save posted `cycleTrackingEnabled` on every submit, and the route
writes it whenever it is present. An explicit boolean beats the sex-derived
default in the cycle gate, so a guardian turning Labs off for a record created
without a recorded sex wrote cycle OFF as a deliberate answer; setting the
record's sex to FEMALE afterwards then left cycle off with nothing on screen
saying why. It also listed `cycleTrackingEnabled` as changed in the record's
audit trail on every save of any module.
The flag now rides only when it differs from the value the form opened on,
which is what the edit form's `managedProfileEditPatch` already does. The
decision moved into a pure `managedModulesPatch` so a test can see it — what a
form does NOT send is invisible in its markup.
Publishing the active record's module map and cycle flag on the account
payload was right for a whole-record grant and too generous for a narrowed
one. `accountAccess.active` is set for any live grant, level and scope
included, so a delegate scoped to `measurements` read whether the owner
tracks their cycle, their screeners, their illness episodes and their
supplement intake, and the cycle flag derives from the owner's recorded sex.
Both fields are now masked to the sections the active grant names: a closed
section answers `false`, which is the answer a module that is off already
gives and the answer the browser's route inventory has always acted on. An
unscoped grant names the whole record and is unaffected, so the own-record
and whole-record payloads are byte-identical to what they were.
The masking table lives beside the sharing vocabulary rather than inside it,
and runs one way only: no value in it can turn a module on, admit a read or
widen a grant.
The percentage on a medication card is the number this product is for, and
nothing in the browser suite walked the path that produces it: the wizard
specs stub `/api/medications` and stop at the POST body, and the compliance
engine's own tests are unit tests over pure functions. The join was the part
nobody checked.
Three flows against the seeded account, serial and in one project because
they read counts back off a cabinet they own:
- a twice-daily medication created through the real wizard, one dose taken
and one skipped from the card, and the figure read back on the card, in
the intake history and on the dashboard tile;
- a dose recorded against a schedule that does not exist, refused, with the
rate exactly what it was before;
- a plan due on two weekdays whose other five days stay out of the
denominator, with a daily twin given the identical doses as the control —
without it, "100 %" would also be what an engine that counted no days at
all would say.
The dose times are derived from the clock rather than picked from the
wizard's suggestions: the card offers one dose, so with fixed times there is
always an hour at which that dose belongs to yesterday or tomorrow, and the
day the write lands on decides what the tile reads. Each medication is aged a
few days as well — compliance is reconstructed from the creation stamp
forward, so a medication created a second ago reads the empty-set 100 % and
no take can move it.
Four surfaces gain data attributes so the journey addresses controls and
figures rather than translated copy: the card's take / skip buttons, the
card's two adherence rates, the intake-history row status, and the dashboard
tile's most recent day, which existed only as bar geometry. Presentation is
unchanged; the compliance block's fixed-geometry guard normalises the two new
rate attributes alongside the ones it already did.
E2. `loadOffhostConfig` parsed BACKUP_RETENTION_DAYS, clamped it to a
minimum of one day and put it on the config object, where no caller read
it. Neither annotation fits: it is not deliberately internal, because
there is no internal use, and no consumer is pending. The design forbids
one outright — the worker's grant excludes DeleteObject on purpose, so
nothing here can act on a retention window without giving a compromised
worker the ability to wipe the history. A clamp that guards nothing only
read as an enforcer the worker is not.
So the field and the clamp go, and the reason sits where the parse was.
The variable keeps its place in the two example files and on the compose
whitelist: it is the number the operator sets the bucket's own lifecycle
rule to. The docs table said "defaults to 30", which implied the app had
an opinion; it now says the app does not read it.
E1. Two halves of one correction. `.env.example` still said backup objects
older than BACKUP_RETENTION_DAYS are pruned, which no code does, so a
self-hoster reading that file alone still expected the worker to keep the
bucket tidy. It now carries the same sentence as the production example.
And that sentence overreached in the other direction: "the worker never
deletes an object" is not true of `_healthcheck/<ts>.bin`, the 1-byte key
the Test-connection button writes and then deletes. The delete swallows
its own failure, so an operator who granted exactly the three documented
actions leaves one orphan per press and is never told. Both files now say
"a BACKUP object", and name the one key and what happens without
DeleteObject on that prefix.
Low, review 2. An account with one out-of-range candidate is refused
whole, rows and audit row included, but the dry run counted its rows into
"N row(s) would be repaired" — so the number an operator read before
--apply was larger than the number --apply could ever produce. The refused
accounts are now counted and reported separately, and the per-account line
says its rows are not in the total.
The refusal itself told the operator to check the Health app "before
forcing anything". There is no --force and never was. It now names the
only route that exists: re-run with the matching --unit if the archive
really was in another one.
U1. The new `unit` description on the batch entry said `value` is read in
the unit HealthLog stores the identifier in. Sixteen mappings disagree:
`convertToDbUnit` runs on every entry, and a waist circumference arrives
in metres to be stored in centimetres, an oxygen saturation and a body fat
arrive as a fraction to be stored as a percent. A client generated against
that sentence sends 98 and stores 9800.
The description now says what happens: `value` is read in Apple's own
HKUnit for the identifier, HealthLog applies its storage scaling
afterwards, and `unit` is recorded for audit rather than validated
against. Two worked examples, because the percent-shaped ones are the pair
that bites. YAML regenerated.
Low, review 2. The self-test walked every leg but never once asked whether
the redactor actually holds. The excerpt path is the only thing between a
token in a failure body and the run log, so it gets its own case: a body
carrying this project's own key prefix must come back with the token
replaced.
Low, review 2. The delete leg asserted HTTP 200 and printed "account left
clean". An instance that answers 200 and keeps the row would have printed
the same line, which is the failure mode the journey exists to catch.
The leg now reads the id back and requires a 404. The self-test carries a
mock that answers 200 without deleting; with the read-back removed that
case goes green, which is the proof the check can fail.
S2. The legs ran in one loop that returned on the first red one, so a
failed read back never reached the delete and the probe account kept the
row. Worse, the id was assigned only after the write's await returned: a
POST that timed out once the server had already committed left a row the
script could not even name.
Legs 3 to 5 now sit in a try/finally. The finally sweeps whatever the
delete leg did not, and when there is no id it asks the instance for the
row by a marker the write puts in `notes`, unique per run and known before
the request goes out. The sweep never throws — the red leg is the verdict,
and it says out loud what it could not remove.
--self-test asserts the mock holds no rows after every case that got as
far as the write, so a red read that skipped the delete is caught.
S1. The journey signs in with a real account and POSTs that account's
password to whatever BASE_URL names, and the workflow took BASE_URL as
free text. Anyone with repo write, or a leaked token, could dispatch the
run at a collector of their own and keep a working credential; secret
masking guards the log, never the egress.
The host now has to be on a short const allowlist in the script, checked
before anything is sent, with exit 2 and a named refusal otherwise. The
workflow input is a choice between the two hosted instances, so the form
cannot offer a third; the script's own check still stands behind it for a
dispatch that goes round the form.
--self-test covers the refusal, the prefix lookalike a startsWith check
would have waved through included.
The other half of the switched-payload change, found by the integration
test that asserted the door was gone and got the door. `isNavDestinationVisible`
short-circuited on `sharedRecord`, so a switched session skipped the module
filter entirely and every destination the grant's scope allowed was offered.
That was right while the map was the actor's: a delegate's own preferences
must not hide a domain the record granted. The map is the record's now, so
the objection is gone and the line is what makes a guardian's toggle visible
inside the profile it was set for. Scope decides which doors the grant opens;
the map decides which of them the record uses at all.
The read-only profile summary needs its marker checked against `!sharedRecord`
rather than reached through the removed short-circuit, and the case that
pinned the old reading is turned round with the reason written out.
Refs #939
`auditLog` serialises `details` with `JSON.stringify` into a text column,
so the assertion was reading a field off a string and finding nothing.
Refs #939
The client's notification service extension replaces the medication name
on the lock screen, and iOS routes a payload through an extension only
when the alert carries `mutable-content`. The dispatcher has set the flag
on every alert it builds since the category work for the v0.5.4 client,
but the only assertion on it sat inside a category test, where dropping
it would have failed nothing and the name would have quietly reappeared
on the lock screen of a device that asked for it to be hidden.
Two cases now say it out loud: the medication reminder carries the flag,
and so does another event type, because that is the shipped behaviour and
narrowing it would take a capability away from the client rather than
tidy something up.
The native client replays its offline outbox with the same
Idempotency-Key on every attempt, and `POST /api/medications`, the
side-effect log and the inventory register ran the handler again on the
replay: after a lost success response the account was left with a
duplicate medication, a duplicate symptom entry or a second pen in the
supply count. The dedupe on `(externalSource, externalId)` only ever
covered a MIRRORED create; a manually entered medication carries
neither field and had nothing to collapse a retry onto.
All three wrap in `withIdempotency`, the composition the batch ingest
already ships, and the three operations publish the header so a client
generated against the contract can tell that a retry is safe. Zod
parsing and `requireRecordAuth` are untouched.
An integration test drives each route twice against real Postgres and
asserts what a status check cannot: one row in the table, and a replayed
201 carrying `X-Idempotent-Replay: true`.
Post-publish verify proves the published image boots. Nothing proved that
on a running instance a person can sign in, write a reading and read it
back — which is exactly the shape of the defects this line keeps
rediscovering: a value written and never read.
`scripts/synthetic-journey.mjs` walks that journey in plain Node 22 with no
dependencies, so it runs from a workstation, a runner or the deploy host.
It warms `/`, `/api/health`, `/api/version` and the measurements list first
and discards those timings (the first hit on each route after a deploy is a
cold start), then asserts five legs in order: the served version equals the
expected one, the password flow signs in through a cookie jar, a weight
reading is written with an Idempotency-Key, the same row reads back with
its value, unit and source, and the reversible delete leaves the account
clean. One line per leg with the elapsed time; the first failure ends the
run non-zero with the status and a body excerpt that has every token shape
redacted out of it.
The workflow is `workflow_dispatch` only, with `base_url` and
`expected_version` as inputs and the credentials read from repository
secrets. It is deliberately not wired into the deploy path: the target
instance and the secrets are the maintainer's call.
Mutation proof, against a production build served from
`.next/standalone/server.js` on a throwaway Postgres.
Green:
warm 35 ms (discarded)
ok version 2 ms serves 1.38.13
ok sign in 78 ms session cookie held
ok write 64 ms wrote cmtup9lwu0007zhovn0mxajdz
ok read back 8 ms 77.7 kg (MANUAL)
ok delete 27 ms account left clean
synthetic journey green
Read leg broken deliberately — the same server behind a throwaway forwarder
that empties the `GET /api/measurements` list and changes nothing else:
ok version 4 ms serves 1.38.13
ok sign in 38 ms session cookie held
ok write 40 ms wrote cmtupa4ck000gzhovwnij64fz
FAILED read back 8 ms read back: the list came back without the reading
status 200
body {"data":{"measurements":[],"meta":{"total":0}},"error":null}
synthetic journey RED on "read back"
Version leg broken the same way, by asking the instance for the previous
tag:
FAILED version 3 ms version: instance serves "1.38.13", expected "v1.38.12"
synthetic journey RED on "version"
`--self-test` carries that proof in the repository: it runs the journey
against an in-process instance nine times, once with nothing broken and
once per broken leg, and fails if a broken leg still comes back green. The
workflow runs it before it touches the real instance.
Gates: typecheck, `node --check`, lint (the three known warnings, no
errors), prettier, the workflow and CI-determinism guards.
The glucose display unit had thirty-odd readers and, until recently, no
control to set it. Nothing in the browser suite walks the path a person
actually takes: capture a reading in mg/dL, switch the account to mmol/L,
capture a second one, and read both back — each converted, none of them
reinterpreted — in the list and on the dashboard strip.
The journey covers that end to end, plus the boundary: a value past the
plausible ceiling is refused by the server, the capture keeps what was typed,
and the list is exactly what it was.
One reading arrives with no meal-time context, the way a meter export or a
device sync delivers one. It reads back correctly in the list; the tile it
should paint does not exist yet (#943), so the tile assertion is skipped by
issue number rather than softened into something that passes.
Three supporting changes:
- The list rows carry what they hold — type and resolved display unit on the
row, a slot on the value cell — so the journey pins "this reading now reads
in mmol/L" structurally instead of matching translated copy that also paints
through a different subtree on the card list. The meal-time options carry
their enum for the same reason.
- The sharing-marker weight rows were seeded by handing a Date to a
`timestamp without time zone` column, which stamps them in the host's zone.
On a machine east of UTC that put them hours in the future, where every fold
whose window ends "now" skips them, and the type stayed uncovered — which
keeps the snapshot's thick phase cold and the whole tile strip short. Seed
the instant as UTC, the shape the app's own writes use.
- The file mutates one shared account and reads global verdicts off it, so it
runs in a single project alongside the visits and immunization journeys.
Upload a synthetic export.zip on the import card, wait out the queued
job through the card's own status poll, and read the imported readings
back off the measurements list with the units the parser resolved.
Weight, the blood-pressure pair and the step count are asserted; the
walking-distance case is written and skipped, naming issue #944 — a
record written in km lands as metres today, and the assertion is kept
at full strength so the fix has something to turn green.
The refusing control uploads a valid ZIP with no export.xml: the card
surfaces the extractor's own refusal and the account's Apple Health row
count does not move.
Measurement rows now carry data-measurement-type / -value / -unit
beside the rendered cell, on both the desktop table and the mobile
list, so an assertion binds to the reading rather than to whatever the
locale formatter made of it. The import card's error line gets a test
id for the same reason.
The runbook said the worker prunes objects older than BACKUP_RETENTION_DAYS. It never calls DeleteObject; its grant is PutObject, GetObject and AbortMultipartUpload, on purpose, so a compromised worker cannot wipe the history. The sentence now says what expires objects and what the variable is for.
BACKUP_S3_REGION and BACKUP_RETENTION_DAYS are both on the compose whitelist and
both read by the backup job, but the example file listed neither — so an
operator following it had no way to learn that the region defaults to auto, or
that the retention number is what the bucket's own lifecycle rule has to be set
to, since the worker never deletes an object itself.
The settings profile form painted the server's refusal under the input
and left it there until the next save, so a value the person had already
corrected still read as refused. The onboarding baseline step drops that
sentence while they type; the account card now does the same.
Only the edited field's slot goes: the other fields the same save
refused were not touched, so their sentences still stand. The guard
derives both sets from the form itself, so a new rejection slot without
a clear fails.
Review finding L3.
The guard read `* 1000` anywhere in the two mapping files as a
re-implemented kilometre factor. Both files also turn seconds into
milliseconds, so an unrelated conversion would have failed it for the
wrong reason and the next person would have deleted the rule.
A rescale now counts only in a length context: a distance identifier on
the same line or within the five above it, which covers the body of a
distance-named helper. The offending line is named in the failure. The
matcher's limit is written next to it — a length conversion in a helper
named nothing like a distance, more than five lines below its signature,
still slips through.
Checked both ways against the real file: a millisecond conversion passes,
a `km * 1000` helper fails.
Review finding L4.
The rollups and the cached status insights are recomputed after the
transaction, so a throw there left the rows repaired and every chart on
the pre-repair numbers — and no later run would notice, because the
audit row makes the account skip from then on.
The tail is wrapped now. The audit row is written with
`rollupsRefreshed: false` and only flipped once the recompute returned,
so a re-run can tell "repaired, rollups pending" from "done" and says so.
A run that could not finish the tail names the account, prints the
backfill to re-run — `scripts/backfill-rollups.ts --user <userId>` — and
exits non-zero.
Review finding M2.
The route was exempt from the published contract as a browser handoff. It
is not one: Nightscout has no OAuth step and no authorise page, and the
route is an authenticated JSON POST that Zod-parses a URL and an optional
token, probes the instance and encrypts the pair onto the row. It was the
only POST among eight `connect` exemptions, and its three siblings —
status, test, disconnect — have been published all along, so a client
could read every part of the Nightscout flow except the one that starts
it.
Register it from `nightscoutConnectSchema` so the request shape stays
single-source, document the 400 / 413 / 422 / 429 arms the handler
actually answers with, and drop the exemption. Removing the registration
now fails the coverage guard by name.
An in-place multiplier with no reverse operation was documented with only
a dry run in front of it. Point at the backup runbook where the write
happens.
Review finding M1.
The provenance criterion proves where a row came from, not which build
imported it. An account re-imported on the fixed build carries the same
`EXPORT_XML_SOURCE_MAX` stamp and the same `stats:` external id with the
right numbers and no audit row, so it is selected exactly like a broken
one — and its rest days, under 200 m because the phone stayed at home,
survive the plausibility check after another thousandfold multiply.
So one candidate row that would leave the range now refuses the whole
account: nothing written, no audit row, the offending rows and a printed
reason instead. The idempotency guard only ever held against the
script's own prior run; this holds against the case it could not see.
The runbook says the same in words — a re-imported account must not be
repaired, the re-import already did it — and says what the dry run is
for: it is where the decision is made, not a formality in front of one
already taken.
Integration coverage for the re-imported account: correct rows, an
`--apply` run, nothing changes and no audit row is left behind.
Review finding H1.
The record-unit conversion is now opt-in per caller and only the
`export.xml` archive path opts in. There the attribute is authoritative:
Apple stamps every quantity `<Record>` with the account's own display
unit, so `km` means kilometres and the reading is meaningless without it.
`POST /api/measurements/batch` keeps its documented contract — `unit` is
captured for audit and never read. Nothing in the tree pins the native
client's unit strings for the seventeen convertible identifiers, and a
client that reads a sample in metres while stamping the person's display
unit into the field would have had every walking distance multiplied by a
thousand on ingest. Reading the field there is a wire change and needs
the client's strings pinned first.
The mapping-table audit and the shared conversion module stay. The batch
path's identity behaviour is written down where it is decided and pinned
twice: every convertible identifier round-trips a sibling unit unchanged
by default, and a walking-distance entry sent as `km` is stored as it
arrived. The OpenAPI description says what the server actually does.
Review finding H2.
The audit had moved off the pull_request trigger entirely, which left a window in which a tag could be cut from a merge the audit had not seen. It runs on pull requests again as an advisory job (continue-on-error), stays blocking on the push run for main and on the daily schedule, and a tag is only ever cut from a green push run.
Picks up the managed-profile read and edit endpoints, the sex field on the
create body, the record-scoped module map on the account payload, and the
delegated cycle field on the record's module settings.
Refs #939
Turning a module off for a profile you look after wrote a column nothing
on screen was reading. GET /api/auth/me published the ACTOR's module map
while switched, and the navigation, the dashboard tiles and the insights
sections all gate on it — so a guardian who turned Cycle off for the child
kept seeing a Cycle destination inside the child's record, and a guardian
who tracks it saw one there whether or not the child did (#939).
The map and the resolved cycle flag now answer for the record the session
is inside. They are not display preferences: every surface they gate shows
the record's data, so the record is what decides. Locale, theme and layout
stay the actor's. The record is taken from the grant-validated active
entry rather than the session's own selector, so a revoked switch cannot
publish somebody's configuration, and with no switch the payload is what
it was.
Cycle also gets a row on the guardian's Modules surface, which it never
had. It is a delegated module, so its state is the record's own cycle
profile and modulePreferences.cycle is refused — which left a guardian no
way to turn it off for a record whose sex says it should be on. The family
names the real column instead of a blob entry the gate ignores.
Refs #939
A managed profile could be created and deleted and never changed. A name
typed as a placeholder was permanent, a household that moved kept the
timezone the record was created in, and there was no way to correct a date
of birth somebody finally had (#939).
PATCH /api/managed-profiles/{id} takes the fields creation takes, every
one optional and at least one required — an empty body is a refusal rather
than a save that audits a change nobody made. It carries the gate the rest
of the family carries: a cookie session with a fresh second factor, an
active Guardian grant on the record, and the same byte-identical 404 for
"no such record" and "not yours". A GET beside it answers the record as it
stands, because the account payload names a managed record and nothing
else, and a form filled from that would have had blank fields to send.
The new field is gender. Leaving it out of v1.37.0 meant every managed
record started with the column NULL, which is what the cycle module
derives its default from: a guardian creating a record for a child could
neither record the answer nor turn the module off from there. It reuses
the column and the three values every other surface writes, so there is
one word for one column rather than two.
Refs #939
The onboarding step keeps the preference and the adapter where the
save happens and renders the control from its inputs file, so the
three conditions belong to the surface, not to one file. The control
check is word-bounded now: a wrapper named after it passed a substring
check while rendering something else.
A partial save is a whole-class trap: any 200 read as a clean save
throws the person's correction away. The guard enumerates every
surface writing to either profile route and requires each to call the
per-field describer, or to prove its body carries a single field, for
which the partial arm is unreachable.
It shares the route and had the same gap: the banner named the first
refused field and nothing marked the input that held the value. Each
of the seven submitted fields now carries the reason in its own slot,
through the same component the onboarding step uses.
The onboarding baseline step flashed a toast naming one refused field
and walked on to the done screen, stamping the account as set up over
a value the server never stored. The value the person typed was gone
with the step.
A refused field now writes its reason into that field's own error
slot, marks the control invalid, and holds the step until the person
corrects it. Every refused field is named, not only the first, and the
reason comes from the validator code rather than the validator's own
prose, in all seven shipped languages.
The step's profile fieldset moves to `baseline-fields.tsx` unchanged.
The markup can now be rendered against a server answer on its own,
which is where the next commit puts a test.
The defect was invisible from the outside because every behavioural test in
the suite seeded a tagged reading. Two checks close that:
A structural guard that reads the dashboard surfaces and fails when a tile
derives presence from a list of enum buckets with no null / unknown arm and
no written reason, following one hop through a named constant. It carries
its own positive and negative controls, so a refactor that silently stops
matching anything fails here rather than going quiet.
An integration test that seeds twenty readings with a genuinely NULL context
against a real Postgres and reads the tile back through the same resolver
the page renders with: eligible, latest value intact, and a tagged reading
still filed under its own context.
Refs #943
Both report paths dropped them: the raw path iterated the named contexts,
and the dense path skipped any row without a context outright, so a report
built for a meter that records no meal time carried an empty glucose panel
while the readings sat in the database. The FHIR export dropped them for
the third time, at the LOINC lookup — its own comment already said an
unspecified reading codes as 2339-0, but the table had no entry to hit.
The insights context breakdown names the bucket, and the Coach's glucose
block now resolves through the shared helper rather than its own copy.
Refs #943
`buildGlucoseTargets` had the same gap as the dashboard tile: it iterated
the four named contexts, so an account whose readings carry no meal time
got no glucose target at all. Route the loop through the shared grouper.
The untagged card is judged against the RANDOM band and is not editable —
one threshold, one editor row — so the cog renders disabled the way it does
for a derived card. The insights reference panel lists the new target type,
otherwise such an account sees no band on the blood-glucose sub-page.
Refs #943
Tile eligibility filtered the four named contexts against the per-context
summaries and asked whether any of them held readings. An account whose
source records no meal time matched none of them, so the tile was omitted
with the module on, the layout toggle on, and a reading from today. Nothing
warned; the strip was simply short one card.
Move the gate out of the page into `resolveGlucoseTiles`, which walks the
shared bucket list — untagged included — and hand the caller the label key
with the summary. Both summary producers group through the shared resolver,
so `UNSPECIFIED` reaches the wire with its own count, latest value and
trend, and a tagged account still gets its per-context breakdown.
Refs #943
`glucose_context` is nullable, but every surface that fanned out over the
four enum members treated the named list as exhaustive. A meter synced
through Apple Health writes no HealthKit meal-time metadata at all, so an
account can hold nothing but rows the list cannot match.
Add one shared notion of the untagged bucket — the name the Coach's glucose
block already used — with a resolver that files NULL, blank and unrecognised
values under it, and a grouper that walks the named contexts first and the
untagged bucket last. The untagged bucket borrows the RANDOM threshold: an
untagged reading is a spot reading, and a fifth threshold would give the user
a second editor row judging the same readings by a different number.
Labels in all seven locales.
Refs #943
FENCE-AC-07 drove its external switch through the browser context's API
request context, which shares one process-wide keep-alive agent with every
other API context in the runner. A POST there can be handed a connection the
server has already closed on its five-second idle timeout, and a POST is never
replayed on a fresh one — twice this line ended the spec with
`read ECONNRESET`, on all three attempts.
The switch is sent with the page's own `fetch`. It stays external in the sense
the case needs: the switch UI is what runs the cache wipe and a bare `fetch` is
not it, and the service worker returns without touching any non-GET, so the
request reaches the network unaltered. The positive control below still proves
the cached entry survived.
The scan's fixture was written with a Playwright API context. Every API context
in a runner shares one process-wide keep-alive agent, so a request can be handed
a socket the server has already closed on its five-second idle timeout, and a
POST — unlike a GET — is never replayed on a fresh one. That is what ended this
spec twice, on this line, on all three attempts each time: `socket hang up` and
`read ECONNRESET`.
The dose is written from the page instead. The browser opens its own connection,
the service worker is blocked in this project, and the row still goes through the
real route, so nothing about the fixture changes except which socket carries it.
The maximize case clicked the drawer's maximize control as soon as the drawer
was on screen. Those are not the same event: the vault records `?doc=` as the
way back only once the detail sheet has actually closed with the drawer owning
the same document, and maximizing before that sends the close down the other
branch, which calls `history.back()` and races the push to `/coach`. The URL
then stays on `/documents` and nothing is raised. The sheet's own close control
leaves the tree with the sheet, so its absence is that hand-off, observed.
The delete case is addressed to the toast's attributes rather than its copy.
The literal there has already been wrong once — a trailing full stop moved and
the assertion spent its whole timeout hunting a sentence that no longer
existed, on every attempt, on both projects.
The two metric sub-page cases waited for `.recharts-wrapper`, a class the
charting library writes and every chart on the page carries, so the gate was
satisfied by whichever chart rendered first rather than by the one the route
exists to show. On `/insights/mood` that is exactly what happened: the mock
carried a single mood entry on a fixed date, the chart withholds its line below
three distinct days, and the sparse placeholder painted while some other chart
answered the gate. The mood line was never waited for and may never have been
scanned.
Both charts now carry `data-slot="chart-plot"` on the wrapper their data branch
renders — an empty window still paints the empty state and the scan still waits
— and the mood fixture is anchored on now with ten daily scores, the same way
the measurement rows above it are.
The window-reset test burned its budget in a 50 ms window: four sequential
round-trips to a containerised Postgres had to finish inside a twentieth of a
second or the window closed under them, the counter reset, and the fourth call
came back allowed. Three failures in thirty days, two of them on `main` — run
33614804032 shows `expected true to be false` at rate-limit.test.ts:56 in a file
that took 1278 ms to run.
The short window bought nothing: the expiry this test is about is forced by hand
a few lines further down, by writing `reset_at` into the past. The burn now runs
in a 60 s window, so no round-trip can outlast it, and the test asserts the row's
count is 4 after the denial — a window that closed mid-burn would also have
produced three allowed calls, and the old assertion could not tell the two
apart.
Gates:
pnpm exec vitest run --config vitest.integration.config.mts \
tests/integration/rate-limit.test.ts
three consecutive runs, 2 passed / 2 passed / 2 passed.
Mutation: with the burn window back at 1 ms the run fails exactly as CI did —
"AssertionError: expected true to be false" on the denial assertion.
Three of the four watched routes sat within a kilobyte of their ceiling —
444 of 445, 459 of 460, 459 of 460 — because the numbers had been re-stated
for four releases and never re-based. A budget a route is already touching
does not measure the route; it measures whichever dependency bump landed that
week. e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the
445 KB budget` with nothing in the diff that touched the bundle, and it was one
of at least four e2e failures that were the budget gate rather than a test.
The file states the measured value per route now, plus one drift allowance over
it, and a route fails on the delta. Measured on a local build at d48bbff01:
/ 447, /insights 434, /measurements 433, /insights/mood 447, allowance 20 KB, so
the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The
allowance covers the runner delta and ordinary week-to-week drift; the step
change the gate exists for, a statically imported catalog or a duplicated chart
library, is several times larger and still fails. Every run prints the delta, so
a route that has crept most of the way through its allowance is legible in the
log long before it is red. The reason and the re-base instructions are written
into the file's running note, as with every raise before it.
A budget file with no baselines used to gate nothing quietly, since the loop
had nothing to walk; it now refuses.
Gates, against the current build:
real baselines passed; /page +0.3, /insights +0.3,
/measurements +0.2, /insights/mood +0.3 KB
/page baseline 425 (cap 445), FAILED: "/page: 447 KB gz is 21.6 KB over the
/insights 413 (cap 433) 425 KB baseline, past the 20 KB drift
allowance (cap 445 KB)" and the same for
/insights/page, exit 1
routeBaselineKbGz removed exit 2, "states no routeBaselineKbGz entries,
so no route is gated"
The hero-geometry check stopped skipping under CI in v1.38 and started failing
instead, which was the point: it had caught its own launch failure and passed
for months, measuring nothing. The install step went into the quality job of
`security.yml` and nowhere else, and a second job runs the same suite — the
auto-merge job for dependency bumps. Every Dependabot pull request has failed
since on `browserType.launch: Executable doesn't exist`, four of them on
2026-09-06 alone (run 34043840314), on an error that says nothing about the
bump. One workflow was fixed; the class was not.
That job installs the same headless shell now, off the same cache key. The
error the check throws names both jobs rather than sending a reader to the
workflow that was already correct, and a guard sweeps the workflow directory so
a third job cannot run the suite with no browser anywhere in it. Its limit is
written down: the sweep is file-scoped, so a workflow with two jobs where only
one installs still satisfies it.
Gates:
CI=1, browser path hidden exit=1, "This check is the gate in CI and it
has no browser to measure with"
CI=1, browser present exit=0, 4 tests passed (it measures)
install step deleted from the guard fails: ["dependabot-auto-merge.yml"]
auto-merge workflow
`totalEnergyBurned` rides the same account-display-unit contract as the
distance beside it: a Health app set to kilojoules writes `kJ`, and the
column is kilocalories. Convert it through the shared table.
Refs #944
The unit fix only helps the next import. An account that imported an
archive before it carries a decade of days understated 1000x, and the
authoritative repair — re-importing the archive on the fixed build —
needs an archive the account may no longer have.
`scripts/repair-apple-health-distance.ts` is dry-run by default and
selects rows only by the stamp the archive fold itself writes: the
EXPORT_XML_SOURCE_MAX provenance plus the `stats:` external id minted
for that fold. Never by "the value looks small", so a row a native sync
has since overwritten is left alone. `--apply` multiplies one account
per transaction and writes the audit row in the same transaction, which
is what makes a second run a no-op. The archive's own unit is named by
the operator (`--unit=mi`) because the discarded attribute is the defect
itself, and a row whose repaired value would leave the plausible range
is reported rather than written.
Refs #944
`pnpm audit --prod` answers a question about the published advisory database,
not about the diff. The same lockfile passes on Monday and fails on Tuesday
because someone else published, and the branch that turns red changed nothing:
seventeen of the forty-one Security & Quality failures in the last thirty days
were advisory publications on branches that touched no dependency
(GHSA-jqff-g426-hqxp, GHSA-fph4-wmhf-6fwf, GHSA-f65p-4m7j-42xc and
GHSA-5jgf-p345-68v8 in one run alone). A gate that fails for a reason the author
cannot act on is a gate people re-run instead of read.
It keeps running on every push to `main` and once a day on a new schedule
trigger, which is the cadence the question actually has. The other three jobs
sit out the scheduled run. Nothing about what ships is loosened: the container
scan still builds and scans the image on every push to `main`, and the daily
Trivy workflow still scans the lockfile with dev dependencies included and the
published image.
Gate — the trigger set, read back from the parsed workflow:
triggers: push, pull_request, schedule
quality | if: github.event_name != 'schedule'
dependency-audit | if: github.event_name != 'pull_request'
secret-scan | if: github.event_name != 'schedule'
docker-security | if: github.event_name == 'push' && github.ref == 'refs/heads/main'
The three score engines' unit tests hand `persist*Score` a fake Prisma, but
`upsertScoreRow` routes the written row through `afterMeasurementMutation`,
which reads the `@/lib/db` singleton instead of the injected client. Both legs
of that tail therefore opened a socket to a Postgres the unit job does not run:
on CI the attempt failed with ECONNREFUSED a few hundred milliseconds later and
logged on the way out, after the test that started it had ended. A console write
that lands while the worker is closing its RPC channel fails the whole run with
`EnvironmentTeardownError: Closing rpc while "onUserConsoleLog" was pending` —
four of nine unit-suite failures in thirty days, every one of them with all
tests green, blamed on whichever score file the worker happened to be tearing
down. Run 31961548185 carries the full stack: recomputeBucketsForMeasurement <-
afterMeasurementMutation <- upsertScoreRow <- persistStressScore <-
stress-score.test.ts:105.
It stayed invisible on a maintainer's machine because a local Postgres answers
on 5432: the same call succeeded quietly, and the suite was writing rollup rows
for its fixture users into a real database.
The four files that drive `upsertScoreRow` stub the tail. That removes the
doomed I/O rather than the log line, and the call they were making by accident
is now asserted in score-row.test.ts instead: the v1.37.19 wiring is pinned by
an expectation rather than by a stack trace. Verified by mutation — dropping the
`afterMeasurementMutation` call from score-row.ts fails the new assertion.
The settle guard's sweep is widened while here. Its eight-line handler window
and five-line registration preamble were both measured against the sites that
existed when it was written and both cut through real code: the console write in
mood-rollups.ts sits nine lines into its handler, so the one genuinely
unregistered detached promise in the tree was never seen, and the registration in
fire-and-forget.ts sits below its handler, so a correct site read as a violation.
Registration is now looked for wherever it wraps the statement, by paren depth
rather than by line count, and mood-rollups.ts registers its WEEK/MONTH/YEAR
enqueue. Verified by mutation: unwrapping that call fails the guard.
Gate: the four files, twenty consecutive runs, 20/20 green (53 tests each).
Test time across the three engine files falls from 708 ms to 20 ms — the
round-trips that are no longer made.
Apple stamps every quantity `<Record>` of an export archive with the
account's own display unit, not the unit the mapping table assumes: a
metric archive reports walking distance in km, an imperial one in mi,
body mass in lb, energy in kJ, temperature in degF. The mapper accepted
`input.unit` and never read it, so a 2.484 km day was folded into the
day's total as 2.484 m and a decade of imported history read as a couple
of metres walked.
`hk-units.ts` is now the one place a HealthKit unit is converted, shared
by the record path and the workout path (which carried its own km/mi
branch — the duplicate is what let the record path stay wrong). It
converts only inside a family and only for units Apple can emit, and
returns the reading untouched for anything it cannot place, so an
archive that already speaks the table's unit is unchanged.
Every mapping-table entry whose unit cannot be converted — a
dimensionless count, a pinned event, a logarithmic dB level, a compound
rate, the percent units whose value rides as a 0..1 fraction — now
carries a written reason, and a structural guard fails on an entry that
offers neither a convertible unit nor a reason.
Refs #944
The Apple export writes DistanceWalkingRunning in the account's own
length unit; the record mapping assumes metres and never reads the
attribute, so a 2.484 km day lands as 2.484 m.
Refs #944
pnpm-workspace.yaml carried two overrides for the same packages: an older floor (hono < 4.12.25, js-yaml >= 4.0.0 < 4.3.1) and the newer one added for the scanner findings. The older selector won the resolution, so the lockfile kept hono 4.13.3 and js-yaml 4.3.1 and four of the seventeen findings stayed open after v1.38.12. The stale bounds are gone; the lockfile now resolves hono 4.13.7 and js-yaml 4.3.2.
The account-access integration contract compares whole entries, so the
two lists the payload now publishes belong in its expected shapes. The
managed-record leg posts the mood entry with the logged-at instant the
create schema requires.
The scanner job installed Trivy through apt, which runs `apt-get update`
across every repository on the runner image. On the v1.38.12 merge
commit the runner's preinstalled Chrome repository answered with a
corrupt index ("Hash Sum mismatch") twice in a row, and the release gate
went red on a step that has nothing to do with the image under test.
The step now fetches the pinned release binary through Trivy's own
installer, which downloads the tarball for that exact version and checks
it against the published checksums. No apt, so no unrelated repository
can fail the job.
The integration leg reads GET /api/auth/me as a guardian acting on a
managed profile and asserts both lists, lands a mood entry and an
allergy on the profile's record, is refused a vault write, and shows a
WRITE delegate writable sections without manageable ones together with
the refused mood entry the list withheld.
The browser leg opens the managed profile, adds a mood entry through
the control that used to be hidden and asserts the POST's status, and
finds the owner-only sentence and no upload control on the documents
page. The spec gets its own session jar and runs in the desktop
project only, like every other spec that moves the record selector.
useRecordCapabilities answers per section now. canWriteDomain and
canManageDomain read the two lists the account payload publishes; in
one's own record they are always true, and under a refused, pending or
unproven context always false. canManage becomes the coarse switch,
true when any section is manageable, and is what the banner and the
navigation read.
Every add, edit and delete control names its own section: labs, mind,
profile, illness, cycle, medications, measurements, documents. Creates
that a WRITE route answers (visits, practitioners) ask canWriteDomain.
The generic delete button, the selection bar, the capture picker, the
dashboard quick entry and the bottom nav take the section from their
callers. Controls whose routes resolve the caller rather than the
record (settings shortcuts, chart overlay preferences, custom metrics,
the Today rail, OCR, efficacy targets, credential tabs) ask for the
caller's own record instead, and the customise-shortcut guard now
requires that shape.
The vault has no delegated write route, so its controls stay withheld
under every grant, and the documents page says so in one sentence
where the upload control would be, in all seven locales.
Every accountAccess entry on GET /api/auth/me carries writableDomains
and manageableDomains: the sections the grant opens, intersected with
the sections whose routes accept a delegated write at that level. A
READ grant gets two empty lists, a WRITE grant gets writable sections
only, a MANAGE grant gets both, and the vault appears in neither.
The client parser checks the structural invariants (no repeats, within
the grant's sections, manageable within writable, nothing below the
level) and requires the two published views of the active record to
agree on the lists. Regenerate the OpenAPI document and extend the
payload consumer guard with the active-record readers.
Add the one table of which record sections have a route declaring
requireRecordAuth("write") or ("manage"), derived from the route files,
and a structural guard that re-derives it from src/app/api on every run
and fails on a disagreement in either direction. The matcher tolerates
the line break a formatter puts after the opening parenthesis and
asserts a non-zero match count.
delegatedDomains() turns a grant's level and scope into the sections it
can add to and the sections it can change, keeping write within manage.
next 16.3 type-checks the whole tsconfig program during the build. That
reaches the unit tests under src/**/__tests__, their fixtures under
tests/, the Playwright config and the e2e tree, none of which is in the
image build context — .dockerignore leaves tests/ and e2e/ out on
purpose — so the image build stopped at "Cannot find module
'../../tests/fixtures/...'" on both architectures.
The build now reads tsconfig.build.json, which extends the main tsconfig
and excludes the test tree and the test-runner configs. `pnpm typecheck`
keeps using tsconfig.json and still covers the full program; the change
narrows only what `next build` compiles, which was already the case on
16.2 by omission. Verified by building with tests/ and e2e/ moved away.
Trivy flags twelve distinct advisories on the lockfile and the image, none
in this code: next 16.2.11 (two critical, fixed in 16.3.3), sharp 0.35.3
pulled by next (high), nodemailer 9.0.3 (one high, three medium), js-yaml
4.3.1 (high), hono 4.13.3 under the MCP SDK's node server (three medium),
and the vitest mocker and baseline-browser-mapping (medium). Dependabot
lists none of them, which is why they sat.
next moves to 16.3.4 and nodemailer to 9.1.1 as direct pins; vitest to
4.1.11. js-yaml, hono, baseline-browser-mapping and sharp are transitive
and move through pnpm overrides bounded to the vulnerable ranges, so the
copies the scanner names change and nothing else does. Typecheck, lint,
the unit suite and the production build pass locally; the release gate
runs the integration and end-to-end suites on the minor Next bump.
Version anchors, the OpenAPI document, the changelog entry, and the
README sentence that still declared immunization records out of scope
while the vaccination record has shipped since v1.37.3.
"Sign out everywhere" revoked every other refresh token and nothing else.
A native login is two rows, and the Bearer check consults only the
paired access token, so the other phones kept working until that token
expired, up to a day on the default policy. The route also handed
destroyOtherSessions a session-kind credential for a Bearer caller, whose
session.id is the ApiToken row id; the exception matched nothing and the
calling phone revoked its own refresh token. destroyOtherSessions now
finds the paired access tokens through the refresh rows and revokes them
inside the same transaction, reporting the count, and the route names a
Bearer caller by its access-token hash. Programmatic tokens are untouched.
rotateRefreshToken marked the consumed row under usedAt: null alone, so a
revocation landing between the read and the write was overtaken and a
pair minted after the family ended stayed live. The guard now includes
revokedAt: null; on a lost race the minted pair is retired and the reason
distinguishes a revoked family from a double consume.
withIdempotency consulted the grant only at replay time, and only for its
existence; a narrow-scoped token was checked only on the delegated arm.
Both move into the cell key: a delegated cell carries the grant's id,
level and scope fingerprint, a narrow token's cells carry the token, and
a request with no live grant neither reads nor claims a cell. Cookie and
wildcard own-record cells are keyed byte-for-byte as before.
Tests: integration for the paired access tokens (cookie and Bearer
caller), a deterministic revoke-mid-rotation race against Postgres, unit
coverage for the new cell keys, and the structural guard moved to the
new invariant.
Rotation revoked the access token paired with the consumed refresh token
the instant the rotation committed. A native client with requests already
on the wire got 401 revoked on those, read that as "re-authenticate",
rotated again with the refresh token it had just spent, and tripped reuse
detection: the device's whole token family died because the client was
busy at the boundary.
Pull the paired token's expiry in to fifteen seconds instead. It is long
enough for an in-flight request on a slow mobile link to finish, including
a retry, and short enough that the token gains nothing meaningful over the
validity it already had. bearer.ts answers a passed expiry with reason
expired, which a client treats as "refresh", not as "sign in again".
LEAST(expires_at, $1) takes the minimum inside the single UPDATE, so the
write can only ever shorten: a token already inside its last seconds keeps
its own expiry, and there is no read-then-write window. The bound value is
an ISO-8601 UTC string cast in SQL so the comparison does not shift with
the host's timezone.
Reuse detection is untouched. A replayed refresh token, or one presented
under a spoofed device id, still revokes the family and its access tokens
on the spot, and so does the logout path.
Nothing calls it any more: the weekly pass and the off-host job both
walk the payload through the streaming writer. Keeping an exported
function that builds the entire record as one string invites the next
caller to reintroduce the memory profile this branch removes.
The off-host job was said to benefit from the streaming work the weekly
in-database backup got, and it did not. Only the payload builder was
shared: `runOffhostBackup` called `buildFullBackupJson` for the whole
document, `gzipSync` over that whole string, a whole-buffer
`createCipheriv` pass over the result, and handed the finished buffer to
a single `PutObject`. Four full copies of the record alive at once, on
the same arithmetic the weekly pass had already been fixed for.
Configuring an S3 target on a long-lived record therefore restarted the
container. On an account of 445 000 measurements the JSON alone is
242 MB, and against a 1 GB container's 524 MB heap the run died of heap
exhaustion seventeen seconds in — taking every signed-in session with
it, because the job shares the app process.
The write path is a stream end to end now: `streamFullBackupJson`
produces the document a page at a time, gzip and an incremental
AES-256-GCM writer consume it as it arrives, and `@aws-sdk/lib-storage`
puts it up in two 8 MB parts. What the process holds is fixed by the
pipeline's shape rather than by the record. Measured on that same
445 000-measurement account under `--max-old-space-size=450`, with the
heap pre-loaded to what a warm server holds: the old path dies with
`Ineffective mark-compacts near heap limit`, the new one finishes with a
peak 79 MB above its baseline and writes a 9.1 MB object.
The stored envelope gains version 3, which moves the GCM tag from in
front of the ciphertext to the end. That is the only change the stream
required — the tag exists only once the last block is in, so a leading
one means the whole object must exist before its first byte can be sent.
Nothing about the authentication moves: the tag still covers every
ciphertext byte and is verified before a byte of plaintext comes back.
Versions 1 and 2 still read, so every object already in a bucket
restores through the same reader with no flag to tell them apart.
The incremental writer is the crypto module's, split into its byte-level
half so the base64 form the in-database blob uses and the raw form an
object needs are one implementation under two framings. The off-host key
handling is unchanged: a separate `BACKUP_ENCRYPTION_KEY`, 64 hex or
32-byte base64, passed in rather than looked up.
A run that uploaded nothing for anybody is a failed job now, not
`ok: true` with an empty bucket behind it. Wrong credentials, a missing
bucket and an unreachable endpoint all fail every account rather than
one, and the target's own sentence rides out as the cause. A run where
some account got a copy still succeeds; retrying the whole cohort over
one object would re-upload everybody's.
The one ceiling left is structural rather than a memory bound, and it is
counted rather than discovered: a multipart upload carries 10 000 parts,
so an object past 80 GB is refused for that account with a clear message
instead of failing halfway through with an SDK error about part numbers.
`tests/integration/offhost-backup-streaming-memory.test.ts` pins the
budget from both sides — what the streaming uploader holds, and that the
materialising path does not fit the same budget on the same fixture in
the same process, so the number cannot pass by measuring nothing.
A gateway can answer a failed generation with HTTP 200 and put the reason
in the body: a top-level `error` object and no `choices`, or
`choices[0].error` beside `finish_reason: "error"` when a generation dies
mid-flight. Both fell through to the empty-content throw, so a dead credit
balance, a data-policy block, a rate limit and a model that is down all
recorded the same hop: status 0, reason "returned empty content", no
excerpt. Nothing on the provider health card told them apart.
Raise the shape a non-2xx reply raises instead, with the gateway's own
message leading and its `error.code` as the status when that code is an
HTTP error status. The chain classifier, the health ledger and the card
then see 402 / 403 / 429 / 503 and treat each of them the way they treat
the same status on the wire. Message and excerpt are bounded and
key-redacted by the same helper the non-2xx path uses, so remote text is
no more trusted here than it is there.
Gated on the gateway tag: api.openai.com signals every failure with a
status, so the pinned tags parse exactly as before, and a genuine empty
reply stays the empty-content throw it was. The local-endpoint provider
had the same hole on its buffered path, its buffered-reply fallback and
its stream, and gets the same treatment. The body classifier learned the
two account-side refusals a gateway words in its own vocabulary rather
than the vendor's.
The per-user date order reached the entry form and nothing else. A
reporter switched away from MM/DD/YYYY, the date picker followed, and
the dashboard chart axes and the measurements list kept the old order.
`makeFormatters` took `timeFormat` and `dateFormat` as optional
parameters defaulting to "AUTO", so six of the seven constructions in
the tree silently rendered the locale default and nothing anywhere
said the setting had been dropped. Drop both defaults. The compiler
then names every caller: the chart bucket labels, the legacy
`@/lib/format` helpers the measurements list renders through, the
medication compliance and efficacy charts, the doctor-report PDF and
the clinician share view.
Thread the preference:
- `makeBucketLabelFormatters` takes the order; the health and mood
charts read `useDateFormatPreference()`. The UTC pin decides which
day the label names, the preference decides how it is spelled, and
the two are separate questions.
- `format.ts` reads `readStoredDateFormat()` beside the timezone and
hour-cycle mirrors it already read.
- The PDF takes `dateFormat` as a required render option, alongside
`timeFormat`, which was optional for the same reason.
- The clinician share view spells the OWNER's dates the owner's way.
It also picked up the owner's hour cycle, which it had been ignoring
while the PDF route beside it already honoured it. Both resolve
through the new `resolveUserFormatPreferences`, the counterpart to
`resolveUserTimezone`.
Textual months stay on the UI locale. The preference pins numeric
field order by rendering through de-DE / en-US / en-CA, and a textual
month through those would change the month name's language.
Add `date-order-explicit-formatter-guard`: a raw numeric date renderer
must take its locale from the preference or carry a written reason,
and a formatter construction may not hard-code "AUTO" for the order.
Its allowlist holds thirteen calendar-day key builders, each with the
reason it has no order to get wrong. The sibling timezone guard passes
every one of them, because naming a calendar says nothing about
spelling a date.
Refs #922
crypto.randomUUID() is [SecureContext] in the Web Cryptography IDL, so a
page served over plain HTTP never gets it. Measured in Chromium against
http://<lan-ip>: isSecureContext false, randomUUID and subtle undefined,
getRandomValues still a function.
The document vault minted its queue id and Idempotency-Key that way, inside
the file-input change handler. On a LAN self-host the call threw before a
queue row existed, so picking a file produced no upload, no error and no
trace — the picker simply looked inert, and the only way through was to
reach the app over HTTPS. The client transport has minted a default
Idempotency-Key the same way since v1.37.18, which put every apiPost and
apiPatch on the same footing: on such a host nothing wrote at all.
Add randomId(), which prefers the platform call and otherwise assembles a
v4 UUID from getRandomValues — the same CSPRNG, the same 122 bits, no
secure context required — and route every browser-side call site through
it. Nothing in an upload needs one: the bytes travel over multipart XHR and
every cryptographic operation happens on the server. Passkeys, web push and
home-screen install still need HTTPS; browsers withhold those outright.
A structural guard fails a direct crypto.randomUUID() anywhere outside the
two server-side files that may keep it, so the class cannot return one call
site at a time.
Also point the CSV and JSON import cards straight at the import guide. They
resolved through a redirect under the integrations tree, which is the wrong
place for a guide that is not an integration runbook. The suite that
covered those links pinned the old spelling rather than the guarantee; it
now asserts an absolute docs-site URL, no internal path, and new-tab
hygiene.
The tile now calls t() with a key looked up from HRV_LABEL_KEY rather
than a literal, and the call-site coverage guard only reads literal
arguments. So the label left the guard's field of view exactly when it
became per-series: dropping either key from a bundle would ship a raw
key into the dashboard of whichever locale lost it, with nothing in the
gate to say so.
The keys are read from the map, so a third HRV series is covered by
construction rather than by remembering. The count is anchored, because
an empty map would satisfy the loop by matching nothing. A second case
asserts the RMSSD label actually names the measure in every locale,
which is the whole reason the second key exists.
Verified by deleting the key from one bundle: three cases fail and name
the locale and the key.
Two notes from review, neither a behaviour change.
`counts only live tokens carrying this scope` mints successfully and then
inspects the `where` handed to the count — it was sitting in the refusal
block and belongs beside the other what-was-the-helper-given cases. Moved
verbatim.
`still mints on the last free slot` stays where it is, next to the 409 it
is the other half of: the pair is what pins `>=` against `>`, and reading
either alone tells you less than reading both together.
The ceiling is count-then-create and so not atomic — two mints racing at
nine both pass and the account lands eleven. Now said out loud, along with
the two things a reader cannot see from this file: the overshoot is bounded
because the rate limit above is a single atomic upsert, and the house
pattern for making it exact is an advisory lock, named with call sites. Left
unserialised on purpose — this catches a runaway loop rather than metering a
quota, and a lock on a credential mint buys nothing for what it costs.
The mint endpoint's description and its new 409 landed in the OpenAPI source
without the generated spec following, which CI's drift check would have
caught. No behaviour change; the two hunks are the reworded limits sentence
and the ceiling response block.
The mint's rate limit bounds a burst and does nothing about accumulation:
a stuck automation retrying politely inside its 10-per-minute allowance
still leaves several hundred live credentials behind in an afternoon,
each one to be revoked by hand.
Count live rows before the create and refuse at ten.
Live, not minted-ever, so revoking frees a slot and an expired token
stops occupying one — the ceiling bounds what currently exists rather
than what the account has ever done. Scoped to measurements:write for a
reason worth stating: signing in mints a wildcard access token per
session and those accumulate on their own, so counting every row would
let ordinary browser use exhaust the budget and then refuse a mint for a
reason invisible to the person hitting it.
409 rather than 429. This is a conflict with the account's current
state, not a rate: it does not clear by waiting, it clears by revoking,
and a 429 would tell the caller the opposite of what is true.
Placed after validation, so a malformed request hears about the
malformation rather than the ceiling, and before the create, so hitting
the limit costs no credential.
Ten is set to catch a loop rather than to ration — a household running
Home Assistant, a scale bridge and a couple of scripts sits at three or
four, and it is not a security boundary since the holder can revoke and
mint freely.
Tests pin the boundary, the query shape and the ordering, and were
mutation-checked: turning >= into > fails the ceiling case, and dropping
the scope filter fails the query-shape case. The integration leg seeds
a wildcard and an expired row as decoys, then revokes one live token and
asserts exactly one slot opens — the leg that fails if the count is of
mints rather than of tokens alive.
Only CHANGELOG.md conflicted: v1.38.7 released while this branch was open,
so the new release heading landed where the branch's [Unreleased] section
sits. The HRV entries stay under [Unreleased] above 1.38.7; everything from
1.38.7 down is upstream's file verbatim.
docs/api/openapi.yaml auto-merged. Upstream's only change there was the
`info.version` bump to 1.38.7, which the merge took while keeping this
branch's DashboardLayoutReadResult, and package.json now reads 1.38.7 to
match — so the merged file should equal a fresh generate. It is a generated
file, though, so confirm with `pnpm openapi:generate` rather than trusting
the three-way merge.
The widgets GET grew a response field and the spec was never told. Nothing
would have caught it: `openapi:check` compares the generated yaml against
the Zod registry, and an undeclared field changes neither — so CI stays
green while the published contract quietly stops describing what the route
returns. iOS decodes this payload.
`dashboardLayoutResult` is shared by the GET and the PUT echo, but the
field is resolved per request from the caller's modules and their data, so
it exists only on the read. A separate `dashboardLayoutReadResult` extends
the shared schema for the GET rather than widening both verbs and
describing a field the PUT never sends.
docs/api/openapi.yaml is NOT regenerated in this commit: `pnpm
openapi:generate` cannot run here (the Prisma engine download is refused,
so `@/generated/prisma/client` is missing and the registry will not load).
The tree is therefore in drift on purpose and `pnpm openapi:check` will
fail until `pnpm openapi:generate` is run and the yaml committed.
The writer added in v1.38.6 compared the process's live heap usage against
80 % of V8's heap limit and aborted above it. That reading is not the
backup's footprint; it is the whole process's, garbage included. A
long-running app sits at 400 MB of largely collectable heap, so on a 1 GB
container (a 419 MB budget) the weekly pass aborted the first flush of every
account: four out of four in seven seconds, the smallest of them a demo
record whose entire stored copy is 1.2 MB, each told its record was too large
and its operator to buy memory.
Drop the gauge. What the writer holds is bounded by its shape and needs no
reading to prove: the three unbounded tables are pulled a page at a time,
every other section is released as soon as its JSON is in the sink, and at
most one flush of text is pending. The one copy that does grow with the
record is the stored blob, so the bound moves there — counted in the
ciphertext bytes actually produced, against a fifth of this process's heap
limit, which is what two copies of the answer plus a live set have to fit
inside. The message names both numbers.
A pass that wrote nothing for anybody now fails its job instead of reporting
`ok: true` with `backed: 0`, so the failing-queue panel and the backups page
read the same thing an operator would. A pass where some accounts were
written keeps passing with the failures as counts.
Measured under --max-old-space-size=450 from a heap deliberately dirtied
first: 445 000 measurements peak at 28 MB held by the backup, a one-row
record at under 1 MB.
Both routes 500'd in their unit tests because the change gave them
dependencies the mocks did not know about: the widgets GET reads a module
flag and probes for an SDNN row, and the analytics slim slice resolves the
module map. Neither test mocks `@/lib/modules/gate`, and the widgets test's
prisma double has no `measurement` model — so both calls threw and the
handler answered 500. Real breakage from this branch, not stale
expectations.
The widgets GET no longer resolves the whole module map for one answer.
`hrv` is the only conditionally-dead widget and `recovery` the only module
that decides it, so it reads that one flag through `isModuleEnabled`, the
way this route's neighbours read a single flag.
Both gate mocks spread the real module so only the one call is stubbed;
everything else that imports it keeps live behaviour. The widgets
`beforeEach` states the modules-on default explicitly, because
`resetAllMocks` strips a factory's implementation and every test below
assumes it.
Added coverage for the behaviour itself rather than only unbreaking the
suite: recovery off with no SDNN marks `hrv` unavailable, an account WITH
SDNN keeps the toggle, and recovery on does not run the probe at all — the
one added read on a hot cached route must not touch the common account.
Five points from the review, in the order they were raised.
1. The RMSSD marker did not survive. The tile label paints truncated,
uppercase and on one line, so an appended `· RMSSD` was the first thing
dropped in the longer locales — the part a reader needs to tell a 40 ms
RMSSD night from a collapsed SDNN one. `measurements.typeHrvRmssd` already
exists in all seven bundles, so each series now carries its own key.
`pickHrvSummary` returns that key beside the series it chose, which keeps
one mapping from series to label rather than a conditional at the call site.
2. The module gate bit on one path only. `gateSummariesByModules` was
private to the snapshot builder, and with the snapshot flag off the tiles
are fed by `/api/analytics`, which filtered nothing — so recovery-off plus
RMSSD-only still painted the tile, on the exact path the helper's docblock
promised it could not. The filter moves next to the map it reads, and both
analytics slices apply it. Gated after the cache read, so the cached cell
stays module-independent and toggling a module takes effect on the next
request instead of waiting out the TTL.
3. The same class one step on: recovery off plus RMSSD only left a switch
that was on, a tile that could never paint, and no explanation. The repo
already calls that a dead switch and hides the row; HRV is only dead
sometimes, so the answer needs one fact the Settings screen does not have.
The server resolves it — `unavailableWidgetIds` on the widgets payload —
and Settings drops those rows. An account with SDNN keeps the toggle: its
tile still works with recovery off, because SDNN is a vital no module owns.
The SDNN probe is an index-only existence check that runs only when
recovery is off, outside the layout cache so the row goes the moment the
module does.
4. The brittle test sliced from the helper to the end of the file, so any
function added below it would have gone red pointing at HRV. Bounded to the
function's own body, with a guard on the bound itself. The three wiring
assertions matched formatted source text — they would have passed just as
well if the tile never rendered — and are replaced by a test that renders
the tile through a real TrendCard and asserts the reading is on screen.
One negative source check is kept, naming the expression the bug lived on
rather than the formatting of its replacement.
5. Rebased onto v1.38.6; the changelog entry sits under [Unreleased].
Not verified here: `pnpm db:generate` cannot run in this container (the
Prisma engine download is refused), so snapshot.test.ts, the widgets route
test and the analytics route test could not load, and typecheck is
incomplete. Lint, format and the HRV suite (17 tests) pass.
The "neither series" test walks three inputs in one array literal. Inferred
from the literals alone, the bare `{}` widens to `{ HRV_RMSSD?: undefined }`,
and TypeScript rejects that against `pickHrvSummary`'s
`Record<string, DataSummary> | undefined` — an optional-undefined property is
not assignable to the index signature. Vitest never typechecks, so the suite
ran green while `tsc --noEmit` failed.
Annotating the array pins the parameter type at the declaration and leaves all
three cases exactly as they were.
HRV lives under two measurement types and they are not the same measure:
Apple Health / Fitbit / Google Health write SDNN as
`HEART_RATE_VARIABILITY`, while Oura / Polar / WHOOP — and a bridge
pushing a watch that reports it that way — write nightly RMSSD as
`HRV_RMSSD`. Every HRV surface unions the two: `/insights/hrv` through
`fallbackMeasurementType`, the Coach through
`COACH_SOURCE_MEASUREMENT_TYPES.hrv`, the MCP reads through
`withHrvFallback`. `sub-page-metric.ts` even says listing both is what
keeps "the tab, pill, and dashboard tile lit whichever source the user
has".
The dashboard tile did not. It read SDNN alone, so an RMSSD-only account
got a Settings → Dashboard toggle that turned on and rendered nothing,
with no explanation, while the insights page charted the same readings —
which is why it reads as a broken layout rather than a missing series.
`pickHrvSummary` picks the series with the same rule `withHrvFallback`
uses: the primary wins whenever it has any rows, the fallback only when it
has none. Never a merge — the two share `ms` but measure different things,
so blending them would invent a number no device reported. The caller gets
the winning type back, because the freshness caption has to read the
series on show and the label has to name the measure when it is not SDNN
(`· RMSSD`, as the insights chart title already does).
Second, less visible half: `HRV_RMSSD` is recovery-owned in
`METRIC_STATUS_MODULE_OWNERS`, but the dashboard snapshot gates off
`SUMMARY_TYPE_MODULE`, a different map, and that one did not list it. The
omission was inert while the tile read SDNN alone; adding the fallback
without it would hand a recovery-off account a tile built from
recovery-owned rows — the exact hazard the owner map documents. The entry
goes in beside the four sibling recovery types, so the gate stays server
side and the client keeps no competing copy.
Two integration assertions carried the import path's own copy of the
mmol/L conversion, so unifying the two conversions left them asserting
numbers the code no longer produces. They now multiply by the exported
factor, which is what the unit tests already do.
The reason these were missed before the merge is worth writing down:
`pnpm test` does not run tests/integration, and neither the change that
unified the factor nor the gate on the integrated branch ran the suite
that covers the import path. A literal here re-pins whichever copy of a
conversion the caller happened to use when the test was written, so the
assertion is derived rather than pinned.
`DataBackup.data` holds every weekly disaster-recovery snapshot and every
uploaded pack, AES-256-GCM at rest like everything else, under a column
called `data`. The rotation registry was keyed on the `*Encrypted` naming
convention, so the script never read a backup, reported zero rows
remaining, and the runbook read that zero as permission to retire the
previous key. Following the documented procedure destroyed every stored
backup — the copy an operator reaches for precisely when nothing else
works.
Register it, and register the idempotent-replay response cache the same
sweep turned up. The backup column walks in bounded batches (one row is a
whole compressed account) and re-seals the ciphertext without reading the
plaintext, so both stored envelopes come back byte-identical and so will
any later one. The cache is marked disposable: a row it cannot read is
dropped rather than counted as an error, because keeping it only
guarantees the next replay gets a body it cannot parse.
Running the script against a seeded database rather than reading it turned
up two more. It died on the mood day-context note, whose table is keyed on
the entry rather than on an `id`, and abandoned every column after it —
the backups included. And the document index's verbatim text rotated
without ever being named in the summary.
Make the naming assumption stop carrying weight. A new guard traces the
encryption helpers through their wrappers, walks every Prisma write
payload, and fails when a column receives ciphertext without being
registered. It asserts an anchored, non-zero match count so a matcher that
stops matching fails instead of passing. A second check pins each model's
primary key against the schema.
The script now ends by naming the registered columns it did NOT walk and
exiting non-zero when any is missing: a zero that means "nothing left" and
a zero that means "never looked" must not print the same. The runbook's
retire-the-key step was rewritten around that signal, and tells an
operator who already rotated on an older release how to get their backups
back.
Three assertions repeated 18.016 and the product it gave. They were the
reason the two factors could sit in the tree at once looking correct. They
take the exported constant now, and the parser test asserts the round trip
a reader on mmol/L experiences rather than a magic product.
The list renders a reading through the metric/imperial transform registry,
and glucose is not in it, so the list kept saying 95 mg/dL while the
dashboard tile said 5.3 mmol/L for the same reading — correct on both
counts and disagreeing anyway.
The edit sheet is the half that could have done damage. Its label follows
the same resolution as the row, so on mmol/L the number in the input is in
mmol/L and has to come back the other way before the PATCH. Row, edit seed
and inversion move together; converting the display alone would rewrite a
95 mg/dL reading as 5.3 the first time somebody corrected a typo.
An mg/dL account edits in the unit the column already holds and stays on
the identity path, deliberately: the converter rounds, and sending an
mg/dL edit through it would quantise a stored 95.48 the moment somebody
opened the sheet to fix a note. A row whose stored unit is not the type's
canonical one still shows verbatim, as before.
`User.glucoseUnit` has existed since v1.2 and around thirty surfaces read
it — the dashboard tiles, the glucose page, the targets panel, the CSV and
FHIR exports, the doctor report, the Coach snapshot, the low-reading push
copy. Nothing could ever write it. No route, no schema, no field on any
form, so every account resolved to the mg/dL default and the mmol/L half
of the app was unreachable code. Anyone who reads glucose in mmol/L
converted in their head on every screen.
This is the missing end: a `GET`/`PATCH /api/auth/me/glucose-unit` scalar
on the pattern the unit-preference endpoint next door established, and a
dropdown in the profile form beside it. Deliberately its own control
rather than a branch of metric/imperial: metric countries are split on
which unit they read glucose in, so one dropdown would get half of them
wrong.
Storage is untouched and stays untouched. Every reading is written in
mg/dL by every ingest path and the unit only selects a display branch, so
nothing already recorded is reinterpreted. The entry form moved with the
preference, which is the half that could have hurt: it asks in the chosen
unit and inverts through `toCanonicalMgdl` on submit, so a 5.3 typed by an
mmol/L reader is filed as 95 mg/dL rather than as 5.3 — a value the
plausibility band accepts in silence and every surface reads back as a
severe hypo.
The contract gains the endpoint and nothing else; the account payload
already carried the field.
A value imported in mmol/L was multiplied by 18.016; one displayed in
mmol/L was divided by 18.0182. The two sat in different files, both
correct-looking, and a reading that went in as 5.3 did not come back out
as 5.3. Latent while nobody could select mmol/L, live the moment they can.
One exported constant now, on the guideline figure the display path
already used, and the glucose insight page takes it from there rather than
keeping a third copy. The test names the constant instead of repeating the
digits, which is how the two came apart in the first place, and it asserts
the round trip a reader on mmol/L actually experiences.
`UserKnownDevice.firstSeenAt` has a database default, no application
writer and no reader anywhere in the tree, and it is invisible to the
column sweep because the sweep skips a column nothing writes. Annotated
rather than dropped: the first sighting a user sees is the audit row the
login-alert path writes, and that row goes on the audit-retention
schedule, while the ledger row does not. This column is the ledger's own
copy of the same instant, reachable through the database only — the model
holds salted hashes and a coarse label precisely so it has no user-facing
surface, and the backup excludes it wholesale.
The provider-health ledger has recorded the HTTP status of every failure
since v1.11.0 and nothing has ever read it. The operator card could say
three of five chains were failing without saying whose problem that was,
which is the one thing the operator needs: a 401 is a credential only they
can replace, a 429 or a 503 is the provider's afternoon and waiting is the
right move.
The fold carries the status of the failure whose instant the row already
shows, not an aggregate over the column. A MAX of two HTTP statuses is
arithmetic on a label, and a status describing a different failure than
the one on screen would send the operator to the wrong place. The column
joins the grouping key instead, and the fold picks the group belonging to
the newest failure.
Blank where there was nothing to record: a connection that never completed
has no status, and a chain that has since recovered had its status cleared
along with the rest of its failure state.
The import status endpoint returns the instant the Health app stamped on
the archive, the published contract requires the field, and the schema
comment said the parser filled it during unpack. It never did: ExportDate
sat on the parser's ignore list, so the column answered null on every job
ever run, for a year.
The parser reads the element now and hands the stamp to the worker on the
next chunk boundary, so the value lands while the run is still going and a
job that later fails still records which export it was working from. An
archive that omits the element, or writes a value the runtime cannot read,
leaves the column null rather than guessing at an instant.
No contract change: the endpoint and the OpenAPI description already
described this behaviour.
The compression that landed last release was real and was measured in the
wrong place. On a development machine with a multi-gigabyte heap the pass
finished; the container the job runs in is capped at 1 GB, which gives V8
a 524 MB limit, and there the same pass died of memory about fifteen
seconds in. Because the job shares the application process, that was not
one failed backup — it was a restart, and every signed-in session on the
host went with it.
Reproduced before touching anything, under `--max-old-space-size=450`
against a seeded account of 445 000 measurements, 30 000 mood entries and
60 000 intake events: `FATAL ERROR: Reached heap limit` at 34 s. The same
fixture doubled to 890 000 measurements now completes inside a 296 MB
limit, holding 16 MB of live heap where the old writer held 168 MB.
The document is written incrementally now. `buildFullBackupPayload` takes
a `deferBulk` mode that declares the three unbounded tables instead of
reading them, and `streamFullBackupJson` walks the payload in key order,
pulls those tables through page by page, and releases every other section
as soon as its JSON reaches the sink. `packBackupBlobStreaming` feeds that
into gzip and an AES-256-GCM stream, so nothing between the database rows
and the stored value ever exists whole. Both writers go through the same
per-row serialisers, and the round-trip suite pins their output byte for
byte rather than trusting that they agree.
The intake and mood reads are ordered totally now. A keyset cursor needs
it, and a partial order also let two exports of one account differ over a
tie, which every multi-dose day has.
The streamed ciphertext is a new shape at rest, because GCM only produces
its tag once the last block is in and a leading tag is what makes the old
layout unstreamable. It carries a marker no older writer can produce, so
the two families are disjoint by construction rather than by sniffing.
All three stored shapes — plain, compressed, streamed — restore, proved
both ways.
The writer also watches its own live heap and stops at 80 % of V8's
limit. An account too large for its host is now one failed backup,
counted in the run's meta, instead of a restart for everybody on it.
The stored column is the ceiling that is left: `data_backups.data` is a
single `text` value, so the finished artifact has to exist as one string,
and that is the only part of the job still growing with the record. The
operator runbook says so, with the number at which it starts to matter.
The correction was inserted mid-bullet, which left the off-host sentence
trailing after it and the paragraph unwrapped. The off-host sentence goes
back with the technical description it belongs to, and the correction
reads last.
`User.locale` is the only signal a cookie-blind path has. The nightly
briefing warm, the Coach nudge, the reminder senders and the native aggregate
all resolve from that column; the screen in front of the user resolves from
the `healthlog-locale` cookie first, then the column, then Accept-Language. So
a browser can read the whole app in German while the column says English, and
while it does, nothing on screen looks wrong: the divergence surfaces as one
generated paragraph in the wrong language inside an otherwise correct page.
Keeping the two in step was a fire-and-forget PUT from a mount effect. A 401
on the paint before sign-in, an offline blip, a tab closed mid-flight — each
loses the write, and the next mount repeats the same unchecked attempt rather
than noticing the column is still wrong, so a stale column can stand for the
life of the account.
`getSession` now corrects the column when the request carries a valid locale
cookie the column disagrees with. It is ordered by the request pipeline,
needs nothing from the client, and is self-limiting: once the two agree the
condition is false. Only a cookie counts, and a cookie is only ever written
alongside a committed UI locale, so Accept-Language never stamps a guess onto
a column that generated prose depends on. The mount PUT stays for the
server-set cookie it gets in reply, which Safari's 7-day cap on script-written
cookies makes worth having; it is no longer the mechanism.
The obvious suspect was a first-paint race between two unordered writes. It is
not that: the provider takes the locale the server already resolved and
nothing on the mount path changes it, so the effect fires once with the right
value. `locale-first-paint-single-write` pins both properties so a future
mount-time flip has to face them.
The salutation and the streak label are the two strings this route still
translates server-side, and the native client is its only caller: a Bearer
request carries no language cookie, so `User.locale` is the sole per-user
signal. The coercion here stopped at English when that column was NULL, so an
account that never opened the language picker was greeted in English on an
instance configured for German, while every string beside it was German.
`resolveJobLocale` is where the fallback order lives — stored preference, then
the operator default, then English — and every background writer was moved
onto it earlier. This was the last copy of the old coercion outside that tree.
The structural guard covers `lib/jobs` and `lib/daily` only, so it never saw
this one; the two tests added here pin the behaviour directly instead.
Three pieces of code answered "what hour is it" for the salutation. The
snapshot builder computed a `greetingHour` in the user's zone and put it on
the payload so clients would not have to; no client ever read it. The
insights hero derived one through the shared `hourInTz`. The dashboard header
derived one through a local helper that fell back to the DEVICE clock
whenever the runtime could not resolve the configured zone, which is what
happens to an IANA entry the browser's data does not yet carry. At one
instant, for one account, those three returned 20, 21 and 05.
Keep the client end and drop the server one. The hour is a clock reading, not
a fact about the record, and the snapshot body serves stale for up to an hour
past its fresh TTL, so a stored hour can name a salutation the clock has left
behind. What the server IS authoritative for is the zone, and that already
travels on the same payload and is already what both surfaces read.
Both greetings now share `hourInTz`, so an unresolvable zone falls back to the
same default the rest of the app uses instead of to the device. The header's
private helper is gone; it had no other caller.
Two tests pinned the old shape by asserting the payload carried an hour.
Inverted rather than deleted: the payload must now be shown NOT to carry one,
which is the property that was missing when the field went unread for months.
`greetingHour` leaves the response contract, so a native client decoding it as
required needs the field made optional or dropped.
A `z.object()` without `.strict()` strips an unknown key and parses
clean, so a caller that misspells a field name gets a 200 and loses the
value with nothing to tell it. The worst of them was the Coach
question-dismiss endpoint, where an omitted `question` means "dismiss
all": a typo in that key wiped every pending question and answered as
though one had been dismissed.
Twenty-four inline schemas across twenty-two endpoints are strict now —
the settings toggles, the restore and bulk-delete endpoints, message
feedback, the admin user update, the onboarding acknowledgements, the
provider chain, the chart overlay preferences, the medication extractor
and the two client-side reporters.
The strip stays where it carries weight. Sync ingest, device
registration and the layout PUTs are built out of fields that shipped as
additive and optional, on the promise that a server which does not know
one ignores it; an app running ahead of the install it talks to is
routine here rather than exceptional, and refusing would turn a dropped
field into a refused batch — a year of history lost to one row, a
dashboard that will not save, a phone that quietly stops registering for
push. A read filter discards nothing when a stray query parameter rides
along, the OAuth endpoints are required by RFC 6749 and RFC 7591 to
ignore parameters they do not recognise, and an import file comes from
another version or another tool by definition. Each is registered with
its reason in a new inventory test, so a schema cannot join them without
someone making the call. That test parses the tree rather than grepping
it: fourteen of these schemas are written `z\n .object({…})` and a
`z.object(` sweep never saw them.
`sanitiseZodIssues` renders `unrecognized_keys` instead of passing it
through. Zod leaves `path` empty and puts the names in `issue.keys`, so
the sanitised issue named nothing at all and the caller was told only
that its body had failed. The names now ride a bounded `keys` field and
the message is rebuilt from them, capped at ten keys of 64 characters
and restricted to identifier characters, so a caller-supplied key cannot
echo back verbatim through the error path or into the audit ledger. The
names survive `stripValuesFromMessage` because a field name is
structure, not user-typed content.
The entry said the weekly pass finishes again. It was written from a
measurement taken on a development machine with a multi-gigabyte heap,
and it does not hold on a small container: capped at 1 GB the runtime
gets a 524 MB heap, and the pass still runs out of memory there. It gets
roughly three times further than before, which is a real improvement and
is now described as one, but it does not complete, and the failure takes
the whole process down rather than failing as a job.
Building the record incrementally instead of whole is the fix and has not
landed. Until it does, an instance backing up a large record needs more
than 1 GB, and the entry says so.
The unit suite reported all 1937 files green and then failed the job with
`EnvironmentTeardownError: Closing rpc while "onUserConsoleLog" was
pending`, naming a different scoring test each time.
Vitest replaces globalThis.console with a sink that forwards every write
to the main thread as an onUserConsoleLog RPC call. When a worker
finishes a test file it awaits the calls in flight and then rejects
whatever was started after that point, so a console write from work that
outlived its test lands in the gap and fails the run. The blame goes to
the file being torn down rather than to the test that started the work,
which is why the named file never logged anything itself.
Three paths detach a promise and log on the handle: fireAndForget, the
stale-while-revalidate cache refresh, and the offline query persister.
They now register the handled handle in a set that both suite setups
drain in afterEach, so no test leaves work in flight. The registry is
inert outside test: nothing there awaits it, and holding a reference to
an unsettled promise would retain it for the life of a worker. No
production behaviour changes, so there is no changelog entry.
Evidence. A focused harness that starts failing background tasks and
returns hit the teardown error 10 runs out of 10 before the change and
0 out of 10 after. Instrumenting the real suite showed two detached
console writes per run escaping their test, both from the rollup tail;
after the change, none, and thirteen writes per run now complete inside
the test that started them.
The guard asserts the behaviour, not a count: a registered task must
keep settleBackgroundTasks from resolving ahead of it, and the set must
drain back to empty. It goes red on unregistered work, on a disabled
registry, on an unwrapped call site, on a sweep that matches nothing,
and on a setup that drops the hook.
Three advisories stood open, all development-scope. Two are closed with
version floors in the workspace overrides, following the existing pattern:
@humanfs/node for GHSA-p498-v437-472g and esbuild for GHSA-g7r4-m6w7-qqqr.
Both patched lines stay inside the same minor, so nothing downstream moves.
The third, GHSA-jmr9-qjv8-65gv in extract-zip, had no patched release to
float to. It arrived through @openai/codex-security, which nothing in the
tree imports: the package was referenced only by its own declaration, a
knip ignore entry, and a release-age exclusion. Removing it closes the
advisory and takes about 340 MB of node_modules with it. The knip ignore
and the release-age exclusion go with it rather than being left to rot.
pnpm audit now reports no known vulnerabilities.
The column-reader sweep read every `field:` key as a write, so
`where: { ticketHash }` counted as writing `ticketHash`. All twelve columns it
reported were a lookup key or a cron's discovery predicate being filtered on,
and a review tool that has cried wolf twelve times is not consulted on the
thirteenth. It also reported only columns that already had a write, which made
a column with no write at all the one thing it could not see.
Mask each file, bracket-match every Prisma argument, and tag its contents as
write, projection or query so nesting resolves innermost-wins. Follow a payload
assembled before the call: a const handed to `update:`, a spread merged into a
`create:`, a `rows.push({...})` builder filling a typed array. Credit raw SQL
against the `@map` column name. Report a column nothing touches as its own
category, and split the output so a write with no reader reads differently from
a query predicate, a relation's foreign key and an annotated column.
Twelve false alarms become nineteen findings that hold up. Four are worth
acting on and are left for a decision rather than annotated away: the glucose
unit preference and an import job's export date have readers and no writer, and
the provider-health status code and the known-device first-seen stamp have the
opposite.
The matcher underneath is a test now, checked by breaking the fix three ways.
Carries the night's audit work: the weekly backup ran out of memory
rather than out of time and now completes, two dashboard reads stopped
scanning the largest tables, six webhook verbs compare a secret behind a
rate limit, three global channel switches actually stop delivery,
thirty-five settings reach the container, four guarded helpers are used
where their naive twins were, and three guards now see what they claim.
The export was its only user, and the export now reads through a helper that
marks an undecryptable note instead of blanking it. Nothing else wanted the
silent null: the list DTO never carries the note at all, only `hasNote`, and
the single-resource GET has always used the throwing decrypt so a key gap
surfaces there.
Deleted rather than kept behind a dead-code exemption. A fail-soft twin
sitting unused next to a strict one is the exact shape this branch has been
clearing out — the next person writing an export reaches for whatever is
nearest, and the whole point was that the nearest thing should not silently
swallow a row. It had no test of its own, so none was removed; the three
comments that pointed at it are rewritten to say what is true now.
The weekly pass can stop and leave no trace where anyone looks. The
handler's own error never runs when the job is killed rather than thrown;
`recordError()` increments an unnamed counter; and the admin status page's
failing-queue panel looks back 72 hours, which for a weekly queue means a
permanent failure is on screen three days in seven. The backups page
itself showed an ordinary row with an ordinary timestamp, because a copy
made six weeks ago reads exactly like Sunday's until somebody subtracts
the dates.
The listing now carries two facts that fail at different times: the age of
the newest scheduled copy, which never leaves any retention window, and
the terminal state of the last scheduled run, which is the only place the
reason is written down. `readLastQueueRun` takes the newest run rather
than a window, so a weekly queue's outcome stays readable until the next
one replaces it, and it unwraps the value pg-boss stores a thrown error
under, so the page says "job timed out" instead of raw JSON. The
failing-queue reader gets the same unwrap.
Manual uploads are excluded from the cadence judgement by the shape of the
input, so an ad-hoc upload cannot make a dead cron look alive, and a fresh
instance is not called overdue for having no copy yet.
The listing also stops reading every blob into the web process to measure
it; `octet_length` answers that in the database, which on an instance with
large records was its own way of running out of memory.
The weekly pass built the whole account in memory, serialised it whole,
encrypted the string whole, and handed the result to the database whole.
That is four full copies of the record at the same moment — the object
graph, the JSON, the base64 ciphertext at 1.33x the JSON, and the copy the
driver makes of that parameter. Measured against a seeded account of
445 000 readings, the JSON alone is 242 MB and the run dies of heap
exhaustion; under a modest heap it never reaches the insert. The pass was
not slow, it could not finish.
A health record's JSON is extremely repetitive, so gzip takes that 242 MB
to 14 MB and every copy after it shrinks with it. The same run completes
in about twenty seconds with a peak heap of 434 MB and a stored row of
25 MB instead of 322 MB — which also matters because the copy lives inside
the database it would be needed to restore. Both backup jobs now hand the
serialised form back from a frame of its own, so the payload graph is
released before the compress step allocates anything.
The marker sits inside the ciphertext, so the stored column keeps the one
shape `decrypt()` already understands and rows written before this still
restore. The off-host object gets the same treatment through the version
byte its envelope always carried; version 1 objects still read back, which
both the restore drill and the restore script depend on.
The obvious saving — dropping soft-deleted readings, four rows in five on
a real record — is not taken, and the round-trip test now seeds a
tombstone and asserts it makes the journey. A tombstone inside the
retention window is what tells a synced client the row is gone and what
makes a re-sent sample a no-op; a restore without them brings back every
measurement the account ever deleted on the next device sync.
The `data-backup` queue was registered without send options, so every job
it minted took pg-boss's queue defaults: a fifteen-minute expiration and
two redeliveries. A snapshot that serialises every account's whole record
does not fit in fifteen minutes on a large one, so the pass was killed
part-way through, redelivered twice against the same record, and reached
`failed` with "job timed out" about three quarters of an hour after it
started — having never once completed.
Two hours, on the schedule rather than on the queue: `create_queue()` ends
in ON CONFLICT DO NOTHING, so a queue-level default would apply to a fresh
database and be inert on every instance that already has the queue. The
admin run-now route sends with the same options, or an ad-hoc snapshot
would silently keep the default the cron was rescued from.
The default two retries stay. The old redeliveries were pointless because
the first attempt could not succeed either; a transient failure genuinely
benefits from a second go, and the alternative is losing the week.
Derives both sets mechanically — the whitelist from docker-compose.yml, the
read set from the source — and fails when a variable is read without being
forwarded. Written red against the tree before the fix, where it named 35.
A search for process.env. alone is not the read set: four modules resolve the
name through a helper and index process.env dynamically, which hides every
OIDC_* variable and ALLOW_LOCAL_AI_PRIVATE_HOSTS. Each is registered with its
own call-shape matcher and its own non-zero assertion, so a rename fails the
file loudly instead of quietly shrinking the set. The walk and the matchers
both assert a floor, because an empty match set agrees with any allowlist.
Thirteen deliberate exceptions each carry a written reason, and the list is
kept honest from both ends: an entry that stops being read, or that later
appears in the whitelist, fails too.
The environment: block is a whitelist. Compose substitutes ${VAR} from .env
at compose-up, so a value set there looks configured, but a variable the block
does not name never reaches the Node process and the default stands silently.
Twenty-eight variables were in that state: DEMO_MODE, the four LOG_* knobs and
the three LOKI_* shipping vars, DATABASE_STATEMENT_TIMEOUT_MS, four retention
and alerting windows, TELEGRAM_WEBHOOK_SECRET, NIGHTSCOUT_PRIVATE_ORIGINS,
three OAuth callback overrides, both Open-Meteo endpoints, four AI knobs,
three APNs key-source vars, and DASHBOARD_SSR_PREFETCH.
APNS_KEY and APNS_KEY_FILE are the sharpest of them: .env.production.example
and the env manifest both document a precedence chain of APNS_KEY_B64 before
APNS_KEY before APNS_KEY_FILE, and only the first arrived.
Each is documented in .env.production.example in its neighbours' style. The
VAPID section's note claiming those three names are absent from the whitelist
is corrected; they have been on it since v1.17.1.
Six WEB_PUSH_* names and NEXT_PUBLIC_VAPID_PUBLIC_KEY sat behind the three
real VAPID_* variables in the env fallback. None was ever on the compose
environment: whitelist and none appeared in .env.example, .env.production.example
or the env manifest, so under the bundled stack a value set under any of them
could not reach the process. Remove them and the self-hosting note that
advertised them.
`adherenceRatePercent` returns null exactly when nothing was expected, so
callers render a placeholder instead of a misleading 0 or 100. The FHIR
exporter coalesced that null to 0, which would put a clinical claim of zero
adherence into an exported bundle. The guard above it means the branch
cannot be reached today; skipping rather than coalescing keeps it
unreachable if that guard ever moves.
A deleted reading is kept with a `deletedAt` stamp, and the schema says in
as many words that normal reads filter it. Three did not.
The custom-metric correlation channel filtered the metric's own tombstone
but not its entries', so a reading the person deleted kept shaping the
correlation they were shown. The status input hash — which decides whether a
cached AI assessment is still current — filtered its measurement arm but not
its mood aggregate and not its custom-metric count or newest-entry probe. A
deletion left that fingerprint unchanged, so the gate re-dated yesterday's
text and served it as today's over data that was gone.
Pinned in the existing soft-delete guard, whose matchers were checked by
removing each filter again.
A lab note or a biomarker context written under a key this instance no
longer holds came out of a portable export as `null` — byte-identical to a
column that was never written. The restore importer then wrote nothing, so
the text was gone and nothing said it had ever been there. The whole loss
fit inside a fail-soft `catch` written for a list view, where returning null
is the right answer.
Both now export a short marker naming what happened, and the marker survives
a restore. The Coach transcript already did this with a placeholder sentence
and the doctor report with a rendered flag; the marker is now one constant
the transcript shares, and its comment says why an empty string will not do.
The unit test that required `note: null` here pinned the loss. It keeps the
half that matters — one bad row must never abort the export — and asserts
the marker instead. The round trip is proven end to end against a real
restore.
The blood-pressure medication continuity series joined every day that had a
systolic reading against `takenByDay.get(day) ?? 0`, so a day with no intake
record at all entered the scatter and the coefficient as a measured 0 %
continuity. A week away from the app read as a week off the medication.
Only days carrying an intake record are paired now. A logged skip is a real
zero and stays; a day with no record is absent, and absence is not zero.
The timezone-parity fixture in the route test recorded three logged days out
of five and relied on the other two arriving as zeros, which pinned the
defect rather than the timezone question it asks. Its two remaining days are
recorded as skips, which keeps five paired days and keeps every one of them
something the person actually logged.
`calculateCompliance` answers `rate: 100, totalExpected: 0` for a medication
that expects no dose. Arithmetically honest, and a lie on a surface that
reads the number as adherence — it also inflated the averages the AI prompts
are built from. The doctor report has excluded both the PRN arm and the
empty-schedule arm since it was written, with the reason beside it. The
insights envelope, the blood-pressure gate and the medication-compliance
snapshot filtered only PRN, and the empty schedule is reachable:
`POST /api/medications` accepts `schedules ?? []` on a scheduled medication.
Move both arms into one predicate, `expectsDoses`, and route all four
surfaces through it so they cannot drift apart again.
Four advisories landed against the URI parser that arrives under ajv,
including the copy the connector SDK pulls, all of them host confusion
or request forgery through percent-encoded schemes, skipped IDN
canonicalisation and malformed authorities. The floor set yesterday was
one patch below the fix. A query-string parser needs 6.16.0 for an
array-limit bypass and a denial of service.
The audit reads no known vulnerabilities after the bump.
The mood page called the plain `pearsonCorrelation` — strength banded from
|r| alone above a floor of five pairs, no significance test — so five days
of noise sloping the right way arrived at the reader as a "Strong" badge,
with n and r behind an info icon. The guarded twin was written for exactly
that failure and names it in its own comment, and the weight, blood-pressure
and mood-status surfaces had already moved onto it. The mood page had not.
Route the page's five cross-metric channels through
`significantPearsonCorrelation` (n >= 20 AND p < 0.05) and carry the reason
a pairing was refused, so the card can distinguish "still building" from
"twenty days and nothing in them" instead of showing the same blank for
both. New copy in all seven locales.
Add a structural guard that fails when a new caller of the unguarded helper
appears outside a written allowlist. Two callers remain on it, each named as
open work rather than as a blessing: the /insights envelope and the AI
feature payload both still band at n >= 5, and moving them changes payload
shapes their own tests pin.
Dismissing a correlation pattern stores `dismissedEvidenceHash` alongside
the effect size and sample size, and the gate that decides whether to raise
the finding again read the two numbers and never the hash. The hash is the
only part of the snapshot that covers the whole evidence tuple, and a
snapshot that carries it without the two numbers — what a partial restore
leaves behind — could not be released by anything: the gate had no value to
compare and fell through to `false` on every future run.
Move the decision into `shouldReleaseDismissal`, which reads the hash twice
and for two different reasons. Evidence byte-identical to what was
dismissed settles immediately, without evaluating a threshold. Evidence
whose hash no longer matches goes to the existing thresholds when the
numbers are there, and releases the dismissal when they are not, because a
changed hash is still proof the evidence moved.
The thresholds themselves are untouched. A dismissal still has to survive
ordinary sampling noise, which is what they are for; a hash that changes on
every recomputation would otherwise undo a deliberate dismissal on the next
nightly run.
`adminSettingsSchema` validates five `assistant*` booleans and the strict
schema refuses anything else, but the route's `booleanFields` list never
carried them. Sending one alone answered 422 "No valid fields"; sending one
alongside a real field answered 200 and dropped it. The schema comment says
the entry exists so an operator scripting their settings over the single
route can carry the values, so the route is brought up to the contract
rather than the contract cut down to the route.
The flags are echoed in the GET and PUT payloads as raw column values — the
master-over-sub resolution stays with `/settings/assistant-flags`, which
owns that shape and was never affected.
With the switches now enforced at dispatch, a user could still flip a
channel on, see the toggle stick, and get nothing. Refuse the enable
instead, with 403 and a message that names the reason: the ntfy and
Telegram config endpoints on both accepted body shapes, and the Web Push
subscribe endpoint, which is the enable step for that channel because it
creates the channel row.
Telegram is checked before the webhook registration, so a refused enable
makes no outbound call and stores nothing. Disabling a channel keeps
working while the switch is off — a user must always be able to turn a
channel off, whatever the operator has done.
The three refusals are published in the contract; `/api/settings/global-services`
already exposes the switches so a client can hide the control instead of
provoking the refusal.
`AppSettings.telegramGlobal`, `.ntfyGlobal` and `.webPushGlobal` were read
in two places, and neither of them was the one that matters: the setup-card
visibility on Settings -> Integrations and an echo in the preferences
payload. The dispatcher never looked at them. An operator who switched a
channel off instance-wide lost the setup card and kept every existing
delivery on that channel, indefinitely.
Gate each of the three in the dispatcher, in the shape
`isApiGloballyEnabled()` already uses for the twelve API routes. The read
sits above the per-user channel row and below the per-event preference, it
happens at most once per dispatch, and only when a switchable channel is
actually in the cascade. A skip writes a `globally_disabled` row to the
push-attempt ledger so the admin diagnostics name the cause, and does not
count as an attempt, so `dispatched` keeps telling the truth. The account's
own channel row is left enabled: the switch is the operator's, and flipping
it back has to restore delivery without every user re-enabling anything.
APNs, the generic webhook and email carry no instance-wide switch and are
not gated here; they are turned off at their source instead.
The unit test drives the real dispatcher over a stubbed settings row; the
integration test writes the actual singleton and reads the ledger back out
of Postgres, which is the layer that proves the column the admin route
writes is the column the dispatcher reads.
Two spellings the egress guard could not see, both probed.
src/proxy.ts, src/instrumentation.ts and src/cli/ sat outside the enforced
roots (src/lib/ and src/app/), and the companion api-fetch-required guards
the client surface, so it did not reach them either. A raw fetch dropped
into instrumentation.ts linted clean. All three are egress-free today, but
instrumentation is exactly where an exporter pointed at an operator-supplied
URL would land.
The callee match knew the identifier `fetch` and the globalThis / window /
self member forms only, so an aliased import escaped it in every file:
`import { fetch as f } from "undici"` then `f(url)` linted clean under
src/lib. That spelling is what src/lib/safe-fetch.ts itself writes. Local
names bound to an imported `fetch`, and namespace-imported `ns.fetch(…)`,
are now callees; the wrapper internals and test files stay exempt as
before, and the wrapper's own aliased import is pinned as a valid case.
Adds the rule's missing unit-test file. The header records what the
binding scan still cannot see: require(), dynamic import, a fetch passed
as a parameter, and egress that never spells fetch at all.
Neither read is a bug a behavioural test can see: both shapes return the
right rows, and only the plan differs. So the guards split by what each
can actually answer.
The unit guard asserts on the SQL the readers build, with comments
stripped so it matches the query text and never the prose describing it:
no DISTINCT ON on this path, a LATERAL driven from an unnested type list
with a LIMIT on the per-type branch, a bound type predicate on the rollup
read sitting alongside the user and granularity filters, and the route
still calling the shared readers rather than growing a second copy. Four
of its five cases fail against the pre-change SQL.
The integration companion runs the shipped readers and the pre-change SQL
side by side over one fixture on the real Postgres and requires identical
rows. The fixture carries the cases the rewrite could have broken: a type
whose only rows are tombstoned, a type with no rows, a type whose newest
row is a tombstone over a surviving one, and coarser rollup granularities
the DAY read must ignore. Dropping the soft-delete filter or shortening
the type list makes both cases fail.
Plan assertions are deliberately not in the integration file: a
testcontainer fixture is too small for the planner to choose the access
paths a real account produces, so an EXPLAIN there would pin nothing.
Two reads behind the metric tiles scanned far more than the rows they
return, because PostgreSQL 16 has no index skip scan: a query that wants
one row per measurement type but never names a type cannot be served by
any index keyed on (user_id, type, ...).
The latest-reading read used DISTINCT ON (type), so it produced every
live row of the account in (type, measured_at DESC) order before keeping
one row per group. On a multi-year account that is a sequential scan plus
a sort that spills to disk once it outgrows work_mem. It is now a LATERAL
join over the type list, one lookup per type against the live-row partial
index from migration 0183, each stopping at the first row. CROSS JOIN
LATERAL is an inner join, so a type with no live reading drops out as
DISTINCT ON dropped it, and ORDER BY reproduces the same enum ordering.
The sparkline read filtered measurement_rollups on user_id and
granularity only, skipping the second column of the table's key, so on a
single-account host neither the primary key nor the user/type/
granularity/bucket index could drive it and every stored granularity got
scanned. It now names the full canonical enum, which filters nothing out
and restores the equality the key needs; the existing index serves it.
Measured on a fixture matching a real account (454k measurements, 79%
tombstoned, 75.5k rollup rows): the first read goes from a seq scan plus
a 3.2 MB external-merge sort at 112 ms to a nested loop at 0.7 ms, and
the second from a seq scan discarding 72k rows at 6.9 ms to a bitmap
index scan at 1.6 ms. Same rows, same order, same types.
Both reads move out of the route into src/lib/dashboard/summary-reads.ts
so the integration suite can exercise the SQL itself rather than a copy
of it, the same seam buildMedsTodayBlock already uses. No schema change:
adding an index on (user_id, granularity, bucket_start) would have landed
in the same range while leaving the existing index unused and adding a
fourth write target to a table the rollup workers upsert into constantly.
Two holes, both proven with a probe that produced zero errors.
The rule matched the object properties `queryKey:` and `mutationKey:` and
nothing else, so the positional form was never seen —
`setQueryData(["bare"], 1)` seeds the same cache cell a `queryKey:` reads,
and `getQueryData` / `getQueryState` / the four defaults setters read and
write it the same way. Those seven methods are checked at their first
argument now, whether the call is bare, a member, or a computed member.
The enforced roots were components, hooks, query modules, and files
carrying their own "use client", which left src/app out. That is where the
server prefetch pages build the keys the client later reads, and they
carry no directive, so a server key diverging from the client's was
unguarded. src/app is a root now.
No violation surfaces on the current tree. The rule header records what it
still cannot see: a key assembled into a variable before the call site,
and the method-name match, which neither follows a project helper nor
knows an unrelated object with the same method name.
The inventory walked a flat, hand-written array of route trees, so its
coverage grew only when somebody remembered to grow it. It had stopped
growing: the coach, environment, mental-health, inbound-document and MCP
trees were all outside it, and so was the doctor-report export route the
header already named as a doctorReport surface. Every route in the first
four turned out to be gated, so nothing leaked — but that was the authors'
care rather than the inventory's, and a green run claimed more than it
covered.
The trees are declared per module key as an exhaustive Record<ModuleKey, …>:
a new registry key does not compile without an entry, one test asserts every
key is declared, another asserts every declared tree exists, and a third
discovers an API directory named after a module key on disk and fails when
it is not declared. An empty array is now a claim with a reason beside it
(glucose and recovery ride the measurement engine), not a silence.
Walking the newly covered trees classifies every route: coach, environment,
mental-health, the document vault and /api/export/health-record all gate
directly. The seven /api/mcp routes are the credential surface behind the
remote endpoint and go on the exempt list with the reason written out — the
endpoint itself lives outside /api and carries the gate, so a credential
minted while the module is off is inert. That refusal is added to the
behavioural list so deleting its proof fails here.
/api/export/health-record joins /api/fhir as a key-pinned tree: both serve
the whole-record aggregate, so doctorReport is the only correct key.
The Withings entrypoints, the Telegram bot webhook and the Coolify deploy
hook limited POST and left the verification verbs unlimited. GET and HEAD
compare the same secret and answer 200 against 401, which is an oracle for
guessing it and a free load channel besides; timingSafeEqual protects the
timing channel, not the status code. The project's rule is that the
per-source limit runs before secret verification, and it was being applied
per handler rather than per file.
Each verification verb now runs the same limit its POST sibling used, on the
same bucket. Telegram and the deploy hook grow the small local helper the
Withings routes already share. HEAD re-shapes the limiter's JSON envelope
into a bodyless 429 and keeps the rate-limit headers.
The new guard walks every webhook route from the filesystem, resolves which
local helpers reach a comparison and which reach the limiter, and fails when
an exported verb reaches a comparison without the limiter in front of it. It
asserts a non-zero match count so an empty match set cannot read as green.
WHOOP already passed: it exports POST only.
The lockstep guard compared only the pins both matchers happened to
recognise, and neither matcher said so when it recognised fewer.
Workspace side: the key had to be quoted. Every entry in the file carries
an `@<selector>` suffix and therefore needs quoting, so the matcher looked
complete — but `mysql2: "^3.22.0"` is a valid pnpm override with no
selector and no quotes, and it parsed to nothing. Unquoting that key and
pointing the Dockerfile at `^1.0.0` left all six tests green.
Dockerfile side: only `npm pkg set 'overrides.X=Y'` was recognised, so
re-quoting the same line as `npm pkg set overrides.X=Y` removed the pin
from the comparison without changing what the image installs.
Both sides now parse line by line: quoted or bare keys and values on the
workspace side, any shell quoting of the same assignment on the Dockerfile
side, plus a raw JSON `"overrides"` block. What neither table can read is
collected and asserted empty, so a future spelling fails loudly instead of
shrinking the compared set in silence.
The admin AI key now goes to the API its base URL names, seven code
scanning findings are closed at their source, and the guard that was
meant to hold the image's two dependency pins in step can see scoped
packages for the first time. Two accessibility markers and one audit
read no longer race their own page.
The operator card offered a free-text base URL with an OpenAI placeholder
and no word about what else it takes, so an Anthropic endpoint looked
unsupported even though the field accepts that host. One line beside the
existing hints, in all seven locales, plus the changelog entry.
The operator's global provider always built an OpenAI client, so an admin
who saved an Anthropic key and https://api.anthropic.com/v1 (a host the
settings allowlist accepts) had every AI call fail: an OpenAI-shaped body
posted at Anthropic's Messages API, with nothing in the error pointing at
the mismatch.
Route by hostname instead. api.anthropic.com and any subdomain of
anthropic.com get the same AnthropicClient the per-user ANTHROPIC arm
builds, with the same Claude model default; every other host keeps the
OpenAI client, which is what an OpenAI-compatible endpoint needs. The
match parses the URL rather than searching the string, so a lookalike
host cannot claim the Anthropic wire, and an unparseable value keeps the
OpenAI path it had before.
The chain tag stays admin-openai: it names the slot, not the wire, and it
is persisted in user chains and pinned by the OpenAPI enum. The wire is
already visible in the instance type the wide events read.
The login case failed with a strict-mode violation: the marker it waits
for resolved to two elements, one of them hidden. Two copies of a page
coexist for a moment during a route transition, so a bare attribute
locator can see both, and Playwright refuses an ambiguous match rather
than picking one. The run had passed on the same page code minutes
earlier, which is what a race looks like.
Four locators in this file already carry the :visible filter for that
reason. Seven did not, and had no .first() either, so every one of them
was exposed to the same failure on a slow enough runner. They carry it
now, which leaves the assertion aimed at the copy a reader would see.
The contributor graph has carried every outside author correctly, because
fork pull requests are merged as merge commits and the authorship
survives onto main. But nobody reads a graph tab. The front page now
names the practice and renders the graph inline, so a first-time
contributor sees their own avatar on the page they landed on.
The avatar strip comes from a third-party renderer, the same class of
service as the four shields badges already at the top, and GitHub proxies
it so no visitor address reaches that host. It is generated rather than
hand-written on purpose: a hand-kept list is the kind of thing that goes
stale quietly.
The workflow granted contents: write and pull-requests: write at the top
level, so every step in the run held them, including the checkout and
the dependency install that execute code from the pull request being
merged. Scorecard flags the shape, and the shape is the problem: a
compromised transitive in a Dependabot branch would run with a token
that can write to the repository.
The workflow default is contents: read now, and the two write scopes sit
on the auto-merge job, which is the only job there is and the only place
they are spent. gh pr merge --auto needs contents: write to enable
merge-on-green and pull-requests: write to act on the pull request; the
major-bump comment needs pull-requests: write. The metadata fetch and
the test run need neither. Nothing about which updates merge
automatically changes.
The image carries two installs. pnpm builds the app tree from
pnpm-workspace.yaml; npm builds /opt/prisma-cli and reads only its own
overrides field. The scan reported @hono/node-server@1.19.11
(GHSA-frvp-7c67-39w9, CVE-2026-39406) and valibot@1.2.0
(CVE-2026-59952) under that path while both packages were pinned and
clean in the app tree. Same shape as the deepmerge-ts finding, same
cause.
Rebuilt this exact install both ways to confirm rather than assume.
Without the two fields npm resolves @hono/node-server 1.19.11 and
valibot 1.2.0, which are the two versions the scan named. With them it
resolves 1.19.17 and 1.4.2, and prisma --version still reports 7.8.0.
Both arrive through prisma -> @prisma/dev.
The lockstep guard could not have caught this. Its workspace matcher
started a package name at [^"@], so every scoped key was invisible to
it: @hono/node-server was in the workspace file and absent from the
Dockerfile, the shared set never contained it, and the guard stayed
green. Scoped keys parse now, a test pins the reduction itself so the
blind spot cannot return quietly, and a second test asserts that the
three packages the image scan has actually flagged under /opt/prisma-cli
keep their npm-side mirror. Both fail when the pin is removed or moved
out of step.
Three overrides had drifted behind the advisories they were written for,
and the scan found the packages at versions the pins were supposed to
exclude.
dompurify moves from <=3.4.10 to <3.4.13. GHSA-55q2-fjhq-7xh7 is fixed
in 3.4.13 and the tree carried 3.4.12. The copy is jspdf's optional HTML
renderer, an optionalDependency at ^3.3.1, so the raise stays inside the
range jspdf declares.
postcss moves from <8.5.10 to <8.5.23 for CVE-2026-69153. next declares
an exact postcss@8.4.31, so nothing but this bound moves it; the build
has run on the 8.5 line since the first bump and stays there.
valibot gets a floor it never had, <1.4.2, for CVE-2026-59952. Two
copies arrive and neither is ours to call: an optional peer of
@hookform/resolvers, which this app does not use because it validates
with zod, and prisma's local dev server. 1.4.2 is the same major, so no
consumer is moved onto a new one.
@hono/node-server moves from <1.19.13 to <1.19.15 for
GHSA-frvp-7c67-39w9. The app tree already resolved 1.19.17, so this
changes nothing here; it exists so the bound states the real floor and
so the Dockerfile mirror has something correct to match.
The floor test in ci-determinism asserted the exact key and replacement
string, which meant every legitimate raise failed it while a floor gone
stale against a newer advisory passed it unchanged. It now reduces each
key to a package name and asserts the pin is at or above the fixed
version, with a case proving the comparison can return false.
The scope-fence test asserts that a refused section leaves a
sharing.access.denied audit row naming the reason. That row is written
without being awaited, on purpose, so the audit never delays the 403 the
caller is waiting on, and it lands some time after the response returns.
The test bridged that gap with a fixed 150 ms sleep, which a slow runner
outran: the read found an empty table and the assertion reported no
refusal, which reads exactly like the section having reached the wire,
the one thing this file exists to catch.
The positive case now polls for the row and fails with a message naming
the caller when none arrives within five seconds. The absence case keeps
a fixed wait, longer than before, because an absence claim is only worth
something if it waited. Proven by dropping the audit write behind an env
flag: eleven cases fail with the new message, and pass again with the
write restored.
The account-deletion fix landed after v1.38.2 was tagged, so its entry
moves out of that section and into this one where it belongs. Also in
this release: every measurement source carries a display name in all
seven languages, with a test that reads the enums out of the schema.
The four raw values in the sources ladder shipped because nothing checks
the catalogues against the enum they render. The new guard reads
MeasurementSource and IntakeSource out of prisma/schema.prisma, not out
of a copy, and fails naming every member no catalogue labels; it also
asserts the key each entry names resolves in all seven bundles, since a
key that resolves nowhere renders no better than the enum value. Run red
against the tree before the labels landed, it reported seven unlabelled
measurement sources per catalogue and one unlabelled intake source.
The schema reader now carries each enum's declared members so the guard
can read them.
Settings, Sources listed GOOGLE_HEALTH, OURA, POLAR and STRAVA by their
internal name: the catalogue was written for seven sources and every
provider added since fell through to the raw value. The measurement list
carried the same fall-through in its source filter and its table column,
and the medication intake history read "via APPLE_HEALTH" for a dose
synced from Apple Health.
The three catalogues move into one module keyed by the enum, so an
omission is a type error rather than a raw value on screen, and the sleep
discrepancy tooltip drops the private brand-name map it had grown to work
around the gap.
The published contract still told a client that a Bearer token can never
clear the step-up on account deletion or the record wipe, which is what
an app generates its client from. Describe the elevation arm on both:
which factors qualify, that a password proof does not, that the proof
rides the X-Step-Up header, and that it is spent only when the erasure
runs. The 403 a narrow-scope token gets was never published either;
it is named now, ahead of the second-factor question it precedes.
The mint endpoint lists the surfaces an elevation unlocks, so both
routes join that list.
Also note on the record wipe that API tokens are on its own delete list:
a client that wipes over Bearer signs itself out, which is worth knowing
before the retry rather than after it.
Account deletion and the record wipe both demand a fresh second-factor
proof once a factor is enrolled, and that proof lived on the session row.
A native client has no session row, so an account with TOTP or a security
key could not delete itself from the app on any request it could build:
requireFreshMfaIfEnrolled fell through to requireFreshMfa, which resolves
via getSession and answers 401 to a perfectly valid token. App Store
guideline 5.1.1(v) asks for exactly that deletion.
Add requireFreshMfaOrElevationIfEnrolled beside the cookie-only sibling
rather than softening it. Its cookie arm is the sibling's, unchanged;
its Bearer arm takes the single-use elevation the second-factor
management routes already accept, with the fresh-factor rule pinned on,
so a password-proved elevation cannot erase an account any more than a
password login satisfies the gate on the web. No factor rides the request
body: one mint surface, one redemption path. Scope stays fail-closed, so
a narrow token is refused before the header is read.
The elevation is spent immediately before the erasure, after the typed
confirmation and the last-admin check, so a rejected request does not
burn a proof against a 5-per-15-minute mint ceiling. The Bearer branch
of requireMfaManagementAuth moves into a shared helper so both gates read
and claim in the same place.
The elevation guard grows a section naming the two routes, because the
set of surfaces an elevation can unlock only stays small if adding to it
is a visible edit.
No assistant prefill on Anthropic JSON calls, Claude 5 and Fable count as
vision-capable, the briefing cache carries its language and is never
served to a reader of another one, and job paths resolve a missing
account language to the instance default.
Every background path that resolved a user row's locale collapsed a
NULL column to English: the nightly briefing warm, the status crons,
the period-narrative warm, reaction lines, the morning refresh, the
briefing push, coach nudges, the reminder workers, and the document and
workout summaries each carried their own copy of
`locales.includes(x) ? x : defaultLocale` or called
`normalizeLocale(user.locale)`. None consulted the instance's configured
default language, so an account that never touched the language picker
got English prose and English pushes on a German-language instance.
Add resolveJobLocale (src/lib/i18n/job-locale.ts): stored user locale
when valid, else the operator's app_settings.default_locale when valid,
else English. The settings read lives beside the other app-settings
accessors. Every job and daily path resolves through it once, where it
has the row; the template helpers keep coerceLocale for a value that is
already resolved, and queue payloads keep normalizeLocale.
The briefing push hands its resolved locale down to the digest, and the
digest to the snapshot read, so the push copy and the digest lines are
in the same language instead of two different fallbacks.
A structural test walks src/lib/jobs and src/lib/daily and fails on a
hand-rolled coercion, a validator applied to a row column, or a row
reader that does not import the helper. It was red against the previous
tree on all three counts.
The comprehensive-briefing cache (User.insightsCachedText) is one slot
per account with no record of the language it was written in. Every
reader served whatever was there, so a German-language account whose
nightly warm had written English kept seeing English on the dashboard
lead and the Today briefing. The request path only replaced it once the
row was a day old and the provider answered, which with a provider at
its usage limit was never.
Add User.insightsCachedLocale (migration 0335). Both writers store the
locale they generated in; the timestamp-only refresh on an unchanged
snapshot tags the row too, since the fingerprint covers the generation
locale. Every reset that nulls the text nulls the tag.
The snapshot briefing lift and GET /api/insights/generate treat a tag
that differs from the reader's locale as an empty cache: no prose, no
timestamp, the preparing / no-provider state, and the out-of-band warm
enqueued in the reader's locale through the existing path. The
generator's own short-circuit and the POST short-circuit refuse to call
a foreign-language row fresh. A NULL tag (rows written before this
change) is served as before until the next write tags it. The Today
digest derives from the snapshot and follows.
Covered by unit tests on the lift, the route and the generator, and an
integration test that writes the row through Prisma and reads the
snapshot for a reader in another language.
The Anthropic vision check listed the Claude 3 and 4 families by prefix,
so a key pinned to claude-sonnet-5, claude-opus-5 or a Fable model was
told the provider cannot read images, and lab and document OCR refused
the upload even though the model handles image and PDF blocks.
Replace the per-release prefix list with a rule: any claude-<tier>-<major>
slug from the 4 family upward, plus every claude-fable-* model, counts as
vision-capable. Claude 3 handling is unchanged. The Settings preset list
gains claude-sonnet-5 and claude-opus-5 alongside the existing entries.
The Anthropic client steered JSON replies by appending an assistant turn
containing an opening brace and gluing the brace back onto the reply.
Current Claude models (the 4.6 / 4.7 / 4.8 family, Opus 5, Sonnet 5,
Fable 5 and 5.1) reject an assistant prefill with HTTP 400, so every
JSON surface on the provider (briefing, status cards, document and lab
extraction) failed for a self-hoster on a current model while plain chat
kept working.
Drop the prefill on every model. The request now carries a plain
instruction on the last user turn to reply with a single JSON object and
nothing else, and the reply is narrowed to the outermost object body via
a shared extractJsonObject helper, so a fenced or prefixed answer still
parses. The insights stripJsonFences helper delegates to the same
function rather than keeping a second copy. The tool-use path is
unchanged.
Reported independently by @sweidinger.
The v1.38.0 notice that warns when the configured OAuth callback origin
differs from the origin the browser is on never rendered in the published
image, and the copyable callback address in the redirect guide showed a
bare path. `integrationCallbackUrl()` read `process.env.NEXT_PUBLIC_APP_URL`
inside a "use client" module. Next.js inlines NEXT_PUBLIC_* into client
bundles at build time, and the GHCR image is built without that variable
(only the version, build-sha and built-at args reach the build), so the
base compiled to an empty string, `new URL("/api/<provider>/callback")`
threw, and the notice returned null on every card for every self-hoster.
The value now comes from the server at request time. A new map
(`src/lib/integrations/callback-urls.ts`) calls each provider's own
redirect-URI function (the four private `getRedirectUri()` helpers are
exported under provider-specific names; Polar, Oura and Strava already
exported theirs), so the page shows exactly what the handshake registers,
explicit `*_REDIRECT_URI` override included. The settings route resolves
the map and passes it as a prop through `IntegrationsSection` and
`ConnectionsPanel` to every card. Fitbit and Google Health validate their
URI and throw on a bad configuration; that surfaces as `null`, the notice
skips the comparison and the guide falls back to the fixed path.
Tests: a map test proving the explicit override wins and the derived form
is used otherwise, a component test rendering the notice from a passed
value (mismatch renders, match does not), and a structural guard that
fails when any "use client" file under src/components/settings/integrations
reads NEXT_PUBLIC_APP_URL.
The dependency audit went red on the release branch: mysql2 below 3.22.0
downgrades its auth plugin to mysql_clear_password and leaks the
credential. It reaches the tree only through prisma's MySQL driver, which
this app never opens, but the gate does not distinguish reachable from
present. Pinned to ^3.22.0 in the workspace overrides and mirrored with
npm's own override for the /opt/prisma-cli install, the pair the
lockstep guard holds together. The audit reads three moderates, below
the gate.
A measurement ingest token for readings from your own hardware, a
reference-range bar beside every lab reading, lab OCR working with a
GPT-5 model, an impossible reference window no longer written to the
record, and the image build kept intact across the Prisma adapter bump.
Dependency updates ride along.
Three of the last four pushes to main failed the integration workflow
without a single failing test. Shard 1 of 3 was still logging green
results when the 25-minute job timeout cancelled it; the same tests in
the same shard had run in 13 minutes the day before. A slow runner day
doubles every test's wall time, and a guard under twice the ordinary
run cannot absorb that.
Four shards keep each slice around fifty files, and the guard moves to
40 minutes, three times an ordinary run. A wedged test still fails
through vitest's own 60-second budget, so the job timeout only has to
catch a container that never boots. The fan-in job is unchanged; it
still refuses a matrix with any cancelled shard.
A floor at or above its ceiling fell through to the one-sided branch and
was read as "minimum only", which paints a high reading green. That is
the wrong direction for a transcription error to fail in: the colour
answers a question nobody could answer from that data.
Each one-sided branch now requires the OTHER bound to be absent, so a
present-but-impossible pair lands on the neutral dot instead. It matches
lab-reference-range-bar.tsx, which renders nothing for the same input —
the two components sitting next to each other should not disagree about
what a nonsense range means.
The file had no test. It has one now, covering all five window shapes
plus the zero-width band; with the two conditions relaxed again, exactly
the two new cases fail and the four existing ones stay green.
The production pass reports 22 files, and the comment said 21 — a small
drift, but the paragraph reads as a triage nobody has redone, so the
number is dated now and carries an instruction to re-measure.
Two things the block did not say:
The standing exception. `signals/adapters/metric-status.ts` is reported
unreachable because its only importer is its own registry-invariant test.
That is what the module is for: it projects the signal registry onto the
MetricStatusMeta shape so the test can assert the two agree field for
field. Deleting it to clear the line removes a check rather than ballast.
Someone triaging the list without that written down has to rediscover it,
and the obvious move is the wrong one.
The healed example. The false-positive slot cited a module reported
because scripts/ sits outside the tsconfig that defines the @/ alias.
It no longer appears — module and importing script both still exist, so
resolution improved under us. Replaced with the case that does hold
today, the service-worker policy that public/sw.js mirrors by hand, and
the old one kept as a note: an example that heals silently makes the
reasoning around it look wrong to whoever checks it next.
A lab report's reference range reaches the record by four paths. Three of
them validate that the floor does not exceed the ceiling; the document
path did not, and the catalog store writes what it is handed. An
integration run against a real Postgres confirmed the consequence: a
100/30 pair landed on the row and on the freshly minted biomarker, so
every later reading of that analyte inherited a window that cannot be
true and a high value came back reassuring.
Dropping beats swapping. A transposed pair says the transcription is
wrong, not which of the two numbers is, and inventing an order would
state something the report never printed. The verbatim string survives
either way, so nothing the document actually said is lost.
Three points, answering according to what each one is:
- the extraction schema drops the pair, because every field there is
catch-guarded so one bad value cannot sink a 120-fact document
- the review-screen edit refuses with the wording the lab schemas
already use, because a person can see both numbers and fix them
- the commit path drops it as the last gate, which is what covers facts
staged before this rule existed and confirmed afterwards
parseReferenceRange has answered a transposed PRINTED window this way
since it was written, and the commit path already counts a window it
could not read at all. This extends both to the case that was readable
and cannot be true.
That file stubs @/lib/api-handler wholesale, so it lists the exports it
knew about when it was written. /api/measurements now reaches for
isScopedCredential to decide source attribution and whether the sync
checkpoint may move, the mock does not provide it, and every test in the
file dies on import rather than on an assertion.
`false` is the honest stub: those cases resolve as a cookie session,
which is exactly what unscoped means, so the route takes its ordinary
path through both rules.
Checked the rest of the tree rather than fixing only the file that went
red — this is the only suite mocking that module for a route which
reaches the new export. The batch route's tests mock at a different
seam and are unaffected.
Not verified here: this environment cannot generate the Prisma client,
so that file is one of the suites which fail to import for an unrelated
reason and the fix could not be run.
Three failures in the integration run, all mine, none in the product.
The two delegation cases set the account selector as a header on the
NextRequest. Nothing reads it there. `readSelectorHeader()` resolves
through `next/headers`, which this suite mocks against the shared
`headerJar`, so the carrier came back `none` and both cases quietly
exercised the ordinary own-record path instead of the one they name. The
scoped case wrote to its own record and returned 201; the wildcard
control wrote to the caller rather than the owner. Neither was testing
delegation at all.
That is worse than a broken test. The refusal these two cover is the
regression this change could most easily have shipped in silence, and
the tests standing in front of it were not looking at it.
They now set the selector on the jar, through the exported
ACCOUNT_SELECTOR_HEADER rather than a literal, so a rename cannot
reintroduce the same gap.
The third failure is unrelated and smaller: the read-leg case read the
audit row once, racing a fire-and-forget write, and reported `undefined`
for a row about to exist. It now polls, matching B1, which carries a
comment about this exact hazard that I did not follow.
Not verified here: this environment has no Docker daemon, so the
integration suite cannot be run against a real Postgres. The diagnosis
is from the mock and the resolver, not from a green run.
The mint used requireAuth() with no scope, which refuses a narrow token
but still admits a cookie-equivalent one. The reach is not the problem —
a wildcard token can already do everything this endpoint does. The
lifetimes are: a native access token lives a day, and what it mints here
lives a year. So a credential that leaked for an afternoon could leave
behind one that outlives revoking it, and the revocation would look like
it had worked.
requireCookieAuth() closes that. It is the helper the passkey
registration and trusted-device routes already use for the same reason,
and the sharing guard recognises it as cookie-only.
The prose said more than the code did, in three places: the route header
and the OpenAPI description both claimed the endpoint "cannot mint
another token", which held only for narrow tokens, and the 403 arm
described a scope refusal that a Bearer never reaches. All three now say
what is true — no Bearer credential reaches this endpoint at any scope,
and a Bearer is refused 401 before the operator switch is read.
Tests follow the status change: the integration case asserting a scoped
token cannot mint now expects 401 rather than 403, since the refusal is
authentication and not scope, and a companion case pins the wildcard —
the credential class this change is actually about — and asserts no
token row is created.
The card read like API documentation sitting in a settings page: it named
the Authorization header, spelled out both endpoint paths, and listed the
scope's limits as four clauses. None of that is what someone deciding
whether to mint a token needs, and the endpoint paths were already in the
API endpoints card immediately above it.
Rewrite to the shape the MCP connector card next door uses — a short
title, one line on what the token is for, one line saying it is shown
once, and one sentence on what it can and cannot do. Drop the endpoints
note entirely rather than rephrase it, since the card above owns that.
Say the useful consequence instead of the mechanism: readings arrive as
if entered by hand, so they can be corrected. That is the thing a person
will actually notice.
Translations were revised across all seven locales — the previous German
in particular read as translated English rather than as the rest of the
app, which uses short informal sentences.
`stdResponses` already carries 401, 422 and 429. The three response
blocks added with the measurements:write scope each declared their own
422 (and, on the mint route, a 429) BEFORE spreading it, so the generic
wording overwrote the specific wording and TypeScript refused the
duplicate key. The build failed at auth.ts with "'422' is specified more
than once".
Move the explicit keys after the spread, which is what the MCP mint next
door already does and the only order in which a per-route description
survives.
Worth stating because the failure mode is quiet: had TypeScript allowed
it, the published contract would simply have carried "Request validation
failed" everywhere instead of naming
measurement.create.source_not_permitted and its siblings.
Since v1.30.17 there has been no self-service path to a token that can
write a measurement. That release made the Bearer scope default
fail-closed, which closed a real privilege-escalation hole, and removed
the generic mint at POST /api/tokens along with it. The generic mint had
been the only way to get a token that could reach /api/measurements, so
a local sensor pushed through Home Assistant or Node-RED lost its door
while the OAuth integrations kept theirs.
Add a narrow scope accepted by exactly two routes, both writes, both on
the holder's own record.
The scope constant is a leaf module beside the surface that consumes it,
matching FHIR_READ_SCOPE and the MCP scopes. requireRecordAuth grows an
options object rather than a sibling function, because the sharing
surface guard keys every leg on that one identifier and a second name
would mean two copies of the delegable declaration. AuthContext carries
the admitting scope as a single nullable string rather than the token's
permission array, so nothing downstream can re-derive an authorisation
decision from it.
A scoped credential is refused a record selector before any grant is
read. Placing the refusal above the sharing check rather than beside it
means no future widening of what a grant permits can reach one of these
tokens.
Rows are attributed MANUAL. An entry that explicitly names APPLE_HEALTH
is refused 422 rather than relabelled: that source is half the
(userId, type, source, externalId) dedup key the iOS client writes into,
it participates in the cross-source merge, and it gates the HealthKit
sync fields. For the same reason a scoped write no longer advances
User.lastSyncedAt, which the phone reads to size its undelivered window.
Also closes a narrower gap found while building this. The idempotency
wrapper resolved a replayed delegated response against a live grant
without considering which credential was presenting it, so a cached
response about a shared record could be returned to a token the handler
itself would refuse. No write was reachable that way; the response body
was.
Guards: T3 gains the mint site, T4's vocabulary gains the scope and
learns to read the requireRecordAuth form (without which this change
would open a way to widen a route that the closed vocabulary could not
see), and a new T5 freezes the set of delegable routes naming a scope.
The sharing surface guard's AST matcher reads only the first two
arguments, so it needed prose but no code.
Follow-up to #860, promised in review rather than asked of the
contributor. `not.toContain("left: 4%")" passes for a marker at 4.02%,
which is the same defect one rounding away, so the test now reads the
marker position and asserts it sits inside the padding at both ends.
EDGE_PADDING_PERCENT moves to module scope and is exported, so the
assertion uses the number the component clamps with instead of a copy
that can drift. It was being reallocated on every render besides.
Verified by mutation: dropping the outlier expansion puts the marker at
exactly 4 and turns this test red.
The Dockerfile asserts that the standalone image can resolve pg, because
pg-boss loads it through native Node resolution. That assertion has been
passing on a coincidence: pg sat in devDependencies, and the version our own
entry pinned happened to match the one @prisma/adapter-pg pulled in, so both
names resolved to a single copy in the store and Next's file tracer packed it
whole.
Bumping the adapter to 7.10.0 moves its transitive pg to 8.23.0 while ours
stayed at 8.22.0. The tracer packs only the copy the adapter reaches, the
top-level link points at the other one, and the image fails to build on both
architectures:
Error: Cannot find module '/app/node_modules/pg/lib/index.js'
Move pg into dependencies at ^8.23.0. The lockfile now carries one pg version
instead of two, so the two resolutions cannot drift apart again on the next
adapter release. Carries the adapter bump from #865, which cannot build alone.
init, analyze and upload-sarif are the same release pinned in three
workflows. Dependabot opened them as three separate pull requests, so each
run mixed two versions and analyze rejected the config init had written:
"Loaded a configuration file for version 4.37.7, but running version
4.37.9". None of the three could go green alone, and the code-scanning
analysis stayed red while they waited on each other.
Bump all five references together and group the action in dependabot.yml so
the next release arrives as one pull request.
Three areas of health become a recommendation rather than a gate. A one
or two area record now scores, the card says what the number rests on,
and refusal narrows to the one honest case: nothing scoreable yet.
Carries the composition notice, the reference-citation guard that keeps
invented targets out, @TimonBed's OAuth callback warning, and the fix
for a geometry check that had never measured in CI.
The constant was left over from an earlier shape of the guard, which
located the fixture by path before it imported it directly. Nothing reads
it now, and the dead-code gate says so.
Three descriptions in the OpenAPI registry still describe the floor that
v1.38 removed, and one of them contradicts its own error-response
description two dozen lines below it.
- The score-config PATCH still said the write is refused below three
distinct areas and one physical measurement. The route refuses the
empty selection and nothing else; its 422 description already said so.
- The dashboard snapshot's healthScore still said the block is null until
three eligible domains include a physiological one. It is null only
when no pillar is readable at all.
- The pillar domain field still pointed at a three-distinct-domains rule
that no longer exists, where what a client needs to hear is that domain
count comes resolved on scoreBasis and must not be counted out of
composition.
Regenerated docs/api/openapi.yaml.
The user-facing description of the score lives in the docs site's own
repository, not here; nothing under docs/ describes the composite.
The demo seeds no waist measurement and no wellbeing screener, so two of
the seven pillars have never had data and the score rests on three areas
instead of five. Adiposity and wellbeing render as empty rows on the card
that is meant to show the feature.
Both are seeded now: a fortnightly waist that walks 96 cm down to 88 cm
over the window (0.527 to 0.484 at the demo profile's height, so the
ratio crosses NICE 2022's threshold inside the seeded span rather than
sitting flat green), and six WHO-5 / PHQ-9 / GAD-7 administrations with
their item answers encrypted the way the check-in route writes them, each
with the COMPUTED projection measurement so the history view and the
trend chart agree. Totals sit in the calm bands and item 9 is answered
zero throughout, which also keeps the pillar clear of its
crisis-signposting arm.
Verified against a throwaway Postgres: before, five ok pillars over three
areas; after, seven over five, tier full.
The dates were never the problem. Every timestamp the seed writes is
already relative to the run, and the header says so. What ages is the
seeded DATA — the pillar windows keep moving after a seed, so four weeks
on, activity and sleep fall out of theirs and the score narrows to
whatever cardiometabolic data is still fresh. Since v1.38 that is a
labelled partial score rather than a blank card, but it is still not the
demo at its best, and the header now says that too.
The score once graded weight against 22 x height squared, widened by 2 kg
either side: a band nobody set, no surface named, and the only producer
of that pillar's verdict. v1.34 removed it and the composite rebuild left
a pillar set where every scored band cites somebody. Nothing but review
has kept it that way since, and review is what let the original in.
So the rule is written down. PillarReference.kind becomes a runtime list
carrying the reason there is no fourth kind, reference.source is
documented as a citation a reader could go and look up, and a guard
beside the pillars checks all seven modules three ways.
The arm worth arguing about is the second. An invented target is a
continuous function of a profile field; a guideline band is a step
function into a published table. So the guard sweeps every pillar across
a matrix of heights and ages and requires the scored bands it emits to
land inside a small enumerated set transcribed from the guideline that
pillar cites. No finite enumeration can hold a per-person curve, which is
what makes the arm able to fail rather than merely able to pass. The
other two arms cover the citation string and freeze which pillar may read
which profile field, with the reason written beside it and stale entries
failing as loudly as missing ones.
The proof is in the file: the pre-v1.34 weight target is restored as a
fixture in today's shape and each arm is shown refusing it.
A pillar can leave the set behind the number without anybody choosing it:
a window rolls past its floor, a module goes off, a source goes quiet. The
delta already refuses to narrate that as a health event, which stops a
false "you dropped six points" and puts nothing in its place. The level
moves, the panel shows a different number, and the cause is nowhere on the
surface. Under the adaptive minimum this matters more rather than less,
because compositions are smaller now and sit closer to their floors.
The report carries `compositionNotice` when today's set differs from the
last local day the account has a stored score for. Optional and nullable,
the additive shape the score's wire has used six times, so a payload
cached before it existed stays valid. It names what left and what joined
as pillar ids; the surfaces already own the labels, and the reason a
departed pillar stopped counting comes from that pillar's own row in the
same report rather than from a second vocabulary.
Two changes deliberately do not raise it. A method move and a recipe
change each already have their own note, and telling someone twice about
one event teaches them to ignore both.
The key is `health-score:v<version>:composition:<hash of the sorted set>`
and rides the existing dismissed-item ledger, so a dismissal holds until
the set moves again rather than until tomorrow. The read excludes today on
purpose: the row for today is written by the same request that computed
the report, so comparing against the newest row would find today's own
composition on the second load of the same page and the note would vanish
between two views of it.
This gives `HealthScoreRecord` its first production reader. Since it
shipped it has been written on every scored read and consumed by nothing
but backup and restore, which is the write-only shape the contributor
notes call a liability and which nothing in the gate can notice, because
every check proves one end. The round trip is proved against real
Postgres: raise the note, wipe the table, restore it through the admin
route, get the same note back, and hold the dismissal across it.
Rendering is one muted line on the score card and a card of its own on the
settings page, beside the recipe note rather than inside it. Deliberately
not a push.
Four fake Prisma clients gain the new read. They are doubles of a client
the reader really does call now; leaving them short and guarding the call
site instead would have been a shim for a caller that does not exist.
The hero-geometry check has never measured anything in CI. It launches
Chromium in a hook, catches the launch failure, and every case then
returns early — green. The quality job installs no browsers, so every
run of it since the file landed printed
[hero geometry] SKIPPED — no Chromium build available (Error:
browserType.launch: Executable doesn't exist at
/home/runner/.cache/ms-playwright/chromium_headless_shell-1234/ch)
and passed. The file's own header warned about exactly this trap and
then fell into it.
Both ends move. The quality job installs the headless shell before the
unit suite — `--only-shell chromium`, because that is the build
`chromium.launch()` resolves to and the one the failing runs named, with
a cache on the lockfile hash so the download is paid once. And the check
now refuses to be optional: with CI set, a launch failure throws out of
the hook and fails the suite with the step to restore. Without CI it
still skips, through the test context so the skip and its reason land in
the report rather than in a console line nobody reads.
No panel number changed. The measurements the check reads back are the
same ones the reserve was set against: panels of 400 / 428 / 456 / 484
px for three to six rows, 420 px for three rows plus the basis line,
against a 448 px reserve.
The key shipped with the English sentence in es, fr, it, pl and ko, which
the English-leak guard catches by design: a value byte-identical to the
English one for a multi-word string is a forgotten translation, and that
guard exists because this project has shipped exactly that before.
Nobody sending a two-line fix should have to produce seven languages.
Two areas of health score now, and one does. The arithmetic is the same
at every breadth, so nothing on the web surfaces distinguished a number
resting on blood pressure alone from one resting on five areas. The
panel now states its scope.
The basis line. Below the recommended breadth the card renders "Based on
{n} of 3 areas of health" under the band sentence, in the same weight,
and stops there. It stands where the learning gate used to stand for
those accounts, which is what the gate was for before the refusal moved.
The number keeps its size and its band colour at every tier, there is no
warning glyph, no second colour, no apology: the coverage meter and the
confidence band underneath already do the quantitative caveat work, and
the composition list names the areas one tap away. At full breadth the
line says nothing, because a fraction that reads "3 of 3" (or "4 of 3" —
the block counts domains, the recommendation caps at three) is not a
label. The daily hero carries the same sentence beside its ring, in the
hero's quiet tier, because the ring shows a number and nothing else.
The gate survives for the one case that is still a refusal: not a single
pillar readable. Its copy stopped claiming the score needs three areas
and stopped counting them — above zero there is a score now, and the
meter below renders the fraction anyway.
The coverage axis. "Covers the three areas the score needs" was false
the moment three stopped being needed, and the branch that chose it was
worse: it asked whether a score exists, so a two-area account would have
been told it covers the three areas the score recommends — the old lie
one floor up. The branch is the breadth tier now. Full breadth states
the recommendation; everything narrower shows the fraction, which since
this release is a real moving number rather than a floor every scored
account cleared by definition. The key was renamed off "minimumMet"
because there is no minimum left to name.
Settings names the recommendation rather than a requirement: three areas
is what the score recommends, a selection with one pillar saves, and a
narrower score is worked out the same way.
Copy lands in all seven catalogues.
Tests: the axis guard gains a third claim, rendered rather than read out
of the source — the real panel at each tier, including the two-area arm
that was unreachable for a scored account until now. The card gains a
partial-score block comparing the headline number's markup byte for byte
against a full-breadth render, so "renders the same" is asserted where
it can actually break, plus the counter-case that a refusal still gets
the gate and nothing else. The hero strip proves the sentence reaches
the band, not only the component. The geometry reserve was re-measured
with a browser rather than widened: a three-row panel carrying the basis
line lands at 420px, inside the 400-484px range the reserve brackets, so
the declared minimum is unchanged and the narrow shape is now measured.
This integration test pinned the three-domain floor from the other side
of the wire: two graded pillars in two areas, composite insufficient.
That is the defect the milestone removes, so the assertion inverts and
now reads the partial tier and its basis out of the serialised response,
which is the only place a client would ever see it.
It survived the unit suite because integration tests run under their own
command; the model change was green everywhere the model is unit-tested
and red at the one place that exercises the route end to end.
Six suites pinned the old rule as the rule. Each inversion, and why it is
one:
`breadth.test.ts` — the case table asserted a refusal reason per set; it
asserts a tier and a `physiological` flag now. Every row in it except the
empty set used to be a refusal, and each is a number somebody gets to see.
Adds the tier boundaries (one pillar, two areas, physiological-absent), a
case proving the same pillars produce the same number at `partial` and at
`full`, and a block whose only job is the counter-case: the empty verdict
still carries a reason and no tier, and a composite whose selected pillars
all came back short still refuses with `no_usable_data`. A rule that has
stopped refusing anything is a deleted rule, not a graded one.
`composite.test.ts` — "requires three domains and one measured
physiological domain" asserted the refusal that no longer happens; it now
asserts the score and the basis block on the same fixture. The coverage
case keeps its fixture and its arithmetic and flips its outcome: two of
three used to be a refusal and is a scored account with a real coverage
fraction. Adds the one-area case and the zero-pillar counter-case, and the
`SCORE_VERSION` floor moves 2 -> 3 because widening who gets a score is
exactly the kind of change that must not ship without a version bump.
`record.test.ts` — "records nothing when the composite did not resolve"
used a two-pillar fixture to prove a below-the-floor day stored nothing.
That day has a number now and the table has to keep it, or an account that
starts narrow and widens later has a hole where its early days were. The
assertion is now that a one-pillar day builds a draft AND reaches the
database; the absence case survives on the fixture that genuinely produces
no score.
`composition-source.test.ts` — three recipes asserted as refused at read
time. Two of them score. What the block proves is unchanged — the reader
publishes the shared rule's verdict on the reader's own resolved set, so a
second copy of the rule would still surface here — but the agreement is
over a tier rather than over a refusal.
`route.test.ts` (score config) — the two too-narrow refusals become
acceptances, because a write that refuses what the read is willing to serve
is the two layers disagreeing. The empty selection keeps its 422 and gains
the assertions the old cases carried: errorCode, reason, and the sentence.
The errorCode driver loses the two selections that no longer 422.
`score-refusal-consumer-guard.test.ts` — its non-zero control anchored on
`three_domains_required`; it anchors on `no_pillars_selected`.
New: `health-score-basis-additive-contract.test.ts` pins that `scoreBasis`
is published, is optional on all three carriers, and points at the one
shared component — a required field on a cached surface would invalidate
every cached payload with nothing failing anywhere. It also pins that the
walk does not confuse the composite's basis with the older per-pillar
`scoreBasis` that blood pressure uses for its axis, since they share a key.
Fixtures: the notice-key assertions derive the method half from
`SCORE_VERSION` rather than restating 3. Three fixtures that named
`three_domains_required` now name `no_usable_data`, a reason the server can
still emit. The settings e2e drove its refusal through a two-pillar
selection that now succeeds; it deselects every pillar instead.
`scoreBasis` rides the composite, the dashboard snapshot's flattened hero
block and the daily digest's score. It says how many distinct areas of
health the number counted, how many the method recommends, which tier that
lands in, and whether any counted pillar was a physiological measurement.
Optional everywhere, because two of the three surfaces are served out of a
cache and a payload written before this release cannot grow a field. The
snapshot and digest blocks have taken six additive fields this way already;
this is the seventh.
Resolved on the server for the same reason `configured` is. Counting
`composition` client-side would get it wrong more often than right — three
of the seven pillars share the cardiometabolic area, so a three-pillar
score can rest on one.
One shared component, `HealthScoreBasis`, declared once in the insights
schema module and referenced from the other two registry modules, rather
than three inline copies that drift the first time the block gains a field.
Declared through the assigning `.meta()` form: a bare `.meta()` statement
registers an instance nothing references, and the id then never becomes a
component.
The composite's `insufficient` reason narrows to the single value
`no_usable_data` and says so in the published description. That field is an
open string in the document, so the change is additive on the wire even
though the value space shrank.
The score-config 422 description drops the two retired reasons and states
plainly that the empty selection is now the only refusal, so a client does
not keep a three-area rule of its own in front of a write that no longer
has one.
Three distinct areas of health stops being the price of admission and
becomes what it always described: a recommendation. `evaluateScoreBreadth`
returns a tier instead of a pass/fail — `full` at three areas or more,
`partial` at two, `minimal` at one — and refuses only when not one pillar
came back usable, with the reason `no_usable_data`.
The arithmetic does not change with the tier. Same mean, same worst-pillar
band drag, same noise floor. A one-area score of 82 is an honest 82 that
rests on one area, and discounting it for the pillars somebody does not
track would have been a new invented target, which is the thing this score
spent two releases removing.
The physiological requirement becomes a labelling fact rather than a veto.
An account whose only readable pillar is activity or wellbeing gets its
number with `physiological: false` on the basis block; the old refusal told
that person nothing they could not already see.
`SCORE_MIN_ELIGIBLE_DOMAINS` is now `SCORE_RECOMMENDED_DOMAINS` and still
feeds `deriveCoverage`, so the coverage fraction and the confidence blend
keep telling the truth with no new machinery: a one-area score lands at low
confidence through the path that was already there. The fraction used to be
a floor every scored account had cleared; it is a real moving number now.
`SCORE_VERSION` 3 -> 4 and `SCORE_ALGORITHM_CHANGED_AT` moves to the
release date. Both are method-identity duties: deltas suppress as
`algorithm_changed` across the straddle window, the notice key rotates so
each account is told once, and stored days stamp 4. No v3 row is rewritten
and no v3 number changes meaning.
The settings write follows the same rule rather than a stricter one. It
refused two-area and non-physiological selections on the ground that they
could not produce a score; they can, so refusing the write would be the two
layers disagreeing about the one question they share. The empty selection
is still refused, under a single reason `no_pillars_selected`, and the seven
message catalogues trade the two old refusal sentences for one.
Also fixes the stale `./series.ts` reference in the config header — that
module was deleted as dead in v1.35; the columns the sentence describes are
still written by `./record.ts`.
A contributor's first code fix, a self-hoster's Tailscale recipe turned
into documentation on both doc sites, the repeating admin geo notice
anchored to configuration state, the notification HTML stripper made
structural, and the code-scanning backlog triaged to zero untreated
findings.
- flatted 3.3.3 → 3.4.4: prototype pollution and unbounded recursion in
parse(), fixed in 3.4.2 / 3.4.0.
- js-yaml 4.1.1 → 4.3.1: quadratic CPU on merge-key chains and !!omap
resolution. The override is bounded to >=4.0.0 so a hypothetical 3.x
consumer is never dragged onto a new major.
- minimatch 3.1.3 → 3.1.5: ReDoS via nested extglobs. The scanner
suggested 10.x, but the fix is backported — only <3.1.4 is vulnerable —
so the 3.x line stays where its consumers (eslint) expect it. The 5.x,
9.x and 10.x copies in the tree were never affected.
The refreshed lockfile also carries brace-expansion 1.1.12 → 1.1.18
transitively, which clears that package's DoS advisories. pnpm audit
reports none of these packages afterwards.
The /opt/prisma-cli npm install named prisma@7.8 and @prisma/engines@7.8
while its neighbours (tsx, dotenv) were exact — the one unpinned spot the
Scorecard pinned-dependencies probe flagged. Both ranges resolve to 7.8.0
today, so the exact pin changes nothing in the built image; it only stops
a future 7.8.x from arriving unreviewed.
Resolves Scorecard alert 63.
Both class-string regexes carried (?:[^\s"']*:)* for the optional
modifier prefix chain. The inner class includes the colon, so the star
could carve the same prefix into chunks many ways, and a long
non-matching string made the engine try them all — exponential
backtracking (CodeQL alert 44, js/redos). Excluding the colon from the
chunk class accepts exactly the same strings with one parse each. The
rule's 35 tests pass unchanged.
Resolves CodeQL alert 44.
Five senders (APNs, Web Push, webhook, ntfy, email) each carried their
own single-pass replace(/<[^>]*>/g, "") — the shape CodeQL flags as
incomplete multi-character sanitization, because a removal can re-form
the pattern it removed. Whether one particular regex happens to be
idempotent is an argument about that regex; five copies of the argument
is how the next edit quietly breaks one of them.
The shared stripHtml re-applies the strip until the string stops
changing, with an iteration cap and a bracket-dropping fallback so no
tag-shaped sequence can survive any input. Plain text passes through
unchanged, same as before. Unit tests pin the nested-tag bypass shapes
and the no-surviving-tag invariant.
Resolves CodeQL alerts 38-42.
The sent-latch was a module-level boolean, so every worker boot re-sent
the same notice while the host's configuration never changed (#851).
Anchor the notice in the notification_events ledger instead, through the
same claimNotificationEvent helper the reminder schedulers use. The
anchor is unwindowed: entering the unconfigured state sends once per
admin, staying in it is silent across any number of restarts, and the
anchor is released when the state exits — databases configured, or
IP_GEO_LOOKUP_DISABLED set — so a genuine re-entry notifies again.
Three consequences worth naming:
- A host with lookups disabled is no longer notified at all. With no
egress there is nothing the notice warns about, and the disabled state
counts as an exit, matching the notice's own remediation advice.
- The evaluation now keys on offlineGeoReady() rather than the ASN
reader, so a host with only the ASN database mounted (city lookups
still egress) hears about it, where before it stayed silent.
- The retention prune excludes the new ADMIN_CONFIG_NOTICE event type:
a state-scoped anchor has a reader for as long as the state holds, and
the 90-day horizon delete would have re-opened the repeat on a slower
clock.
Refs #851
The note predates the whitelist fix that landed alongside the restore
permission change; keep the manual-edit instruction as a version note
for older compose files.
Integrates @aucun6352's Korean locale (PR #839): the full catalog, the
NFC recompose in the shared fold, the Hangul lab-name normaliser, the
tilde range separator, and the officially published Korean PHQ-9, GAD-7
and WHO-5 wording. Resolves the CHANGELOG and OpenAPI conflicts against
the current trunk and regenerates the spec. Raises the aggregate bundle
budget to 3700 KB gz for the seventh message catalog, with the reasoning
recorded next to the value.
The contributor's three commits are preserved with their authorship.
Ensure minimum-length failures use the destructive token even when the password's entropy score is strong.
Co-authored-by: Cursor <cursoragent@cursor.com>
Two findings from the docs truth audit, both operator-facing.
scripts/restore-backup.ts wrote the decrypted backup, which is health
data in plaintext, with the process default file mode, leaving it
world-readable on the operator's disk while it sat there. It is now
written 0o600.
TRUST_PROXY_HOPS is read from the environment to size the trusted
X-Forwarded-For chain, but it was absent from the compose environment
whitelist, so an operator value in .env never reached the container and
was silently ignored. It now rides all four surfaces: the compose
whitelist, both env examples, and the manifest that pnpm check-env
reads.
- api/README: the YAML is emitted from the Zod registry by
pnpm openapi:generate with a CI drift check; document the real
servers, tags, and coverage (client-facing REST, most admin routes
and the OAuth/webhook surfaces excluded) and replace the hand-edit
workflow with the generator workflow
- mcp-capabilities: add the registered get_intraday_pulse,
get_ecg_recordings and get_nutrients tools and the
nutrient/{code} resource template to the tables
- ai-providers: /api/ai/test returns provider type, model, token usage
and a sample with a reasonCode on failure; drop drifted line numbers
- apple-health: source-priority overrides moved to /settings/sources;
cite the constant instead of line numbers
- data-import: four import paths (medication dose history added); ECG
and metric-typed clinical records are imported; measurement
externalId documented; CSV re-uploads dedupe on the natural key;
mood loggedAt defaults to the start of the given date; the type
table is a curated subset with /api/meta/capabilities authoritative
- fitbit: the source-priority ladder ships, FITBIT is ranked in every
default ladder
- google-health: Sources is its own settings section, not nested under
Integrations
- withings: disconnect unsubscribes the webhook and deletes stored
tokens without revoking the Withings-side grant; the connection test
is self-serve, not admin-only; the safety-net cron is hourly;
refresh stale references
- docs/README.md: drop the doctor-report.md and audit/ entries (neither
exists), broaden the ops/ description, list the real subdirectories,
and stop promising release-by-release migration notes
- migration/v1.3-to-v1.4: note which v1.4 routes have since moved or
gone; rotation runs via pnpm dlx tsx
- ui-guidelines: six locales with guard tests, settings sections point
at the registry instead of an eight-item list, /settings redirect is
a 308
- apple-store-connect-checklist: drop the unsourced 40% rejection-rate
figure
- codex-protocol-spec: the slug chain rotated on 2026-06-02; default is
gpt-5.5, the shipped constant is authoritative
- geolite2: quote the real commented compose mount (./geolite2 to
/opt/geolite2, the GEOLITE2_DIR default, so no env var is needed for
the default path)
- getting-started: healthcheck probes /api/health, not /api/version;
the environment module reaches archive-api and geocoding-api
.open-meteo.com, relevant for egress allowlists
- mcp: the module is opt-in and off by default, but no longer the only
such module
- reverse-proxy: the Coach stream is SSE over plain HTTP, not
WebSockets; note that TRUST_PROXY_HOPS is not on the bundled compose
whitelist and must be added by hand
- scaling: /api/health healthcheck; worker liveness is in-memory and
invisible across a web/worker split; pool sizing now goes through
getConnectionBudget with DB_CONNECTION_LIMIT as the compose knob and
a 2-connection pg-boss share
- sso: the native SSO handoff shipped, OIDC_ONLY works with app builds
that carry it
- unraid: geo databases are baked in only when the build had a licence
key, or mounted; replaceable either way
- attempt-number-semantics: name the real bucket column
(consecutiveFailuresByKind) and drop a drifted line citation
- backup-restore: show the smoke-test response inside the standard
{ data, error } envelope
- deploy: the manual migration step now uses the bundled Prisma CLI
via node (the image strips pnpm); WHOOP vars are already on the
shipped compose whitelist
- encryption-key-rotation: point at the encrypted-column registry
instead of a five-model list several releases behind
- env-check: the CI gate shipped (env-check.yml); the partial-APNs
example now shows the synthetic all-or-none row and the non-zero exit
- password-reset: the admin API reset route exists; only the
self-service route does not
- tls-cert-pin: stop asserting client-repo pin-set details that cannot
be audited from this repository
- drop v141-followup-issues.md: a v1.4.1 punch list whose items are
fixed, removed, or superseded; keeping it under docs/ops invites
re-fixing shipped work
The per-user retry ledger has recorded every AI delivery outcome for a
long time, but no surface ever read it for the operator: a central
provider could fail on every call for weeks and the admin console said
nothing. The AI settings section now carries a health card, backed by a
new admin-only endpoint that folds the ledger into one row per provider
type: users tracked, users currently failing, the worst uninterrupted
failure run, and the last success and failure instants. Operator-managed
types sort first because their failure takes the shared fallback down
for everyone whose chain relies on it. Counts and timestamps only; the
response never names a user.
The Korean questionnaire wording was translated freshly from English, so
a score built from it was comparable to nothing in the literature. Each
instrument now carries the text its own publisher put out, or no Korean
at all.
PHQ-9 and GAD-7 come verbatim from the instrument owner's distribution —
Pfizer's `PHQ9_Korean for Korea.pdf` (2010-06-15) and `GAD7_Korean for
Korea.pdf` (2010-05-18), the latter being the file Ahn/Kim/Choi 2019
names as what it validated. The sheets grant reproduction, translation,
display and distribution without permission. Verbatim means verbatim:
the spacing in "7 일 이상 방해 받았다" and the sheet's "안절부절 못하거나"
against the GAD-7 sheet's "안절부절못해서" are reproduced as printed.
WHO-5 has no official Korean arm. Neither the WHO's 2024 distribution
(26 languages) nor the Psychiatric Research Unit folder it inherited
from (31 languages) has ever carried one, and ePROVIDE lists the same
31. Korean therefore carries the published forward/back-translation
from Kim HJ et al., J Korean Geriatr Psychiatry 2010;14(2):90-96, which
is CC BY-NC and so redistributable; the attribution line names it. Its
validation sample was community-dwelling elderly rather than general
adults, which the definition says out loud.
The Sleep Condition Indicator reverts to English. It is validated in
English only, its cut-off was derived from those words, and no openly
redistributable Korean exists — so `ko` now matches de/es/fr/it/pl:
items, section stems and response anchors byte-identical to en.json,
under the localized "validated in English" note.
`ALL_APP_LOCALES` grows to seven, which is what stops the check-in
wizard claiming English wording over Korean items on every Korean
check-in.
One gap is deliberate and not yet resolved. The official Korean PHQ-9
and GAD-7 sheets disagree on anchor 2 — "7 일 이상 방해 받았다" against
"2 주 중 절반 이상 방해 받았다" — but both instruments read one shared
`mentalHealth.options` set, which holds for the other six locales
because their two sheets agree word for word. Korean takes the PHQ-9
wording for both here. GAD-7's anchors are therefore validated in
substance but not verbatim; splitting them into a `gad7Options`
namespace is the fix, and is left for the maintainer to weigh against
the 24 duplicated keys it would add across the other locales.
The functional-impairment item and its four anchors come from the same
PHQ-9 sheet. They are not scored and not covered by the
`validatedItemLocales` contract.
The SSRF pin inventory asserted a literal unconditional pin in the
openai-client; the previous commit made that pin conditional for the
two person-typed base-URL tags. The guard now asserts the exact
ternary — allowlist helper for gateway/admin-key, literal true as the
floor — so neither the floor nor the condition can drift silently.
The global admin provider's base URL is operator-typed in the admin
settings, but the client pinned requirePublicHost unconditionally for
its tag — so an operator pointing it at a private OpenAI-compatible
proxy (an OAuth sidecar on the instance's own Docker network) got a
safeFetch refusal on every call, indistinguishable from an outage. The
admin-key tag now consults the same ALLOW_LOCAL_AI_PRIVATE_HOSTS
policy as the user-configured gateway: a public URL never consults the
allowlist, a private host without an entry is refused exactly as
before, and both ends of the grant are the operator's own. The codex
tag stays hard-pinned; its base URL is a repository constant. Every
non-operator admin-key construction pins the public api.openai.com
constant, so the personal-key posture is unchanged.
The test asserting the old blanket pin is split into the three cases
that actually differ: allowlisted private host passes, unlisted
private host still refuses, codex refuses even a blanket allowlist.
A second native client is starting, and the rules that keep the first
one aligned with the server lived in issue threads and habit. They are
now docs/CONTRIBUTING-CLIENTS.md: where the contract truth lives, the
response envelope, auth from a client's point of view, sync semantics,
the server-authoritative-values rule, and how cross-repo coordination
works in practice.
CONTRIBUTING-AI.md was a snapshot of an old conventions document and
had drifted well away from the codebase it describes (it still spoke
of 26 Prisma models and in-memory rate limits). It now points at the
living sources instead of carrying an aging copy.
Trivy flags CVE-2026-14456 (openssl denial of service via unbounded
memory growth in the QUIC server) in the runner stage: the pinned
node:22-alpine digest ships libssl3/libcrypto3 3.5.7-r0 and the fix is
published as 3.5.8-r0 in the Alpine repo. An apk upgrade in the runner
stage picks up published OS security fixes without giving up the digest
pin, closing the window between a CVE landing in the secdb and the next
base-image rebuild.
The a11y matrix feeds the weight sub-page chart ten rows with absolute
July dates. The range tabs are calendar-day windows anchored on now, so
those rows aged out of the default window, the page rendered its empty
state, and the .recharts-wrapper painted gate timed out on every shard
and theme. Relative dates keep the fixture inside the window on any
date, same as the charts-mobile spec already does.
The range selector said points — 7 pts, Last 7 data points — while the
fetch has always treated the value as a calendar-day window. Someone
who weighs in twice a week picked 7 pts and saw two, and read that as
a broken chart; the labels were the broken part. The tabs now say 7d /
30d / 90d / All across all six locales.
Behaviour is unified on the same reading everywhere. The measurement
chart's visible slice becomes a real day-window filter — the old
slice(-N) was a no-op on the self-fetched path but silently meant
last N readings on the dashboard's preloaded batch slice, so the same
tab meant two different things depending on the mount path. The mood
chart sliced the last N logged days out of the whole history and now
windows by days like its siblings. The header chip reads the same
grain decision the chart and the data table use instead of re-deriving
it in a second memo.
Component fixtures move to now-anchored dates: the windows are anchored
on now, so absolute-dated fixtures age out of the window and would have
exercised only the empty states. The span-dependent cases (weekly chip,
bucketed-caption table) mount the All range, which is now the only
range that can span past 90 days.
The tier for a chart series keyed off the requested window width. The
All range always requests ~3650 days, so every account got MONTH
buckets regardless of how much history exists — a record spanning four
months came back as four monthly means, and the chart captioned them
from the real span as a weekly average. The reader now re-reads at the
tier the returned rows' span calls for; a coverage miss on the finer
tier keeps the coarser rows.
The ladder comment claimed to mirror the client's pickBucket
thresholds and misquoted them. The two ladders are deliberately not
identical; a test now pins the direction that keeps the caption honest
(server never coarser than the client captions) for every span up to
ten years.
Registering `ko` in the `Locale` union left seven locale-keyed tables
without an entry, so the build could not type-check and the safety
screens, the citation-coverage grader and the reference-range parser
read nothing at all for a Korean reader.
Three failures were not missing keys but wrong assumptions:
- The shared fold decomposes with NFD and strips U+0300-U+036F. Latin
accents become plain letters, but Hangul decomposes into jamo, which
are not in that range and survived — so folded Korean text was a jamo
sequence no precomposed table entry could ever match. It recomposes to
NFC now; every Latin, Polish and German fold is byte-identical.
- `normaliseLabKey` kept `[a-z0-9]`, which reduced every Korean analyte
name to the empty string, so the derived index held none of them and a
Korean cholesterol result reached a FHIR export uncoded.
- The reference-range parser had the hyphen and the dashes but not the
tilde a Korean lab actually prints (`3.5~5.0`).
The Korean safety banks carry no `\b` next to a Hangul token — `\b` is
an ASCII `\w` boundary and Hangul is not `\w`, so `/\b증량/` matches
nothing — and the clause order is the mirror of the Latin banks, since
Korean closes with the verb. The dose patterns require a target-marking
particle for the same reason the Latin banks require "to"/"by": without
it the permitted restatement "7.5mg을 복용 중이고, 체중은 내려가고
있어요" tripped the lowering pattern.
The Coach and Insights prompts route `ko` down the reviewed English body
plus a Korean reply directive; there is no `safety-contracts.ko.yaml`, so
the native builder has no Korean ground rules to compose from.
Register `ko` in the locale list, language names, and labels, and ship
messages/ko.json so the whole UI — dashboard, settings, share views,
clinician PDF, notifications, and AI prompts — renders in Korean.
Extend the locale-parity, English-leak, plural-completeness, and copy
guard tests to cover the new locale, and update README/CONTRIBUTING to
list Korean among the supported languages.
`GET /api/insights/derived` accepts `windowDays`, bounded to 1..90, and
threads it to the dispatcher. Omitting it changes nothing: every engine
keeps its own default, so a caller that asks for nothing reads exactly
what it read before.
Ninety is where the tier this route promises runs out. The rollup router
opens its WEEK floor at 91 days, and the baseline engines cannot compose
a spread from WEEK buckets, so a 91-day request falls through to a raw
`measurement.findMany` with no row cap — for a densely sampled type that
is a full history scan, and the analytics-read budget allows 120 of them
a minute per account. The trailing `series` is capped at 30 points
regardless, so past the ceiling a wider window would only broaden the
mean behind the value. Out of range is a 422 rather than a clamp.
An unhonoured window is visible rather than silent. `provenance.windowDays`
is the window the engine actually used and `coverage.historyDays` the days
that actually backed it, so a three-week record answering a thirty-day
request reports both numbers instead of implying coverage it does not
have. `HEALTH_SCORE` composes fixed per-pillar windows and reports the
widest; a new suite freezes that it is the only metric that does, so an
arm added later cannot quietly drop the parameter.
Recovery keeps its canonical resolution under the wider window. A worn
band stamps the wake morning and the computed proxy the night before, and
only `resolveCanonicalRecovery` pairs them; a test seeds forty such
nights, asks for sixty days, and holds the answer to forty nights with a
flat trend — a bypass leaves eighty rows and a false climb.
Contract: model `WellnessScoreValue` field for field and publish it. The
envelope's `value` has to stay an open record because one route serves
eighteen differently-shaped metrics, so nothing can `$ref` the shape and
it went out undocumented — `series`, `daysInWindow` and `asOf` were on
the wire with no way to find them short of inspecting a live response. It
is registered in the forced-components slot instead, held against the
runtime interface by a test, and the fields it does not cover are named
in the schema comment so the remaining gap is known rather than invisible.
Most labels on this page are resolved from a runtime value, not written
as a literal: an illness type, a lifecycle, a visit kind, a vaccine slug,
a blood-type constant, a cycle phase, a GLP-1 side-effect tag. The
call-site guard cannot see any of them, and the enum-derived guard can
only cover a space whose members it can enumerate from a source. A key
that does not exist therefore renders as its own dot notation, in front
of a doctor, with every other guard green — which is exactly how
`encounters.kind.ROUTINE` reached three surfaces.
One fixture with every section populated, six renders, one assertion:
nothing in the reader-visible text may look like a dotted key path.
Break-proof: put the interpolated visit-kind key back and all six go red
naming it.
A reading judged against the window its own report printed, beside a
saved band that states different limits, is two answers about one
number. The PDF names both — the range in the column, the saved band in
a footnote under the table — and says in as many words that showing one
of them is a partial answer. The page carried only the first.
Both fit on the value line here, so there is no footnote to place.
Break-proof: force the divergence branch off and the case goes red.
Both routes were exempt from the contract as `shareLink` — "authenticated
by the URL credential, for a recipient with no account". That reason
holds for the page a person opens. It does not hold for two routes whose
whole purpose is to hand a practice a file it files into another system,
which is exactly the audience a contract has. `/c/{token}/d/{id}` was
published all along, one route away in the same tree.
Each entry states the gate it shares with the page, the frozen selection
it can never exceed, the flat 404 that covers every miss class including
a documents-only link, and the 20/h per-link bucket. The FHIR entry says
plainly that it is a download and not a REST face, and names the two
resource families the bundle deliberately omits.
The create-request description claimed "there is no FHIR or other
machine-readable face behind a share token", which stopped being true
when these routes landed. It now describes what a link actually serves
and records that the insurance leaf is refused outright.
The coverage guard gains the other half of its staleness check. It caught
an exemption for a route that had disappeared, and never the reverse — an
exemption left behind after the route was published, which is how these
two would have stayed on the list while the contract already carried
them. Break-proof: re-add either entry and it goes red naming it.
Eleven leaves stayed invisible for eleven releases because nothing
connected the two lists. The picker-to-aggregator guard was satisfied and
kept being satisfied; the page was not on either end of it.
`DISPOSITIONS` is `Record<StructuredLeafId, …>`, so an eighteenth
structured leaf fails `pnpm typecheck` until someone writes down whether
it renders or why it cannot. Per leaf the suite then renders the real
view over a real fixture and asserts the marker appears; re-renders with
that leaf alone removed from the frozen selection, payload untouched, and
asserts it is gone; and for a refusal, reads the list that enforces it
rather than trusting the note.
Once over the whole set: a carried leaf with nothing behind it says so, a
leaf whose module is off says something else, and a leaf the link never
carried says nothing at all.
The `share-downloads` row is asserted here too. Its test id had zero
references anywhere in the tree, so the two buttons a practice clicks
could have lost a link, pointed at the wrong path, or vanished from a
documents-only share unnoticed.
Break-proofs, each confirmed red then restored: drop a section element
(that leaf's render case); gate `LeafSection` on data instead of scope
(thirteen withholding cases); collapse the switched-off arm (the
distinction case); empty `SHARE_LINK_FORBIDDEN_LEAVES` (the refusal
case); add an eighteenth leaf (typecheck).
Someone ticks "Lab values" in the selection, sends the link, and the
doctor opens a page that says nothing about lab values — while the PDF
download from the same link prints them. Eleven of the seventeen
structured leaves were like that: the control existed, the data reached
the page object, and no component read it. The person had every reason
to believe they had shared something they had not.
Rendered, in catalogue-group order, which is also the PDF's:
- emergency information, framed and first, as it is page one there;
- personal details (name, date of birth, gender, height);
- lab values, with the reference window each reading was judged
against, printed as the source report printed it;
- GLP-1 therapy and the logged-dose ledger;
- conditions and illnesses, visits, immunizations;
- family history, mood, the menstrual cycle.
What each shows follows the PDF, which has already settled what belongs
in a section and in what order. The layout does not: a six-column table
becomes a labelled block, a four-column one becomes a row with its
detail composed onto the value side.
Absence is stated, never implied. A leaf the link does not carry renders
nothing at all — the recipient was never promised it. A leaf on the link
with nothing behind it renders its heading and says so, and one whose
domain is switched off on the owner's account says that instead. This
extends what the health-profile section has always done per fact to the
section level, and the glucose, medication and allergy cards adopt it
too; the measurement groups deliberately stay silent, because a group
card stands for up to seventeen leaves and twelve empty ones is noise.
Every card now asks the frozen selection directly through `LeafScope`
rather than inferring consent from the presence of data. The aggregator
already applies the selection, but a section that reads only "is there
data" trusts a gate it cannot see.
Three rows on the emergency card are composed from other leaves — severe
allergies, the drug list, chronic conditions. Each appears only when its
own leaf is on the link: printing "Not recorded" for a leaf the owner
withheld states an absence in the record where the truth is an absence
in the share, and on that card it is the dangerous direction to get
wrong.
The glucose card's heading was "Lab values", which was the wrong name
for it even before the actual lab results arrived; it is the glucose
group's own label now.
`INSURANCE` gets no renderer and must not get one: the create route
refuses the leaf outright, so no link can carry it.
A leaf on a share link can be absent from the payload for two reasons
that look identical downstream: the owner shared it and recorded
nothing, or they shared it and the domain is switched off on their
account. The aggregator ANDs the selection and the module map and
returns the same null either way, so the recipient could not tell an
empty section from one that never had a chance to carry anything.
`loadShareViewData` now resolves the whole module map — it needed the
`doctorReport` key from it anyway — and returns `unavailableLeaves`: the
leaves the link DOES carry whose owning module is off. Only selected
leaves appear, because a withheld leaf's module state is not the
recipient's business in any direction.
The map is deliberately NOT handed to the aggregator. The third argument
is the frozen selection and there is no fourth, which is what keeps this
surface from growing an options object that widens what it asks for; the
resolver memoises its reads per request, so the aggregator resolving its
own map again costs no round-trip.
The module-gate suite moves off `isModuleEnabled` with it, and gains
three cases for the new verdict, including the one that proves an
unshared leaf is never named.
Three surfaces built the label key by interpolation — the doctor-report
PDF's visit table, the daily digest's upcoming-visit line and the
clinician share view. `EncounterKind` is `ROUTINE` and the bundle leaf is
`routine`, so all three rendered `encounters.kind.ROUTINE` verbatim: into
a document a practice files, onto a lock screen, and onto the page.
Route them through `encounterKindLabelKey`, a literal switch beside the
existing `encounterKindLabel`, so the mapping is written down once.
The enum-derived i18n guard could not have caught this: it only knows
key spaces of the form `prefix + member`, and this one is not. It gains a
second arm for spaces whose mapping is a function, with `encounters.kind`
as its first entry. Break-proof: point one arm of the resolver back at
the uppercase key and six locale cases go red.
The authenticated shell wraps every route and read one destination
predicate off the `@/lib/record-settings` barrel. That barrel also
re-exports the managed settings-patch schemas, so Zod, the module
registry and three validation modules rode into the eager client
bundle of every authenticated page for a function that inspects a URL
slug. The shell imports `record-settings/classification` now; the
barrel loses the re-export nobody else used.
Measured over all 585 client-rendered routes: none grew, 100 fell, by
4.7 KB gz at the median and 8.1 KB at the most. /insights had been
sitting on its 445 KB ceiling for three releases and went over it on
this branch's CI run; it measures 437 now, /measurements 435, / 450,
/insights/mood 450.
The whole-build sum moved the other way, 3451 to 3476, because the
aggregate counts each emitted chunk once: a graph that leaves the
chunk group every route shares gets counted again wherever it lands,
here inside the deferred settings chunks that use it. Bytes on disk
rose, bytes a reader downloads only fell. The aggregate cap moves to
3500 with the same 24 KB headroom the four earlier re-measurements
kept, and the reasoning sits next to the number.
The two halves of the ECG fix were built apart. One taught the parser
Apple's full English verdict set; the other taught it the metadata keys and
verdict wording a German watch writes. Each was correct alone, and each
left "Sinusrhythmus" unresolved for a different reason.
Together they meet: the alias table translates the wording onto Apple's
own English, and the classifier decides what it means. One place still
owns the meaning, which is what stops a per-language verdict table from
drifting the first time Apple adds a case.
The test that expected null was pinning the gap between the branches
rather than a decision about German. It now expects NOT_DETECTED, and a
French verdict takes over the null case, because four of the six
languages genuinely have no observed key map yet.
The CSV is written in the device's language, and the keys are translated
along with the values: a German watch writes `Aufzeichnungsdatum` and
`Klassifizierung`, not `Recorded Date` and `Classification`. Indexing on
the English keys found none of them, so the parser never located the
waveform and rejected the recording outright — a lost import, not a
missing field.
Two alias maps close it, both taken from real exports rather than from a
dictionary: nine metadata keys and two verdicts, consistent across every
German file observed. Adding a language is a data addition to those maps
and nothing else.
The verdict map is an indirection onto the English wording rather than a
second verdict-to-enum table, so there stays exactly one place that
decides what a verdict means.
The other four shipped languages are absent on purpose. A wrong key
silently mis-files a value and a wrong clinical verdict is worse than
none, so an export this parser cannot place is refused — which an operator
can see — rather than read into the wrong column, which they cannot. Two
gaps are written down at the map: no observed file carries an
average-heart-rate row in any language, and the paired layout's column
header has only ever been seen in English.
The rate matcher demanded "Hz". Observed exports spell the unit out and
lower-case it — "511,422 hertz" — and the German ones separate it from the
number with a no-break space. The fixtures had only ever used "512 Hz", so
the mismatch never showed.
Rate and heart rate now split into a magnitude and a unit word, with the
magnitude read through the same decimal helper the waveform uses. "512 Hz",
"512 hertz" and "511,562 Hertz" all resolve; a missing unit, the wrong
unit, a grouped number and a rate past the bound are all still refused.
The unit word is matched loosely and the magnitude strictly. That is the
right way round: the unit is presentation and varies by region, while the
number is the measurement.
A watch set to a comma-decimal region writes its samples as "-180,596".
The single-column reader rejected any row containing a comma outright, so
every one of those files failed on its first sample row.
The two waveform layouts disagree about what a comma means, which is why
this cannot be decided per line. The parser now names the section it is in
— header, single-column, paired — and both crossings are commented at the
point they happen: the paired layout announces itself with a `Lead,Voltage`
column header, while the single-column one begins at the first blank line
AFTER Lead and Unit have been declared, because real exports also carry
blank lines mid-header.
Scalars in both waveforms now read either dialect through one helper, so
"-180.596" and "-180,596" give the same microvolts and neither is
privileged. A number carrying thousands grouping is refused rather than
guessed at, since resolving it needs the file's region and the file never
states it. The one genuinely ambiguous shape is a separator followed by
exactly three digits; it is read as a decimal, because observed waveforms
put two- and three-digit fractions in the same file and grouping is always
three, and because a machine writing one bare value per row has no reason
to group at all.
Every real export begins with a bare `Name` when the name field is blank —
Apple omits the trailing comma rather than writing an empty value. The
parser required a comma on every header row, so it rejected the file on
line 1, before it had read anything.
A valueless header row is now skipped. It contributes no metadata entry
rather than an empty-string one, because `Lead` and `Unit` presence is what
switches the parser into the single-column waveform mode and a key with no
value must not be able to trip that switch.
The relaxation is confined to the header. Inside the single-column
waveform a comma-less row is a sample and is read as one; inside the
paired waveform every row is lead and voltage, so a missing comma there is
still malformed.
ignoreExportsUsedInFile did not only exempt exports used inside their own
module; until 6.28.0 it also marked every re-export as referenced in its
own file, so a barrel line nothing imported was never reported. The
upgrade took this repo from 0 findings to 206 on unchanged code. Write it
down next to the setting, because a green gate on any earlier knip proved
nothing about re-exports.
Also correct the production-pass file count, which had drifted to 21.
CadenceKind and WeekdayToken were imported only to be re-exported. The
re-export went in the previous commit but the import survived, because
the doc comment above it still named both types at the time.
SENSITIVE_LEAVES and COACH_SUGGESTED_ACTION_TYPES each aliased a
constant defined elsewhere and were never imported. Neither is a
re-export the way the barrel lines were — the definition itself is the
dead thing, and both doc comments describe a consumer that does not
exist: the purity guard reads SENSITIVE_LEAF_IDS from ./catalogue
directly, and nothing mirrors the suggested-action allowlist.
The imports that fed them go with them.
Eighteen modules carried a convenience re-export of a symbol defined
elsewhere. In each case the callers the comment describes now import the
concrete module directly, so the line had no importer left. Where the
whole re-export block went, the comment explaining it went with it —
doctor-report-pdf-core, trend-card and medication-extract-prompt each
claimed to be a single import surface that nothing was using.
Definitions are untouched; imports that existed only to feed a removed
re-export go too.
Same shape as the derived and MCP barrels: the line in the barrel had no
importer because the consumer imports the concrete module. Affected:
cycle (15), coach tools (9), record-settings (8), links (4), data-list,
workout-detail and analytics/score (1 each).
analytics/score also carried CompositeValue on an explicit export line
above an export * of the same module, so the name stays exported either
way.
Definitions are untouched.
The barrel mirrored the tool, resource, prompt, write and scope registries;
its only consumer is the /mcp transport route, which needs three of them.
The registries are imported from their concrete module by ./server and by
their own tests, so the other 31 lines had no importer.
The barrel mirrored every export under src/lib/insights/derived. Of the
124 lines, 101 had no importer: consumers and per-metric tests reach for
the concrete module (./sleep-score, ./trajectory, …) directly. Keep the
cross-cutting surface — registry, dispatcher, baseline loader, and the
value shapes outside consumers read — and say in the header that the
barrel is not a mirror, so the next addition needs a consumer first.
Definitions are untouched; only the re-export lines go.
The newer resolver tracks each re-export as its own reference site, so a
barrel line that nothing imports is now reported instead of being counted
as used whenever the underlying symbol was used anywhere. The gate goes
from zero findings to 206 on unchanged code.
The classification map held three values. Apple's set is larger, so a real
verdict was being thrown away on English devices: "Heart Rate Over 120"
and "Heart Rate Under 50" both became null, and the column cannot tell
that apart from a recording that carries no classification at all.
The wordings are observed rather than remembered. The CSV writes a display
string and it does not match the framework's case names — the case
documented as InconclusiveHighHeartRate reaches the file as "Heart Rate
Over 120". Across a corpus of 128 real exports the field held five
distinct values; three of them mapped and two were dropped. The threshold
belongs to the watch generation, so the two heart-rate arms match the
number instead of pinning it, and the rest of the map takes its wording
from Apple's ECG instructions for use.
Classification is now split from the column write so the parser can tell a
verdict it has never heard of from one it knows it cannot represent.
Unrecognized is the second kind: the device did not fail to classify the
waveform, it received a verdict it does not know, so recording it as
"could not classify" would assert something Apple never said. Both still
store null, because that is the column's whole vocabulary, and no column
is added for a distinction nothing reads yet.
The old table pinned one row for "Localized or unknown verdict". That
assertion is still true, but it fused three different facts — a localised
verdict, an unknown one, and the two documented verdicts being silently
dropped beside them — and it read as "everything else is unknown" when two
of those were verdicts Apple documents. It is split into the four outcomes.
The language half stays open and is now named at the mapping. A German
export writes the KEY in German too, so a non-English verdict never
reaches this function; it fails the parse earlier.
The grounding tokenizer read English unit words and English or German
month names, while the reply is written in the reader's language. Two
silent failures came out of that, pointing in opposite directions.
A missing unit loosens the guard. "7,4 Stunden", "45 minuti" and "8000
pasos" came back unkinded, and an unkinded token clears the kind gate
against every ledger entry, so a fabricated night's sleep could ground
itself on a glucose reading that happened to share its magnitude. Kind
scoping is the thing that stops a weight grounding a blood-pressure claim;
for five languages it was not running.
A missing month corrupts correct prose. "Le 21 juillet" left the day
untyped, the plain-magnitude pass graded it, nothing matched, and the
soft-strip replaced it — the reader was shown "Le […] juillet". Spanish
and Italian also put a particle between day and month, so the pass admits
one.
The unit vocabulary had been written out three times and the copies had
already drifted; only one of them knew about "%". One list now feeds the
classifier, the range pass and the measurement pass. Polish months are
listed in the genitive, which is what a date uses and also the only form a
trailing word boundary can match, since that boundary is defined over
ASCII and "ń" is not part of it.
The grid draws its group headers in the reader's language and then
searched only the English Lucide names and the English keyword aids. A
German reader who typed back the header they had just read — "Gesundheit"
— got an empty grid and "no icons found", with nothing to say the query
was fine and the language was not.
The filter now also matches the group header the picker is currently
rendering, folded on both sides so an accent or a capital cannot make
someone miss a word they can see. The labels come from the bundle at the
call site, so a seventh language needs no edit here.
This is a partial close and the test says so. The per-icon aids stay
English, so "Herz" and "kawa" still find nothing; translating roughly
eighty icons times three aids times six languages is a content decision,
not a filter one.
The bot's real commands are slash commands, and `telegram.helpBody`
documents exactly those in all six bundles, so nothing a user is TOLD to
type depended on their language. The undocumented courtesies did: the bare
word "help" and "hilfe" opened the help text and five greetings were
answered, everything else fell through to the intake keyword, failed it,
and returned without sending anything. Someone typing "aide" or "pomoc"
got silence, which is the worst possible answer to a request for help.
Both lists now cover the six shipped languages and are compared against
the folded message, so "cześć" and "czesc" are one entry and a capital
cannot miss. The match stays anchored to the whole message: "help me log
my weight" is prose the later paths should still see.
These are ordinary words rather than app labels, so there is nothing in
the bundles to derive them from.
The gate is "looks off-topic AND does not look health", and both halves
were English/German word lists. Two things followed. An off-topic ask in
French, Spanish, Italian or Polish was never recognised. And a genuine
health question in those languages that happened to brush an English deny
word was refused outright: "Ho una serie di misurazioni strane" trips
"serie", and nothing on the allow side knew "misurazioni", so a question
about the reader's own data came back as a refusal.
The allow side is no longer transcribed. Every metric label key and the
health nav labels already name these concepts in all six bundles, and
those labels are the nouns people type, so the vocabulary is read from
`messages/<locale>.json` and a seventh language is understood the day its
bundle lands. Matching is whole-word over a shared fold applied to both
sides. A subtractive list keeps label scaffolding out; French "bien-être"
alone would have contributed "bien" and "etre" and marked nearly every
French sentence as health.
The deny side stays hand-written — these are not app labels — and its new
arms match the folded message. A pattern anchored with \b cannot match a
word starting with "ż", because neither the preceding space nor the letter
is an ASCII word character, so there is no boundary between them.
The prompt-injection bank is untouched. It reads the person's own message
and the foreign-text path is fenced elsewhere; this is defence in depth,
not an open door.
The normative-claim bank held English and German words. Insight
recommendations are generated in the reader's locale, so for French,
Spanish, Italian and Polish every recommendation scored non-normative:
the annotation reported zero normative claims and zero uncited ones, and
the admin quality dashboard read that as full coverage rather than as no
measurement at all. A check that cannot fail is indistinguishable from a
check that passes.
The bank now has one arm per shipped locale and the caller passes the
generation locale; the reader's arm and the English one both run, because
a fallback provider answers a non-English reader in English. Matching
folds both sides through the shared label fold and anchors the leading
word boundary, so inflections still hit while the everyday Italian
"soprattutto" no longer counts as a comparative.
These words are the model's prose, not app labels, so they are not in the
message bundles and cannot be derived from them. The structural test
refuses an empty arm instead: a seventh language fails the suite rather
than quietly scoring nothing. An empty arm also used to compile to an
alternation that matched every string, which would have flipped the same
blindness to the opposite extreme.
The gate landed with a softer mode for accounts holding no second factor,
on the premise that they could not clear it. That premise was wrong. A
passkey is a possession factor here and the codebase already says so in
three places: a primary passkey login stamps Session.mfaVerifiedAt,
passkey sits in FRESH_FACTOR_METHODS so a passkey-proved elevation reaches
the fresh-factor routes, and the enrolment policy counts a primary passkey
as satisfying it outright. The only thing refusing such an account was the
reachability pre-check inside requireFreshMfa, which counts second factors
and nothing else.
So the mode is gone and the route asks for a full freshFactor: true with
proofSource: "any-possession" — a second knob that widens which credentials
count as "could ever prove this", and changes nothing about the gate. A
passkey-only account now gets a real gate with the re-provable
auth.stepup.required rather than no gate at all, and the dead-end
auth.stepup.mfa_not_enrolled is reserved for an account holding nothing.
hasSecondFactorEnrolled is deliberately NOT widened. It answers a different
question, and requireFreshMfaIfEnrolled asks it before deciding whether
account deletion, the data reset, the password change, the encrypted export
and key rotation gate at all — counting a passkey there would change five
destructive actions in passing. The two predicates sit beside each other
with docblocks saying why they differ, and collapsing them turns eight
existing tests red across three files.
Recovery is named rather than assumed. On Bearer: mint an elevation with
method "passkey". On the web: sign in again, which is what stamps the
session — there is no in-page re-proof for a cookie session because the
mint endpoint is Bearer-only by design. That limit is written into the
route docblock instead of left to be discovered, and the removal's refusal
now renders its own sentence in all six locales rather than the server's
English "second-factor" prose, which was wrong for the account this card
mostly serves.
Four surfaces assembled the correlation-discovery matrix and three carried a
different channel set. The Coach `get_correlations` tool had neither the
environmental-exposure channels nor the custom-metric ones, so asking it
whether air pressure relates to your sleep met a matrix the pair had never
been in. Its own file header said the opposite - "the SAME full-matrix scan
the route runs" - and had said it since the weather channels arrived in
v1.25. The period narrative was missing those two families and the
compliance / symptom pair on top.
`src/lib/insights/discovery-matrix.ts` is now the one place a matrix is
built. v1.30.3 hoisted the fetch discipline into a shared module for exactly
this reason and left the assembly duplicated behind; this finishes it. The
route, the per-metric card, the Coach tool and the period narrative all call
`assembleDiscoveryMatrix` and get the same families. What still differs is
declared as an option with its reason at the declaration: the measurement
read mode (a read-cost choice, left per surface), the extra metric types the
narrative's delta beat needs, and the RATED mood factors.
Mood factors stay on the narrative alone. That capture kind is frozen, the
two surfaces that persist a discovered pair write its channel key into a
plaintext column, and a custom factor's key is an opaque id - so admitting
them elsewhere is a decision about what to ask, not a parity fix.
Restore the per-metric filter on the assessment card's relations. It was
written in place of the pattern-dismissal filter rather than chained with it,
so a weight card's prompt had been receiving every surviving pair in the
record while the function name, the docstring and the early-return guard all
still said one metric.
`discovery-matrix-guard.test.ts` freezes who may fetch a channel, who may
construct one, and who may scan. Each matcher asserts a non-zero match count,
so a matcher that has stopped matching fails rather than agreeing with the
allowlist.
The statistics are untouched - same Pearson, same exact p-value, same
Benjamini-Hochberg, same floors. The Coach's family is larger now, which is
the point: it is corrected over the same family the insights page is.
A restore's first act is to delete the class of records it is about to
rebuild, so a payload whose documents section is simply absent did not
"restore everything else". It emptied the vault, reported success, and left
the operator to find the hole later.
The whole file is refused instead, above the transaction that wipes the
account, and the refusal names the section and says nothing was changed —
an operator holding the only copy of an account cannot act on "restore
failed". The sections ride the error envelope as meta.errorCode
backup.section.missing, and the admin console holds them in a panel beside
the skip report rather than a toast that outlives nothing.
The manifest is the discriminator, and only the manifest. A portable export
leaves the mental-health screeners and the consent receipts out on purpose
and declares both as omitted; those files still restore. So does a file
whose section is present and empty, which is what an account with no
documents honestly writes, and so does every backup written before the
manifest existed. The integration test proves the distinction with the same
builder and the same deleted keys, differing only in what the manifest said.
The route is deliberately outside the published contract — requireAdmin is
cookie-only — so the OpenAPI document is unchanged.
DELETE /api/auth/passkeys/{id} took a plain cookie session or a wildcard
Bearer, while DELETE /api/auth/me/mfa/webauthn/{id} beside it demanded a
fresh factor proof. The softer gate sat on the primary sign-in credential:
the last-method check stops an account locking itself out, but nothing
stopped a hijacked session stripping the credentials the owner signs in
with.
The route now goes through requireMfaManagementAuth, the same gate the
security-key removal uses, in a new "if-enrolled" mode. An account with a
second factor pays the full price on either transport — a freshly verified
cookie session, or a factor-proved X-Step-Up elevation on Bearer, so the
native client keeps the capability rather than being sent to the website.
An account with no second factor keeps the contract it had, because a gate
it can never satisfy is not a safeguard but a locked door in front of its
own credential list.
The elevation is spent immediately before the delete, so a foreign id or a
refused last credential does not burn a proof that then has to be minted
again. The route is added to the elevation allowlist with its reason, and
the either-factor enrolment test that was written out three times in the
API kit is now one function.
DELETE /api/user/thresholds without ?metric= erased the account's whole
override set on an omitted query parameter alone: no confirmation, no dry
run, and no rate limit of its own while the PUT beside it had one.
The wide form now wants { "confirm": "RESET_THRESHOLDS" } in the body — the
same typed-confirmation shape /api/settings/account and /api/settings/data
already use — and refuses 422 without writing anything. The ?metric= form
is untouched: it resets one band the person can retype, and the targets
sheet fires it in a loop. Both forms now run the PUT's 30 / 5 min limit.
The settings card's "reset all" control already opened a confirm dialog;
it now sends that answer to the server too. Its token is compared against
the route's declaration in the tests, so the two ends cannot drift apart
into a permanent 422.
Both named /api/doctor-report, a family removed some releases ago. The
contributor doc sent a reader to a POST endpoint that answers nothing,
and the renderer's own header described itself as serving an endpoint
that is gone.
Found by the sweep behind the retired-route registry, which asks what
this repository still references rather than what a client calls. Two
hits, both prose, neither a call.
The three locale matchers were built in parallel and each grew its own
fold. Two of them are the same function: the side-effect tag fold and
foldForMatch agree character for character, so one now calls the other.
The third stays. foldLabel decomposes with NFKD, maps sharp s to ss and
joins with underscores; foldForMatch decomposes with NFD, leaves sharp s
alone and joins with spaces. That is a disagreement about who typed the
text, not an accident: one side of a lab comparison is a shipped label
retyped or prose a lab printed, where tolerating a different spelling
claims more than the fold can prove, while someone naming a metric to an
assistant is composing rather than reproducing. Both docblocks now say
so and name the other.
Three resolvers turned a user-authored word into an internal code by
comparing it against a hand-typed English list. The app ships six
languages, so five of them resolved nothing: `Fer` and `Żelazo` came back
as `unknown_nutrient` while the settings card rendered exactly those
words, and `poids` and `sommeil` reached no series at all.
Stop transcribing. `nutrients.names.*` and `measurements.type*` already
label these concepts in every bundle, so the match index is derived from
`locales` instead — a seventh language works the day its bundle lands,
with no edit at any call site. The nutrient tool's English labels now come
out of `messages/en.json` too, so the wire wording cannot drift from the
screen.
The MCP contract is unchanged in shape and stays protocol-level English.
What moves is which words are understood: `unknown_nutrient` /
`unknown_metric` still mean "could not place this word", distinct from a
placed name answering `no_data`. Nothing turns absence into data.
Two findings the sweep did not predict:
- `Puls` resolved to pulse-wave velocity, because the English
display-name pass matches substrings and "pulse-wave velocity" contains
it. An exact name now beats a loose English fragment.
- French and Italian use one phrase for body fat and fat mass, and three
bundles use one for fat-free mass and lean body mass. A word two
metrics claim resolves to neither; answering kilograms to someone who
asked for a percentage is worse than saying the word was not
recognised. The known set is pinned so a new collision surfaces.
The medication needles are the exception: they are drug names, with no
bundle to derive from. They fold to a normalised INN stem rather than to
a language, so `Metformin`, `Metformine`, `Metformina` and `Metforminum`
are one entry, and `Semaglutyd` and `Atorwastatyna` cost nothing to add.
A guard asserts no two molecules collapse onto one stem. Iron is the one
class with no stem to fold to — it is named for the element — so its word
list is hand-tabulated and says so.
Every English resolution that worked before returns the same answer.
The derived names extended the analyte class by list index. Reordering the
signal list would have re-pointed them at the units or the report header with
nothing to notice, so the class is a named constant and the check compares
against it.
The gate that decides whether a transcribed document is worth an extraction
call scored four classes of evidence and needed two. Three of them — the
reference-range label, the analyte names, the report header — were written in
English and German, so a French, Spanish, Italian or Polish report scored at
most one: the units, which are the same everywhere. Auto-staging never fired
for it. The document was lost from the flow silently, because the manual
extract button stays available and nothing reads as broken.
The text is folded once — lower case, accents dropped — and every pattern is
matched against that, which on its own recovers a good deal: "Cholestérol",
"Créatinine" and "Emoglobina" reach the existing stems as soon as the accents
are out of the way. The reference-range labels and report headers of the four
locales are added by hand, because they are prose a lab printed and the app
owns no string for them.
The analyte names ARE derived, from `labs.catalog.<slug>` across every shipped
bundle: those are names the app itself displays, and a new locale should not
need an edit here to be recognised. Names under four folded characters are
dropped — "ALT" is German for "old", and a three-letter alternative in a gate
this cheap would make every German letter a lab report. The acronyms worth
having are in the hand-written half, where each was considered on its own.
Which way this errs, stated at the code: a false negative loses a whole
document from auto-staging, while a false positive costs one extraction call
and stages facts a person then declines — nothing commits either way. So the
vocabulary is widened, and the two-distinct-class rule holds the other side.
The derived names extend the analyte class rather than forming a fifth one, so
a document naming one analyte in two languages still scores one. Tests pin
each addition on a case that fails the gate without it, and pin the prose that
must keep failing it.
The lab form offers "negative" and "positive" as quick picks and writes the
label it rendered, so the column holds the bundle's own string in the language
the reading was recorded in. The SNOMED mapper matched that string against a
list transcribed in English and German, so "négatif", "negativo", "ujemny",
"positif" and "dodatni" all exported without a code — text-only on the one
document meant to be machine-readable.
The negative and positive arms read `labs.form.qualNegative` and
`labs.form.qualPositive` out of every bundle now. The form writes those exact
keys, so the write side and the read side cannot drift, and a new locale is
coded the day its bundle lands.
The detected / not-detected pair stays hand-written English and German, and
that is the honest limit of this change: the app ships no string for those
terms, they are prose a lab printed and a person copied in, and coding "non
détecté" or "niewykrywalny" would mean inventing clinical vocabulary in four
languages nobody here can check. This module's own rule is that an unverified
code is worse than an honest `.text`. When they need coding they need a quick
pick and a bundle key, which is the route the other two arms took. A test pins
that they stay uncoded rather than leaving it to be discovered.
Borderline stays uncoded as before; a test now pins that the derivation did
not sweep its bundle key up by accident. Comparison folds accents as well as
case, so a result typed without them still resolves.
A biomarker minted from the labs catalog stores `t("labs.catalog.<slug>")` —
the name in the language of whoever minted it — and that stored name is what
reaches the FHIR exporter as free text. The exporter matched it against an
alias table written in English and German. So an account working in French,
Spanish, Italian or Polish exported "Cholestérol total", "Glucosa en ayunas",
"Glicemia a digiuno" and "Cholesterol całkowity" as text-only concepts with no
LOINC code, on the one document whose whole purpose is being read by another
system.
The names are DERIVED from `messages/<locale>.json` now, not transcribed into
this file. Both sides of the new slug map are app-owned identities with no
language in them, which is what makes it the one edit a new locale does not
need: `locales` grows, `messages/xx.json` lands, and the analyte names it
ships resolve the same day. Transcribing four more languages would have fixed
today's six and reopened the hole at the seventh.
The hand-written aliases keep precedence. They cover what the app owns no
string for — clinical abbreviations, the ways a person shortens a name — and
they were verified one at a time over several releases. A derived name landing
on the same key is a harmless duplicate; one landing on a different key would
be a translation typo re-pointing a verified mapping at another analyte, so
the build refuses it and the guard suite asserts the refusal held.
The lookup fold now drops accents before stripping punctuation. Without it a
name carrying diacritics lost them entirely rather than reducing to its base
letters — "Cholestérol total" folded to `cholestroltotal`, and "Hämoglobin" to
`hmoglobin`. That last one means the German umlaut spellings never resolved
either: the hand-written `hamoglobin` and `nuchternglucose` aliases only ever
caught the un-umlauted typing. Every pre-existing alias is ASCII, so the fold
is a widening and what resolved before resolves to the same key.
The free-text reference-range parser decided what a printed window meant from
an alternation of German and English words. The app ships six locales, and a
lab prints in the language of the country it stands in, so `jusqu'à 5,0`,
`hasta 5,0`, `fino a 5,0` and `do 5,0` all fell through to `low: null,
high: null`. The string was stored, the reading was then judged against the
biomarker's own band or against nothing at all, and no surface anywhere said
that the lab had printed a window the app could not read.
The vocabulary is TABULATED here, not derived from `messages/<locale>.json`
the way an app-owned word list would be. These words are prose a lab printed
on paper; the app owns no string they could be read out of, and the language
of a report has nothing to do with the language of the UI. So the table is
hand-written, keyed by the shipped `Locale` union so that adding a locale
fails to compile until someone has answered how that language prints a range,
and every locale's words are tried against every input at once. The cost is
written down at the table: a report in a language absent from it stays
text-only.
Three things had to change beside the word lists. Introducing words are now
stripped only on a second pass, because Italian "da", Polish "od" and French
"de" also state a floor on their own and stripping first read "da 3,5" as a
bare number. Input and table are diacritic-folded and their apostrophes
normalised, so a table row written "jusqu'a" meets a report's "jusqu'à" and
the curled apostrophe a word processor emits. And one-letter separators ("a",
"e", "y") are only accepted with whitespace on both sides, so "3.5e5" is not
read as a window. Decimal commas already worked and are pinned by the new
cases.
An unreadable window now has a name. `isUnreadableRange` separates "the report
stated nothing" from "the report stated something nobody could read" — the two
were indistinguishable at every call site, which is what let the French case
pass unnoticed. The manual form already showed the reader that the text was
kept but not read; the two ingest paths now count it into their wide event, so
an unsupported notation surfaces as a number rather than as silence.
The German and English lists are the previous alternation split word for word,
and the existing suite covering them passes unchanged.
Three surfaces, one source. The registry the proxy answers 410 from now
also emits the tombstones into `docs/api/openapi.yaml` and rides on
`GET /api/meta/capabilities`, so a retirement cannot be honoured at
runtime and invisible everywhere a client could have read it first.
The contract publishes each retired path with 410 as its only response,
the removing version, and the replacement. `deprecated: true` is
imprecise — the word means "still works, stop using it", and these do not
work — but it is what makes a generator warn at the call site, and the
description carries the exact truth the flag approximates. `security: []`
because the answer is produced before any handler and reads no
credential; publishing it as authenticated would tell a client to sign in
to be told a path is gone.
The capability read carries the same list. That surface is the one a
client already polls on launch, so a retirement can reach it before the
failing call rather than through it.
The coverage guard gains a third state. Published and exempted both
describe a route that exists, so removing one used to leave exactly one
legal move — erase it from the contract as well — and that move is
invisible to every client already calling it. A registered path is now
required to be absent from `src/app` AND present in the contract, which
is the one combination the phantom-path case rejected outright. Both ways
out are named in the failure message.
Its docblock says what the state cannot do, because the first draft
overclaimed: a check over one commit's state cannot see a deletion, so it
cannot insist that a removal should have been registered. Proving that
needs the previous release tag, and the job running this suite checks out
at depth one with no tags, which would make the check one that silently
cannot fail. What is held instead is the registration's integrity, in
three directions and with the mutation for each written down.
The sharing-denial guard learns the same state: a retired path resolves
no record fence because it reaches no handler.
A route removed on purpose still leaves a client calling it. As reported
in iOS #94: between 2026-08-08 and 2026-08-20 the native client asked
`/api/auth/me/research-mode` on every GLP-1 detail screen, got a bare
404, and painted a permanent error card with a retry button aimed at the
same removed path. Twelve days, on every reachable instance including the
public demo, which was built after the removal. Deleting the route was
right and the removing commit reasons it out in full; the answer said
nothing. A 404 from a deployment is indistinguishable from a broken
build, a reverse proxy pointed at the wrong upstream, or a typo in the
caller's own path, so reading it as transient and retrying was the
correct reading of the only evidence there was.
One registry, `src/lib/http/retired-routes.ts`, carrying the path, the
release that removed it, the replacement where there is one, and one
sentence on why. Every other surface reads it.
410 over a 404 with an error code because it is the status whose entire
meaning is "this existed here and is gone": a client that understands
nothing else about this API still learns not to retry. The code is not
dropped, it rides in the same envelope with `removedIn` and `replacedBy`
for a client that reads further. The status is the floor, the code is the
detail.
Answered in the proxy, before routing, rather than by a catch-all route
module: a catch-all under `/api` would intercept every unmatched path and
make the ordinary 404 ours to manufacture, which is a far wider blast
radius than the problem. The proxy already owns the exact-path decisions
of this shape. It reads nothing off the request but the path, so an
anonymous caller and an authenticated one get identical bytes and the
tombstone cannot become an authentication oracle.
Seeded from the removed-route set: every route path under `src/app` at
every release tag, unioned and differenced against the current tree.
Twenty-seven paths have been removed over the project's history; the five
registered here are the ones that were published or client-facing, and
each removal is pinned to its release by the route's presence at one tag
and absence at the next. Whether any client still calls one of them is a
separate question that evidence in this repository cannot answer, and the
registry deliberately makes no claim about it.
The GLP-1 side-effect chips in the mood form write the localised label into
the flat `MoodEntry.tags` column, and three readers matched that label against
a hand-written list of English and German words: the therapy timeline, the
Coach snapshot, and the doctor report's GLP-1 block. The app ships six locales.
Tap the nausea chip with the UI in French, Spanish, Italian or Polish and the
day never reached the timeline, the Coach grounded its answers on an empty
side-effect list, and a clinician read an empty table over a record that had
three months of symptoms. Nothing on any of those surfaces said the table was a
filter rather than the truth. The three lists had also drifted apart from each
other.
The chips are a closed set with stable keys, so matching the label was the
defect and the key is the fix. `glp1-side-effect-tags.ts` holds the catalogue —
seven keys, each with the i18n key its chip renders — and the mood form now
draws its strip from it, so the write side and the read side cannot diverge
again. `glp1-side-effect-tag-match.ts` builds the label-to-key index by reading
`messages/<locale>.json` for every entry in `locales`, rather than transcribing
the labels into a second list. Transcribing would have fixed today's four
locales and reopened the same hole on the day a seventh is added; deriving
means a new bundle is matchable as soon as it lands, with no edit in the
matcher. Comparison folds case, accents and separators, so the English and
German tags already in the database keep resolving and a straight apostrophe
matches the typographic one the French bundle uses.
All three readers now report the catalogue key instead of the stored string.
The same recorded symptom yields one key whatever language it was tagged in,
which also fixes the mixed-language output an account gets after switching UI
locale; the doctor-report PDF resolves the key through the label map so the
clinician reads it in the report's language.
A tag outside the catalogue is still never counted — a free-text mood tag must
not reach a doctor as an invented symptom, and the rest of somebody's day does
not belong on a therapy timeline. It is no longer dropped without trace: the
timeline route annotates the unresolved count on its wide event, so the
classification rate stays answerable while the tag text stays in the database.
The Coach's copy of the list also carried `vomiting`, `reflux` and `erbrechen`.
Those are removed. No chip writes them, so they only ever matched a hand-typed
tag in two of six languages; keeping them would have rebuilt the defect inside
its own fix. Restoring either symptom means giving it a chip and a label in all
six bundles.
The response schema for `GET /api/insights/glp1-timeline` now enumerates the
keys, and its description no longer documents the bilingual match as if it were
the contract.
Three places where the copy promised more than the handler delivers.
The hero's personal-baseline line rendered unconditionally while both its
siblings in the same meta row gate on their own data. It claims the subtitle
above it was drawn from the reader's own last 90 days, but with no briefing the
subtitle is fixed fallback copy computed from nothing — so an account with no
briefing at all was told its generic sentence rested on a personal baseline.
Gate it on the briefing, like the rest of the row.
The pulse sub-page promised a personalised Karvonen target band. Karvonen is
not computed anywhere: the band comes from CDC/NCHS population percentiles by
age and sex, and falls back to a fixed 60-100 for a reader with no age on file.
The copy now says that in all six locales, and the fallback's own source label
says it is a general adult range rather than a personal one, because that arm
is the same band for everyone. Karvonen itself stays unimplemented; whether to
build it is a product decision, not a copy fix.
The Coach's `allTime` window returns 365 days, identical to `lastYear`, and it
is the default. "year so far" was wrong too — a rolling 365 days is not
year-to-date. Both labels now say what the reads do. The two options remain
functionally identical and one of them should probably go, but removing a
default is a product decision.
A rollup band outlives the rows it was folded from. The read-swap falls back to
live SQL only when a band is EMPTY, so a bucket that is merely wrong is served
as current; `ensureUserRollupsFresh` repairs the DAY tier over the trailing 90
days only, and keys off `Measurement.updatedAt`, which a hard delete does not
bump. The WHOOP body sync hard-deletes a WEIGHT row with no invalidation, so a
MONTH bucket keeps reporting a value the record no longer holds.
The block was handing the model two irreconcilable accounts of the same record
and marking neither: the coarse tail said one thing, the all-time extremes read
live beside it said another, and `asOf` stamped the whole thing current because
it is derived from raw reading age and knows nothing about rollup age.
Reconcile them where they are assembled. A bucket mean is an average of real
rows, so it cannot fall outside those rows' own all-time extremes — when it
does, the band is describing readings the live read cannot see. That is an
invariant rather than a heuristic, so the disputed tail is dropped and
`asOf.coarseHistoryWithheld` records that it went. Narrating a ghost is worse
than narrating less.
`currentForTodayClaims` stays keyed to raw recency, which is what it means. The
surviving readings really are from today, and suppressing a true present-tense
statement to punish a stale history band would trade one dishonesty for another.
The bucket on disk is still wrong, and a wide event now says so. The deeper fix
is to make the tier notice — compare a bucket's `computedAt` against the type's
in-window MAX(`updatedAt`), or invalidate on the hard-delete path — which is a
larger change than this repair.
The numeric grounding guard keyed its activation off `loop.toolResults` alone.
A tool that finds nothing resolves to `{ present: false, reason: "none" }` with
no `available` payload, `runCoachToolLoop` drops that shape, and the no-tools
snapshot fallback is populated only in the non-tool branch — so the guard sat
out every turn where the record was empty, which is precisely the turn where a
fabricated figure is likeliest and does the most harm. Ask about a metric you
have never logged and an invented number was persisted and streamed verbatim,
with no withheld-figure marker to say it had never been checked.
`toolTrace` is the discriminant, because it records the pure misses the results
array drops. Tools ran and every one missed: grade the reply — an empty ledger
there is not missing information, it is the positive statement that nothing can
be cited, so `findUnverifiedCoachNumbersInLedger` takes an explicit opt-in to
grade against it rather than no-opping. The model answered without calling a
tool: still dormant, unchanged, because the tool-mode base prompt carries no
pre-computed figures and flagging a legitimately recalled one was the v1.32.1
regression. The inventory counts ride along as a widener so a plain count
restatement stays grounded.
Eliding the digits is not enough on an empty record. "Your sleep averaged […]
minutes, up from […]" still asserts a series, an average and a direction for a
metric with no readings; only the precision goes, the false claim stays. So a
turn that missed every tool and lost a figure to the guard is replaced with the
honest answer, in all six locales. Scoped so a reply whose figure reconciles
against a prior turn or an inventory count is left alone.
Three characterisation tests for grounding gaps found while verifying that
every figure the Coach states comes from the record.
The numeric grounding guard rewrites any figure the turn's Grounding Ledger
cannot account for, and it holds: removing the strip reddens the existing
cross-turn ledger test. What is not covered is its activation. On the tool
path the guard runs only when `loop.toolResults` is non-empty, and a tool that
found nothing resolves to `{ present: false, reason: "none" }` with no
`available` payload, which the loop drops. So the guard disarms itself exactly
when the record is empty: ask about a metric with no readings, and an invented
figure is persisted and streamed verbatim, with no withheld-figure marker to
tell the reader it was never checked. The no-tools snapshot fallback is
populated only in the non-tool-mode branch, so the tool path has nothing to
fall back to, and every cloud provider runs the tool path.
Separately, a rollup bucket outlives the rows it was folded from. The read-swap
falls back to live SQL only on an empty band, `ensureUserRollupsFresh` repairs
the DAY tier over the trailing 90 days only, and it keys off
`Measurement.updatedAt`, which a hard delete does not bump. The integration
test reproduces the hard `deleteMany` in the WHOOP body sync against a
MONTH-band bucket: the deleted value keeps reaching the Coach's coarse tail
while the all-time extremes beside it report only the surviving value, and the
block is stamped current because `asOf` is derived from raw reading age and
knows nothing about rollup age.
All three assert current behaviour and carry a docblock saying so. Closing
either gap will redden them, which is the point.
The generated document is the whole visible effect of both halves, so it
conflicted line for line. Regenerated from the merged registry rather
than resolved by hand.
`.meta()` clones the schema in Zod 4 and registers the clone, so a bare
`schema.meta({ id, description })` statement registers nothing at all.
Forty-one of them sat across nine route modules. The ids they named —
`MeasurementType`, `MeasurementSource`, `LoginPasswordRequest`,
`CreateMeasurementRequest`, `CreateBatchWorkoutRequest` and thirty more —
reached `components.schemas` zero times, the descriptions written for them
were published nowhere, and every use site inlined an anonymous copy. A client
generated from the contract got duplicated nameless types where one named
model belongs, which is the part that matters, because the iOS client is why
the contract is published.
Forty of them now bind their clone, and every use site points at it. Where the
schema comes from `src/lib/validations/*` the import is aliased and the clone
takes the name the use sites already spell, so nothing mutates the runtime
validation schema. The seven that cross module boundaries are exported from
`./shared` and the eight consumers import them from there; importing the raw
schema from the validation module instead is exactly what inlined them.
Thirty-four named components appear where inline copies stood. Fully
dereferenced, the document differs by eighty-six added descriptions and
nothing else: no operation's request or response shape moves. `CoachPrefs`
was already force-registered in the index and finally carries its description.
Five ids legitimately stay out of `components.schemas` —
`ListMeasurementsQuery`, `ListAllergiesQuery`, `ListEncountersQuery`,
`SuggestEncounterQuery` and `ListPractitionersQuery`. The emitter expands a
`requestParams.query` object into inline `parameters`, so a query id has
nowhere to land; they stay in the assigning form for consistency.
Three findings the wiring turned up:
`CreateShareLinkRequest` and `HealthRecordExportRequest` were annotated in the
Coach module, which imported the two schemas for no other reason and never
referenced them. Both operations that send these bodies live in the
health-record module, so the annotations move there. Both descriptions
described a `sections` field: the export body has no such field and rejects it
with a 422, and the share-link body freezes scope through `selection` plus
`documentIds` / `documentOnly`. Publishing either sentence verbatim would have
published a lie, so both are corrected to the shape the routes actually take.
`CoachPlansListQuery` — the forty-first — named a component that nothing
referenced and that a query object could never reach. The operation publishes
its two parameters by hand, because the schema carries a cross-field
refinement the expansion cannot express, and every clause of the annotation is
already published on the operation and on both parameters. It is deleted
rather than left registering nothing.
The annotation was already in the assigning form and still never reached
the document, because both consumers extend the base rather than
reference it and .extend() builds a new object. Same shape as Medication
and CoachPrefs, same remedy: name it in the forced-registration block.
Zod 4's `.meta()` clones the schema and registers the clone, so a bare
`someSchema.meta({ id })` statement registers nothing at all. Thirty-nine of
those across eleven route modules published neither the name nor the
description written for it: the schema inlined anonymously at every use site,
and a client generated from the contract got a duplicated nameless type where
one named model belongs. That last part is the cost that matters, because the
native client is the reason the contract is published.
Bind each annotated clone to a const and point the route table at that const.
Twenty-nine ids now emit under `components.schemas`, referenced from their
thirty use sites. Seven stay absent by design: they annotate
`requestParams.query` objects, which the emitter expands into inline
`parameters` rather than a component. `MoodTagLayout` stays absent too, for
the reason `Medication` and `CoachPrefs` already carry a forced registration
in the components block: its only consumer is an `.extend()`, and extending
builds a new object rather than referencing the base.
Two annotations in the illness module named ids that no route ever referenced,
`GetIllnessDayLogQuery` and `ListIllnessDayLogsQuery` — the day-log path
publishes the merged `GetIllnessDayLogsQuery` instead. They were dead twice
over, so they go rather than get wired to nothing.
`docs/api/openapi.yaml` grows the twenty-nine components and their refs. The
only other movement in it is thirty descriptions appearing where the text had
never reached the document. No request or response shape changes.
The document defined bearerAuth and cookieAuth and referenced neither,
so the schemes were decoration: a client generated from the contract
wires no credential and reads the whole surface as open.
The default now offers the two as alternatives, and the ten operations
reachable before a credential exists opt out with an empty array. The
opt-out list is derived from what each handler accepts, not from the
proxy's public-path list — /api/ingest/medication is on that list and
requires a Bearer token, so reading it as the answer would publish an
authenticated route as open. Writing the list from the proxy is exactly
the mistake the guard caught: /api/auth/oidc/callback went on it and is
not published at all.
Two groups created environment.ts independently; one carried all six
paths, the other only the overview read, so the fuller side stands and
the barrel keeps one import.
Two component-id collisions, both from the same cause: two paths that
are two doors onto one action, documented independently.
DELETE /api/devices/{id} and DELETE /api/auth/me/devices/{id} both call
revokeDeviceCascade and answer with the same shape. Each grew its own
DeviceRevokeResponse, which the emitter refuses outright. The response
moves to shared.ts so neither module owns it and the next door onto the
same call finds it; the two envelopes keep separate names because an
envelope names an operation.
The settings module took both halves by hand rather than by keep-both:
one side added nine paths, the other three, with no overlap but a shared
import block and a shared reminder-thresholds entry that a mechanical
union duplicated.
Four surfaces answered a bad request by quietly substituting a good one. Each
fix comes with the test that catches it, and each test was confirmed red
against the old behaviour before the fix went back in.
`PATCH /api/user/ai-provider` inspected its body key by key with `typeof`
guards, so a numeric `model` or a boolean `baseUrl` failed every guard,
produced no entry in the update, and the write went ahead without it — a 200
that says "saved" for a field that was discarded. A body of nothing but such
keys came back as "No valid fields" naming none of them. The real schema now
lives in `src/lib/validations/ai-provider.ts`, the route runs one `safeParse`,
and a mismatch is the multi-issue 422 every sibling answers with. The accepted
field set is unchanged, and the object is deliberately not strict: an unknown
key has always been ignored here and still is. The one thing the schema has to
carry that no other does is that this body holds plaintext credentials, so no
rule may interpolate the value it rejected — noted where the next `.refine()`
would go.
`?slice=` on `/api/analytics` was compared against one literal and fell
through to the DEFAULT body on anything else, so `?slice=summary` answered 200
after running the heaviest chain on the surface. Invisible and expensive at
once. Closed enum, 422 on anything outside it.
`?metricType=` and `?limit=` on `/api/personal-records` were parsed
defensively, which read as robustness and behaved as silence — and in one
direction it was worse than silence: dropping an unrecognised `metricType`
WIDENS the read to every metric, so a typo returned more of the record than
was asked for. Both refuse now, and the 500 ceiling refuses rather than
clamps, because serving a different number of rows than were requested is the
same quiet substitution. No caller in the tree sends a value the new rules
reject.
`POST /api/ingest/medication` bucketed on the client address, so a household
running a chat bridge, a button and a home-automation rule — the normal
deployment for this route — put all three in one 60/min cell and let the
chattiest starve the rest. It already resolves a per-caller identity two lines
further down and threw it away; the bucket is that token now. A request that
resolves no token keeps the per-IP bucket it always had, through the shared
anonymous helper, so a flood is still capped and a broken proxy chain
collapses into the tight global cell rather than a shared "unknown" one. Its
429 goes through `apiError` and carries `meta` like every other refusal.
Two older tests pinned the defects by name — "drops an unknown metricType
silently" and "falls back to default on garbage ?limit" — and one pinned a
throw where the rest of the surface returns an envelope. All three are
rewritten with the reason beside them.
The generated YAML is deliberately not in this commit.
Twenty-five path entries across nine domains, all of them served and none
of them described. Same cause as the first batch: `openapi:check`
compares the registry against the YAML and never the route tree against
the registry, so a route registered nowhere drifts with every gate green.
Four groups where the contract had to say more than the shape does.
The full backup is the download that carries a whole record in plain
text, so what it contains is a safety question. It has two modes and this
route reaches only one: called with no options, it always emits the
PORTABLE form — decrypted columns, no tombstones, no row ids — while the
disaster-recovery form belongs to the worker and the admin path. Three
things it leaves out say so in the payload's own manifest: document
bytes, workout GPS and per-sample series, and the sensitive pair, where
the screener answers include the PHQ-9 self-harm item and a consent
receipt restored elsewhere would assert an agreement that operator never
obtained. The undeclared gap is named too, since roughly thirty models
the plan marks as backed up still have no reader or restore branch.
The four intake mutations differ in ways a caller has to know before
firing one. The by-id delete, the bulk delete and the purge all
tombstone, so the sync feed carries each removal; only the purge's
compliance rollups are dropped outright, and those recompute. The first
two refund the inventory stamp because not taking a dose puts the units
back; the purge deliberately does not, because refunding a whole history
would inflate the current container count instead. The bulk delete drops
unmatched ids silently, so a partial match succeeds with a lower count
than the ids sent.
The two provider-configuration surfaces are documented by what they do
NOT return. The chain answers provider types and boolean state, never a
credential. The per-medication ingest endpoint returns a raw token
exactly once, on the PUT that mints it, and a re-enable answers a null
token with `created: false` rather than re-issuing — the stored value is
an HMAC and there is no path back.
Insight generation costs money, so both gates are published with the
refusals they produce: the hourly per-user limit, and the day's token
ceiling, which is refused at reservation time before any provider is
contacted and carries its own code so a client can tell "too often" from
"the day's spend is gone". A 200 there does not mean a provider ran.
Two things the code said that were no longer true. The seeded-question
route's docblock declared it deliberately unregistered; it is registered
now, and a comment contradicting the registry is worse than none. And
the idempotency guard went red on `/api/insights/feedback`, whose
exemption said the path could not be published — which is exactly what
that leg exists to catch, so the entry is gone rather than the assertion.
`docs/api/openapi.yaml` is regenerated separately.
The drift check compares the registry to the emitted YAML. It has never
compared the routes on disk to the registry, so a route that was never
registered produced no drift and no failure: the document stayed
internally consistent while going quietly incomplete. Two hundred and
twenty-two paths reached that state.
The coverage guard walks src/app, reads the verbs each module exports,
and requires every verb to be either published or named with a reason.
The reasons are kinds rather than free text, because these fall into a
small number of genuinely different shapes and writing one sentence
seventy-three times would hide the entry whose sentence is different.
The security guard is the same idea one level down. The document defined
bearerAuth and cookieAuth and referenced neither, so a generated client
wired no credential and read the whole surface as open. The schemes
being present is what hid it. The document now declares them as
alternatives, and the guard checks the reference rather than the
declaration.
Both are red until the routes are documented, which is the point.
The eighth teardown endpoint, missed when the seven siblings were split across
the earlier batches. It has no per-user unsubscribe to make — WHOOP webhook
subscriptions are registered once per developer app rather than per category
the way Withings does it — and it leaves the BYO-key credentials in place so a
reconnect does not force a re-paste.
Like every other `POST /api/<provider>/disconnect`, a repeat call answers 404
rather than an idempotent 200, and the description says so. That completes the
tally: all seven disconnect endpoints refuse a repeat, all six credential
DELETEs accept one.
`POST` and `DELETE /api/notifications/web-push` both answered a flat
`apiError("Invalid data", 422)` and discarded the Zod issues. A non-HTTPS
endpoint, an endpoint aimed at an internal host, an over-long one and a missing
subscription key produced byte-identical bodies, so a browser whose
subscription was refused could not tell a fixable client bug from a policy this
instance will never satisfy. Both now answer the multi-issue envelope every
other body-parsing route in the tree uses.
Echoing the issues is safe here and the route says why, because it is the kind
of thing that gets re-derived wrongly later: the endpoint is a subscription's
routing secret and `keys` is its crypto material, but `sanitiseZodIssues` emits
`path`, `code` and `message` only — the rejected value lives in `issue.params`
and stays server-side — and every message this schema can produce is a fixed
string or a length/format default rather than an interpolation. A test pins
that none of the four secrets reaches the wire, so a future schema change that
adds an interpolating message fails here instead of leaking quietly.
The tests assert on content rather than status: a status-only check passes
against the flat error it replaces, which is how this survived. Two of them
compare whole bodies between two different refusals and require them to differ.
The two `/test` routes were checked in the same pass and need nothing. Neither
accepts a body — they take no parameters at all — so neither runs a Zod parse
to discard. Their 422s already carry `not_configured`, `vapid_not_configured`
and `rate_limited_self` as machine-readable codes.
Each of these made the server report something that was not so, and each now
has a test that fails without the fix.
The Withings credential DELETE wrote no audit row and left the ledger at
whatever state it last held. Fitbit, Google Health and WHOOP all park theirs,
and three of them say in their own comments that they do it for parity with a
list that never included Withings. A client reading `state` after the teardown
was told `connected` about a connection that no longer existed.
Oura's and Strava's credential DELETEs audited only when an access token
happened to be present, so removing a stored pair that had never completed
OAuth left no trail at all. Polar's twin has always done both unconditionally;
they match it now. The old Oura test asserted the skip, which is why the gap
survived a rewrite — that expectation is inverted here, with the reason next to
it.
The Withings and WHOOP manual syncs had no rate limiter, while `fullSync` walks
full history against a per-user provider budget. The Fitbit route's own comment
records that it was the outlier until it got one; these two were never brought
along. Both now carry the same 5/60s baseline and 1/hour full-sync buckets as
their siblings.
Every sync route parsed its body inside a catch commented "no body provided",
which was true for one of the three ways to reach it. An absent body still
means an incremental run — that is the documented shape and the web cards send
it. A body that is present and unparseable is now 400, and one that fails the
schema is 422: `{"fullSync": "true"}` used to be read as false and answered 200,
so a client that asked for full history and mistyped the value was told the run
it never requested had succeeded. Unknown keys are still ignored. The four
copies collapse into one helper.
The consolidated integration status swallowed a failed freshness query into an
empty array, which is exactly what a record with nothing in it also produces —
so the signal built to say "this metric has gone quiet" answered "nothing has
gone quiet" when it had failed to look. `metricFreshnessDegraded` separates the
two, as one flag rather than one per entry, because it is a single query for all
eight providers.
The contract follows the behaviour in the same commit.
`GET /api/medications/{id}/glp1` resolved the caller as themselves while
the POST beside it has resolved the record since v1.37.0. A manager
inside a shared record could therefore add a titration step to a
medication whose history the same path refused them: may write, may not
read. No reading of the sharing model produces that shape, and the
sibling reads on the same medication — `/inventory`, `/side-effects`,
`/cadence` — have all resolved the record at READ level for releases.
The read joins them at that level rather than at the POST's MANAGE,
because it is strictly narrower than the write already admitted. The
module was already on both reviewed allowlists, so nothing is newly
delegable; its reason line said the module was reached through the
MANAGE arm alone, which is no longer true and now says so.
The gap survived this long because a route that refuses every switched
caller is indistinguishable from a route nobody classified. Three legs
close that: a delegate scoped to medications reads the owner's titration
history and still cannot reach their own medication, a delegate scoped
elsewhere is refused, and a caller who never switched is unchanged. Both
delegate legs assert the refusal CODE rather than the status, because the
pre-fix answer was also a 403 — verified by reverting the handler, at
which point the first leg reads 403 for 200 and the second reads
`sharing.not_permitted` for `sharing.access.denied`.
Twenty-eight more verbs that exist, are called, and said nothing in the
contract: registration, password rotation, the profile patch, the SSO entry
point, passkey enrolment, the Codex device-auth flow, the device list and its
revocation cascade, four per-user preference scalars, the email channel and the
four channel self-tests, the record wipe, the privacy facts, the update check,
and the Web Push transport.
Four of them needed more care than the rest.
`GET /api/auth/oidc/login` is documented as the two-armed route it is. The
browser arm redirects to the provider; the native arm takes `client=native`
plus a mandatory S256 challenge and returns to a compile-time scheme with a
one-time code — or an MFA ticket when the account has a second factor. The two
PKCE exchanges in play are named apart, because confusing them is the way to
get this wrong.
`POST /api/auth/register` is the only genuinely anonymous surface in the set,
and its refusals are stated exactly: the SSO-only block, the 5-per-15-minutes
bucket that collapses rather than falls open, the invite that is consumed
whenever it is sent, the zero-user bootstrap that mints the first admin under a
lock, and the one 409 that covers a taken email and a taken username so it
cannot be used to enumerate accounts. `POST /api/auth/password` is a change
behind authentication, not a reset, so it is described as one.
`DELETE /api/settings/data` names what it takes and what it leaves: ninety-nine
models hard-deleted, the account's own personal columns reset, credentials and
interface preferences kept, one transaction, and a confirmation string that is
deliberately not the one account deletion wants.
Two collisions surfaced while wiring this up and are resolved rather than
worked around. `PUT /api/auth/profile` and `PATCH /api/user/profile` are two
doors onto one handler, so they now share one published request component
instead of two descriptions of one validator. Their RESPONSES differ, though —
one carries id and role, the other the display preferences, and neither is a
superset — so they are named apart and the older description no longer claims
to cover both.
The four Web Push and APNs surfaces land in their own route module rather than
beside the notification preferences: that module answers what gets sent and
whether it arrived, this one the plumbing a client sets up and then verifies.
Both self-tests report failure inside a 200, which the contract says plainly,
because a client reading only the status will tell someone their notifications
work when they do not.
The generated YAML is left out; it is regenerated once the remaining groups
land.
`openapi:check` compares the registry against the committed YAML, so a route
that was never registered produced no drift and no failure. These nineteen
had shipped without a contract: the health probe, the dashboard analytics
aggregate, the profile summary, personal records, the badge grid, the whole
environmental-context module, both onboarding writes, the Apple Health import
pair, the external medication ingest, the self-context adopt, the AI-provider
update and the AI connection probe.
Each entry is read off the route rather than inferred from a neighbour, so it
carries what a client cannot guess: that `/api/health` answers a bare object
and not the envelope, that `/api/analytics` records a score row and mints
pattern ids on a GET, that `/api/ai/test` reports a provider failure as 200
with `ok: false` because a 5xx comes back as somebody's HTML error page, that
the ingest route replays with 200 and creates with 201, and that a home
location re-stamps its effective-from instant on every write.
Three route-local request schemas move to `src/lib/validations/` so the
published shape and the runtime parse are one object rather than two copies —
a route module may only export handlers, which is why they could not be
imported where they were. The AI-provider PATCH has no schema to move: it
hand-rolls its parse field by field, so its entry says so and spells out that
a wrongly-typed key is skipped in silence.
The generated YAML is deliberately not in this commit.
Thirty-seven more paths the clients already call and the published spec did not
describe: the six OAuth providers' credential, status, disconnect, sync and
probe endpoints, the cross-provider surfaces under `/api/integrations/*`, the
managed record's configuration tree, and the native device-revoke path.
Where providers share a verb name they do not always share a contract, and the
descriptions say so instead of averaging it out. Three divergences are stated
per path. A disconnect refuses a repeat with 404 on six providers and answers an
idempotent 200 on two. `/api/<provider>/test` answers a bare probe result while
the same-named `/api/integrations/<provider>/test` adds the connection's last
sync, and the Google Health one accepts a body that replaces the response shape
outright. `GET /api/withings/status` refreshes and re-persists the OAuth token
while it reads; the other six status endpoints write nothing, and each now says
which it is.
Two further facts a client cannot infer. The Withings credential DELETE writes
no audit row and leaves the ledger at its last state, unlike every sibling, so
its `state` is not proof of a live connection afterwards. The WHOOP connect
ticket is a credential in a response body, returned once and stored only as a
hash.
The Google Health credential schema moves to the validations module; the managed
record's six strict patch schemas are exported from where they already lived, so
the contract publishes the objects the handler parses rather than a restatement.
Four defects the registry work surfaced, each with a check that fails without
the fix.
`GET /api/tokens` no longer consults the instance-wide API switch. That switch
gates the surfaces a token is FOR — external ingest, the MCP bridge — and not a
token's ability to authenticate: nothing on the `requireAuth` path reads it, so
a token minted before an operator flipped it stays live while it is off, and
refusing the list hid exactly the credentials their owner most needed to see,
their own signed-in phone among them. The revoke keeps the gate; the asymmetry
is stated in both route comments and pinned by the test, because widening it is
a decision about what the switch means rather than a fix to the read.
`PATCH /api/auth/me/unit-preference` reads its body through `safeJson` with a
1 KB cap, like the scalar beside it. It used a bare `request.json()` and was the
one endpoint in the family with no body-size limit at all — the new test proves
it by sending 2 KB and watching the old handler answer 200. Malformed JSON now
answers 400 like every sibling instead of 422; the only caller checks `res.ok`
and never the status, so nothing depends on the old code. The existing
malformed-JSON assertion was changed deliberately: it pinned the inconsistency,
not a contract.
`PATCH /api/auth/passkeys/{id}` returns the multi-issue envelope. It answered a
flat "Invalid request", so an empty name and a 200-character name produced
byte-identical bodies and the form had nothing to show beside the field.
`GET /api/user/thresholds` has a header that describes the handler. It promised
`{ defaults, overrides, effective }` and has always returned
`{ effective, overrides }`. The guard compares the two, so the comment cannot
drift again in either direction.
The two published operations whose contracts moved are updated with them.
Eleven routes the native client calls have never been in the registry, so
`docs/api/openapi.yaml` does not describe them. Nothing caught it:
`openapi:check` compares the registry against the YAML and never compares
the route tree against the registry, so a route registered nowhere drifts
with every gate green.
GET /api/analytics
GET,POST /api/cycle/symptoms/custom
GET /api/dashboard/summary
GET /api/environment
GET /api/export
GET,PUT /api/insights/settings
GET /api/insights/targets
GET,POST /api/medications/intake
GET,POST /api/medications/{id}/glp1
GET /api/mood/insights
GET /api/personal-records
Each contract is derived from the handler rather than from its comments:
the methods it exports, the parameters it reads with their real defaults
and bounds, the statuses it can actually produce, whether it resolves the
record fence, and which module gate it sits behind. Where the answer is
awkward it is written down rather than smoothed over — `/api/export`
answers a bare `data` key and not the standard envelope; `/api/analytics`
serves two unrelated shapes behind one query parameter and records the
health score it computes; `GET /api/medications/intake?scope=today`
projects rows before it reads them; the GLP-1 GET is not delegable while
the POST on the same path is; and `highlightInsight` on the dashboard
summary is pinned null by the handler and has never carried anything.
The intake aggregator's query and body schemas move from inside the
handler to `@/lib/validations/medication`, so the contract generates from
the same object the route parses with instead of a second copy of it. The
environmental overview gets its own route module because nothing owns that
surface. `docs/api/openapi.yaml` is regenerated separately.
`pnpm openapi:check` compares the registry against the committed YAML and
never the routes against the registry, so a route that was simply never
registered stayed invisible to the gate. Sixteen of them are called by the
native client and describe nothing: sign-out, the three per-user preference
scalars, the two pre-session discovery reads, passkey login options, the
passkey rename and delete, registration status, the consent latest-receipt
reader and its revoke, account deletion, the instance service switches, the
API-token list and revoke, the threshold overrides, the version read, and the
assistant feature matrix.
Each contract is derived from the handler rather than from its docblock, so
the published statuses are the ones the code can actually produce — the 413 on
a 1 KB body cap, the 400 the consent family answers where the rest of the API
answers 422, the 403 an instance-wide API switch produces on a token READ, the
last-admin and last-Guardian refusals on account deletion, and the fresh-factor
arm that a Bearer transport cannot clear at any scope.
Four request bodies moved to the validation modules the handlers now parse
with, so the published wire cannot drift from the runtime one: the three
`/api/auth/me` preference scalars into a new `user-prefs` module, and the
passkey rename beside the login schemas. Behaviour is unchanged.
The generated YAML is left out; it is regenerated once the remaining groups
land.
Sixteen routes the native client already calls were absent from the OpenAPI
registry, so the published spec said nothing about them. `openapi:check`
compares the registry against the YAML and never the routes against the
registry, which is why the gap survived.
Nightscout status / test / disconnect, the WHOOP credential and status reads,
and the Withings status / disconnect pair land in the integrations module. The
three per-channel configs under `/api/settings/*` join the settings module; the
notification preference and delivery-health surfaces get their own module, as do
the MCP connector connections and tokens.
These are credential-bearing surfaces, so the contract states what each response
actually carries. Every channel GET reduces its secret to a boolean, and the
descriptions say so, along with the consequence: a client cannot read the value
back, so an empty value on the way in preserves the stored one rather than
clearing it. `POST /api/mcp/tokens` is the one operation that returns a usable
Bearer, and its description says outright that this is the only copy.
Two inline request schemas move to the validations modules they belong in, so
the registry documents the same object the handler parses rather than a
restatement of it.
The register said a restored medication keeps today's schedule and loses
the record of when the dose or the timing moved. Measured, it costs more
than a record: the band minter reads each past day against the schedule
that was live THEN, so an account that came back without its eras had its
own compliance history rewritten against a plan it was never on.
The hard part was supersededByRevisionId, the pointer from a corrected
ARCHIVED era to the MANUAL row that replaced it. Three things about it
had to be settled.
It is a bare id column with no relation, so nothing in the database
refuses a value that points at nothing. That makes losing it quieter and
worse than a broken foreign key would be: every era consumer SKIPS a row
that carries the pointer, so a corrected era whose link came back null
goes live again beside its own correction, and the timeline shows one
window twice with past days minted against whichever the query read
first.
A portable restore mints fresh ids, so the pointer cannot travel as one.
It travels as a POSITION in the drug's own ordered era list. That list is
the only address space both ends share: the builder sorts it totally
(validFrom, then createdAt, then id) so two eras sharing an instant
cannot swap places between two exports of the same account, and the
pointer never crosses a drug, because every route that writes it scopes
the update to one medication. The same mechanism serves both purposes
rather than one carrying ids and the other positions.
Writing it needs two passes, because the row a link addresses is as often
written after the row addressing it as before. Pass one creates every era
with the link null; pass two patches the links once every position has an
id.
A hand-edited file that names a position out of range, or an era's own
position, gets the era and not the pointer, and the position is reported
as scheduleRevisionLink with the JSON path into the file. Refusing the
file would cost an operator every era of every drug over one bad integer,
and writing the link would point a correction at whatever row happened to
be there. A row that supersedes itself is treated the same way: every
consumer would skip it, so the era would vanish from the timeline while
still sitting in the table.
The round trip seeds three eras with one supersede among them and asserts
the shape of the chain, then asserts that exactly two are live. Three
rows with every link null satisfies any count and has thrown away the
only thing the pointer says.
The register said the drug comes back with no statement of what it was
for, which is exactly what a pinned efficacy target is: a row exists ONLY
where the person overrode the derived ATC / name resolution, so it is a
decision and never a computation. Nothing recomputes it after a restore
because there is nothing to recompute it from.
The lab arm travels as a biomarker NAME rather than an id, the way a lab
result's cross-reference already does. A portable restore mints a fresh
catalogue id, and `Biomarker` is unique on `(userId, name)`, so the name
is the only end of the reference that survives the trip.
The obstacle was ordering, not machinery: the remap map already existed,
but the drugs are rebuilt several hundred lines before the analytes are.
The targets are collected against the id each drug actually got and
written in a second pass once the biomarkers exist.
The alternative was hoisting the biomarker restore above the medications,
and it loses on two counts. `biomarkerByName` and `restoredBiomarkerIds`
are read by the lab-result loop immediately below the block, so moving it
would separate a map from its only other consumer and leave a later edit
free to re-order them back with nothing noticing. And the dependency
would read backwards: a pinned target is a leaf of the medication tree,
and letting a leaf dictate where the clinical record gets rebuilt inverts
what the file is about. The second pass states the constraint where it
applies instead of hiding it in a line order.
A named analyte the restore did not put back is dropped and reported as
medicationTarget rather than written. `biomarkerId` is a real foreign
key, so a dangling value would fail the constraint and cost the whole
account over one override. Dropping beats nulling here because the
override IS the reference: a row with neither arm set is what the
resolver already reads as "no override", so keeping it would restore
something that means nothing while still holding the primary slot.
The round trip asserts the restored target points at the biomarker the
restore itself wrote, resolved from the database rather than remembered
from the fixture. Counting would have passed on a row whose biomarkerId
came back null, and nothing else would have complained: the resolver
falls back to deriving a target and the page still renders.
The register said the originating device may still hold them. That holds
for about as long as the phone beside the watch does, and not at all once
a Withings connection is revoked: the signal id stops resolving with it.
So a self-hoster who exported and wiped had a file with no traces in it
and a restore that reported success.
Both ends live in src/lib/export/ecg-backup.ts, the arrangement the
reminders, visits and Coach sections already use.
Three things needed deciding.
The waveform follows the note contract: ciphertext verbatim in a
disaster-recovery payload, the micro-volt samples decrypted in a portable
one. There is no placeholder for a strip that will not decrypt the way
the Coach has one for a turn, because waveformEncrypted is a required
column. Such a recording is left out of a portable file with a warning on
the wide event, so the number of strips in the file is the number of
strips that can be shown.
measurementId is a REAL foreign key against the event row a strip was
filed with, unlike the Coach references next door. A value that will not
resolve does not dangle quietly here, it violates the constraint and
rolls the whole restore back over one cross-reference. A portable export
omits soft-deleted measurements, so that case is reachable rather than
theoretical. The reference is resolved against the measurements the
restore wrote, nulled when it cannot be, and reported as ecgReference.
The strips are wiped and rewritten by the section itself. The wipe at the
top of the restore never reached them: a recording hangs off the account,
not off a measurement, so deleting the measurements only nulled the
pointer and left the row behind.
The round trip asserts the row column by column rather than counting it.
A recording is three things at once and each is the whole value of the
row on its own: a strip with no rhythm verdict is a squiggle nobody has
read, a strip with no recordedAt belongs to no day, and a strip with no
samples is a claim that an ECG exists which nothing can draw.
Four models leave the coverage-pending register with both ends and a
round-trip proof: PersonalRecord, UserAchievement, EnvironmentContext and
EnvironmentTravelLocation. Two new section modules hold reader and restore
beside each other, the arrangement the visits, vaccinations, reminders and
Coach sections already use.
The register understated two of them. The bests are "recomputable in
principle", which is true and does not help: nothing recomputes them, and a
recomputation over a restored history would find different rows, because
sample series and GPS routes never travel, a portable file omits deleted
readings, and a record set in a reading that has since been corrected cannot
be found twice. The badge's unlock date is worse. The evaluator prefers a
persisted date over a derived one, and several of the dates it can still
derive come from counters no backup carries, so a dropped row does not relock
the badge, it re-earns it today.
PersonalRecord.sourceMeasurementId is the reference that needed care, and it
is the dangerous kind. The schema declares no relation for it; migration 0054
created the column as a real foreign key against measurements. Writing it
blind is not a dangling pointer, it is a constraint violation that rolls the
whole restore back and hands the operator an empty account. Measured, with the
resolution removed: "Foreign key constraint violated on the constraint:
personal_records_source_measurement_id_fkey", HTTP 500, nothing restored. So
the restore runs after the measurements, resolves the pointer against the rows
it actually wrote, nulls what it cannot resolve and reports it under a new
personalRecordReference skip kind.
An unlocked badge names a definition in the code catalogue, which drifts
between the release that writes a file and the one that reads it. The id is
carried and written back verbatim rather than judged, the same answer the
vaccination restore gives an antigen slug it cannot resolve.
The readings and the location periods travel as a pair because carrying the
readings alone is worse than losing them. The environment resolver reads the
periods live, so with them gone the nightly seven-day lookback re-resolves
every trip day to the home location and upserts over the row: an account would
come back with a fortnight abroad and then have it quietly rewritten as a
fortnight at home, days after a restore that reported success. Day keys stay
strings end to end, and fetchedAt is restored verbatim so a two-year-old
provisional reading does not claim it was confirmed today.
The round trip seeds bests that share a metric type and an instant and differ
only in the sport slot, two badges earned years apart (one of them under an id
this build no longer defines), and a trip day beside a home day, then reads
each back column by column. Counting alone passes every one of the failures
worth fearing here: slots dropped so a 5 km best is presented as a 10 km best,
unlock dates stamped at restore time, a trip day re-attributed to the home
city, a location period whose bounds no longer cover the reading it explains.
Each of those was applied on purpose and each failed on its own assertion.
Two schema comments claimed no read path selects a column that this backup now
selects; both say what reads it instead of staying wrong.
Reported from the iOS side, iOS #96. `GET /api/auth/native/login` answers
location: https://0.0.0.0:3000/auth/login?flow=native
behind a proxy that does not pass `Host` through. `NextResponse.redirect`
needs an absolute URL, the route built one with `new URL(path, req.url)`,
and `req.url` there is the address the Node process bound to.
That is the primary onboarding path. ASWebAuthenticationSession follows
the redirect, iOS refuses the restricted port, and the person lands on a
browser error page with no way forward. Distributed builds carry no
associated domains on purpose, so the browser handoff is also how
passkeys are meant to work; without it a new account is down to a
password and a confirmation code.
Two things hid it. Every error branch of that route builds the fixed
healthlog:// scheme with no host at all, so the route behaved correctly
on every failure and broke only when it was supposed to succeed. And its
own test built a request whose host matched, so the absolute URL it
produced looked right. The assertion there used toContain, which is
satisfied by the broken value just as happily as the correct one.
The same line was in six more places: the error redirects of the
Withings, WHOOP and Fitbit connect routes, which send a browser to
/settings/integrations. Behind the same proxy those land on the same
unreachable address, so all seven move to a relative Location.
A relative Location is valid per RFC 7231 and the client resolves it
against the address it actually called, which is the public one by
definition. The alternative is reading X-Forwarded-Host, and that would
put one more proxy setting between a self-hoster and a working sign-in.
Three checks, as the report asked for. The route test now drives a
request whose URL names the bind address, which is what a proxy hop
looks like from inside the handler. A structural test freezes the
pattern out of the four files and, separately, requires each of them to
still reach for the helper, because a file that simply deleted its
redirect would pass the first check and fail its users. The helper
refuses a protocol-relative value, which is a host in disguise.
Verified by breaking it: putting the old call back fails four checks,
the last of them reporting the reported value verbatim.
Refs iOS #96.
The scoped-sharing spec routes `/api/auth/me`, and the account switch it
drives ends in a full reload. The reload cancels whatever call is in
flight, Playwright marks the route handled, and the handler's `fulfill`
then lands on it with "Route is already handled!". With
`failOnFlakyTests` on CI that turns the whole shard red over a teardown
race rather than over anything the test is checking.
Same guard the record-session-fence spec and this file's own
accessibility sibling already use. The `fetch` is guarded too, because a
request cancelled mid-flight rejects there rather than at the fulfill,
and the earlier of the two is the one that decides which error the run
reports.
`DocumentConditionLink` and `ExtractedFact` were classified as backed up and
came back from no restore. The register said a restored vault is unsorted and
that the facts are re-derivable by re-running the extraction. The first is
right; the second understates it. What a re-run cannot produce is the REVIEW
DECISION on each fact, and every column holding one defaults to "nobody has
looked at this yet". `POST /api/documents/inbound/[id]/confirm` acts only on a
PENDING fact and commits it into the structured store, so a restore that let
those columns default would offer an already-committed reading for approval a
second time and write a second lab result behind it.
Both ends live in `src/lib/export/document-filing-backup.ts`, beside each other
like the Coach, reminder and vaccination sections. `status`, `needsReview`,
`confidence` and the commitment pair are carried and restored verbatim, and the
wire schema requires the first three rather than defaulting them.
Ordering: the section runs after the documents, the condition episodes, the lab
results and the medications. The first two are real foreign keys, unlike the
Coach's bare id columns, so an unresolvable value there does not cost one edge,
it aborts the transaction and the operator gets none of the account back. The
last two are what `committedRecordId` resolves against. The builder therefore
carries a filing or a fact only when both of its ends are carried, and the
restore still checks and drops what a hand-edited file leaves unresolvable.
`committedRecordId` is nulled together with its type, because a type with no id
claims a commitment the row cannot name.
The round trip seeds a second condition and files the page under it, so a
restore that kept the count and lost the pairing fails rather than handing back
a lab report filed under a head cold. A third case breaks both references in a
saved file to prove the drop path runs and still answers 200.
Three new skip catalogues arrive with it, and with them labels for the three
that had none: a dropped Coach attachment had been reaching the operator's
screen labelled "mood factor" since it was added. The label chain is exhaustive
now, so the next catalogue without one stops the build.
Two models come off the coverage register together, and neither is an
ordering or an id-remap problem. Both hang off the account and nothing
else. What kept them off was a question the code never wrote down, so
this change writes it down.
The screeners hold completed WHO-5, PHQ and GAD administrations, and for
PHQ-9 the encrypted blob contains item 9, the question about thoughts of
being better off dead. The application already draws a line around that
blob: it keeps a coarse item9Flagged boolean so the history view can
re-surface crisis resources without decrypting anything. The intent here
was to follow that line and carry the administration while leaving the
answers encrypted. The schema settles it more firmly. responsesEncrypted
is Bytes and NOT NULL, so there is no half-row to write and the choice
is the whole thing or none of it. A plaintext export in a downloads
folder is the wrong home for that answer, so the row rides in a
disaster-recovery file only.
The consent receipts are not sensitive in that way. Their problem is
that a consent is given to an operator. A receipt restored onto a
different instance asserts an agreement that operator never obtained,
with a signature timestamp to make it look settled. A disaster-recovery
file goes back to the same instance, so it travels there and not in a
portable one.
In both cases the portable file SAYS so in its own manifest rather than
reporting an account that has neither. The manifest is merged rather
than added beside the records one, because two keys of the same name
would shadow each other depending on spread order.
item9Flagged is restored from the file rather than recomputed.
Recomputing would mean decrypting every administration during a restore,
which is the handling this data is kept away from, and the stored flag
is what the history view has been reading all along.
Verified by breaking it: dropping revokedAt on the way back fails the
round trip with a withdrawn consent coming back as an active one.
Two entries sat side by side on the coverage register, described as "the
same shape of loss". They are not the same shape at all, and the
difference is worth writing down.
ReminderPhaseConfig hangs off a medication, one row per drug, nested
inside something that already travels. It needed nothing new and it
travels now. The round trip tunes all four thresholds away from their
defaults and puts one of them in PERCENT rather than MINUTES, because a
restore that quietly fell back to the schema defaults would hand back
four plausible numbers and change when the drug turns orange.
NotificationPreference cannot travel, and the register was wrong to
imply it was merely unbuilt. A preference addresses ONE notification
channel by a hard foreign key. The channel model is deliberately
excluded from every backup because it carries channel secrets, and the
restore deletes every channel it finds. A carried preference would point
at a row that cannot exist: the same constraint violation the custom
mood categories were causing, waiting for somebody to implement it in
good faith.
So it moves to the deliberate exclusions with a reason that names the
dependency instead of the symptom. Re-applying somebody's tuning to a
channel they add after a restore is a feature about channel types, not a
backup contract about rows, and it should be argued for on its own
terms rather than smuggled in as debt repayment.
An account that ever created its own mood category could not be restored
at all. Not partially, not with a gap: the restore violated
mood_tags_category_id_fkey and rolled the whole file back, so the
operator got a 500 naming a constraint and an empty account.
The coverage register described this as custom tags landing in a
grouping that no longer exists, which reads like a cosmetic loss.
Measured, it is the same shape the retired-catalogue-key work already
removed once: one row nobody thought about taking down everything else
with it.
MoodTag.categoryId is a real foreign key. Seeded categories belong to
the instance and are deliberately still not carried, but a category the
person made is theirs, cascades away with them, and has to travel. It
now does, and it is written before the tags that point at it.
The hidden set travels by key rather than id, because what people hide
is almost always a seeded tag whose id differs on every instance. A key
this instance no longer knows costs one hidden row and is reported: a
silent drop would un-hide a factor somebody had deliberately taken out
of their mood editor, and they would have no way to know why it came
back.
Found by experiment rather than by reading: seeding a user-owned
category into the round trip turned it red with the constraint name, and
the same test is green now.
A restored account reported no supply at all. Every pack was gone, and
with it every low-stock reminder, on a feature that became first-class
when medication moved to one stock per drug.
The two travel together because the register itself paired them: without
the events the count "cannot be rebuilt even in principle", and without
the packs the events are a list of deltas against nothing.
The count comes back exactly as recorded, not recalculated from the
ledger. It is the value the server had resolved and the person was
shown, and every reminder already sent was computed from it. The fixture
makes the difference visible on purpose: thirty in, eight consumed, and
a remaining count of twenty-one, because somebody dropped one without
recording it. A restore that "corrected" that to twenty-two would
disagree with the account's own history and look like a bug fix while
doing it.
Both decimal columns cross the wire as strings. Half tablets are a real
prescription and a JSON number would round them.
The note follows the contract the side effects and dose changes beside
it already use, and the packs nest inside their drug, so a portable
restore minting fresh ids needs no remap.
Verified by breaking it: recomputing the remaining count from the ledger
fails the round trip with twenty-one against twenty-two.
The transcript came back in the previous change; what the Coach keeps
BETWEEN threads did not. A restored account had every word of every
conversation and a Coach that has to be told its own history again — the
register said as much for all three: "a restore makes the account
re-tell its history", "keeps the talk and loses the commitment", "a
restored account silently stops sending them".
Tombstones ride only in a disaster-recovery file. All three soft-delete,
and a deleted fact is hidden from injection while kept for audit — a
distinction only the owning instance can act on. A portable file
carrying them would hand a person rows their own app refuses to show
them, and a restore that resurrected one would put a fact back into the
Coach's mouth that the person had deliberately taken out of it.
Neither reference here is a foreign key. sourceConversationId, on all
three, and relatedPlanId on the reminder are bare id columns, so nothing
in the database refuses a value pointing at nothing. That makes them
more dangerous than a real key rather than less: a dangling reference
costs no error, it just quietly stops meaning anything while the row
goes on looking complete. The restore resolves both against what it
actually wrote, nulls what it cannot resolve, and reports the count.
Nulling rather than dropping is the point. A live reminder must not be
lost because the plan it mentions was tombstoned out of a portable file.
The reminder is still the person's and it still fires; what it loses is
a pointer that was already going nowhere.
Order follows from that: threads, then plans, then reminders. Running
the memory before the transcript would cut every fact loose from the
conversation it came out of and still report success.
Verified by breaking it: making the conversation lookup always return
null fails the round trip on the fact's own source field.
A restored account got a Coach that had never spoken to it. The
conversations, every turn in them, and the record of which vault
documents a thread was grounded in were all classified as backed up and
travelled with nothing. The register's own entry for the messages called
them the largest volume of free text an account owns after its
documents.
The three travel together because two of them mean nothing alone: a turn
without its thread has nowhere to hang, and an attachment names a
pairing rather than a thing.
documentScoped is the field that makes this more than lost history. It
is a permanent fence — set when a conversation is created through the
fenced endpoint or gets its first document, and lowered by no code path
anywhere, not by detaching every document and not by deleting one. A
restore that let it default to false would hand the tool loop back to
precisely the conversations the fence exists to keep away from it, and
nothing in the interface would look wrong afterwards. So it is carried
verbatim, restored verbatim, and asserted per thread rather than in
aggregate: a restore that wrote false everywhere would still return both
threads and every turn and satisfy every count.
Prose follows the contract the rest of the payload already uses. A
disaster-recovery file carries the stored ciphertext; a portable file
carries the text, because a portable export exists to be readable by the
person who owns it, exactly as it already decrypts medication notes,
mood notes and document summaries. A single row under a key the instance
no longer holds is named rather than thrown, so one unreadable turn
cannot cost someone the rest of the transcript.
Ids ride in both modes, as the reminders do, so an attachment resolves
and the facts, plans and reminders still owed have something real to
point at. The restore runs after the documents for the same reason, and
an attachment naming a document the file does not carry is dropped and
reported instead of failing the whole file over one link.
Verified by breaking it: forcing documentScoped to false on the way back
fails the round trip on that field by name.
The restore refuses a portable backup, because a portable export carries
document metadata without the stored bytes and `contentEncrypted` is NOT
NULL. That refusal is deliberate and documented at the guard.
What nothing pinned is the half that makes it survivable: the check runs
above the transaction that wipes the account. The guard reads like input
validation, and input validation drifts toward wherever the value is
first used. Move it below the wipe and the operator loses everything to
a file the server was always going to reject — and the non-null
assertions further down are safe only because it stands where it does.
The test drives a real portable payload through the real route and reads
rows, not return values: the refusal happens, the account still has
every row it had, the cache invalidation never fired, and the audit row
names the document. Asserting the status alone would stay green through
exactly the edit this is here to catch, so the surviving rows are
asserted separately rather than inferred from it.
Verified by breaking it: disabling the guard turns the 422 into a 500,
which is the raw TypeError from decoding bytes that are not there.
The titration history was classified as backed up and travelled with
nothing. An account restored from a backup showed the current dose and
no record of how it got there: the ramp, the reductions, the note the
prescriber gave for each step. For a long-running drug that history is
often the more clinically interesting half.
Export writes the rows nested inside their drug, ordered by
effectiveFrom, so a portable restore that mints fresh ids needs no
remap. The note follows the contract the side effects already use: a
portable export decrypts it for a human reader, a disaster-recovery
export carries the ciphertext verbatim, and a restore never writes it
into the legacy plaintext column.
Order is the content here, not decoration. Two rows on the same drug in
the same unit, 5 mg then 10 mg, describe a ramp; the same two rows
returned in the other order describe a taper. Counting them back cannot
tell those apart, so the round-trip test asserts the sequence and the
values, not the count.
MedicationDoseChange moves from the coverage-pending register into the
two-ended list, which makes the round-trip registry incomplete until a
row is seeded and counted. That is a compile error, not a review note.
`MedicationPauseEra` was classified as backed up and travelled nowhere. The
spans where a drug was deliberately paused are the difference between "stopped
taking it" and "was told to stop taking it", and nothing else in the payload can
reconstruct them: an era is a decision, not an observation, and no intake row
records its own absence.
Without them the compliance recomputation reads a deliberate pause as missed
doses, so a restored account looks non-adherent for a stretch during which it
did exactly what it was told.
Both ends land together. The eras ride nested inside their medication, like the
schedules and side effects beside them, so a portable file that mints fresh
medication ids needs no remap — Prisma binds each child to whatever id the
parent actually got.
## The open era is the point
`resumedAt` is nullable end to end and the writer always emits it. An open era
means the drug is paused right now, and readers run it to the present. The
restore passes the null through rather than closing it at restore time: a pause
that was still running when the backup was taken is still running afterwards,
and closing it would silently turn an ongoing decision into a historical one.
Counting alone cannot catch that — a restore that closed the era would still
hand back two rows. So the round-trip test seeds one closed era and one open
one and asserts both field by field.
## What is proven and what is not
Moving the model out of `COVERAGE_PENDING` and onto `TWO_ENDED_MODELS` makes
the round-trip test's registry incomplete, which is a compile error. Verified
by watching `tsc` fail with TS2741 before the count-back was written: the
coupling the plan's comment describes does hold.
The round-trip test itself needs Postgres via testcontainers and has not been
run here. Typecheck, lint, format and the 21458-test unit suite are green; the
integration proof comes from CI.
`withIdempotency` has made writes safe to retry since v1.5.x. It was described
in route comments and in no schema, so an app generated against
`docs/api/openapi.yaml` could not learn that a retry was safe. The iOS side
asked for it; the mechanism does not change, only what it says about itself.
34 operations now declare `Idempotency-Key` and the `X-Idempotent-Replay`
header, plus the 409 that refuses a duplicate still in flight. The description
leads with the one silent failure in the mechanism: a key that misses
`^[A-Za-z0-9_\-:.]{8,128}$` is IGNORED rather than refused, so the write
proceeds unprotected and looks exactly like a request that carried no key.
## What the work turned up
Three things the contract had wrong or missing, none of them the thing being
built:
- `/api/insights/chat` describes `withIdempotency()` in its header comment and
applies it nowhere. The Coach chat is not retry-safe. Its prose says it is.
- `/api/measurements` exports GET, POST, PUT and DELETE and wraps only POST.
Publishing the header per PATH would have promised a safe retry on two
methods that do not honour one — the dangerous direction, so the guard works
per OPERATION.
- `/api/insights/feedback` and `/api/admin/backups/{id}/restore` are not in the
document at all. Both are named in the guard's exemption list with a reason,
not filtered out silently.
## The guard
`idempotency-contract-publication.test.ts` reads the expected set out of the
route tree on every run — nothing is hand-listed, so it cannot fall behind the
code. It fails in both directions: a new wrapped route that forgets to publish,
and a published claim no handler honours.
A fourth assertion checks the exemption list itself is still true. It earned
its place immediately: the first draft exempted the whole `/api/medications`
family on the strength of a grep for path literals that came back empty. The
paths are not written as literals there; the emitted document carries 19 of
them. The test went red, the claim was wrong, the list is two entries now.
Measure the artefact, not the source that builds it.
The image builds two dependency trees. `pnpm install` builds the app tree and
honours `pnpm-workspace.yaml`. A second, npm-owned install builds
`/opt/prisma-cli` for the migration CLI, and it reads none of that file.
Only the first was pinned. `prisma@7.8.0` resolves `@prisma/config@7.8.0`,
which resolves `deepmerge-ts@7.1.5`, so the Trivy image scan reported
CVE-2026-40345 (HIGH, stack exhaustion on recursive merges) against
`/opt/prisma-cli` on a commit whose dependency audit over the app tree was
green. The pin read as complete because the check that could see the gap runs
against the artefact, and the one that ran against the lockfile could not.
`overrides` is npm's own mechanism, so `npm pkg set` writes it into the
package.json `npm init -y` just created, before anything resolves. Rebuilding
this exact install both ways: without the field `deepmerge-ts@7.1.5` lands,
with it `8.0.1` lands and `prisma --version` still reports 7.8.0.
The guard beside it fails when the two files name one package at two ranges,
and asserts a non-zero match count first, so a matcher that stops matching
fails loudly instead of passing vacuously. It compares declared ranges only —
a new vulnerable transitive that neither file mentions remains something only
the image scan can see, and that job runs on main-push, not on pull requests.
A new advisory turned the dependency audit red on the trunk without a line of
our code changing: DeepmergeTS recurses without bound on deeply nested input
until the stack gives out (GHSA-ggr8-5vv4-36mx, fixed in 8.0.0). Re-running the
same job against the same main commit that passed on 16 August confirms it is
the advisory database that moved, not us.
Waiting for Prisma does not help. `@prisma/config` still asks for 7.1.5 on the
current 7.9.1, so the copy never updates on its own, and an override is the only
route to the patched version. The workflow reserves `auditConfig.ignoreCves` for
advisories with no upstream fix, which is not this one.
The reach is the config-loading and CLI path rather than anything under a
request, so the practical exposure was small. The blocked release train was not.
Verified rather than assumed: `pnpm audit --prod --audit-level=high`, the exact
command CI runs, drops from twelve findings with one high to eleven with none
and exits clean. Because this is a major bump under Prisma's own config loader,
`prisma generate` and `prisma validate` were run explicitly alongside typecheck,
lint, format, 21446 unit tests and the production build. All pass.
The edge01 demo refresh copied `password_hash` straight from the source
account, and the README beside it asserted "demo login works". That only held
while the source account happened to use the password the project publishes.
The source is the operator's own instance, so the moment that password was
rotated the demo started rejecting the credentials the README hands to every
visitor, and nothing anywhere noticed. Someone eventually filed it as a bug
(#805) after being locked out of the shop window.
The load now hands the row an explicit password: the same Argon2id literal the
seed script writes, applied right after the copy, then read back and compared
byte for byte. A mismatch aborts the run rather than waiting for a stranger to
discover it at a login screen.
The statement goes in on psql's stdin. Interpolating it into the ssh command
string would let the remote shell expand the hash's `$argon2id`, `$v`, `$m`,
`$t` and `$p` segments to empty strings and store something no password can
ever match, which is the same outage wearing a different hat. A guard test
pins that too, alongside pinning the literal to the seed script's copy so the
two cannot drift apart again.
The guard pins the installed version alongside the three source facts the
72-hour reader depends on, so a dependency bump has to stop and be looked at
rather than sliding past. It did its job here: 12.27.0 tripped the version
line.
Checked the release against 12.26.0. The retention plan is untouched, all
three assertions still match the shipped dist/plans.js verbatim. What moved
sits in the fetch path (group concurrency reworked to a keyed JSONB lookup,
schema names now routed through normalizeSchemaName / resolveSchemaName) and
in start_after, which becomes inclusive so a job written with the default
start time is fetchable on the next statement. None of that touches the
failed-row ledger.
So the assumption holds and the pin moves with it.
Dependabot treats `init`, `analyze` and `upload-sarif` as separate
dependencies, so it raised `init` to 4.37.7 in one PR and left `analyze` on
4.37.6 in the same workflow. The action refuses that pairing outright:
"Loaded a configuration file for version '4.37.7', but running version
'4.37.6'", and the CodeQL job failed on the very PR doing the bump.
All five pinned references move together here: init and analyze in
codeql.yml, and the three upload-sarif steps in scorecard.yml and trivy.yml.
The commit SHA is the one the v4.37.7 tag dereferences to, which is also the
SHA both Dependabot PRs pin.
The scenario purges every sleep row so last night can be the arrival that
flips the day. That left an account with no sleep source at all, which now
reads `final` on purpose, so the provisional assertion could not be reached.
Seeding a night from three days ago restores what the scenario means: a
record that does track sleep, waiting for last night's. Written through the
same ingest route as the fresh night and swept by the same purge.
The hero card on the start page told anyone without a sleep source, every
morning, that last night's sleep was not in yet. The freshness rule only
asked whether last night's sleep had landed, so a record that has never had
a night in it, or whose owner has stopped wearing the tracker, read as
permanently waiting. Not everyone owns one, and people put them away.
Waiting now requires that sleep is actually reaching the record: a night
within the last seven days. A flat battery or a few nights without the
tracker keeps the note, since tonight's sleep really is still expected. A
week of silence retires it.
The same rule settles the day itself. It used to park on `provisional`
forever for those records, which the daily push line reads too, so the
notification carried the same untrue "last night's sleep is still missing"
aside.
Two existing tests pinned the old behaviour by name ("is provisional when
sleep has never been recorded", "stays provisional when sleep never
arrives"). They described the defect rather than a contract worth keeping,
so they now assert the opposite, and a third covers the boundary between an
ordinary gap and a source that has gone quiet.
The preventive-care card kept postponing in the overflow kebab, three taps
from the thing a person actually wants when a due date does not fit: open
the menu, find the row, open the sheet. The medication card has never done
that — taken and skipped sit side by side on the card, one tap each — and
the checkup card should read the same way.
Postpone is a second button in the action row now, outline against the
filled primary, at its natural width beside it. The dense list branch gets
the same action as an icon button, so neither view offers what the other
withholds. The kebab keeps edit, measurements and delete.
The sheet behind the button is unchanged in what it offers (quick offsets,
a free date, the honest-skip zone), but the offsets are computed on the
calendar now rather than by adding 86 400 000 ms per day. Across a DST
shift the old arithmetic moved the wall clock over midnight and wrote the
neighbouring date, so a chip labelled "+7 days" could fill in the sixth or
the eighth.
The schemas stay: the insight result still parses recommendations
through them. Only the exported aliases went unused once the card that
consumed them was removed, and the dead-code gate says so.
The production dead-code check has been documented rather than enforced
since PR #691, because its list mixed real debt with false positives.
Triaged against the shipped tree, six of them are genuinely unreachable
and go now.
Five died when their host was retired and the leaves were left behind:
the recommendation card with its confidence meter and feedback control,
the grid that mounted them, and the suggested-prompts chip strip that
left the hero band in v1.18.7. The sixth, the comparison toggle, has had
two regression tests pinning its absence since v1.4.22, because the
preference it once set is a Settings concern now.
Deleting them freed sixteen translation keys in every locale, a pinned
entry in the success-affordance register, and two entries in the Coach
gate fixtures. Each of those was found by a guard rather than by
reading, which is the point of having them.
The adapter under signals/adapters stays: knip reports it as unreachable
because only a test imports it, but that test is the registry invariant
it exists to prove, and deleting it would remove a check rather than
dead weight.
Two findings from the deferred-audit list, both live since July.
A FHIR lab quantity stamped the UCUM system unconditionally while the
code was only added when the analyte map resolved a symbol for exactly
that unit. A receiver was told the unit came from UCUM and then handed
no UCUM code, which is a coding claim the bundle cannot back. System
and code now travel together, on the value and on both reference-range
bounds.
The doctor-report export accepted a selection that admits nothing. The
panel disables its own submit in that state, so only a hand-built
request reached it, and it rendered an empty artefact while spending a
rate-limit slot. This route has no documents-only mode that would make
an empty leaf list a legitimate scope, so it refuses with 422. The
share-link route, which does have that mode, is untouched.
The two halves of a resolved dose were split across routes: the single
intake route reset the medication's snooze but never closed the pending
dose-due Web Push reminder, while the bulk route closed the reminder but
left the snooze standing. Each path was missing exactly what the other
had. A dose drained from a device's offline queue lands on these routes,
so a synced take could keep ringing as snoozed or leave a stale web
badge behind.
The bulk route now nulls snoozedUntil once per affected medication when
a non-skipped entry actually lands, and the by-id route dispatches the
same fire-and-forget web clear the other intake paths have carried since
v1.18.4.
The two new measurement-reminder routes were admitted in the frozen
fixture but never got their integration drivers, so the matrix count
stayed at 75 and the executable proof was missing. Both routes now
carry a full manageContract driver: the skip driver asserts the stamped
column, exactly one SKIPPED ledger row, and that lastSatisfiedAt stays
untouched; the snooze driver asserts the snoozed-until write under the
same invariant. All three inventory pins move to 77.
Apple Health exports write ECG CSVs in two shapes. The importer only
understood the paired Lead,Voltage form; the single-column form (Lead
and Unit as metadata rows, a blank separator, then one bare reading per
line) failed every row as malformed and the whole recording was skipped.
The parser now treats a blank line after Lead and Unit metadata as the
start of the waveform, reads one bounded numeric sample per row, and
converts the declared unit to integer microvolts before the existing
amplitude and safe-integer bounds. The paired layout parses exactly as
before, and an unknown unit fails with a fixed message.
Refs #794
The rewritten notification-status description ran to two sentences and
130 characters — the card-description guard is right, the slot holds one
meta sentence. Folded to one em-dash sentence in every locale. The new
documents_retokenised job fact joins the outcome allowlist with its
reason.
The German bundle's 109 spaced en-dash pauses join the em-dash norm (the
two date-range placeholders stay ranges); the three straight-quote
stragglers and three mixed-quote strings take German quotes; the eleven
Bitte-Infinitiv validation errors speak du-imperative like the rest of
the app; the two wirklich confirms become bare questions; the
notification-status description trades its semicolon telegram style for
two sentences in every locale; the Withings setup action shortens to the
sibling single word. Twelve arbitrary text-[10px]/[11px] sites move onto
the text-2xs/text-xs tokens; chart tooltips and the vendor calendar stay
as they are.
The knip blind spot (ignoreExportsUsedInFile) left eight symbols carrying
an export nothing outside their file consumes — the modifier goes, the
symbols stay. The score series module goes whole: its only importer was
its own guard test, whose composite- and config-guarding suites survive
in place.
DocumentContentIndex.tokenizerVersion gains the consumer that was the
point of storing it: the content-index job now re-tokenises rows whose
version trails CONTENT_TOKENIZER_VERSION, from the decrypted index text
along the same path the rotation script uses, keyset-paginated and
fault-isolated per row — a future tokenizer bump migrates the index
instead of silently splitting it. ImportJob.uploadSha256 turned out to
already have its dedupe consumer (both kick-off routes short-circuit on
same-bytes non-failed jobs); its comment now names that instead of
pretending to be write-only. The remaining write-only columns carry
their honest annotations in the shape the audit sweep parses.
The entries gained a soft-delete this release; both backup ends follow in
the same one. A portable export omits tombstoned readings so a restore
cannot resurrect them; a disaster-recovery payload carries the tombstone
and the restore writes it back, so the account returns exactly as it
stood — undo affordance included.
Four small debts from the audit tail. The FHIR bundle's missing
Immunization resource gets the same written-decision paragraph the visits
exclusion carries — deliberate follow-up, not oversight. The service
worker's offline allowlist gains /api/daily/digest (both copies, policy
module and sw.js, plus the test) so the Today rail renders on an offline
reopen. The two Telegram plumbing tables that link to an account by a
bare userId string are frozen as a closed NO_FK_BY_DESIGN set in the wipe
guard — a third such table now fails the test instead of passing rule 1
silently. And deploy.md documents the manual digest-pin bump end to end:
imagetools inspect, the env update, and the three-probe /api/version
verification with the too-fast-deploy warning.
The restore dialog asked for its typed confirmation on nothing but a
filename and a date; the counts only surfaced in the audit row after the
rows were already replaced. A read-only summary endpoint now decrypts and
schema-validates the stored file through the same helpers the restore
itself runs and answers the summarizeBackup counts, and the dialog shows
them — per section, with an other-records line so a future summary key
can never silently vanish from the preview. A failed preview states its
own absence and never blocks the restore.
Refs A3-8. The per-item PHQ-9 / GAD-7 / WHO-5 / SCI answers have been
stored encrypted since the assessments shipped, and nothing ever read
them back — the clinical detail (item 9 included) was invisible. A new
by-id read decrypts them for whoever holds the mind read grant (the same
grant that already serves the mood diary's prose and the item-9 flag),
degrades to an honest items-unavailable state on any decrypt or shape
problem, and the history rows gain an expandable breakdown rendering the
official instrument texts the check-in flow already carries — nothing
retranslated, no alarm styling on item 9 beyond what the house already
does.
Rides along: the guard inventories learn the branch's new routes (frozen
counts moved with reasons), the reminder-engine test harnesses carry the
ledger delegate, and the custom-metric restore toast keys sit in their
right namespace in all six locales.
The hero card composition was fixed: score panel first, the worth-a-look
rail beneath. A reminder-driven person asked for the reverse. The stored
dashboard layout gains an optional hero field ('score' default,
'reminders' promotes the rail into the hero slot), validated as a closed
enum, preserve-when-absent on write, omitted from the blob at the default
so untouched accounts serialize byte-identically. The choice sits in
Settings beside the other hero controls; the promoted rail degrades to
the calm all-clear line when nothing is due.
Refs #778. Two honesty gaps from the first-run report: the web onboarding
presented Apple Health like a web connector although the connection only
completes in the iPhone app, and the initial backfill gave no sign of
progress, so a working first sync looked broken.
The onboarding source step now says plainly that Apple Health is set up in
the iPhone app after onboarding, without dead-ending the flow. The
integration card gains a server-authoritative progress summary — rows
accepted, oldest reading reached, and an honest flowing/waiting line
derived from the last accepted batch. Nothing the server does not know is
shown: no percentage, no ETA, no invented throttle state; a quiet stretch
reads as waiting for the phone, not as an error.
The reminder cadences were the oldest entry on the coverage-pending
register: carried in name, restored never — an account came back silent
until every reminder was rebuilt by hand. Both models are two-ended now,
in the house shape (both ends in one file, reminders-backup.ts): every
column travels, nextDueAt verbatim rather than recomputed, the completion
ledger beside it, tombstones in disaster recovery only, and a portable
export omits a tombstoned reminder together with its ledger rows.
Encounter.reminderId and VaccinationRecord.reminderId stop being dropped
on restore: both remap against the restored reminders and fall to NULL
with a skip report only when the file genuinely lacks the row. The wipe
plan names the ledger child-before-parent; the classification guard,
completeness guards and the integration round trip all demand the new
coverage (proven failing with the section removed).
Refs #223
Document search does whole-token equality over a blind index, so
'Blutdruck' never found the discharge letter that says 'blood pressure'
and 'hypertension' missed every German Arztbrief. A curated German/English
medical synonym list (vitals, common labs, imaging, specialties, document
words — static, no vendor names) now expands the QUERY tokens before they
are hashed: the stored index, its tokenizer version and the schema are
untouched, the expansion happens ahead of the one-way hash, and recall
simply widens. Typed tokens always survive the expansion cap.
Refs A3-11. Deleting a logged custom-metric value was the one hard delete
left on an entry surface — everywhere else a deletion tombstones and the
toast offers Undo. The DELETE verb now stamps deletedAt, every read
(list, latest-value, counts, revive check) filters the tombstone, and the
new entries/restore endpoint clears it scoped to owner and parent metric —
a foreign, live or mismatched id is a silent no-op, never an existence
leak. The history table's delete toast carries the Undo action.
The documents view already round-trips its filters through the query
string; the measurements and mood lists kept theirs in memory, so any
navigation dropped them. The documents pattern is extracted into a shared
hook (use-url-filter-sync) and both lists adopt it: type/source/day-bounds
and the measurement value range survive reload, back and sharing, defaults
stay out of the URL, invalid facets drop leniently. The insights readings
subpage keeps its locked type in memory — that URL belongs to the insights
route. /measurements no longer strips ?type=, so the deep links the
Vorsorge cards mint now persist as filter state.
Refs #223. A due Vorsorge item could only be done, deleted or ignored;
Telegram's postpone moved the date with no trace. Both decisions are
first-class now, and both are honest:
- POST /{id}/skip (no body): the interval restarts from the skip instant
through the recurrence engine, lastSkippedAt is stamped, skipCount
increments, any snooze clears. lastSatisfiedAt is NEVER touched — done
and skipped stay distinguishable forever, enforced in the primitive and
pinned by tests.
- POST /{id}/snooze ({until: YYYY-MM-DD}): the server resolves the day to
the notifyHour in the profile timezone and moves snoozedUntil and
nextDueAt to the same instant, so every due-state consumer follows
without knowing snooze exists. At least tomorrow, at most five years.
- GET /{id}/history: the new append-only completion ledger
(MeasurementReminderEvent), one row per satisfy from every engine path
and per skip, onTime derived at write time against the due instant that
was current when the event landed.
Two anchor fixes close the backdated-event gaps: the auto-resolve floor is
now the later of last satisfy and last skip (an old reading synced late no
longer resolves the cycle the user just skipped), and a backdated real
result re-anchors the next due at the later of its own instant and the
skip decision.
Screenings stay skippable and snoozable — the satisfy-side 409 guards
claimed fulfilment; a skip claims the opposite. Appointment reminders 404
on all three routes, in the lookup, like every by-id sibling. /checkups
gains the postpone sheet (quick chips, date field, the skip zone with its
own confirm) in both view branches, with muted meta lines and no card
colour change. The MCP preventive-care tool carries the resolved fields;
the OpenAPI contract publishes all three paths and the widened DTO.
Refs #611. The nap separation itself shipped in v1.34.4; what remained of
the report was the tooltip footer labelling a single night's total with the
orphaned 'Average per night' key (the headline card that owned it was
dropped in v1.22.0, so hovering one column claimed an average that was that
night's sum). The footer now names the main session's sleep, and on a day
with an inferred nap adds the whole-day total beside it — the Main sleep /
Naps / Total triple the report asked for. Percentages keep dividing by the
night alone.
Ten operations the spec owed: the seven mood-entries routes (list, create,
read, edit, delete, restore, bulk-delete) whose /bulk twin was already
published, GET /api/mood/linked-context (a cross-section read through the
mood URL that grant-scope reviewers need visible), and POST /api/import
(the spec documented polling an import job with no way to create one).
Every schema mirrors the handler's own Zod source; the envelope, refusal
and error semantics are read off the routes, not invented. Request-body
ids are bound instances now — a bare .meta() call clones in Zod 4 and
never reaches components.schemas.
Migration 0334, all additive. MeasurementReminder gains snoozedUntil,
lastSkippedAt and skipCount; the new MeasurementReminderEvent table is the
append-only completion ledger (kind SATISFIED|SKIPPED, occurredAt, write-time
onTime, source). CustomMetricEntry gains the deletedAt tombstone for undo
parity with every other entry surface. Consumers land in the sibling commits
of this branch.
Refs #223
- The record-settings contract, slug-list, and section-frame tests
follow the slugs that left the registry (layout children resolve
their headings from the layout namespace, like production).
- The orphaned settings.sections.<module>.title/description keys leave
all six locales; the reverse-coverage guard flagged them the moment
their nav entries stopped existing.
- The operator-cap ledger read fails open on a read error — the
reservation layer still meters against the same ledger, and a
transient blip must not take the fallback chain down.
- The reaper queue joins the subsystem surface; its healed count and
the sweep's nag_dismissed join the job-fact vocabulary.
- The intake-event contract fixture carries the full row the resource
now declares; the workouts empty-state test asserts the
source-neutral cue; the persister poll gates on rendered content
first.
- The tiered AI context's band reader now falls back to a live grouped
aggregate when a rollup band is empty (the module doc promised
replace-with-fallback from day one; only the replace half existed), so
a coverage-miss or never-folded YEAR partition no longer hands the
model empty history. Far-from-UTC users get the day-grain band keyed
on their own calendar (the same ±3h guard the correlation readers
carry).
- The slim analytics slice splices measurements older than the 5-year
fold window into its all-time count/min/max/mean: the rollup tier
only covers the window, so "all time" silently truncated for long
histories while the live fallback told the truth. A type whose only
rows are pre-fold no longer reads as never logged.
- account-sharing polls the offline persister's own IndexedDB store for
the flushed snapshot instead of sleeping past the debounce.
- The record-session-fence cases poll for the observable halves of their
setups (the switch round-trip landing, the exit switch being issued);
the one remaining fixed window is a documented absence-assertion grace
with nothing to poll on.
- The documents suite's two remaining sleeps are named for what they
are: a focus-dance grace the outer document cannot observe, and the
long-press gesture duration itself.
- The inventory-date fixtures derive their printed expiry from now
instead of a fixed 2027 date that would silently flip the fixture to
expired when the calendar catches up.
- The restore summary counts practitioners, encounters, the three link
tables, and vaccinations — restored in full since 08-01 but absent
from the report, so a file carrying visits or an Impfpass read as if
those sections were empty.
- A portable export that cannot open a field's ciphertext still nulls it
fail-soft, but now names the loss in the file's own decryptFailures
manifest — an export with a lost emergency note is no longer
byte-identical to one where the note was never written.
- A timezone change enqueues the medication-compliance re-fold: the
rollup keys its rows by the local day minted under the OLD zone, and
the coverage probe compares counts, not keys, so nothing else could
notice the stale bucketing.
- The measurement-reminder tick claims its slot in the record-event
ledger BEFORE any provider egress (the documented claim-first pattern
from reminder-dedup.ts); a crash between dispatch and the nextDueAt
advance can no longer double-send.
- mood-entries/bulk classifies a lost P2002 race as the duplicate it is
(with the winning row id) instead of a skip carrying a raw Prisma
message — the row IS stored, the racing twin wrote it. The workouts
batch already re-derives per-entry status from RETURNING.
- The vault list's content-index and thumbnail probes ride the same
Promise.all as the link loads: four independent grouped queries, no
serialized round-trips on a 4s-polled list.
The six measurement routes carried eight copy-pasted rollup-recompute +
status-invalidate blocks — the documented two-ended failure class
waiting for the next surface to remember one leg and forget the other.
All of them now call afterMeasurementMutation (the helper the deletion
fix introduced), and the two write surfaces that bypassed the hook
entirely join it: the nightly Recovery/Stress/Strain score rows and the
PHQ-9/GAD-7 assessment projection now recompute their (type, day)
bucket on the tick that writes them.
- The provider-chain walker now checks the day's recorded spend against
OPERATOR_COST_CAP before invoking an operator-funded admin-* entry. A
request whose primary provider runs on the user's own key reserves
under the generous user-plan ceiling; a fallback hop (or health-ledger
reorder) onto the operator's key could then spend past the operator's
cap unchecked. An exhausted cap skips the candidate like a failed hop.
- summaryState PENDING gets a giver-upper: the detail serialiser
degrades a PENDING older than an hour to UNAVAILABLE (which offers the
manual generate action), and an hourly reaper persists the same heal
following the orphan-ImportJob reconcile precedent — a job dead past
its retry budget no longer leaves the sheet saying "generating"
forever.
- MedicationIntakeEvent declares the full row both intake responses
actually return: autoMissed, attributionSource, injectionSite,
doseTaken, inventoryConsumption, externalId, updatedAt, syncVersion,
deletedAt were served but undeclared.
- GET /api/medications/{id}/intake is published (query schema, paged
envelope); the spec only knew the POST while the client-facing
history list has consumed the GET since v1.4.37.
- WorkoutListEntry's sportType/startedAt/endedAt/durationSec become
nullable, matching the DTO — declaring them non-null was a
strict-decoder bomb for the Swift client.
- OpenAPI regenerated.
A medication whose slots carry different doses (a whole tablet in the
morning, a half at noon — #219) was consumed correctly but reported
wrongly: the wire shipped only the raw nullable per-slot column (clients
had to derive the inheritance), stockDosesRemaining divided by the
medication-level units alone, and no runway figure existed outside the
low-stock push.
- Every schedule now carries resolvedUnitsPerDose, resolved server-side
exactly like the intake consumption resolver.
- stockDosesRemaining divides the units pool by the schedule-weighted
average units per dose.
- runwayDays (whole days of supply under the slot-aware burn rate) rides
the list AND detail payloads; the detail GET also gains the stock
aggregates the list already had.
- The low-stock engine runs the same slot-aware math, so the push and
the wire cannot disagree; the web cards, table, and detail Übersicht
prefer the server figure over their client estimate.
- OpenAPI regenerated with the new fields.
- The Coach verbosity picker is disabled while the concise tone forces
brief, with a hint saying so, instead of a live control that silently
does nothing.
- A fresh document upload flashes its new row like the duplicate path
always did; the settle decision is a pure exported helper with a test.
- /about gains its two inbound links: the /privacy footer and a
Settings→About credits row (the GeoLite2 EULA wants the attribution
reachable from the running app).
- The eight settings slugs shadowed by permanent redirects (the seven
layout children + sharing) leave the slug registry: no more
prerendering unreachable pages, no dead component wirings, and the
SectionPlaceholder fallback that could never render is gone. The
redirects, section components, and layout-hub subpages are unchanged.
- The daily Coach sweep now charges each ignored day against
surfaceCount and auto-dismisses at three — the cap the schema comment
promised since v1.22 but nothing enforced.
- The welcome gate compares the stored disclaimer acknowledgment
version against the current one, so revised disclaimer text
re-prompts; /api/auth/me carries the version for the client.
- slope90 and anomalyCount leave the analytics wire: slope90 had no
reader anywhere (computed on every request, in SQL twice), and the
slim path could never compute anomalyCount (it always reported 0).
anomalyCount stays as an in-process field on summarize() for the
insights feature builder, which is its only real consumer.
- Vorsorge dashboard tile title joins the dashboard heading ladder
(titleAs=h2 like its sibling tiles).
- A user's own note on the mobile measurement card renders in the
foreground colour; user data is never muted.
- The compact Vorsorge list row expresses its density on the Card
instead of overriding the content slot's padding with an off-scale
step.
- The AI-consent card holds its space with a skeleton while the receipt
loads instead of popping in and shifting the section below.
- Singular variants for the bulk-delete toast and confirm dialog
(measurements + mood); the call sites branch on a count of one.
- Consent confirm buttons shortened so they no longer overflow the
small button on phones.
- Workouts empty-state CTA is source-neutral instead of assuming an
iPhone with Apple Health.
- "Pills" is no longer a navigation term in a medication app: DE says
Navigations-Chips, EN chips, PL chipy; ES/FR/IT subtitles align with
their own established terms.
- Health-score insufficient hint drops the translationese and fixes the
pronoun.
- Side-effect tag helper uses the established Stimmungs-Tags term.
- Success toasts that are single fragments lose the terminal period
(37 keys, all locales).
- Integration pill parked state short enough to survive truncation.
- The iPhone-managed reminder badge carries a short label; the full
sentence moved to a helper line below instead of clipping mid-word.
The evaluator stubs hoist properly for vitest's factory rewrite, the
WHOOP webhook route test's prisma double learns the pre-image read the
delete branch now performs, and the connector-token card description
shrinks to the one-sentence, 120-character shape the card guard
enforces — still saying what the mint truthfully does.
The bearer MINT matcher demanded a literal apiToken.create( in the very
file that documents how a whitespace-wrapped call walked past its T1
sibling; it is whitespace-tolerant now, asserts a non-zero match count,
and the always-true filter beside it is gone. The T1 resolver matcher no
longer demands tokenHash first in the where object. The English-leak
guard pins a non-zero key floor per bundle so a broken walker cannot
pass as no-leaks-found. The two audit-row integration suites poll for
the fire-and-forget rows instead of sleeping a fixed span, with one
deliberately widened grace kept for the absence assertion. And the
fixture fuses with 2027 horizons (suggested-actions next-due, the
printed-expiry trio) are now-anchored so they can never age into
inverted assertions.
The Withings arm of the backfill deleted the source's entire
SLEEP_DURATION history while its re-sync fetches only the trailing 30
days regardless of fullSync — every older night was raw data loss with
orphaned rollup buckets on top. The delete is now bounded to the
re-sync window and the touched rollup span is recomputed afterwards;
WHOOP keeps the whole-history clear because its full sync genuinely
re-imports every night.
The fallback stayed at v1.37.15 through two releases while package.json
moved on, and the anchor test that exists to catch exactly this reads
red on the tip. Point it at the current release.
api-handler imports the record-session fence and the fence threw
RecordSessionChangedError / SharingAccessDeniedError imported back from
api-handler — the one real import cycle in the tree, working only by
evaluation-order luck. HttpError, the SharingAuthError family and
StepUpRequiredError now live in api-errors.ts (importing nothing but
the fence contract), api-handler re-exports the identical class objects
so no importer moves, and the fence imports the leaf. A guard pins both
the identity and the leaf structure.
Every apiHandler route accepts the header and the wrapped write routes
dedupe on it, yet almost no client call site sent one — a replayed
create could land a duplicate dose or vaccination and shift the series
index and booster-due date behind it. apiPost and apiPatch now mint a
per-call crypto.randomUUID() key unless the caller set its own; reads
stay bare.
get_preventive_care passed a reminder's label and location — the user's
own free text — unfenced beside a fenced sibling, and get_lab_history
shipped the document-derived canonical analyte raw while sanitizing the
valueText next to it. Both are the marker-forging class the fence
doctrine exists for: fence label and location like the appointment
reason, scrub and sanitize the analyte like the default labs path.
Both hourly sync tails left the per-user analytics, correlations,
targets, derived and achievements caches serving pre-sync bodies until
their TTL lapsed — the correlations route documents the exact invariant
these tails were breaking. Mirror the Fitbit tail: mark the cells stale
whenever rows landed, on partial failures too.
tsc rejects spreading unknown[] into a zero-parameter vi.fn; give the
context-evaluator stubs a variadic signature and cast the recorded call
tuples where the tests read them back.
Three silent gaps in the import accounting, one commit:
The reproductive samples of an export land in recordsRead but were
discarded without a counter when cycle tracking is off — the result now
names them (samplesSkippedModuleDisabled). A cycle flush that failed
partway zeroed its stats AFTER day-by-day writes had landed; the flush
is now fault-isolated per day and reports daysFailed plus the first
failure reason instead of claiming nothing happened. And the import
card hardcoded skipped: 0 while the worker has computed, persisted and
served per-class skip counters since v1.15 — it now renders them,
splitting deliberate exclusions (unsupported and deferred types, a
disabled cycle module) from genuine refusals (unparseable, out of
range, failed writes), with a per-type breakdown. Only refusals tip
the outcome tone.
The evidence disclosure stopped honouring showEvidenceByDefault in
v1.4.27 and the owner surface retired the control with it — but the
managed-record coach form still offered the switch, silently governing
nothing. Remove the control, the managed-schema pick, the response
field, and the label in all six locales; the strict schema now refuses
the key outright.
Both import cards linked /docs/integrations/data-import — a path the
app never serves, so every click 404'd. Link the external docs site
through the shared INTEGRATION_DOCS_BASE constant, opening in a new tab
like every other integrations runbook link.
The mobile bar pinned /medications unconditionally while the page
renders nothing when the medications module is off — a visible tab over
a blank screen. Run both fixed flanks through the one slot filter the
Insights slot already uses (sharing coverage + module map, fail-closed
until mounted), so a disabled module is hidden everywhere instead of
relocated.
The titration-curve docblock contrasted itself against an estimated-PK
chart 'behind the Research-Mode gate' — that switch was retired as inert
and the columns dropped with migration 0321. Describe the real contract:
both charts render ungated, the estimated one carrying the persistent
modelled-not-measured disclaimer.
A Coach reminder captured with NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED,
NEXT_SLEEP_LOGGED or NEXT_APP_OPEN confirmed to the user and then never
fired: the daily sweep skipped context triggers and no other evaluator
existed.
Build the evaluator. The three logging cues resolve on evidence — a
live matching-type measurement logged after capture — and ride the same
eventful reminder-satisfy hook every ingest path already fires, with the
daily sweep re-running them as the idempotent backstop. NEXT_APP_OPEN
resolves on the nudge-status read (the poll that mounts with the app
chrome), owner-only so a delegate's visit never satisfies it, with a
capture grace so the session that created the reminder does not count as
the next open. Every trigger delivers through the surfacing transaction
extracted from the sweep: conversation + assistant message + status
flip, all-or-nothing.
The iOS tracker was told both endpoints were in the spec; neither was
registered. Publish POST /api/vaccinations/{id}/booster (201 mint /
200 re-anchor with the resolved MeasurementReminder DTO) and
GET /api/vaccinations/suggest (the server-decided one/many/none
verdict), and regenerate the YAML. A structural guard now derives the
expected path set from the route files so the next vaccination route
cannot ship unpublished.
The mint card called every connector token read-only while the
health:write toggle sat directly beneath it — a token minted with the
toggle on writes measurements and mood entries. Rewrite the card
description and scope note in all six locales so the copy matches the
mint: read is the floor, write is a per-token choice made at mint time.
The copy guard now pins the pair so the read-only claim cannot return.
The biomarker catalog mints name and unit from labResult.analyte —
model-transcribed free text out of an uploaded document. Both rode into
the status generator raw, the name in system-prompt position, so a
hostile PDF could smuggle instructions past the fence the coach
labs-snapshot already applies to the same fields. Sanitize both at the
snapshot and the system-prompt site.
A soft-deleted measurement never reached the rollup tier: the WHOOP
delete webhook tombstoned rows without recomputing the dirtied buckets,
and the freshness watermark probe filtered tombstones out, so the
deletion's updatedAt bump was invisible and the ghost aggregate read as
fresh forever.
Extract the post-write tail every measurement surface repeats into
afterMeasurementMutation (rollup recompute + status-insight
invalidation, both best-effort), call it from the WHOOP delete webhook
with the (type, measuredAt) pre-image collected before the updateMany,
and drop the deletedAt filter from the watermark probe so a tombstone
counts as staleness.
The unit battery derives rollup fixtures in JS; this pins the same
parity through the pipe itself. Seed raw measurements, read the tiered
fetch before the fold (coverage miss, raw path baseline), fold with the
real recomputeUserRollups DAY pass, read again, and assert the two
daily series are equivalent — including a dual-source weight day whose
per-source rows must compose to the all-source mean. A drifted writer
projection, coverage-probe SQL, or bucket compose surfaces here.
Both paths read identical fixtures — per-(day, source) DAY rollup rows
derived from the same raw rows the fallback reads — and must produce
equivalent daily series: the plain daily-mean case, the multi-source
day (the all-source weighted mean, never a ladder-collapsed single
source), and the pre-sum_value mean-times-count compose. A far-from-UTC
profile timezone must force the raw path so day keys stay profile-tz
keyed; the near-UTC midnight band's single-day attribution shift is
pinned explicitly so it stays a documented decision rather than drift.
Coverage misses and empty in-window bucket reads fall back per channel;
sleep and the cumulative types never enter the rollup query.
Every assertion family was broken on purpose and observed failing with
the site named before restoring green; the record sits in the test's
doc comment. The route test mocks the new coverage probe and bucket
read as empty so every channel rides the raw path unchanged there.
The discovery scan read every raw measurement in the 180-day window for
all nine measurement channels and bucketed them into daily means in JS,
paying tens of thousands of rows on every cache miss (a CGM glucose
channel alone can exceed the read cap). The DAY rollup tier already
stores those per-day aggregates, so eligible spot channels now read one
pre-aggregated row per day per source and compose the all-source
count-weighted mean — the exact reduction toDailyMeans applies.
Read-swap replaces the raw read per channel, with fallback-on-miss:
structural ineligibility (SLEEP_DURATION's per-night reconstruction,
the cumulative types' device-type collapse axis the rollup rows do not
carry), a far-from-UTC profile timezone (the v1.4.38 W-A near-UTC
guard), a coverage-probe miss, or an empty in-window bucket read all
route that channel to the existing raw path. The SWR cache cell stays
in front; the swap only lowers the cost of a cache miss.
The prefetch-caching guard's frozen route set and the session-surface
guard's direct-derivation list both name every RSC prefetch wrapper;
/checkups and /mood join them with the same reasoning as their five
predecessors — getUnswitchedSession only, skipped under a switch.
placeholderData keeps the previous range painted while a health-chart
range switch fetches, paired with its truth condition: the container
dims and goes inert while isPlaceholderData is true, so the old range
cannot masquerade as the new one. The mood chart needs no pairing — its
range is a client-side slice over one static-key query and never drops
to a skeleton.
/checkups and /mood gain the dashboard-template RSC prefetch: the
primary list read moves into a shared lib read (route + wrapper consume
one query, so filter/order/wire shape cannot drift), the wrapper honours
the DASHBOARD_SSR_PREFETCH kill-switch, resolves identity through
getUnswitchedSession, JSON-round-trips the dehydrated state, and fails
soft to the bare client on every error path. Page-prefetch unit tests
pin the seeded keys and the fail-soft arms; the SSR hydration spec
gains an assertion per page.
The achievement-unlock poll drops to five minutes (in-app writes already
invalidate the cell; the poll only covers out-of-band ingest), and the
plan-proposal poll gains the advisor-style bounded attempt ceiling so an
open thread stops polling after its budget.
GET /api/insights/correlations was the most expensive uncached read in
the app: a 180-day window, ~10 parallel series reads, Pearson + FDR
in-process, plus two syncAcceptedPatterns DB writes — all paid on every
mount. The built body now reads through the analytics bucket's SWR cell
(60 s fresh / 1 h stale, keyed userId|correlations|locale), and the
pattern-sync writes live inside the builder, so a cache hit performs no
write on the read path. Eviction truth comes free: measurement, mood,
medication, illness and custom-metric writes already sweep the user's
prefix in that bucket, and a pattern dismiss sweeps it through
invalidateUserCorrelationPatterns.
The route test pins the contract: a second GET inside the fresh window
re-runs neither the measurement window read nor the pattern sync, and
serves a byte-equal body.
Ten client mutation sites forgot a consumer: a coach-accepted checkup
stayed invisible for up to five minutes, a threshold or target edit left
the dashboard bands stale, a score-config save marked the daily reads
stale but never refetched them, a booked visit missed the Today rail, a
source-priority reorder kept every chart on the old canonical picks, an
adopted self-context note never reached the Settings read. Each site now
invalidates what its write changed and forces the unmounted snapshot +
digest reads to refetch through refetchInactiveDailyReads.
New illnessDependentKeys bundle carries the daily reads (Rest Mode
frames both), dailyDigest joins encounterDependentKeys, and the
chart-data prefix gains a factory accessor for whole-cache flushes.
The daily-reads guard learns the cycle / encounter / vaccination /
illness bundle families and a second matcher for direct snapshot/digest
invalidateQueries without a forced refetch, each with a frozen reasoned
allowlist. Both matchers were proven live by reverting a fix and
watching the guard go red (see the test doc comment).
Integration syncs (Fitbit, Oura, Polar, WHOOP, Strava, Nightscout) now
mark the per-user analytics cells stale and sweep the workouts cache at
their sync tails — imported rows used to stay invisible to the cached
dashboard, analytics and workout readers until the TTL lapsed. The WHOOP
per-resource webhook/cron driver and the Polar workouts leg carry the
same call, matching the posture Google Health and the workout batch
route already had.
Illness, cycle and encounter write routes hard-evict the analytics
bucket through the new invalidateUserHealthContext helper: Rest Mode,
the cycle context and the Today-rail appointment feed all read from
cached cells under that bucket, and an interactive write must be true on
the very next read.
The settling state's copy, the index-outcome plain-words line across
six locale catalogs and the reindex-count wiring take the whole-build
client sum to 3429 KB gzip, one kilobyte over the cap set at v1.37.4.
Every per-route ceiling stays met, so nothing a person downloads on a
given route grew beyond its ceiling; the aggregate cap moves to 3452
with the same margin as previous re-measurements.
The residual scan from the report is a CCITT Group 4 single-image PDF,
the codec class pdfjs rasterization is known to fail on. Chain checks:
a pdfjs open failure or throw already lands as render-failed with the
error text annotated; the zero-pages shape now annotates too instead
of returning silently. An end-to-end test drives the REAL rasterizer
through prepareVisionInput and pins the rasterFailed mapping; the test
header documents why a tiny in-repo CCITT fixture is deliberately not
checked in (probed against this pdfjs version, it renders the page as
blank instead of throwing, so the fixture cannot deterministically
fail and would be flaky across upgrades). The detail view's
raster-failed line now names scanned fax compression formats as a
known cause, in all six locales.
Refs #776
Same guarded transition, expressed as the render-phase state
adjustment the hooks lint rule asks for: a resolved settle clears the
state in the same committed render that carries the outcome, so the
falling-edge toast still sees both flips at once and the block runs at
most once per settle.
Two contract tests enumerate every branch of the summary job and the
index tree and assert each ends in a terminal state write: the summary
job's opt-out branch heals a phantom PENDING back to NONE, every
failure branch lands UNAVAILABLE or WITHHELD, success lands READY; the
index tree records an attempt outcome on every terminal branch,
success included. A third block pins the enqueue side: no PENDING is
claimed when the job's only branch would be the opt-out no-op, and
none before a job id exists. Honest scope stated in the test header:
these pin the two known machines, not a generic detector.
Refs #776, #788
A content-index attempt that failed (raster failure, a scan with no
text layer, a provider error) recorded its reason only in the server's
observability stream. In the vault, hasContentIndex false looked
identical for a document nobody ever tried and one that failed three
times, and the detail sheet had nothing to say about either.
Apply the summaryState pattern to the index. Two additive columns on
InboundDocument (migration 0333) record when the last attempt finished
and why it produced no index, as a closed reason set: local-empty,
local-unsupported, decrypt-error, raster-failed, provider-error,
pdf-needs-anthropic, empty-transcription. The writer sits at the choke
point the auto job and the corpus backfill share, and in the manual
index route; a success clears the reason. The provider path's specific
failure now survives the local fallback, since "no text" is less
actionable than "the PDF would not render". rasterizePdf names its
failure class so a build that cannot rasterize at all is distinguished
from a PDF that would not render. New guard in both index paths: an
empty provider transcription never becomes a successful empty index.
The detail sheet renders the reason in plain words (muted, six
locales). The DTO and OpenAPI carry the two fields; the disaster
recovery backup and restore carry them verbatim like their summary
siblings.
Refs #776
The force regenerate aborts its request at 45 s while the server's
inline generation keeps running and usually writes the fresh briefing
moments later. The old handling fired an error toast at the abort and
re-read the old cache once; with a one hour staleTime and focus
refetch off, nothing ever converged, so the page claimed failure while
holding a finished assessment at arm's length.
A timeout now enters a settling state: the hook remembers the cachedAt
baseline it was showing and polls the read path on the existing
bounded cadence (25 s, up to 10 attempts) until it can prove an
outcome. Cache advanced past the baseline: the fresh payload lands and
the success toast fires. A failure marker newer than the baseline, or
the attempt cap: an honest error. While settling, the strip's button
stays busy and says the assessment is still being prepared; no toast
fires until the outcome is proven. Both poll reasons live in the
nextAdvisorPollInterval seam, the verdict in resolveAdvisorSettle, the
toast mapping in regenerateToastKind; all three are pure and pinned by
unit tests. The contract is documented at both ends (hook and generate
route). New copy in all six locales.
Refs #786
The index-all trigger returned Boolean(jobId) while the client typed
the field as a number, so the toast read "Indexing true document(s)".
The route now answers with the count of live documents still lacking a
content index, computed from the same live-scoped counts the usage
gauge reads, and an honest 0 when no job was created. OpenAPI schema
updated to the integer shape; the hook's type carries a contract note
so the wire and the toast cannot drift apart again. Route test pins
the number, a component test pins that the toast renders the count and
never the word "true".
Refs #776
Since the record fence, vault previews load through object URLs: the
detail sheet frames a decrypted PDF from a blob: URL and thumbnails
render from blob: images. The app CSP carried no frame-src, so the PDF
iframe fell through to default-src 'self' and was blocked outright,
while img-src 'self' data: silently degraded every thumbnail to its
fallback icon.
Add frame-src 'self' blob: and extend img-src with blob: in both the
dev and prod policies. This admits exactly the app's own documents: a
blob: URL is mintable only by same-origin script, script execution
stays nonce-bound in production, and object-src 'none', frame-ancestors
'none' and default-src 'self' are untouched. The vault serve route
keeps its stricter document CSP. A unit test pins the directives so a
future CSP edit cannot silently regress the previews.
Refs #787
The upload path enqueued a summary job and stamped PENDING without
consulting the owner's auto-read opt-in; the job's opt-out branch then
deliberately left the state alone, so a document uploaded with the
opt-in off said "summary is being generated" forever. Two writers,
contradictory assumptions.
Both ends of the contract now agree. enqueueDocumentSummary checks the
opt-in first and refuses to mint a job (or a PENDING) it knows the job
cannot resolve; the job keeps its own check for a pref flipped between
enqueue and run and heals a stranded PENDING back to NONE, scoped so
READY / WITHHELD / UNAVAILABLE stay untouched. Migration 0333 drains
the rows already orphaned. The view needs nothing: NONE already
renders the manual summary action.
Refs #788
Sister file of the HR-curve integration fixture fixed on the release
branch: the same fixed session day aged past the 90-day dense-intraday
horizon today. This suite stayed green because its pulse read is mocked
empty either way, which is worse — the fallback branch its comments
describe was silently no longer reachable, and the timezone assertion
pinned a literal next-day key. The session day is relative now and the
day key is computed from it.
The workout session sat on a fixed calendar day, and the pulse-window
fallback only reconstructs a curve inside the 90-day dense-intraday
retention horizon. On day 91 the fixture aged out and all three curve
assertions went red on every branch, ours and main alike. The session
day is relative to the test run now, ten days back, which stays inside
the horizon forever.
The medication quick-add mounts from the bottom-nav capture picker on
every page, not only on the dashboard, so its bare dependent-key bundle
invalidation (default active refetch) left the unmounted Today digest
and dashboard snapshot stale until the next poll — back on the home
screen the dose still read as due for up to two minutes. Route the
submit through invalidateMedicationReads, the one blessed entry point
that pairs the bundle with the forced inactive refetch, and drop the
surface's allowlist entry in the refetch guard: its dashboard-only
rationale stopped being true when the capture picker started mounting
this form globally. The other capture-picker forms (measurement, mood)
already pair the refetch.
A weekly or rolling medication with the default dose window opens its
availability ~25 h ahead of the anchor (one early day plus the grace
minutes), so the rail announced tomorrow's slot as today's pending
business — on Friday the Saturday dose appeared as due. Thread the
profile-timezone end of today from the digest IO seam into the builder
and keep a not-yet-overdue candidate off the rail unless it falls due
before that instant. Overdue candidates still always surface, and the
early-take affordance on the medication card is untouched.
The extracted export.xml lands in the shared system tmpdir under a
name built from the clock and Math.random, which is predictable enough
for a local pre-creation or symlink attack to aim at. The name comes
from crypto.randomBytes now. Flagged by code scanning on the import
path.
The auto-read spec pinned the old collapse rule: with auto-read ON the
sheet always dropped the AI block. Since the read action stays visible
for a document that has no content index yet, the collapse assertion
now runs against the indexed content fixture, and a second test pins
the recovery path: an unread document keeps its AI section and manual
read under auto-read. Refs #777.
The facts fold already skips the empty assistant row a cancelled turn
leaves behind; the plan fold read it as a blank turn and spent one of
its recent-turn slots on nothing. Same skip, same reason. Also state
the accepted one-slot slack a late rate-limit refund can leave in a
freshly opened window. Refs #781.
The rolling-summary and fact-extraction workers read message bodies
directly, so an empty cancelled marker would ride their prompts as a
blank assistant line. Skip empty bodies in both folds. Refs #781
The free-text self-context note and the free-text allergy line are
personal medical context, so they join conditions, allergies and family
history in Settings -> Anamnese. The note is its own card (same AI-surface
gate as the inclusion card); the allergy line sits directly under the
structured AllergyManager in the same card, labelled as a free-text
supplement so the two inputs never read as duplicates. The account
section drops the old panel. Store, API and query keys are unchanged;
the scoped Clear (note only, allergies untouched) carries over with its
tests. Orphaned strings removed, new labels in all six languages.
Refs #159
An aborted stream now leaves a trace on both ends. The unmount cleanup
records the interrupted conversation (sessionStorage, one-shot) before
the abort fires, and invalidates the cached thread + rail so the return
visit refetches the advanced server state. The /coach page consumes the
record on its next mount: a known id reopens that thread, a first-turn
abort resolves via most-recent; every explicit entry intent (?c=, ?doc=,
?workout=, ?scope=, ?ask=) wins over the resume. The persisted cancelled
marker renders as a muted interrupted note with an Ask-again action that
resubmits the question through the normal send path and its gates.
Localised in all six languages. Refs #781
Navigating away mid-reply aborts the SSE fetch; the provider call tears
down, but the conversation was left with the user message dangling and
no trace of the interruption. Persist an empty assistant row tagged
providerType "cancelled" (the refusal/nudge sentinel channel) when the
request signal is aborted, refund stays intact, and the abort is
annotated as insights.coach.cancelled instead of a provider failure or
a GlitchTip defect. Cancelled markers are filtered out of the provider
transcript on later turns. Refs #781
Generate a 700k-record export.xml (~200 MB decompressed, 500k
cumulative step records each carrying a fresh HKDevice runtime address,
200k interleaved spot rows) streamed into a temp stored-method ZIP —
never checked in, never held in memory whole — and push it through the
real extractExportXml + streamParseExportXml pipeline against a
discarding Prisma stand-in.
Live heap is measured after forced collections at every progress tick;
the asserted growth bound (48 MiB) is a quarter of the decompressed
volume. Both historical failure modes blow past it: reverting the
device-address normalization retains ~61 MiB of fragmented source
buckets, and unbounding the batch buffers retains ~76 MiB and starves
the progress ticks. The fixed pipeline measures under 1 MiB of live
growth. The suite also pins semantics alongside memory: every record
read, every spot row flushed, and exactly one cumulative estimate row
per day rather than one per device address. Refs #775.
A user whose import died to a worker restart saw the literal string
interrupted_by_restart in the import card — the reconcile writes it as
a machine-readable code, and the card echoed it raw. Classify the two
coded reasons (interrupted_by_restart, the insufficient_memory prefix
from the memory preflight) onto translated, actionable copy across all
six locales; the copy tells the user their data is safe and to upload
the export again, which the re-enabled card already supports. Honest
free-text reasons keep passing through verbatim. Refs #775.
Before the multi-minute unpack starts, compare the archive's declared
uncompressed export.xml size against the V8 heap limit. The streaming
parse keeps resident state span-bounded, so the model is generous —
a fixed 192 MiB base plus 1/64 of the XML size; the 8 GiB extraction
cap needs ~320 MiB, which any default heap clears. The check only
refuses the truly impossible (an operator-pinned tiny heap against a
huge export) and does so with an actionable failureReason naming
NODE_OPTIONS=--max-old-space-size, instead of an OOM-killed worker
and a job stuck in a running state (#775).
Document the memory model, the insufficient_memory refusal, and the
interrupted-import reconcile in the self-hosting scaling guide.
The export.zip extractor buffered the whole archive through
readFileSync — hundreds of MB resident for the entire unpack phase on
a real multi-year export. The central directory is now located through
a bounded tail read on a file handle, read as one size-capped slice,
and the export.xml member streams through a byte-range read stream
into the inflate pipeline, so peak extraction memory no longer scales
with archive size.
The cumulative fold hashed the raw device attribute into its per-day
source buckets. Apple serialises that attribute from the live HKDevice
object including its runtime address (<<HKDevice: 0x283a08640>, ...),
which is not stable across the export enumeration — the map fragmented
toward one bucket per record and an 11-million-record export grew it
past the default 1 GB Node heap, killing the worker mid-parse (#775).
Stripping the volatile address before hashing restores the documented
invariant: parser state bounded by actual source cardinality.
Also carry split multi-byte UTF-8 sequences across read-chunk
boundaries with a StringDecoder — a bare per-chunk toString decayed a
split umlaut in a source name to U+FFFD.
GET/PATCH /api/auth/me/diabetes carried no test; the web toggle on the
Targets page and the iOS client both ride it. Cover the auth gate, the
resolved-state echo both ways, the field-by-field write, the audit row,
the 422 shape rejections, and the 429 path.
POST /api/consent/ai/web now accepts an optional { intent } body. Without
one (the AI-settings mount heal) a standing revocation still blocks the
mint; intent "affirmative" is the user's own consent act and may
supersede an earlier revocation, exactly as a first grant would. The
consent card offers the explicit grant control whenever consent does not
stand, and the audit row records which intent minted the receipt.
The document auto-read opt-in already declared "affirmative" on enable;
the settings-mount heal posts no body. Both stay as they are.
The server chain existed end to end (extract stages PENDING facts, per-fact
edit clears the low-confidence gate, confirm is the only write into the
structured stores) but the document detail offered no control over any of
it: it counted observation facts and linked to Labs. A user whose automatic
staging run was skipped had to re-upload the same PDF through Labs > Scan a
report.
- The detail sheet gains a staged-facts block with honest states: pending
facts open an inline review (approve/reject per fact; a low-confidence
fact is checked and saved first via the existing per-fact edit); a
lab-filed, already-read document with nothing staged offers "Extract lab
values" over its stored text; otherwise nothing renders.
- The extract route gains { mode: "stored" }: the same text-structuring
pass the automatic worker runs, over the document's own content index.
No re-upload, no second read of the original; full gauntlet including
the shared document-AI bucket with charge-at-dispatch honesty; 422
notIndexed when no stored text exists.
- A manual "Read with AI" / "Read again" now continues into the same
lab auto-staging the background worker performs; the response reports
labFactsStaged and the toast names the values waiting for review.
- Unchecked values are rejected and the copy says so; an unverified
low-confidence fact receives no decision and stays pending rather than
being silently discarded. Confirmation remains the only write into
Labs, conditions, and medications; approved facts carry the
committedRecordId link back to the source document (existing model, no
schema change).
- The "lab values from this document" link now counts APPROVED facts
only — it claims values that actually exist in Labs.
- Copy in all six locales; OpenAPI regenerated for the new extract mode
and the index response field.
The document-AI actions (read/index, extract, suggest, summarise) shared a
hard-coded 6/hour bucket that lied twice: the 429 said "try again in a few
minutes" while the window is an hour, and several failure paths burned a
slot before any provider was dispatched.
- DOCUMENT_AI_LIMIT_PER_HOUR configures the shared per-user ceiling
(default 6, clamped 1-1000), wired end to end: compose environment
whitelist, .env.production.example, env manifest, operator docs.
- INSIGHTS_RATE_LIMIT_PER_HOUR joins the compose whitelist and manifest;
it was documented but never listed, so an operator value could not reach
the container.
- The 429 mirrors the bucket's reset instant into meta.retryAt (plus the
existing X-RateLimit-Reset header); the client derives "try again in
about N minutes" from it in all six locales, with a plural-aware key.
- A slot is consumed only when a request actually reaches the provider (or
the local index/echo work): malformed bodies, unreadable originals,
refused consent, and exhausted daily budgets refund the charged slot via
a window-scoped refundRateLimit primitive. Consistent across all four
routes sharing the bucket.
The content-index backfill built its candidate MIME list from the
provider's native PDF support, so a provider that reads images but not
PDFs never saw a single PDF in "index all documents" — while the
per-document path already rasterises a PDF to page images for exactly
that provider class. The prefilter was stricter than the authority it
fronts.
PDFs are always candidates now; prepareVisionInput stays the per
document authority (native pass-through or rasterised pages). The run
summary also stops folding outcomes into one number: skipped (nothing
billable happened, retried later) and failed (a provider call missed)
are counted separately, carried through the wide event and the worker
log, and reruns stay idempotent because indexed documents drop out of
the candidate set.
Refs #776
The AI section of the document detail sheet collapsed whenever auto-read
was on and nothing was pending review, without checking whether the
document actually has a content index. A document the automatic pass
never reached (uploaded before the preference, or the pass did not
complete) then had NO visible way to be read; turning auto-read off was
the only workaround.
Collapse only once the document is read (or when indexing is unavailable
on the transport, where no action could be offered anyway). An unread
document keeps the Read with AI action, introduced by a short honest
note in all six locales; the suggest and summarise extras stay hidden
because the auto-read promise still covers them. The three states stay
distinct: unread shows the note plus the action, an in-flight read shows
the reading label, a read document collapses the block.
Refs #777
The score reader's labs query selected referenceLow/referenceHigh on the
joined Biomarker, but the catalog's range columns are lowerBound and
upperBound. Prisma rejects a select against names a model does not have
at query-validation time, before any row is read, so every labs read
threw and both the Glycaemia and Lipids pillars reported a failed read
permanently, Retry included, even for accounts with valid lab results.
Select the real columns, map the catalog bounds onto the reader's range
fallback, and pin two contracts: a real-Postgres round trip that scores
both pillars through the biomarker join (including a lipid row whose
range lives only on the catalog), and the honest empty state, where a
labs account with no rows reads as not tracked rather than read_failed,
which stays reserved for a read that genuinely failed.
Refs #780
The `/api/mood/insights` mock still returned the pre-heatmap shape, so
`summary.totalEntries` was undefined, the empty-state guard never
short-circuited, and the page threw reading `heatmap.cells`. Correct the
mock to the current MoodInsightsResponse and add `/insights/mood` to the
a11y route matrix so the page is scanned henceforth.
The mood chart hand-rolled a CardTitle where every other insights tile
header goes through TileHeader. Swap it for the shared primitive, which
renders the same CardTitle at text-base, and drop the now-unused CardTitle
import. Like-for-like; the surrounding control rows are untouched.
The lifestyle-context tile carried a Save button per fact kind. Collapse
them into one footer Save that writes only the dirty kinds, firing the
existing bitemporal call per kind (POST a first value, PATCH a revision)
and settling them together so a single conflict cannot take the healthy
writes down with it. A kind that 409s keeps its draft on screen and names
itself, instead of vanishing behind one green toast. The per-kind Remove
stays.
The consume hook now loads the user's zone, the schedules, and the
intake's scheduled anchor to resolve per-slot units, so the fake
client grows the schedule read and the batch scheduledFor lookup. The
Italian per-dose card label reworded so it no longer echoes English.
Integration: a medication with a morning slot at 1 unit and a noon slot
at 0.5 draws the right count for each taken dose against a real
Postgres. Unit: a per-slot dose + units set in the wizard survives
create and round-trips on edit-hydrate.
The schedule exporter enumerates columns, so the new per-schedule
units-per-dose needs adding by hand: export it as a Decimal string in
the disaster-recovery payload, accept it in the wire schema, and write
it back on restore (NULL stays NULL to inherit). Extend the round-trip
test to prove a half-tablet slot survives export and re-import.
Surface the per-schedule units-per-dose beside the per-slot dose the
card already prints, identically on the standard and the GLP-1 card,
so a split plan reads at a glance which time takes a half. Muted
addendum, no width movement.
Give each parallel schedule its own optional dose and units-per-dose
input on the times step, so a split plan (a whole tablet in the
morning, a half at noon) is ONE medication with three schedules
instead of two drugs that double-count. Leaving both blank inherits
the medication-level values, so a single-dose plan is unchanged. An
as-needed slot that sits alongside scheduled ones now rides the same
medication as a PRN schedule rather than flipping the whole-medication
as-needed flag. New keys land in all six locales.
Add schedules[].unitsPerDose to the create/update contract with the
same whole-number-or-curated-fraction validation as the medication
level, built into the Prisma data field-by-field on both the create
and the schedule-replace path. Unwrap the nullable Decimal to a JSON
number at every medication read/write response through one shared
serializer so the wire shape cannot drift, keeping NULL as NULL to
mean inherit-the-medication-level.
The inventory consume hook counted the medication-level units_per_dose
for every dose, so a split plan could not decrement a whole tablet in
the morning and a half at noon. Resolve the units from the taken
intake's own slot: match its scheduled wall-clock time in the user's
zone to a schedule and read that schedule's units_per_dose, inheriting
the medication level on NULL or no match. A medication whose schedules
all leave the column NULL resolves exactly as before. The batch-import
path binds each dose by its scheduled anchor the same way.
Add a nullable units_per_dose to medication_schedules so a split-dose
plan can consume a different unit count at different times of day (a
whole tablet in the morning, a half at noon) on ONE medication instead
of two. NULL inherits the medication-level units_per_dose, so every
existing row keeps its current behaviour. Migration 0332 adds one
nullable Decimal(10,4) column with no backfill.
Add a Notfalldaten card to the Anamnese settings section with a manager
for the three enum facts and the three free-text fields, writing through
the new emergency route. Add every string across the six shipped locales.
Add an EMERGENCY leaf to the identity group of the report catalogue,
default-on, no module gate. When the leaf is admitted and the profile
holds emergency data, the aggregator collects it and the PDF renders an
emergency sheet as page one (banner, blood type, severe allergies, active
medications, chronic conditions, implants, advance directive and organ
donor, contacts, notes), then breaks to send the rest of the report to
page two. The section is withheld when the leaf is not admitted or no
data is present. Guards updated for the new leaf counts.
GET/PATCH /api/anamnesis/emergency, wrapped in apiHandler. The PATCH
validates a partial body with per-field length caps, builds the Prisma
data field-by-field from the parsed input (never a spread), narrows the
user from requireAuth rather than the body, encrypts the free text on
write, and audits the change. The GET decrypts fail-soft for the form
prefill. Registers the OpenAPI operations and schema. An integration
test reads the row back to prove the write lands.
Register the three encrypted emergency columns in the rotation registry
and the key-rotation script so a legacy-key drop cannot strand them.
Carry all six new columns through the profile backup builder and restore
(the enums by value, the free text as ciphertext in a DR payload or
decrypted plaintext in a portable export) and extend the wire schema.
The full-backup round-trip integration test seeds the six columns and
asserts each survives an export and restore.
Fold six nullable columns into user_health_profiles: three closed-set
enums (blood type, organ-donor status, advance-directive status) read as
plaintext, and three AES-256-GCM free-text columns (emergency contacts,
implants, ICE note). Migration 0331 is additive only: three CREATE TYPE
plus one ALTER TABLE with nullable ADD COLUMNs. An integration test
asserts the columns and enum types exist on the migrated schema.
addWarning pushed the raw string while setError, setHttp and
addExternalCall all scrub through redactSecrets. A warning routinely
carries an outbound error string, and for several integrations that
string is the request URL with a credential in it, so a secret could
reach stdout and the log store unredacted. It now scrubs on the way in
like the other entry points, and a test asserts a licence-key-bearing
warning is masked.
A structural guard asserts that no file outside src/lib/links touches
the five link tables the service owns, with the two backup restore
writers as the only, frozen exemption. The matcher is whitespace
tolerant and proven non-vacuous: it must match every owned table inside
the service, so it can never pass by matching nothing.
This makes provable the property the link module was built for, that the
later absorption of these narrow tables into one join model is a storage
change behind the module rather than a sweep across every call site.
The Coach can now see that a visit is coming and what the last one was
about, without being handed a visit archive. The block is deliberately
small: the appointments booked within the next 14 days plus the single
most recent visit that happened. A full history is a get_visits
question, not a prompt-budget one.
Built as a peer of the illness and labs blocks and registered for
degradation EXPLICITLY against the lowest-priority cluster, so it is
among the first blocks shed when the character cap binds, before any
clinical cluster. Encrypted reason and outcome decrypt fail-soft and
every free-text string routes through the prompt sanitiser first.
Add get_visits, a bounded read of past visits (default the last 12
months, optional practitioner-name filter) returning date, status,
kind, practitioner, specialty, the visit's own reason and outcome, and
the linked condition labels, newest first. Absence is explicit: an
account that has never recorded a visit reads { present: false }, never
{ present: true, visits: [] }.
Fold upcoming appointments into get_preventive_care as their own named
field rather than minting a second due-list tool, so a question about
what is coming up gets one answer. The Vorsorge arm stays free of
ENCOUNTER-origin reminders; the appointments are read from Encounter
directly. Reason and outcome ride the USER_TEXT wrapping, and every row
is scoped to the caller by construction.
The runtime fetch extracts databases in pure Node, but its unit test
builds a MaxMind-shaped tar.gz fixture with the system tar, which the
dead-code gate flagged as an unlisted binary. It is available in CI and
dev, alongside the docker and pg_isready entries already listed.
A self-hoster on the published image can set MAXMIND_LICENSE_KEY at
runtime and the app fetches the GeoLite2 databases itself, no rebuild or
manual mount. Refs #659.
safeFetch embeds the full target URL in its thrown error, and the
GeoLite2 download URL carries license_key=<secret> in its query string.
The wide-event warning and job-failure paths do not redact it, so a
timeout or network error on the runtime fetch printed the key to stdout,
Loki and GlitchTip in clear. The download timeout is 120s for slow
self-host links, so that path is expected, not rare.
The fetch now re-throws with the edition and the error kind only, never
the URL. As defence in depth, the central query-param redactor also
learns license_key, so any other path that logs such a URL through
redactSecrets is covered too. A regression test asserts the returned
error carries neither the key nor the parameter name.
Cover the new MAXMIND_LICENSE_KEY route in the self-hosting guide as the
first of the three ways to get the offline databases (fetch, mount, bake),
and note that the monthly runtime refresh keeps a keyed host current on its
own. Add the variable to both env examples and to the env-check manifest so
the optional group lists it.
Refs #659
The offline geo tier only reached a self-host two ways: baked into the
image at build time, or mounted from the host. Both need something the
operator of a stock `:latest` pull does not have. Add a third: set
MAXMIND_LICENSE_KEY and the worker downloads GeoLite2-City + GeoLite2-ASN
itself into GEOLITE2_DIR, so the offline tier starts working with no
rebuild and no manual mount.
A worker-only pg-boss queue drives it. Boot discovery fetches once when a
key is set and no database is present yet; a monthly cron (past MaxMind's
first-Tuesday reissue) keeps a keyed host current. The fetch is fail-soft:
each edition extracts to a temp file in the target directory and is
promoted with an atomic rename only after both are in hand, so a bad key
or an upstream error leaves the previous databases exactly as they were
and never writes a partial `.mmdb`. After a successful place the reader
cache resets so a running process picks up the files without a restart.
Extraction is pure Node (zlib + a minimal tar reader), not a shell-out:
this module is reachable from the worker-boot import chain, and a
child_process import there makes Next's file tracer pull the whole project
into the standalone output. zlib keeps the trace bounded and drops the
busybox runtime dependency. The download goes through safeFetch with the
public-host pin and redirect-following, since the MaxMind permalink 302s
to a signed CDN.
The runtime image now owns /opt/geolite2 as the unprivileged app user so
the worker can write it; a read-only bring-your-own mount at that path
still wins, because the fetch cannot write over it and fails soft.
Refs #659
Two hardening changes collected on the trunk: a screening reminder can no
longer be marked done through a crafted request, and editing a custom mood
tag, tag group or cycle symptom now leaves an audit entry the way deleting
one already did.
Editing a custom mood tag or group, and editing a custom cycle symptom,
wrote nothing to the audit ledger, while their delete siblings did. Two
of these paths destroy data: a custom mood tag or cycle symptom purged
with ?purge=true cascades its entry links and unpicks historical
entries, so a destruction left no trace of who ran it or when.
Every vocabulary-mutating verb now records an audit row: create, edit,
and delete for both custom tags and custom groups, and edit for a custom
cycle symptom. Encrypted labels are never logged; the row carries only
the field names touched and the resulting active state. The two
display-only writes on this surface, hiding a catalogue tag and
reordering the layout, stay ledger-free by design and say so inline,
since a per-user display preference is not a health-data write and a row
on every drag-to-reorder would be noise rather than a record.
A Vorsorge screening (PHQ-9, GAD-7, WHO-5, SCI) resolves only from the
score row a completed check-in writes, never from a manual done. The web
and iOS clients enforce this by routing a screening to the check-in page
instead of offering the done action, but the satisfy and complete routes
trusted that gate and would stamp any reminder, screening included. A
crafted POST could therefore mark a screening satisfied with no
assessment behind it.
Both routes now reject a screening reminder with 409 before the shared
satisfy primitive runs, mirroring the client rule as defense in depth.
Typed numeric reminders keep their manual satisfy, which is intentional
and covered. The satisfy route gains its first route test alongside the
new guard case on complete.
The delegated-caregiver settings forms were the last hand-rolled control
block: native <input>/<select> with a copied border literal, a native
type=date for the birth date (the browser-locale trap closed for the
owner surface in v1.25.4), module checkboxes where the owner section
shows switches, and a save button mid-form with the error line dropped
below it.
Route them through Input, NativeSelect, DateField, and Switch, and give
every form a shared footer that places the error line above the action
row and the action row last (§12). The Switch rows keep a hidden mirror
input so the FormData submit contract is unchanged. Extend the section
tests to hold the DateField primitive and the action-last order in place.
The shell check caught only the bare `5` step on p/px/py/space-y/gap, so
off-scale directional and half-step spacing (`ml-5`, `pl-5`, `gap-3.5`,
`py-2.5`, the `7` step) slipped through on hand-rolled card shells. Widen
it to the whole spacing prefix family (padding, margin, gap, space) and
every banned step — `5`, `7`, and half-steps other than the sanctioned
`1.5`. Height stays out of scope: it is a dimension, not a scale step.
Verified the rule bites by injecting `gap-3.5` onto a real bg-card+border
shell and watching lint flag it, then reverting; the sweep this rule
guards left the tree clean apart from one tooltip shell, now on-scale.
Bring the remaining settings and dashboard surfaces onto the shared
anatomy: the API and MCP revoke buttons become the destructive variant
instead of an outline hand-tinted red; the runtime-actions row places
its raw-data setting and status lines above the actions, not under them;
the account section paints its card and header while loading instead of a
lone spinner; the share revoke becomes a right-aligned row action rather
than a full-width alarm strip per link; the recent-workouts tile and the
MCP and notifications empty states compose Card and EmptyState instead of
hand-rolled bg-card shells.
Sweep the spacing rests that sat off the 1/1.5/2/3/4/6/8/10 scale: the
health-score panels and their skeleton, the recommendation ask-Coach
gutter, the onboarding tour footer, the vaccination row, the danger-zone
alert margins, the recovery-codes action wrapper, and the chart-tooltip
tile. Move the last hand-written micro-sizes (coach counters, the
personal-record badge, the score-ring label, the dose-history and
cycle-calendar headers, the mood tag chips) onto the text-2xs token, and
give the documents upload buttons the mobile tap floor their peers set.
Move the last hand-rolled tile and section headers onto TileHeader and
SectionHeading so every card speaks one header language: the vorsorge
dashboard tile, the three illness cards, the metric-correlation section,
and the custom-metric list group. The mood-chart title drops to the
primitive semibold weight and its bucket label becomes a TagChip.
Align the in-card sub-headings of the integration cards to the one
sanctioned section-label tier (muted uppercase eyebrow) instead of a
second text-sm font-semibold dialect.
Keep the onboarding and mental-health wizard hero H1s at their
deliberate semibold weight, now marked inline as an intentional
exception so a future heading sweep leaves them alone.
- the single- and bulk-delete flows on the measurement list both say
Messwert now, instead of switching to Messungen for the bulk case
- the practitioner field on the visit and vaccination forms reads
Praxis oder Arzt on both, dropping the slash form and the lone feminine
variant so it matches the practitioner list heading
- the visit saved/updated toasts lose their trailing period to match the
vaccination toasts they ship beside
- measurements subtitle drops to a single sentence and loses the phrase
that named Insights twice
- the mood self-rating card takes a short title so it stops wrapping three
lines and stops sharing the word Einschaetzung with the AI assessment
beside it; its noPattern and expectation lines move to direct address
- the daily-briefing empty state drops the worked example, leaving the
setup instruction on its own
The sub-page explainers had grown to four sentences of guideline detail that
pushed the stat strip and chart below the fold; the contract is a one- to
two-sentence definition, with the long form living behind the knowledge
disclosure. Each body now states what the metric is and why it matters.
Also aligns the readiness explainer to the score title across locales
(Bereitschaft / Disposición / Disponibilité), retiring the stray Tagesform.
- mood-insights-sections: the docblock claimed the sections render nothing
on a failed read; the rest region now surfaces a QueryErrorRow with retry
- band-tokens / coverage-meter: the raw dracula primitives now carry their
own AA-tuned light overrides, so the no-override claim no longer holds;
the band still rides the semantic token for concept cohesion
- medication-card-body: drop the pb-2.5 header claim the header no longer sets
- modules-section: the body follows the card gap rhythm, not an mt-4 margin
The visit form's document / lab / condition link blocks and the
vaccination form's document link were flat lists capped at 25 rows with
no search: 30 lab values arrived undifferentiated and anything past the
cap was unreachable. Replace them with one shared picker — an inline
summary of removable chips over an add button, and a sheet that searches,
groups and multi-selects. Lab results gather under their panel and sample
date with a select-all per group, documents gather by month, illnesses
stay flat. The picker raises only the fetch limit; the server link cap
stays authoritative.
Search now covers the specialty column alongside name and practice, so a
field name like "Zahnmedizin" finds the practice even when the name and
practice hold nothing of the sort. The same OR reaches the visit form's
practitioner picker, which now shows the specialty beside the practice so
a specialty hit is legible. The list groups its rows under their
specialty, sorted alphabetically with the unspecified rows gathered into
one group at the end; when nothing carries a specialty the headings stay
suppressed and the list reads flat as before.
The file-a-visit affordance painted on every checkup reminder, including
the self-measurement ones (weigh-in, blood pressure, a screening
check-in) where there is no appointment and 'you went' is never a
reading. Gate both the card and the dense-list branch on the same
free-text predicate the mark-done action already uses: a reminder with a
measurement type is satisfied in the app, a free-text reminder stands for
a practice visit. The server states the same boundary in resolve.ts.
Manual water entry is gone from the app: the water item on the capture
picker, the dashboard quick-add menu and the hydration card's add button
are removed, and the manual quick-add sheet is deleted. Water that syncs
in through Apple Health still arrives and still shows on the hydration
card with its daily total, history and reference line.
The POST /api/nutrients/water endpoint and the batch sync path stay
untouched. The nutrients page's module-enable error, which had borrowed
a water-quick-add string, gets its own key across all six locales.
Input-only water keys (nav.capture.water*, nutrients.hydration.quickAdd*)
are removed.
Adding recoverable read-failure handling to thirteen surfaces and the MCP and
managed-record copy rewrites across six locales lifts the whole-build client
total to 3404 KB gz. Every per-route ceiling is still met; the aggregate cap
moves 3352 to 3428 with the same margin.
Eight admin surfaces rendered a failed read as their own destructive-
tinted box, and most offered no way to recover. Sweep them onto the shared
QueryErrorRow with retry so the console uses one error form (§13). The
statically renderable sections were watched fail first against the
hand-rolled boxes.
The section carried a bespoke muted error row with its own retry button —
the exact drift QueryErrorRow was made to absorb. Swap it for the shared
primitive so the failure reads as foreground content. Test watched the
bespoke row first.
A family of settings surfaces rendered a failed read as a bare
text-destructive line with no way to recover: the security sessions,
trusted-devices and activity cards, the coach memory and reminders
sections, the about-me section, and the document link/share pickers. Swap
each to the shared QueryErrorRow primitive with a retry control. The suite
covering the statically renderable members was watched fail first against
the bare lines; the two portaled pickers take the same swap.
The cycle tabs rendered a bespoke error row in muted text, wrapped in a
bare card. Replace it with the shared primitives: QueryErrorCard on the
standalone prediction/insights/settings tabs, and a borderless
QueryErrorRow inside the calendar card so the failure reads as foreground
content with retry rather than muted meta. Test watched the muted row
first.
The mood-insights regions returned nothing on a failed read by documented
intent, degrading to the line chart. Enforce the honest-error standard
here too: on error the main region paints a QueryErrorRow with retry, once,
mirroring how the loading skeleton is scoped to that one region so the
three regions do not stack identical rows. Test watched the silent empty
first.
The ECG detail rendered a read failure as a muted, non-recoverable line;
the routed list page had no error branch at all, leaving the shell
heading over an empty body that read the same as loading. The detail now
paints a QueryErrorCard with retry (an alert is content, not muted meta),
and the page shows the same instead of an empty shell. The overview teaser
still unmounts silently, which is right for the overview. Tests watched
each fail first.
The three AI configuration reads each returned null on a non-OK response,
so a failed read painted the default provider form as if the account were
configured that way. Throw on a non-OK response so the query reports its
error, and render a QueryErrorCard with retry when any read fails. Test
watched the misleading form render first, then pass on the honest error.
The tile already read the query's error flag but never branched on it, so
a failed reminder read fell through to "no upcoming checkup reminders" —
a missed preventive-care nudge hiding behind an honest-looking empty
state. Add a QueryErrorRow with retry before the empty check. Test
watched fail first against the fall-through.
The connected-assistants and connector-token queries had no error or
loading branch, so a failed read rendered "no connected assistants" or
"no active tokens" on this security surface. Gate both lists with
QueryErrorCard and retry, a skeleton while loading. Test watched fail
first against the old empty-state fall-through.
Same fall-through as the share-link list on another security surface: the
token query had no error or loading branch, so a failed read rendered
"no active tokens". Gate the list on the query state with QueryErrorCard
and retry, a skeleton while loading. Test watched fail first against the
old empty-state fall-through.
The share-link query had no error or loading handling, so a failed read
collapsed to an empty list and rendered the "no active share links"
copy — on a clinical sharing surface a load failure was indistinguishable
from "you have shared nothing". Gate the list on the query state:
QueryErrorCard with retry on failure, a skeleton while loading, the lists
only once the data is in. The added test was watched fail first against
the old fall-through before this fix made it pass.
Three small copy corrections that share the locale bundles:
- Resolve the formal-address outliers on the owner sharing and report
scopes to the informal register the rest of those surfaces use.
- Add the direct-address comma to the personalized dashboard greeting.
- Split the managed-record form error: a client-side validation failure
no longer surfaces as a network error, and a failed save gets its own
line.
The respiratory-rate tile hard-coded the English unit while the metric
sub-page already reads the localized string. Point the tile at the same
key so every locale shows its own unit.
The central-codex and auto-read consent notes are safety copy the user
has to read before enabling a data-sharing path, but both rendered as
muted extra-small meta. Promote them to foreground text-sm and keep only
the decorative icon muted.
The note above the write toggle claimed tokens are read-only in every
case, which the toggle directly below it contradicts by minting a
read_write scope. Rewrite it to state the real model: read-only by
default, write opt-in per token, never admin. Resolve the pronoun that
read as a formal address, and promote both scope notes to foreground so
this security copy no longer renders as muted meta.
A role=combobox button is not named from its content, so the visible
placeholder text left both the vaccine-catalogue trigger and the shared
practitioner trigger without an accessible name, and the wrapping field label
pointed at an id the button did not carry. Name each from its field label. The
practitioner trigger is shared, so the visit form gains the same name.
Also settle CSS animations before the accessibility scan and poll the reminder
reads in the booster flow: the scan otherwise raced the capture sheet's fade
and measured a half-opacity blend as a contrast failure, and the flow read the
reminder list before the mint and the re-anchor had propagated. Both wait for
the settled state rather than relaxing any assertion.
The booster route was admitted into the MANAGE matrix but had no driver, so
the strict-driver and real-effect inventories stayed one short of the admission
count and the completeness guards failed. Register the write-effect driver that
mints the reminder against a tetanus dose and asserts the delegated WRITE actor
on the audit row, matching every other admitted write.
A rolling reminder at the ten-year maximum interval lost its next due when
the satisfy instant fell in the late-UTC evening. The next-occurrence search
bounded the single deterministic rolling slot by the end of the anchor's UTC
day, but the applied notify-hour slot lands on the following UTC day whenever
the anchor sits in the evening (the local morning is the next calendar day in
a positive-offset zone). The anchor-day ceiling was then a day short and
clipped the very slot it meant to admit, so logging the next dose set nextDueAt
to null and the booster silently disappeared. Key the ceiling off the applied
slot instant instead; endsOn stays enforced inside expandRolling.
A rolling reminder at the engine's ten-year maximum interval lost its next
due date the moment its cadence was satisfied. The rolling branch of
nextOccurrenceAfter bounded its search by the 10-year anti-spin hardCap, and
a satisfied reminder computes its next slot with after === lastSatisfiedAt,
so the single deterministic slot lands exactly after + 3650 days — equal to
the hardCap to the millisecond. The notify-hour slot then spilled a few hours
past the raw boundary and was clipped to nothing, leaving nextDueAt null.
The rolling slot is one deterministic instant, not an open-ended walk, so the
anti-spin cap must never gate it. Bound the rolling search by that slot's own
day instead; endsOn stays enforced inside expandRolling, so a course-window
reminder still self-expires.
This surfaced on a ten-year tetanus booster: logging the next dose silently
dropped the reminder instead of rolling it forward a decade.
The booster mint adds one mutating handler, so the admitted inventory and its
strict-driver and effect mirrors are 75, not 74. The registry and the frozen
route count already moved; these three assertions had not.
The capture helper branched on an instant isVisible() read to pick between the
header add and the empty-state add. Right after a navigation that read races the
client render and loses, so it fell through to the empty-state button, which is
gone once a record exists, and the click timed out. The header add renders in
both states; clicking it directly lets click() auto-wait for hydration.
Two record-scope properties through the real routes and a real Postgres. A
Guardian standing inside a managed profile writes a dose against the resolved
record — the profile's rows, never the Guardian's own — and reads it straight
back. A delegate holding a profile READ grant on an ordinary record lists and
reads the doses but is refused the PATCH with a 403, the escalation stopped at
the manage check before the row is looked up, and the row is left untouched.
Five Playwright flows over the built app, each asserting stable data-slot
attributes rather than viewport text: transcribe a catalogue pick with a lot
into its antigen group, a combination dose rendering under each of its three
component groups, the booster prompt minting a Vorsorge reminder that shows on
checkups and moves its due date when the next dose is logged, the module-off
redirect with the nav entry gone, and an axe pass over the list and the form.
The catalogue option gains a data-catalog-slug hook so a flow can pick an exact
antigen deterministically. The spec runs in a single project and serial, and
clears its own rows before each test, so a grouped verdict counts only what the
test wrote.
- freeze the booster-mint and upload-suggestion routes into the sharing
surface guard with their why sentences; delegable-write and mutation
inventories move by the two routes they add
- inventory the two new routes as module-exempt data routes (SURFACE-gated,
like the medications posture)
- give the booster-plan audit action its own owner-facing verb in six locales
- register the dose delete as a destructive control; route the vaccination
success toasts through the outcome module
- move the report leaf counts (structured 15→16, all 92→93) and the history
group count in the panel; regenerate the OpenAPI selection bound
- seed the immunization rows in the export-route mock so the aggregator no
longer throws for a fully-selected report
- the ~35 antigen and combination display names, medical orthography per
locale, umlauts intact end to end; disease and antigen names only, never a
trade name
- the three clinical proper nouns whose written form is identical across
locales (Hib, Hepatitis A/B in Spanish) are recorded as legitimate matches
- IMMUNIZATIONS report leaf in the history group, gated both ways: the
vaccinations module and the leaf selection each exclude independently
(WR-7 seen red on the selection gate, reverted by content hash)
- collector loads the whole live history (reference data, not windowed, like
allergies) with server-resolved series positions; the PDF renders a compact
per-dose table — date, vaccine, dose, lot — with no due-status or gap analysis
- added to the default template so a new report offers it; stored selections
and frozen share links never widen
- leaf label and section copy in six locales
The access options render as a row with an info affordance rather than a
wrapping label, the scope question preselects nothing, and the write and
manage detail moved behind the (i). Point the consent-copy and form-reset
guards at the shipped bundle for the detail text and at the unset scope
default.
The pre-create sweep in inviteGrant writes an audit row when it closes a
lapsed grant, so the transaction slice it accepts now covers auditLog
alongside accountGrant. Point the module and route test fakes at the
same shape: they stub updateMany to close nothing, so the audit branch
stays unreached in the unit fixtures.
- from the record: a document link picker on the capture form, gated on the
documents module (blanked when off, never post-filtered)
- from the upload: a VACCINATION scan offers to file against a dose recorded
within the shared ±7-day window; a single candidate pre-selects, two offer a
picker with nothing pre-selected (WR-8 seen red on the pre-select rule,
reverted by content hash), zero shows nothing, and it never blocks the upload
- the suggestion rule reuses the visit moment's window constant rather than
forking it; it registers with the shared filing module when that lands
- suggestion and link copy in six locales
The write and manage levels each carried a paragraph of capability text
under their label, stacking into walls of text on the invite form. Cut
each to a single consent sentence inline and move the full enumeration
behind the info affordance at the option-heading height — what exactly
write may add, and the fence manage does not reach. The consent line
stays inline where it has to be read; the detail is one tap away for
anyone who wants the whole list. Six locales.
The scope question defaulted to whole record, which reads as the form
nudging the choice. Preselect neither option: an untouched fieldset now
blocks the form with a quiet hint instead of falling through to a
whole-record grant. The wire contract is unchanged — scope-absent still
means the entire record on the server, so the unset state deliberately
does not omit scope, it blocks the client until the owner picks. The
empty-selection and manage floors stay fail-closed as before.
The live-pair uniqueness is a partial index (WHERE revoked_at IS NULL),
which cannot read the clock: an expired but never-revoked grant kept
occupying the slot, so re-inviting the same person after expiry always
failed as a duplicate though they had no access. Close the lapsed row at
the moment of re-invite — conditionally, the expiry state in the WHERE —
so revocation still never deletes and the fresh invitation mints its row.
The revoker is stamped GRANTOR because the paired revoked-by check
requires both columns set and the enum has no expiry member. An audit row
records the close. Proven against the real index: the guard was watched
fail with a duplicate before the fix landed.
- rung 2: a prefilled, user-confirmed, editable, declinable prompt writes an
ordinary VORSORGE reminder keyed on the dose's primary antigen; the
published origin enum and reminder DTO are unchanged
- idempotent by antigen — a second confirmation re-anchors the existing
reminder rather than minting a second (WR-9 seen red on the reminder count,
reverted by content hash)
- rolling day cadence so logging the next dose re-anchors the due date through
the Phase 1 satisfy matcher; the whole loop proven through real routes
- booster prompt copy in six locales, plural-aware
Security now reads authenticator, then security keys, then passkeys,
then the password — the password card was mounted first. Keep it outside
the factor-status conditional so a stalled MFA read never takes it down;
it just renders last instead of first. A source-order guard pins the
sequence, since this class of ordering regression is easy to miss.
The score-composition settings grouped its topics under domain headings
(Heart and metabolism, Activity, Sleep, Body shape, Wellbeing). Remove
them and list the individual topics one under another with hairline
dividers, the way the health profile lists its facts. Each row keeps its
domain data attribute, so the flattening preserves the registry order and
tests still address a pillar by its domain. The now-orphaned domain-label
map and its six-locale strings go with it.
The desktop dialog body was the lone scroll port and carried no
horizontal padding of its own — the vertical scrollbar rendered flush
against its right edge, exactly where a slider's right-aligned high
anchor sits, so an overlay scrollbar painted over the last characters of
labels like the mood endpoints. Reclaim a 12 px gutter from the dialog's
own padding (-mr-3 pr-3): the scrollbar now sits beside the text, content
alignment unchanged. Universal to every ResponsiveSheet dialog.
DateField and TimeField carried a divergent focus vocabulary — a hard
`ring-2 ring-offset-2` that painted a 4 px ring outside the field's
border box, so the highlight was visibly wider than the div and shifted
on focus. Pull both onto the Input vocabulary: a soft 3 px ring hugging
the box, no offset. Also match Input's `text-base md:text-sm`, so a
sub-16 px input no longer triggers iOS Safari's focus auto-zoom. Every
DateField and TimeField in the app inherits the fix.
The clear-action button sat as muted text beside the value, so a set
slider read as one phrase — the number fused with the word next to it
(a value of 4 next to "leeren" showed as "4 leeren"). Replace it with
an icon-only X that carries the same label as its accessible name, so
the value readout stays the bare number in every locale at once.
- compose rung-1 sentences from the seed's series length, booster interval and
category over a handful of i18n templates, with the per-entry source as a
verbatim footnote; nothing personal, no age or history conditioning
- surface it in the picker's chosen-entry detail and behind the antigen group
header's info affordance
- schedule templates in six locales, plural-aware
- one sheet for create and edit, saving with a date and one identity arm
(catalogue pick or free text); nothing else can block the save
- catalogue picker searches localised names and generic synonyms over the
static antigen list, never overwriting the free-text arm
- optional dose/series, lot, site, practice combobox, visit suggestion and
note, all reusing the encounter-form primitives
- write hooks fan out through the dose dependent-keys and the reminder reads
- form/site/sheet copy in six locales
- read the immunization log through the query-key factory, unwrapping the
envelope; the series arrive resolved per component antigen from the DTO
- render one group per antigen, a combined dose appearing in each of its
component groups with that component's own position; free-text and dead-slug
rows fold under their verbatim name
- neutral cards, meta in muted, no due status on the page
- add the dose-list dependent-keys bundle, evicting the preventive-care root
alongside the dose list so a satisfied booster never lingers on checkups
- series/lot/error copy in six locales
- register /vaccinations in the clinical nav spine, module-gated on
vaccinations (default-on) and part of the shared record
- join /vaccinations to the profile shared-record route family so a scoped
profile grant can present the page it is allowed to read
- add the page shell: authenticated, module-gated, with the standard
module-off redirect the medications surface uses
- add the nav label and the surface title/subtitle/empty copy in six locales
The visit stage's second half landed while this was in flight and both
touched the same three places. Resolved as one set: the link facade
carries the reverse document-to-visit direction and the new dose pair
side by side, the sharing literals hold both sets of admissions with the
count re-added on top, and the language bundles take the shorter module
description the trunk settled on.
A frozen tag is a promise about code; the matrix is the one that proves
behaviour. Each of the four vaccination mutations now has its own
driver: a manager with a live grant changes the owner's record and the
audit row names both people, the same person under a lesser grant is
refused with nothing moved, and a caller who was never granted anything
gets the same refusal.
The dead-code gate is right: a bundle with no caller is dead code. The
capture surface brings it back with the mutation that needs it, and the
key module says what that bundle will have to evict.
- a query-key module and a dependent bundle that also evicts the
preventive-care root, because logging a dose settles a booster and the
checkups list goes stale in the same tick
- the OpenAPI module publishes the resolved series with the contract
written into the description: render text from these numbers, never
recompute them
- the health-background consent sentence now names the immunization
history in all six languages, so the widening is stated where it is
agreed to rather than inferred from a code comment
- no server-side cache helper: nothing cached reads a dose, and a sweep
over caches nobody reads would look like coverage without being it
- the dose is expanded to its component antigens first, so one Tdap
entry settles a tetanus booster and a pertussis booster at once;
matching the preparation's own slug would leave both standing
- it runs inside the create transaction and nowhere else: no cron, no
ingest hook, and an edit never re-runs it, so correcting a typo in a
date cannot move a reminder
- forward-only, which is what makes transcribing an old record safe: a
page from decades ago never clears a booster that is due now
- a free-text-only dose matches nothing; a name string is not evidence
about what was in the syringe
- an ordinary preventive-care reminder carries no antigen key and is
structurally out of reach
- four route files: list and create, read and edit and delete, restore,
and the link pair; every delegable one is classified as health
background, in each leg of the sharing guard, with its reason written
- link and unlink stay bare, consistent with the document and visit link
routes; the three are revisited as one decision
- vaccinations becomes the nineteenth module key, default-on and
surface-gated like medications: the data routes stay exempt so a
restore keeps working and re-enabling finds every dose intact
- a delegate's dose entries get their own sentence in the owner's
activity view rather than reading as a generic change
- a dose needs a day and one way of naming what it was, and nothing
else; a required catalogue pick would make an old Pass line unloggable
- the write path refuses a future date and a slug the catalogue does not
offer, while storage stays tolerant so an older backup restores whole
- the DTO publishes the practitioner, the visit, the linked pages, the
catalogue entry and the derived series already resolved, in numbers
rather than localised text
- a linked page's name and date are withheld from a delegate whose grant
does not reach the vault, and the withholding says so rather than
looking like a page with no name
- one new source and one new pair; the module's invariants carry over
unchanged, so ownership, idempotency, the batch cap and the
soft-delete refusal need no new code
- each invariant gets its own case against the new table, because every
one of them is a where clause on a different table and a pair wired to
the wrong column would pass all the existing ones
- the ceiling is written down and asserted: the vaccination source owns
exactly one pair, and asking for a second is refused loudly
- one pure function resolves "dose N of M" over a person's whole
history, so the number rides the DTO already computed and no client
re-derives it
- a combined dose counts into every antigen it contains, at whatever
position each of those series is at: one Tdap line is a tetanus
booster and a first pertussis dose in the same act
- the Pass's own dose number wins over the count, and the record's own
series length wins over the catalogue's
- a seasonal dose has no series to be part of, so nothing reads as a
booster on a length it never had
- interval adjudication is deliberately absent; this is arithmetic on
what a person wrote down
- 35 entries across the childhood, standard, later-decade, indication
and travel sets, so a Pass covering a whole life has somewhere to land
- ATC-coded from the WHO J07 subtree, component-aware so one combined
dose counts into every antigen series it contains, and each entry
cites the document its numbers come from
- no manufacturer column and no trade name in any shipped string; the
one place a trade name belongs is the person's own transcription
- the resolver is the only lookup path and answers null rather than
throwing, so a slug a later release drops still renders as what the
person wrote
- a new backup section reads the dose and its document link through
their own delegates and writes both ends in one file, the arrangement
the visit section already uses
- the practitioner and the visit remap to the rows the same restore put
back; the booster reminder never travels, so its reference resolves to
null and the drop is reported rather than swallowed
- the antigen slug restores verbatim: a slug the catalogue has since
dropped still says what a person was given
- the round trip seeds a dose with every optional column filled and
reads each one back, so a returning row count cannot stand in for a
returning record
- both models are backed up and claimed two-ended: an immunization
history has no other copy once the paper Pass has been transcribed
- the document link is wiped ahead of the dose, and the dose ahead of
the practitioner it points at with SetNull, so the audit row's
per-model counts stay truthful
- the dose note joins ENCRYPTED_COLUMNS and the rotation walk; the batch
code and the other structured columns stay queryable plaintext
- VaccinationRecord holds one administered dose: date, catalogue slug or
the Pass's own wording, dose and series numbers, batch code, site, and
optional links to a practitioner, a visit and a booster reminder
- VaccinationDocumentLink keeps the scanned Impfpass page beside the dose
- MeasurementReminder gains a nullable vaccination_antigen match key so a
logged dose can clear a booster without a second reminder engine
- migration 0330, additive apart from that one nullable column
2026-08-08 13:35:03 +02:00
3756 changed files with 502532 additions and 58557 deletions
# Directory the offline GeoLite2 databases are read from; default /opt/geolite2 (the path the image bakes its own copy into). Point it at a directory you mount yourself to bring your own MMDBs — see docs/self-hosting/geolite2.md.
# GEOLITE2_DIR="/var/lib/geolite2"
#
# MaxMind licence key for the runtime GeoLite2 fetch (issue #659): set it and the worker downloads both databases into GEOLITE2_DIR itself, no rebuild or manual mount. Free key at https://www.maxmind.com/en/geolite2/signup — see docs/self-hosting/geolite2.md.
Feature ideas welcome. HealthLog is a personal-health PWA — the bar for inclusion is high (privacy-first, offline-capable, self-hostable). Use this template to propose something concrete; for open-ended discussion, prefer a [Discussion](https://github.com/MBombeck/HealthLog/discussions) thread.
Feature ideas welcome. HealthLog is a personal-health PWA, and the bar for inclusion is high (privacy-first, offline-capable, self-hostable). Use this template to propose something concrete. For an idea you'd like others to weigh in on, or a small improvement, start it in [Ideas](https://github.com/MBombeck/HealthLog/discussions/categories/ideas) instead: people can upvote it there, and the most-voted ideas get looked at first (see #1114).
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
HealthLog is built with AI assistance, and contributions made the same way are welcome. This file used to carry a copy of the internal conventions document; the copy aged badly and started contradicting the codebase, so it now points at the living sources instead.
## Project
## Where the real conventions live
HealthLog — a personal health-tracking PWA (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, Dracula-themed UI, mobile-first design, offline caching, and doctor report PDF export.
-`CLAUDE.md` at the repo root is the maintainer's operating manual for this codebase: architecture walk, code conventions, security patterns, test commands, the do-not list. It is checked in and kept current; whatever an AI agent (or a human) needs to work on the server is in there.
-`docs/CONTRIBUTING-CLIENTS.md` covers building a client app (Android, iOS, CLI) against the server: the API contract, auth from a client's point of view, sync semantics, and how coordination between repos works.
-`docs/api/openapi.yaml` is the wire contract, generated from the server's Zod schemas. CI fails on drift, so it is always accurate for the commit it sits in.
## Commands
## Ground rules, whatever tooling you use
```bash
# Development
pnpm dev # Start dev server (http://localhost:3000)
pnpm db:push # Push schema without migration (prototyping)
pnpm db:studio # Prisma Studio GUI
# Docker
docker compose up -d # Start app + postgres
docker compose logs -f app # Tail app logs
```
## Architecture
- **Next.js 16** App Router with TypeScript strict. Pages are RSC by default; `"use client"` only for interactivity.
- **Prisma 7** ORM with PostgreSQL (26 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma).
- **shadcn/ui** components (new-york style) in `src/components/ui/`. Add new ones via `pnpm dlx shadcn@latest add <component>`.
- **Dracula theme** via CSS variables in `globals.css`. Dark mode is default. Use `--dracula-*` tokens for chart colors.
- **TanStack Query** for client-side data fetching. Provider in `src/components/providers.tsx`.
- **Zod v4** (`zod/v4`) for all input validation (API routes and forms).
- **API response envelope**: `{ data, error, meta }` via helpers in `src/lib/api-response.ts`.
- **apiHandler** wraps ALL API route handlers (`src/lib/api-handler.ts`) — error handling, Wide Event logging, x-request-id propagation.
- **Structured Logging** (Wide Events) in `src/lib/logging/` — one JSON event per request/operation via `WideEventBuilder` + `AsyncLocalStorage`. Tail sampling, stdout JSON + optional Loki Push.
- **Vitest** for unit testing. Config in `vitest.config.mts`.
- **pg-boss** (PostgreSQL-native) for job queue: medication reminders, insight caching, weekly data backups.
- **jsPDF** + jspdf-autotable for client-side doctor report PDF generation (`src/lib/doctor-report-pdf.ts`).
- **PWA offline** via service worker caching strategies in `public/sw.js` (cache-first for static, network-first for pages, network-only for API).
-`e2e/` — Playwright + axe-core suite for public smoke checks (version, auth-redirect, login, locale-switch, a11y); `pnpm e2e`
-`prisma.config.ts` — Prisma config (DB URL here, not in schema)
-`public/sw.js` — Service worker for Web Push notifications + offline caching
-`docs/` — long-form audit notes (`docs/audit/`); end-user docs live in the separate site at https://docs.healthlog.dev
-`AGENTS.md` — AI agent instructions (Codex, Cursor, etc.)
-`CHANGELOG.md` — release notes per semver tag
## Important Patterns
- Import Prisma client from `@/generated/prisma/client` (not `@/generated/prisma`)
- Prisma 7 does NOT support `url = env("...")` in schema.prisma — use `prisma.config.ts`
- Next.js 16 uses `proxy.ts` (not `middleware.ts`) — having both causes build error
- SimpleWebAuthn v13 has no `@simplewebauthn/server/script/deps` — define Transport type inline
-`zxcvbn-typescript` uses default export only (no named `zxcvbnAsync`/`zxcvbnOptions`)
- pg-boss v12: `{ PgBoss }` named import, `localConcurrency` instead of `teamSize`, handler receives `Job<T>[]` array
- **i18n**: All UI text uses `useTranslations()` hook with `t("section.key")`. Messages in `messages/en.json` (English, default) + `messages/de.json` (German, user-selectable). Keys missing in the active locale fall back to English; missing from both surfaces the raw key — the `src/lib/__tests__/i18n*.test.ts` suite guards against that. Numbers/dates go through `useFormatters()` — never hand-roll `Intl.*` with a fixed locale.
- **Notifications**: 3 channels (Telegram, ntfy, Web Push). Dispatcher at `src/lib/notifications/dispatcher.ts`. Opt-out model (all events enabled by default)
- **Gravatar**: Server-side SHA-256 hash via `src/lib/gravatar.ts`. URL returned in `/api/auth/me` response as `gravatarUrl` field
- **Sidebar**: Collapsible (localStorage-persisted). Settings mode with section shortcuts. User section at bottom with three-dot dropdown menu
- **Settings vs Admin scope rule (v1.4.16 phase B6 audit)**: `/settings/<slug>` is **per-user** — toggles, credentials, layout, target ranges that affect one account. `/admin/<slug>` is **system-wide** — defaults, feature gating, host metrics, audit log read-access. Two surfaces overlap by design: (a) notification channels — admin can disable a channel system-wide via `/api/settings/global-services`, the user sets their own credentials in `/settings/notifications`; (b) AI providers — admin sets the default Codex/OpenAI key, user can override with their own. **No user-facing toggle should appear in both surfaces.** Keys like `settings.adminAreaTitle` are dead code (planned admin-shortcut tile that never shipped) and stay in JSON marked deprecated until a hygiene PR removes them. See `docs/audit/v1416-settings-audit.md`.
- **ESLint**: Strict `react-hooks/set-state-in-effect` rule — avoid `setState` inside `useEffect`. Use lazy `useState()` initializer for localStorage reads
- **PWA safe area**: Root layout has `viewportFit: "cover"`. Bottom-nav uses `pb-[env(safe-area-inset-bottom)]` for iOS safe area
- **Doctor report PDF**: Client-side via `src/lib/doctor-report-pdf.ts` (jsPDF). Data from `POST /api/doctor-report`. European medical format, German terminology
- **Achievements**: Persistent in `UserAchievement` table. Computed on API call, new unlocks written to DB with stable `unlockedAt` timestamps
- **Data backup**: pg-boss `data-backup` queue runs weekly (Sundays 03:00), stores JSON in `DataBackup` model
- **Wide Events / Structured Logging**: `apiHandler()` wraps all API routes. Use `annotate()` from `@/lib/logging/context` for business-action annotations. Use `requireAuth()` / `requireAdmin()` from `@/lib/api-handler` (auto-annotates auth). Background jobs use `withBackgroundEvent()`. External calls tracked via `getEvent()?.addExternalCall()`. No `console.log` in production code — use event annotations instead. Env vars: `LOG_LEVEL`, `LOG_SAMPLE_RATE`, `LOG_SLOW_THRESHOLD_MS`, `LOG_INCLUDE_STACK`, `LOKI_ENDPOINT`, `LOKI_USERNAME`, `LOKI_PASSWORD`
- **Multi-tenant prep (v1.4)**: `HEALTHLOG_PROCESS_TYPE=web|worker|all` (default `all`) splits HTTP and pg-boss workloads; the proxy refuses HTTP traffic with 503 + `X-HealthLog-Process-Type: worker` in worker mode. `ENCRYPTION_KEYS` is a JSON map of versioned keys (`{"v1": "...", "v2": "..."}`) plus `ENCRYPTION_ACTIVE_KEY_ID` for new writes; rotation via `pnpm tsx scripts/rotate-encryption-key.ts <id>`. `BACKUP_S3_*` env block configures off-host weekly encrypted backups (PutObject + GetObject only — retention is the bucket's lifecycle policy, never the worker).
- **Native API clients (v1.4)**: `POST /api/auth/login` and `/api/auth/passkey/login-verify` issue a 24h access token (`hlk_<64hex>`) AND a refresh token (`hlr_<64hex>`) when `X-Client-Type: native` or the User-Agent starts with `HealthLog-iOS/`. The browser flow is unchanged. Refresh-token reuse-detection revokes every refresh token for the user. The idempotency replay-cache rejects bodies containing `hlk_` OR `hlr_` so cached responses can never echo a token back.
## Headless-Client API Patterns
The iOS app talks to bearer-protected routes while the web PWA uses cookie sessions; both share the same handlers (`requireAuth()` accepts either credential).
- **Bearer-token issuance**: `POST /api/auth/login` and `POST /api/auth/passkey/login-verify` return a `token` field for native clients. `isNativeClientRequest()` (`src/lib/auth/issue-token.ts`) opts in via `X-Client-Type: native` header **or** a `User-Agent` starting with `HealthLog-iOS/`. The raw `hlk_<64-hex>` value is returned exactly once; default lifetime 90 days, permissions `["*"]`.
- **Token storage**: API tokens are stored as **HMAC-SHA-256** hashes (`hashToken()` in `src/lib/auth/hmac.ts`, keyed by env `API_TOKEN_HMAC_KEY`) — not plain SHA-256. The keyed HMAC defends against precomputed rainbow tables if the DB leaks. Same mechanism is reused for external-ingest token verification.
- **Idempotency-Key**: `withIdempotency()` (`src/lib/idempotency.ts`) wraps POST/PUT/PATCH/DELETE handlers so retries with the same `Idempotency-Key` (regex `^[A-Za-z0-9_\-:.]{8,128}$`) for the same `(userId, method, path)` replay the original response with `X-Idempotent-Replay: true` for 24h. **NOT cached**: 401, 403, 408, 429, any 5xx, and any body containing `hlk_` (defence against caching a token-issuing response). Validation 4xx (422 etc.) **is** cached so a broken retry doesn't re-hit the DB. `userIdResolver` defaults to the cookie session — pass a 2nd argument for Bearer-only routes.
- **Device-registration cross-user-hijack guard**: `POST /api/devices` rejects re-registration of an existing APNs token under a different user with **409** (`Device token already registered to another account`) instead of silently transferring ownership. APNs tokens aren't a secret, so trusting wire input would let anyone who learns a token redirect another user's pushes. Audit trail: `device.register.denied` with reason `token_owned_by_other_user`.
- The human who commits a change has read it and stands behind it. The git history knows authors, not tools: no AI attribution trailers, no assistant vocabulary in commit messages, PR text, or release notes.
- No real names of users or reporters in committed text, and no live health figures anywhere, including fixtures and examples. Invent data.
- No secrets in code, fixtures, or docs, and nothing secret-shaped either.
- Run the gates before pushing (`pnpm typecheck`, `pnpm lint`, `pnpm test`) and fix what they find rather than suppressing it. A guard that fails is telling you something; the fix goes in the code, not in the guard.
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), and Polish (`pl`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), and Korean (`ko`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
<img width="2434" height="1247" alt="HealthLog dashboard: healthmetric tiles with 7-day trends, blood-pressure and weight charts, dark theme" src="docs/assets/dashboard-hero.jpeg" />
<img width="2434" height="1247" alt="HealthLog dashboard in dark theme: the Today overview with a lead sentence, the health score ring, Today facts and worth-a-look cards, above metric tiles with 7-day trends" src="docs/assets/dashboard-hero.jpeg" />
<p align="center">
<img src="docs/assets/Insights.png"
alt="Insights"
width="23%">
<img src="docs/assets/resting-pulse.png"
alt="Insights RHR"
width="23%">
<img src="docs/assets/Medications.png"
alt="Medications"
width="23%">
<img src="docs/assets/Coach.png"
alt="Coach Chat"
width="23%">
</p>
## Why HealthLog
HealthLog is a self-hosted health tracking app (PWA + native iOS client) that runs from a single `docker compose up` on a NAS, homelab, or small VPS. It brings weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications onto one timeline — synced from the devices you already own — and keeps everything encrypted at rest on hardware you control. No vendor cloud, no subscription, no telemetry.
HealthLog is a self-hosted health tracking app (PWA + native iOS client) that runs from a single `docker compose up` on a NAS, homelab, or small VPS. It brings weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications onto one timeline, synced from the devices you already own, and keeps it in a Postgres database on hardware you control. No vendor cloud, no subscription, no usage telemetry. The few requests the server makes on its own (an update check, a password breach check, an IP geolocation lookup for logins) can each be switched off.
HealthLog is a personal health record-keeping and wellness tool. It is not a medical device and does not diagnose, treat, cure, or prevent any disease — it helps you log, visualise, and export your own data, and leaves clinical judgement to a clinician.
@@ -36,15 +52,15 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
- **Every vital on one timeline.** Weight, blood pressure, pulse, glucose, body composition, sleep, SpO₂, respiratory rate, mood, and cycle tracking — charted with trends, moving averages, and clinical reference ranges (ESH 2023, ADA 2024), overridable with the targets your clinician set.
- **Clinical signals, opt-in and additive.** Validated self-checks for mental wellbeing and sleep (WHO-5, PHQ-9, GAD-7, Sleep Condition Indicator — official questionnaire wording, calm crisis-resource handling), grip strength, a 0–10 pain score, waist and body measurements, and a longevity lab panel — each added through one signal registry, so a new signal wires into charts, correlations, the coach, the FHIR map, and the API at once.
- **Records, structured.** Allergies and family history as encrypted structured records that flow into the doctor report, plus lab values you key in or scan from a printed report — OCR extracts the stated values, units, and dates for you to review and confirm before anything is saved. HealthLog transcribes what a report says; it never interprets or diagnoses from it. (Immunization records are deliberately out of scope.)
- **Records, structured.** Allergies and family history as structured records that flow into the doctor report (reactions and notes are encrypted, the substance and condition names are not), plus lab values you key in or scan from a printed report — OCR extracts the stated values, units, and dates for you to review and confirm before anything is saved. HealthLog transcribes what a report says; it never interprets or diagnoses from it. A vaccination record keeps a lifetime immunization log, doses grouped under the disease they protect against, and prints in the doctor report.
- **A document vault for the paper trail.** An opt-in module stores every letter, report, and scan encrypted at rest — PDFs and photos preview inline, Office and text files stay download-only, and nothing user-uploaded can ever execute in the app. One searchable timeline with type, condition, and year filters, drag-and-drop and paste-to-upload, bulk filing, undo-able delete with a 30-day grace, and per-file/per-account limits the operator tunes.
- **Context that explains the numbers.** An opt-in environmental module folds weather, daylight, and temperature in as influence signals (winter darkness against mood, heat against blood pressure) using a home location you set, so a pattern has somewhere to point.
- **Your devices, one server.** Withings, WHOOP, Oura and Polar sync over OAuth2, Google Health connects Fitbit and Pixel Watch through your own Google Cloud client, Nightscout streams CGM glucose, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
- **Medication tracking that tells the truth.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a traceable dose history where every slot reads taken, late, skipped, or missed — the same ledger the compliance rate is computed from, so the percentage can never disagree with the timeline.
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so nothing leaves your network.
- **Clinician-ready output.** A doctor-report PDF generated client-side, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
- **Private by construction.** AES-256-GCM encryption at rest with zero-downtime key rotation, passkey login, server-side sessions, strict CSP — and no third-party tracking anywhere.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, and Polish end to end.
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network.
- **Clinician-ready output.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
- **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, and Korean end to end.
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
@@ -81,13 +97,15 @@ NAS and homelab users have ready-made paths: [Unraid Community Applications](doc
## Self-hosting
One container hosts both the web server and the job worker by default; split them via `HEALTHLOG_PROCESS_TYPE=web|worker` for horizontal scale. Encrypted daily backups to any S3-compatible bucket are opt-in via the admin panel, new users join by invite link or QR code, and the stack works out of the box behind Caddy, Traefik, Nginx, or [Coolify](https://coolify.io/).
One container hosts both the web server and the job worker by default; split them via `HEALTHLOG_PROCESS_TYPE=web|worker` for horizontal scale. Encrypted nightly backups to any S3-compatible bucket are opt-in through five `BACKUP_*` environment variables (the admin panel shows their status), new users join by invite link or QR code, and the stack works out of the box behind Caddy, Traefik, Nginx, or [Coolify](https://coolify.io/).
HealthLog ships releases roughly weekly. Pin a tag, back up before upgrades, and skim the [CHANGELOG](CHANGELOG.md) before pulling `latest`. The operator manual — reverse proxy, migrations, encryption-key rotation, backup and restore — lives at [docs.healthlog.dev](https://docs.healthlog.dev).
## Native iOS app
A SwiftUI companion in public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa), built on the same API as the web client: live HealthKit two-way sync, medication reminders with action buttons that work without opening the app, and an on-device coach on Apple-Intelligence-capable iPhones. Code lives in [MBombeck/healthlog-iOS](https://github.com/MBombeck/healthlog-iOS).
A SwiftUI companion on the [App Store](https://apps.apple.com/app/id6769501341), built on the same API as the web client: live HealthKit two-way sync, medication reminders with action buttons that work without opening the app, and an on-device coach on Apple-Intelligence-capable iPhones. Code lives in [MBombeck/healthlog-iOS](https://github.com/MBombeck/healthlog-iOS).
The App Store listing carries a small price, which exists to cover Apple's yearly developer fee rather than to earn anything; write to marc@healthlog.dev for a free code if that price is in your way. [TestFlight](https://testflight.apple.com/join/bucuTBpa) stays free and carries the newest builds, and the source builds on your own machine.
Actively developed — new releases roughly weekly, issue reports and PRs welcome. Behaviour and schema can change between versions; migrations are forward-only.
## Thanks
HealthLog started as something I built for myself. That it has turned into a project other people use, care about and help shape still surprises me, and I am grateful for every part of it.
Thank you to everyone who sent code that shipped: [@TimonBed](https://github.com/TimonBed), [@Antiheld86](https://github.com/Antiheld86), [@BernhardBuckel](https://github.com/BernhardBuckel), [@balajiv113](https://github.com/balajiv113), [@aucun6352](https://github.com/aucun6352), [@mathewcsims](https://github.com/mathewcsims) and [@muhdusama](https://github.com/muhdusama).
Thank you to everyone who opened an issue, asked a question in the discussions or wrote me an email, and especially to [@lutzkind](https://github.com/lutzkind), [@HercoGC](https://github.com/HercoGC), [@mills1975](https://github.com/mills1975), [@sreeramachandramurthy](https://github.com/sreeramachandramurthy), [@CritLoren](https://github.com/CritLoren), [@mbreitkreuz](https://github.com/mbreitkreuz), [@k-nacion](https://github.com/k-nacion), [@NebuPookins](https://github.com/NebuPookins), [@Nazza01](https://github.com/Nazza01), [@Cnote43](https://github.com/Cnote43) and [@juliaanvaneijndt](https://github.com/juliaanvaneijndt), whose reports shaped HealthLog again and again. Most of what got better in the last months started with someone describing what went wrong for them, often with a log, a screenshot and a second try when the first fix was not enough.
And thank you to everyone who supported the project with a coffee or by buying the iPhone app. I don't name anyone here, but I read every message that comes with it. It pays for the servers and the Apple developer account, and it tells me that HealthLog is worth the evenings.
is the full list. Release notes name the people whose work is in that release,
including anyone who found the defect without sending the fix.
## License
HealthLog is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE): free to use, self-host, and modify for noncommercial purposes. Commercial use requires a separate agreement — open an issue or reach out via [healthlog.dev](https://healthlog.dev). Releases up to and including v1.15.18 were published under AGPL-3.0 and remain available under that license.
@@ -117,6 +154,7 @@ HealthLog is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE):
"$comment":"Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged.",
Working notes for anyone building a client app (Android, iOS, CLI, anything else) against the HealthLog server, with or without AI tooling. Everything in here is a rule we actually follow between the server and the iOS app, written down so a second client does not have to rediscover it. Pair it with `CLAUDE.md` (server-side conventions) and `docs/api/openapi.yaml` (the wire contract).
## The contract, and where truth lives
-`docs/api/openapi.yaml` is the single source of truth for the API. It is generated from the server's Zod schemas and CI fails on drift, so what the YAML says is what the server does. Build your models from it rather than from observed responses.
- The contract you target is the one in a tagged release (`vX.Y.Z`), not whatever is on `main` between releases. A running server tells you its version at `GET /api/version` (`version`, `buildSha`, `builtAt`).
- Contract changes are additive. Fields get added, routes get added; existing consumers keep reading what they read before. When a route is retired it does not vanish: it answers `410` with a machine-readable `meta.errorCode`, so a client can tell "removed on purpose" from "you have a bug".
- If you need something the API does not carry, ask for it in an issue before building a workaround. Adding a route or a field on the server is usually cheaper than a client-side reconstruction, and a workaround becomes a compatibility burden for both sides the moment it ships.
## The envelope
Every JSON route answers the same shape: `{ data, error }`, with `meta` on the error path when there is something machine-readable to say. Success means `data` is set and `error` is `null`. Validation failures answer `422` and carry every issue at once, not just the first, so a form can annotate all fields in one round trip. Parse the envelope first, then the payload; never assume a `200` body is the payload directly.
## Auth, from a client's point of view
- Password, passkey, and an OIDC handoff exist for sign-in. The native OIDC flow hands the app a one-time code via a fixed URL scheme; tokens never ride a URL.
- Refresh tokens rotate per device and are one-time-use. Reusing an old one revokes that device's whole token family, on purpose. Persist the newest pair atomically and never retry a refresh with a token that already succeeded once.
- Bearer token scopes are fail-closed. A token minted for one purpose does not open unrelated routes, and a route that does not declare a scope refuses narrow tokens outright. Expect `403` to be a normal, recoverable answer, not an anomaly.
- Admin surfaces are cookie-session only, by construction. A native client cannot be an admin client, whatever scopes its token carries. Do not build admin features.
- Mutations accept an `Idempotency-Key` header. A repeat of the same key with the same body answers with the original result plus `X-Idempotent-Replay`, so retrying on a dropped connection is safe. A different body under the same key is treated as a new request, not a replay; still, give each distinct write its own key. Use it for anything a user would notice twice.
## Data semantics worth knowing before you sync
- Batch measurement ingest treats `externalId` as the dedupe handle. Ids with the `stats:` prefix are overwrites (a re-post replaces the row), every other id is first-write-wins immutable. The per-entry response says which happened (`inserted` vs `updated`). Design your sync around that difference rather than around client-side bookkeeping.
- Absence is explicit. A metric that answers "not present" means the record honestly has nothing there. It is not zero, not an error, and not a reason to fill the gap client-side.
- Modules can be switched off per account. Routes belonging to a disabled module answer `403`; render that as "this feature is off", not as a failure.
- Whatever the user typed stays verbatim. Names of custom metrics, notes, labels: the server returns the user's own text and does not translate or normalise it. Curated content, on the other hand, comes with stable keys a client can localise.
## Server-authoritative values
The strongest rule we have, learned the slow way: when the server computes something (a compliance rate, a health-score verdict, a reference-range judgement, an aggregation bucket), the client renders the resolved value from the payload. It does not re-derive the number from raw data, because two implementations of one formula will disagree eventually, and the user sees two apps arguing about their own health record.
The corollary: if a resolved value you need is missing from a payload, the fix is to ask the server to carry it, never to reimplement the server's logic in the client. And when the server changes a resolved value's meaning, that is a contract change and gets announced, not slipped in.
## How coordination works in practice
- One issue per question or contract need, opened early, ideally before you build on an assumption. A wrong assumption caught in an issue costs a comment; caught after a release it costs a migration.
- Claims about behaviour are made against a running instance, with the route and status code, not from reading code. "The code looks like it should" has burned us; "I called it and got this" has not.
- Answers you get from the server side name file and line or route and measurement, so you can verify them instead of trusting them. Hold your own reports to the same bar and it stays pleasant.
- Release notes and the changelog say when something client-relevant shipped. Reconcile open questions against each release rather than assuming the answer is still pending.
## Ground rules for the repo itself
The voice-and-privacy rules from `CLAUDE.md` apply to any repo under the project, including a client repo: commit messages and public text carry no tooling attribution and no assistant vocabulary, no real names of users or reporters, and no live health figures, ever. Fixtures use invented data. If an AI tool wrote part of a change, the human who commits it has read it and stands behind it; the git history knows only authors.
Secrets never appear in code, fixtures, or examples, not even placeholder-shaped real ones. Anything user-identifying stays out of logs on the client just as it does on the server.
## What a client should not do
- No scraping of web routes or HTML pages; the JSON API is the interface.
- No admin endpoints, see above.
- No CORS assumptions: the server serves same-origin browsers and header-authenticated APIs, nothing else.
- No silent behavioural drift from the web app. Where the web app and a native client both render the same concept, they should mean the same thing; when they cannot, that difference is documented in an issue, not discovered by users.
| `list_metrics` | One row per domain: whether data is present, an approximate sample count, and the tool that retrieves it. **Call this first.** |
| `get_metric_series` | One metric's aggregate (count, min, max, mean, slope) plus recent-daily and weekly timelines, with units and population reference bands. |
| `get_metrics` | Several metric series in one call — a paginated fan-out over `get_metric_series`, one grounded result per metric. |
| `get_glucose_panel` | Per-context daily means plus the trailing-30-day clinical panel (time-in-range, GMI, CV%, estimated A1c). |
| `get_sleep` | Per-night asleep + stage minutes plus the sleep-rhythm summary (sleep debt + chronotype). |
| `get_workouts` | Most recent sessions (sport, duration, energy, distance, avg/max HR) plus a per-sport rollup over the window. |
| `get_medication_compliance` | Cadence-aware adherence: dose-weighted compliance rate, expected vs taken/missed, current-cycle status, any GLP-1 titration context. |
| `get_labs` | Latest reading per biomarker over the last 12 months (optionally one analyte). With `history:true` + an analyte, that analyte's paginated reading trajectory. |
| `get_illness_recovery` | Rest mode, active and recently-resolved illnesses, recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). |
| `get_cycle` | Menstrual-cycle context: phase + day-of-cycle, next predicted event, headline phase-correlation. Gated on cycle tracking; descriptive only. |
| `get_correlations` | FDR-controlled day-to-next-day driver pairs between behaviours and outcomes, each with direction, lag, sample size, and a descriptive (never causal) note. |
| `get_correlation` | The vetted, lag-aware association between **two named metrics**: direction, lag, sample size, Pearson r, and a descriptive note. |
| `compare_metric` | One metric vs another over the same horizon, **or** one metric across two horizons (fixed windows or `{from,to}` ranges), with per-side stats + a delta. |
| `get_metric_baseline` | Where the latest reading sits against the user's own usual range (median ± robust deviation), plus the strongest lagged driver. Needs ≥ 7 days of history. |
| `detect_changepoints` | Points where a metric's level shifted over a window or `{from,to}` range — date, direction, before/after means. High firing bar. |
| `get_medication_schedule` | When each active medication is next due and which are overdue right now — name, dose, next-due, overdue flag, as-needed flag. Reuses the recurrence engine. |
| `get_integration_status` | Sync health of connected devices/services — connected, last sync, reauth-required/failing — to answer "why is my data stale?". No secrets or tokens. |
| `get_preventive_care` | The user's own configured preventive-care (Vorsorge) reminders — upcoming/overdue checkups with next-due dates. Surfaces configured reminders, invents nothing. |
| `search` | Free-text search over the user's record (metric domains, medications, lab analytes). Returns `{ results: [{ id, title, url }], nextCursor? }`. |
| `fetch` | Hydrate one record by the id `search` returned (`metric:weight`, `med:<id>`, `lab:LDL`). Returns `{ id, title, text, url, metadata }` with a citation deep-link. |
| `list_metrics` | One row per domain: whether data is present, an approximate sample count, and the tool that retrieves it. **Call this first.** |
| `get_metric_series` | One metric's aggregate (count, min, max, mean, slope) plus recent-daily and weekly timelines, with units and population reference bands. |
| `get_metrics` | Several metric series in one call — a paginated fan-out over `get_metric_series`, one grounded result per metric. |
| `get_glucose_panel` | Per-context daily means plus the trailing-30-day clinical panel (time-in-range, GMI, CV%, estimated A1c). |
| `get_sleep` | Per-night asleep + stage minutes plus the sleep-rhythm summary (sleep debt + chronotype). |
| `get_workouts` | Most recent sessions (sport, duration, energy, distance, avg/max HR) plus a per-sport rollup over the window. |
| `get_medication_compliance` | Cadence-aware adherence: dose-weighted compliance rate, expected vs taken/missed, current-cycle status, any GLP-1 titration context. |
| `get_labs` | Latest reading per biomarker over the last 12 months (optionally one analyte). With `history:true` + an analyte, that analyte's paginated reading trajectory. |
| `get_illness_recovery` | Rest mode, active and recently-resolved illnesses, recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). |
| `get_cycle` | Menstrual-cycle context: phase + day-of-cycle, next predicted event, headline phase-correlation. Gated on cycle tracking; descriptive only. |
| `get_correlations` | FDR-controlled day-to-next-day driver pairs between behaviours and outcomes, each with direction, lag, sample size, and a descriptive (never causal) note. |
| `get_correlation` | The vetted, lag-aware association between **two named metrics**: direction, lag, sample size, Pearson r, and a descriptive note. |
| `compare_metric` | One metric vs another over the same horizon, **or** one metric across two horizons (fixed windows or `{from,to}` ranges), with per-side stats + a delta. |
| `get_metric_baseline` | Where the latest reading sits against the user's own usual range (median ± robust deviation), plus the strongest lagged driver. Needs ≥ 7 days of history. |
| `detect_changepoints` | Points where a metric's level shifted over a window or `{from,to}` range — date, direction, before/after means. High firing bar. |
| `get_medication_schedule` | When each active medication is next due and which are overdue right now — name, dose, next-due, overdue flag, as-needed flag. Reuses the recurrence engine. |
| `get_integration_status` | Sync health of connected devices/services — connected, last sync, reauth-required/failing — to answer "why is my data stale?". No secrets or tokens. |
| `get_preventive_care` | What preventive care is coming up: the user's own configured Vorsorge reminders (upcoming/overdue checkups with next-due dates) plus the appointments booked as future visits. Surfaces configured items, invents nothing. |
| `get_visits` | The user's own past doctor visits over a bounded window (default 12 months, optional practitioner-name filter): date, status, kind, practitioner + specialty, the visit's own reason/outcome, and any linked condition labels. `{ present: false }` when none was ever recorded. |
| `get_intraday_pulse` | The 10-minute heart-rate shape for one local day (hourly grain outside the dense-retention window), with at most one cautious descriptive elevated-at-rest window. Gated on the `insights` module. |
| `get_ecg_recordings` | Metadata for the user's ECG recordings (time, duration, sampling rate, lead, the device's own rhythm classification). Never the waveform, never a re-classification. Gated on the `insights` module. |
| `get_nutrients` | Without an argument, the presence overview per logged nutrient code; with one, that code's per-day summed series plus its EFSA reference. Gated on the opt-in `nutrients` module. |
| `search` | Free-text search over the user's record: metric domains, medications, lab analytes, and the clinical records (visits including procedures, conditions, documents, vaccinations). Ranked, deterministic, record results capped at 50. Returns `{ results: [{ id, title, url }], nextCursor? }`. |
| `fetch` | Hydrate one record by the id `search` returned (`metric:weight`, `med:<id>`, `lab:LDL`, `visit:<id>`, `condition:<id>`, `document:<id>`, `vaccination:<id>`). Returns `{ id, title, text, url, metadata }` with a citation deep-link; clinical records carry their one-hop links. |
`search` + `fetch` are the de-facto two-tool retrieval convention and the
**only** tools ChatGPT calls in its default (non-Developer) mode. Each
result carries a real, user-openable HTTPS deep link into the HealthLog
web app so the assistant can cite it.
What `search` matches for the clinical records:
-`visit:<id>`: visit kind (a procedure also answers to "surgery" and
"operation"), reason, outcome, body site and side, practitioner name and
specialty.
-`condition:<id>`: label, type, body site and side.
**Why this matters**: Apple's first-submission rejection rate for health apps is ~40%. The most common reason is unclear medical-device classification. The above wording mirrors the in-app disclaimer + the per-feature gating; consistency between the form and the app is what Apple looks for.
**Why this matters**: health apps get rejected on first submission most commonly for unclear medical-device classification. The above wording mirrors the in-app disclaimer + the per-feature gating; consistency between the form and the app is what Apple looks for.
@@ -630,8 +630,9 @@ Confirmed-rejected slugs (despite the source-tree references in the table above)
Confirmed-accepted slugs on a ChatGPT Plus subscription (per `available_in_plans` in `models.json`):
- `gpt-5.3-codex` — coding-optimized, accepted; HealthLog uses this as default
- `gpt-5.5` / `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.2` — also expected to work per plan
- `gpt-5.5` — HealthLog's default (first chain entry) as of 2026-06
- `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.2` — accepted per plan
- `gpt-5.3-codex` — was the default (verified 2026-05-09); the backend rotated off the `-codex` line on 2026-06-02 and this slug is now only accepted intermittently, so it sits on a late chain rung
The `codex-rs/tui/src/model_migration.rs` references to `gpt-5-codex` etc. are _migration prompts_ shown to existing users — they are NOT the slugs sent on the wire after migration. The wire slugs come from `models.json`. Always cross-check `models.json` (`gh api repos/openai/codex/contents/codex-rs/models-manager/models.json`) before picking a default.
@@ -641,17 +642,20 @@ The ChatGPT-Codex allow-list is rotated by OpenAI without prior notice (`gpt-5-c
To eliminate that risk class, HealthLog's `CodexClient` walks an ordered chain on every fresh request series.
**Chain** (top = first try; default order is the empirically-safest most-current-first ordering as of 2026-05):
**Chain** (top = first try; default order is the empirically-safest most-current-first ordering as of 2026-06, after the backend rotated off the `-codex` line on 2026-06-02):
```ts
const DEFAULT_SLUG_FALLBACK_CHAIN = [
"gpt-5.3-codex", // current allow-list entry (verified 2026-05-09)
"gpt-5-codex", // historical default (rejected as of 2026-05 but safe to retry — backend may flip)
"gpt-5", // bare slug — rejected on ChatGPT-auth as of 2026-05 but lives on api-key auth
"gpt-4o", // ladder-rung for last-ditch capability
"gpt-5.5", // current default across paid tiers as of 2026-06-02
"gpt-5.3-codex", // late rung — still accepted intermittently
"gpt-5.2", // legacy floor
];
```
The shipped constant in `src/lib/ai/codex-client.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows.
The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated). The first env-var-named slug supersedes `CODEX_MODEL`; if both are set, `CODEX_MODEL` is folded into position 0 and de-duplicated.
**Trigger condition** (when to walk to the next slug):
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes` | no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, type, source, externalId)` so an edit upstream is reflected rather than skipped. |
### Glucose context
@@ -95,7 +104,7 @@ moment you know it.
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
| `score` | yes | integer | `1`–`5`. |
| `tags` | no | string | Comma-separated tags. |
| `loggedAt` | no | string | ISO-8601 datetime; defaults to now. |
| `loggedAt` | no | string | ISO-8601 datetime; defaults to the start of the given `date` in the default timezone. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
### `MeasurementType` values and canonical units
@@ -172,9 +181,10 @@ hand-authored.
| `WALKING_STEADINESS_EVENT` | event |
| `BREATHING_DISTURBANCE_EVENT` | event |
The live, machine-readable list of writable types and their units is
also served by `GET /api/meta/capabilities` under `ingest.quantityTypes`
— the authoritative source if this table ever lags a release.
The table is a curated subset and lags the enum by a few
device-derived types. The live, machine-readable list of writable
types and their units is served by `GET /api/meta/capabilities` under
`ingest.quantityTypes` and is the authoritative source.
### Worked example
@@ -234,15 +244,15 @@ mints a file with the right header.
A header row is required; column order does not matter and header
| `type` | yes | A `MeasurementType` from the table above. |
| `value` | yes | A number, in the row's `unit`. |
| `unit` | yes | The canonical unit, or a recognised alias: `mmol/L` → `mg/dL`, `lb` → `kg`, `in` → `cm`, `°F` → `°C`. |
| `measuredAt` | yes | ISO-8601 **with an explicit offset** (`Z` or `±HH:MM`, and `±HHMM` is accepted). No offset, no import. |
| `glucoseContext` | no | Only for `BLOOD_GLUCOSE`; blank means no context. See [Glucose context](#glucose-context). |
| `notes` | no | Free text, up to 200 characters. |
| `externalId` | no | A source-stable id. With it, a re-upload updates the same row even when the value changed; without it, an identical re-upload is skipped via the natural key `(user, type, measuredAt, source)`. |
A sensor export therefore needs no glucose column at all:
| `401` | The token is wrong, revoked, or expired. Mint a new one. |
| `403` | The endpoint is outside the token's scope — check you are calling one of the two above and not a read — or the operator has switched the API off instance-wide. |
| `409` | A reading with this type, timestamp and source already exists. |
| `422` | The payload is wrong: an unknown `type`, a non-numeric `value`, an `unavailable` sensor state, or an entry naming `APPLE_HEALTH`. |
| `429` | Too many requests. The batch route allows 60 calls per minute. |
The response envelope is `{ "data": ..., "error": null }` on success and
`{ "data": null, "error": "<message>" }` on failure, so logging `error` from
| `version` | The instance serves a different build than the tag you deployed. Check the image digest and the `pull_policy` before looking anywhere else. |
| `sign in` | Sessions are broken, or the probe account gained a second factor. A 429 here is the login rate limit, not the deploy. |
| `write` | The write path is down: database, encryption keys, or a migration that did not run. |
| `read back` | A value was written and cannot be read back — the shape this project keeps rediscovering. Roll back. |
| `delete` | The write path works and the reversible delete does not; the probe reading is still on the account and needs removing by hand. |
Run `node scripts/synthetic-journey.mjs --self-test` to confirm the check
itself still works: it walks the journey against an in-process instance
once per leg with that leg broken, and fails if any of them comes back
green.
## Error reporting
HealthLog ships no default error-reporting target and never will: an
operator's crash reports must not leave their host because nobody told them
not to. Reporting starts only when someone types a DSN into **Admin →
Integrations → GlitchTip**, and that card states plainly whether reports are
going anywhere and which host they go to.
For a maintainer-operated instance, set the DSN there after the first deploy —
it lives in the database, not in the environment, so it survives an image
upgrade and does not need a compose change.
The badge on that card tracks delivery, not intent. A DSN that is set and
parses is not the same fact as reports arriving: a wrong public key, a wrong
project id and a host that stopped answering all parse. So the card is green
only while a report has actually left this host inside the last fortnight,
amber while a target is set and nothing has come back from it, and it names the
reason underneath when the last attempt was refused. Press **Test** to prove
the target on demand — that send is recorded like any other, so the badge
answers immediately.
## Verify the image signature
Every release image (the multi-arch manifest **index**, covering both
@@ -131,6 +220,34 @@ without touching the file:
- `HEALTHLOG_IMAGE_REF=:v1.16.5` — pin to a release tag (still mutable).
- `HEALTHLOG_IMAGE_REF=@sha256:<digest>` — pin to the immutable digest.
#### Bumping a digest pin by hand
A digest-pinned instance never follows a release on its own — an image
promote does not touch the pin, so moving it is part of shipping. The
mechanic, end to end:
1. Resolve the new release's digest from the registry (the manifest-list
| Read | Read what the grant opens, and change nothing. |
| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, and marking a dose taken or skipped. Editing, deleting and restoring stay with you. |
| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. |
| Read | Read what the grant opens, and change nothing. |
| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, marking a dose taken or skipped, a visit, a practice, and a vaccination dose. Editing, deleting and restoring stay with you. |
| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. |
Some entries are creates only **manage** reaches, and the split runs by
entry rather than by section: an allergy, a family-history entry, a mood
entry, a screener, a cycle day, a water entry and a preventive-care
reminder are all created at manage, in sections a write grant can
otherwise add to. Nothing in the document vault is writable at any level.
One entry needs two sections rather than a higher level. Planning the
booster a vaccination suggests files it as a preventive-care reminder,
which lives under Readings, so a grant that opens Health background alone
logs the dose and is refused the booster. Logging the dose is unaffected
either way, and the app does not offer the booster to a grant that cannot
plan it.
Offering **manage** asks the owner for a fresh second factor if they have
one enrolled, which makes it a browser-only act either way: a Bearer
@@ -106,12 +123,19 @@ whole feature is built on, and it is drawn on the server:
| API tokens, the MCP connector | A durable credential that outlives the access |
| Connected services (Withings, Fitbit, WHOOP, Google Health, Polar, Oura, Strava, Nightscout) | Re-pointing a sync, or feeding somebody else's wearable into this record |
| Notification channels and push devices | The owner's health alerts redirected to another phone or chat |
| Exports, the health-record archive, clinician share links | A one-click copy of everything, or a new door that outlives the access |
| Full exports, the encrypted archive, clinician share links | A one-click copy of everything, or a new door that outlives the access |
| Asking an AI provider anything | AI processing of the owner's data, under the owner's own consent |
| Modules, thresholds, units, the record's language and timezone | Configuration of the account, not content of the record |
| Sharing itself | Inviting somebody else, widening the access, or passing it on |
| Locale, theme, dashboard layout | Presentation belongs to the person, not to the record |
One export is the exception, and it is deliberate: the doctor report
(`POST /api/export/health-record`) answers at manage on a whole-record
grant. It renders a prepared artefact from a selection the caller declares
rather than handing the record over whole, which is the difference the row
above is drawing. The full exports and the passphrase-encrypted archive
stay with the owner.
This table is about a grant between two accounts. A managed profile is
the one place where some of it is deliberately different, because a record
with no login has nobody else to keep its own configuration: see "Records
| **`APP_URL` is set** | The endpoint fails closed without a pinned canonical origin. `APP_URL` (or `NEXT_PUBLIC_APP_URL`) is the origin every OAuth document and audience check is anchored to — `Host` is never trusted. Without it the surface stays invisible (`404`). |
| **The global API is on** | The operator's instance-wide API switch must be enabled (it is by default). With it off, every token path — including `/mcp` — returns `404`. |
| **The `mcp` module is on** | A per-account opt-in. It is the only module that ships dark; you turn it on in Settings. Until then `/mcp` answers `404` for that account. |
| **The `mcp` module is on** | A per-account opt-in that ships off by default; you turn it on in Settings. Until then `/mcp` answers `404` for that account. |
`APP_URL` is the same origin you already use for passkeys and invite
links — for example `https://health.example`. It must be reachable by the
@@ -113,10 +113,12 @@ access token (60 minutes, with a refresh token) behind the scenes.
2. In ChatGPT's **default** (non-Developer) mode, the only tools it will
call are **`search`** and **`fetch`** — these are HealthLog's two-tool
retrieval façade over the same server-authoritative reads. So in
default ChatGPT you ask in natural language ("what's my recent LDL?")
and it searches your record and fetches the matching item with a
citation deep-link back into the app. Developer mode exposes the full
tool catalogue.
default ChatGPT you ask in natural language ("what's my recent LDL?",
"when was my left knee operation?", "what does the discharge letter
say?") and it searches your record and fetches the matching item with a
citation deep-link back into the app. Search covers metrics,
medications, lab values, visits and procedures, conditions, documents
and vaccinations. Developer mode exposes the full tool catalogue.
"description":"Opens the admin's encryption key backup step on the Portainer instructions.",
"default":"portainer"
},
{
"name":"ENCRYPTION_KEY_CHECK",
"label":"Encryption key check",
"description":"enforce refuses to serve when the key does not open the stored data. warn only logs it: a last resort for a refusal you are certain is false; it disables a safety check.",
| Login page shows "Single sign-on failed", log says `discovery issuer mismatch` | `OIDC_ISSUER_URL` must equal the `issuer` inside the discovery document. A single trailing slash either way is tolerated; anything else (scheme, host, port, path, `//`) is a hard reject. |
| `discovery request failed: 3xx` | Discovery redirects are not followed (egress policy pins `redirect: "manual"`). Point `OIDC_ISSUER_URL` at the final URL — e.g. include the realm/application path, match `http` vs `https`, add/remove `www`. |
| IdP shows its own "invalid redirect URI" page | The mismatch happens **at the IdP**, before HealthLog is involved. Compare the registered URI against `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback` byte for byte — scheme and port included. |
| Sign-in loops back with "failed" and the log shows an `exp`/`nbf` claim error | Clock skew beyond the 60 s tolerance. Fix NTP on the IdP or app box. |
| "Your identity provider did not mark your email address as verified" | The IdP omits `email_verified` or sends `false`. Mark the address verified at the IdP (Keycloak: user → Email verified; Authentik: verify the email stage) — HealthLog will not link or provision without it. |
| "That email address belongs to an account that is already linked to a different SSO identity" | Deliberate: identities never re-bind silently. See "Linking is once" above. |
| SSO button missing | One of the three provider vars is empty or not whitelisted in compose. `pnpm check-env` shows which. |
| Login page shows "Single sign-on failed", log says `discovery issuer mismatch` | `OIDC_ISSUER_URL` must equal the `issuer` inside the discovery document. A single trailing slash either way is tolerated; anything else (scheme, host, port, path, `//`) is a hard reject. |
| `discovery request failed: 3xx` | Discovery redirects are not followed (egress policy pins `redirect: "manual"`). Point `OIDC_ISSUER_URL` at the final URL — e.g. include the realm/application path, match `http` vs `https`, add/remove `www`. |
| IdP shows its own "invalid redirect URI" page | The mismatch happens **at the IdP**, before HealthLog is involved. Compare the registered URI against `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback` byte for byte — scheme and port included. |
| Sign-in loops back with "failed" and the log shows an `exp`/`nbf` claim error | Clock skew beyond the 60 s tolerance. Fix NTP on the IdP or app box. |
| "Your identity provider did not mark your email address as verified" | The IdP omits `email_verified` or sends `false`. Mark the address verified at the IdP (Keycloak: user → Email verified; Authentik: verify the email stage) — HealthLog will not link or provision without it. |
| "An account with this email address already exists here. Sign in to it once …" | The IdP email matches a local account that is not linked yet. Sign in to that account with its password or passkey within ten minutes and the link is made. If nobody knows that account's password, an operator decides which account the identity belongs to. |
| "That email address belongs to an account that is already linked to a different SSO identity" | Deliberate: identities never re-bind silently. See "Linking is once" above. |
| SSO button missing | One of the three provider vars is empty or not whitelisted in compose. `pnpm check-env` shows which. |
<ConfigName="API_TOKEN_HMAC_KEY"Target="API_TOKEN_HMAC_KEY"Default=""Mode=""Description="HMAC key for API token hashing. Generate with: openssl rand -hex 32 (64-char hex)."Type="Variable"Display="always"Required="true"Mask="true"/>
<ConfigName="SESSION_COOKIE_SECURE"Target="SESSION_COOKIE_SECURE"Default="false"Mode=""Description="Set to false for LAN / plain-HTTP installs (http://tower:3000) or login fails. Set to true only behind an HTTPS reverse proxy."Type="Variable"Display="always"Required="true"Mask="false">false</Config>
<ConfigName="HEALTHLOG_PLATFORM"Target="HEALTHLOG_PLATFORM"Default="unraid"Mode=""Description="Where this server is configured. Opens the admin's encryption key backup step on the Unraid instructions."Type="Variable"Display="advanced"Required="false"Mask="false">unraid</Config>
<ConfigName="ENCRYPTION_KEY_CHECK"Target="ENCRYPTION_KEY_CHECK"Default="enforce"Mode=""Description="Start-up encryption key check. enforce refuses to serve when the key does not open the stored data. warn only logs it: a last resort for a refusal you are certain is false, and it disables a safety check."Type="Variable"Display="advanced"Required="false"Mask="false">enforce</Config>
<ConfigName="NEXT_PUBLIC_APP_URL"Target="NEXT_PUBLIC_APP_URL"Default="http://tower:3000"Mode=""Description="Public URL users reach HealthLog at. Use the LAN URL (e.g. http://tower:3000) or your HTTPS domain behind a reverse proxy."Type="Variable"Display="always"Required="true"Mask="false"/>
- **`utils/`** — fixtures such as `mock-dashboard-snapshot.ts`.
- **`fixtures/`** — synthetic payloads a journey uploads, built in code rather than committed as blobs so the content stays reviewable (`apple-health-export.ts`).
- **`*.spec.ts`** — one spec per flow: auth/login/redirect, dashboard, charts, the medication-wizard cadences (`medications-wizard-*`), insights generation + scroll restoration, doctor report, settings, mobile-viewport consistency, locale switch, accessibility (`a11y.spec.ts`), and the `v1427-*` regression set.
"Your blood pressure stayed in a steady band over the last month. The morning readings sat a little higher than the evening ones, which is common, and none of the weeks stands out from the others.",
"Sleep looked regular on most nights. The weekends ran about forty minutes later at both ends, so the total stayed much the same while the timing moved.",
"Your resting heart rate held steady across the same weeks. Nothing in it lines up with the later weekend nights, so the two seem to move independently for now.",
"The period before looked much the same for blood pressure. Two readings in the second week were higher than the rest, and both were taken shortly after a workout.",
"Taken together, the month reads calm: steady blood pressure, regular sleep and a resting heart rate without surprises. Measuring at the same time each morning keeps the trend easy to read.",
];
constQUESTIONS=[
"How was my blood pressure this month?",
"And my sleep?",
"What about my resting heart rate?",
"Compare blood pressure with the period before",
"Sum it up for me",
];
constSTEPS=[
{
id:"s1",
tool:"get_metric_series",
labelKey:"coach.step.readWindow",
label:"Checking: Blood pressure, last 30 days",
domain:"bp",
window:"last30days",
status:"done",
count: 28,
},
{
id:"s2",
tool:"get_sleep",
labelKey:"coach.step.readWindow",
label:"Checking: Sleep, last 30 days",
domain:"sleep",
window:"last30days",
status:"done",
count: 30,
},
];
constFOLLOW_UPS=[
{
id:"f1",
kind:"previous_period",
labelKey:"coach.followUp.previousPeriod",
label:"Compare with the period before",
reuse: false,
origin:"server",
},
{
id:"f2",
kind:"related_metric",
labelKey:"coach.followUp.relatedMetric",
label:"See also: Resting HR",
reuse: false,
origin:"server",
},
];
functionmessages() {
// Today, a few minutes apart, so the time reads as a bare clock.
// A fixed midday, pinned in the browser too (see beforeEach): the panel groups
// conversations by the browser's local day, so a run just after midnight in
// the runner's or the profile's zone would move "an hour ago" into yesterday.
constNOW=Date.parse("2026-06-04T12:00:00Z");
constHOUR=60*60*1000;
interfaceConversation{
id: string;
title: string;
updatedAt: string;
}
functionconversations():Conversation[]{
return[
{
id:"frame-bp",
title:"Blood pressure this week",
updatedAt: newDate(NOW-HOUR).toISOString(),
},
{
id:"frame-sleep",
title:"Sleep since May",
updatedAt: newDate(NOW-26*HOUR).toISOString(),
},
{
id:"frame-weight",
title:"Weight over the summer",
updatedAt: newDate(NOW-10*24*HOUR).toISOString(),
},
];
}
constANSWER=
"Your blood pressure held a steady band this week. The mornings ran a little higher than the evenings, which is common and nothing that stands out on its own.";
Loaded 100 of 3756 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.