`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 suggestion anchored on the document's own date, which the DTO carries
as a date only. The suggest endpoint takes a full instant, so every
document asked the question as `2025-10-14` and got 422 back; the field
then rendered nothing and no document ever offered the visit its date
falls near. Lift the date to noon UTC before asking, the shape the two
lab moments already send.
The e2e that should have caught it never authenticated: the visits spec
carried no storage state, so its `page` redirected to login and its
`request` reads answered 401. It also asserts a global verdict — one
visit in the document's window — so clear the account's visits before
each test, run the file serially, and keep it to one project, since two
projects mutating the shared account would leave two candidates in the
window.
The accessibility mock lagged the same feature: the detail sheet reads
`encounterLinks` off the document and the mock omitted it, so the sheet
threw before it painted. Add the field the server always sends.
The account payload gained a full-name field; this integration test
built its expected entries without it, so the exact-match assertions
disagreed with the response. The fixtures name the field now, null for
an owner who set no full name.
The /insights overview crept a kilobyte over its ceiling: the ECG
teaser imported its overview cap from the classification module, which
dragged that module's label map and helpers into the eager page, and
the Health Score card pulled the whole metric-provenance map for its
one citation. The cap now lives in a value-only module and the single
provenance entry is split out, so the overview imports the bare number
and citation it renders. Both routes are back under budget. The
aggregate ceiling rises to hold the six-locale copy the new encounter,
mood-context, mood-prognosis and practitioner surfaces added, which the
whole-build sum counts independently and no single route downloads.
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 switcher and the banner named a shared record's owner by their greeting
name — the value they typed at onboarding for "how should HealthLog address
you". When an owner has set a full name, that is what a delegate should see:
entering somebody's record, you read whose it is by who they are.
- the account-access resolver publishes the owner's `fullName` alongside the
greeting name and the username
- `accountLabel` prefers fullName, then displayName, then username. Optional
on the argument so the grant-view and actor shapes that never carried it
keep the old two-field behaviour untouched
- the closed presentation schema carries `fullName` and its parity matcher
compares it, so the two published views of the active record cannot name
the same owner differently
- the OpenAPI contract regenerates with the additive field
This deliberately discloses the owner's full name to every delegate, read-only
included — it was patient-identity data behind a MANAGE-gated export until now.
That is the intended behaviour, documented at `accountLabel` so it is not
mistaken for a leak and tightened back. The resolver, the schema parity, the
label fallback and a read-only delegate's banner are each pinned by a test.
The demo edge01 rebuild copies the users table by an explicit column list,
and three of the names in it — research_mode_enabled and its two
acknowledgment stamps — were dropped from the schema when Research Mode was
retired. The COPY and its matching SELECT both targeted columns that no
longer exist, so the rebuild would fail on the first users row. Removed from
both lists, which stay aligned.
The five-strip cap belonged on the overview teaser — a glance on /insights —
not on the surface a user opens to read their history. It was living in the
shared EcgSection, which the dedicated /insights/ecg page reuses, so an
account with many recordings could only ever reach the newest five there and
the older strips became unreachable with no pagination behind them.
The cap is a prop now. The teaser passes ECG_OVERVIEW_LIMIT; the page passes
nothing and lists every recording. The constant lives in the React-free
classification module so the overview page can pass it without pulling the
dynamically-loaded ECG component into its bundle.
The per-family withholding of a link's label and date from a scoped delegate
was proven on the detail read and not on the list read. The two resolve links
through different functions — the list through the batched loadEncounterLinksForMany,
added when links moved onto the list — and a batched read that dropped the
redaction would leak a lab analyte and its date to a delegate the single read
withholds them from, with no assertion on the detail path to notice.
Three legs, mirroring the detail coverage against the list route: an
entire-record delegate sees every name, a profile-only delegate sees the shape
of all three links and the name of none, and a profile-plus-labs delegate sees
the lab name and neither of the other two. The entire-record leg is the control
that stops the redaction assertion passing against a read that redacts
unconditionally.
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.
Four honesty fixes with no behaviour change to the math.
The validation header claimed more than it proves. The ridge FIT is
out-of-sample and no training fold sees ahead — that holds. But feature
SELECTION and per-user standardisation both run over the whole window
before the split, a small documented optimism (measured at about one
part in sixty on the error). The header now says exactly what is and is
not leak-free, and says to nest the screening if the feature set ever
grows or the optimism is ever measured larger.
The per-weekday observation floor had no consumer: no weekday board is
built on this fit, the forecast only publishes seasonalUnlocked and
leaves the weekday view to the insights page, which has its own floors.
Removed rather than left as a number nothing reads, with a note to bring
it back alongside such a board.
Two comments were wrong and are now true: the route's parameter is
declared for the exported type, not to steer a method check the wrapper
resolves from the Next.js request with a GET fallback; and the label
surface imports the key parser from feature-keys.ts, not features.ts.
And the write-surface guard now states its own structural blind spot,
the way the session-surface guard does: the wipe path deletes this table
through a dynamically-resolved delegate the literal matcher cannot see,
which is why that deletion is correctly not counted as a writer.
The Coach staleness fixture moves above the forecast floor, because the
read path now refuses below it: a one-entry account correctly gets an
absence rather than the present forecast that case exercises.
The ladder gate counts days carrying a rating. But a day missing one of
the fit's selected features is dropped rather than imputed, so a
sparse-context account can clear thirty rated days and be fitted on far
fewer complete rows — a forecast "built from twenty days" past a floor
meant to say thirty. n beside the value and a wide band on a small fit
keep it from being dishonest, but the floor should still hold.
The number of complete rows the fit actually used now has to clear the
same threshold as the rated-day count; below it the account gets the
absence shape, and the read path then reports "not enough of a pattern
yet" rather than a value resting on a handful of days.
Watched red with a fixture of forty rated days carrying the driving
feature on only the twenty-five most recent — enough to screen the
feature in and to score the recent days, so the refusal is about the
complete-row count and nothing else. Before the floor it wrote n=25.
The job clears a below-floor account's rows nightly, but the read path
trusted whatever row was on disk between passes. So for up to a day
after an account deleted entries back under the floor, GET
/api/mood/prognosis returned present:true with a value the account no
longer had the data to support — and worse, under a stage the row
implied ("insufficient" / "learning") that is not in the published enum,
which fails a strict client decode of the whole payload.
The read path now re-reads the ladder and returns the absence shape
below the floor, exactly as the job would, so the two agree and no
out-of-enum stage can reach the wire. The present type's stage is
narrowed to the three rungs that carry a value, so the compiler enforces
it too.
The integration case that missed this ran the job before reading, which
is precisely why it was silent. The new case deletes entries back into
the learning band and reads WITHOUT running the job, with the row still
proven on disk, and asserts present:false with no stage. Watched red:
before the fix it returned present:true with a predicted value.
The bundle landed a release before the surfaces that read it and was removed
on trunk as dead. Both mutation hooks in this branch use it, and the merge took
trunk's deletion, so it comes back here together with the writes that need it.
The comment now names its consumers, so the next dead-code sweep can see them
without reading two files.
Three lines in a 240 px menu: the name, the access level, the kind of
record. The middle one truncated more often than it read, and it is what
the warning banner states in full once you are inside the record, where it
governs what the page lets you do. Two lines left, and the level stays on
the row as `data-access-level` for the browser journeys.
The switcher's own "Read only" / "Can add entries" strings had no other
reader and leave all six bundles.
The ECG surface opened with three lines of muted text saying what HealthLog
does not do, before a single strip. Two of them said the same thing — one
from the page's explainer slot, one hand-rolled beneath it — and the third
repeated it under the list. The device attribution on the recording itself
already carries that claim where it means something, so the three go and the
page opens on the recordings.
- the device's verdict rides the timestamp as a tag instead of taking a line
of its own beneath it. It qualifies the recording; it is not a second fact
- a recording is a route now, `/insights/ecg/[id]`, with the back link ABOVE
the heading exactly like a workout detail. The detail used to swap itself
into the list card with its back button under the page title, and the
recording had no address at all: nothing to deep link, nothing for the
browser's own Back to return to. It reads by id, so a cold link works
- the list paints the five most recent strips. A watch that records every
morning turns an unbounded list into a scroll, not a history
`ecg-classification.ts` holds the verdict label map the list, the detail and
the cross-link card each used to carry a copy of.
Research Mode was the opt-in behind the estimated GLP-1 drug-level curve:
turn it on, read a disclaimer, get the chart. The chart stopped consulting
the flag several releases ago and has painted for every account since, so
the switch on Settings → Advanced changed no screen in either position. An
inert control that promises to hide a chart is worse than no control.
The curve is simply part of the medication page now. Gone with the switch:
the acknowledgment dialog, `/api/auth/me/research-mode`, the shared status
DTO and its gate helper, the disclaimer-version stamp, the query key, and
the three user columns the acknowledgment wrote (migration 0321). The
Privacy page's second button pointed at the retired control and goes too.
Settings → Advanced is now what it reads as: reset the data, delete the
account.
Environment, external assistant access, Documents, mental wellbeing and
nutrient intake ran 120-165 characters in German where their thirteen
neighbours sat at 30-75. Every one of the five carried the same second
sentence, "off by default", which restates what the switch beside the row
already shows.
The mental-wellbeing row also named two screeners where the module ships
four, and its second sentence held the one statement in the app about what
happens to the answers. That statement moves to the mental-wellbeing page
itself, where somebody is about to answer them.
A guard holds every module row to one sentence in all six locales, and
under the 120-character guideline in English and German.
Two cards on Settings → Account were asking questions that already have a
home somewhere else.
- cycle tracking is a module, and the Modules hub carries the switch that
drives the same `cycleTrackingEnabled` column. The Account card was a
second door to one decision, so it goes, and the "manage in Account"
deep-link beside the module switch goes with it. That link pointed at a
`#cycle-tracking` anchor no element ever rendered, so it never landed
anywhere in the first place
- changing a password belongs with the second factors and the passkeys.
The card moves to Settings → Security and leads that page. It renders
before the factor status resolves: it is now the only way to change a
password and must not depend on a read it does not need
Account keeps the profile, the photo, the identity fields and the AI
context. The `settings.cycleTracking.*` strings leave all six bundles.
The Appearance hub paints its modules as one divide-y ledger inside a
settings card, and the card dropped its padding without dropping its gap.
The gap survived between the rows as unclickable space the first row does
not have: 24 px on desktop, 16 px on a phone, measured. Rows read a step
apart from the top of their own list.
- SettingsCard grows a `flush` variant that drops padding AND gap, naming
the shape `<InsightSectionCard flush>` already names on the Insights side
- the geometry sweep gains a fourth claim: in a divided list card nothing
sits between two rows but the divider, and the first row starts at the
card's inner edge
Two defects behind one omission. The card counts what a visit produced, so a
list that dropped the links made every card read zero for a visit with three
documents. And the edit sheet seeds its pickers from the row it was handed: an
edit opened from a linkless list row would save a replace-set of three empty
arrays and silently unfile everything the visit had collected.
The links now ride the list, resolved in three grouped queries for the whole
page through the batched arm of the same service the detail uses, with the same
per-family redaction from the same predicate. The single-visit read hook had no
caller and is gone with it — the list is where the surfaces read from.
Both defects get a test that would have caught them: the card counts assert
against a populated links object, the draft asserts the arrays survive a round
trip through draftToBody, and the integration file asserts the list response
itself carries every family.
Three properties here cannot be answered by a mocked client, and each has a
shape that would pass a weaker assertion while being wrong:
- filing a visit against a due checkup twice satisfies it ONCE. Phrased as
"the second call returned 200" it proves nothing, so the assertion is that
lastSatisfiedAt did not move — and the instant is captured once, because two
calls milliseconds apart are two different filings and the forward-only guard
correctly advances on the later one.
- a lab commit writes one link PER RESULT ROW. A test that asserted "there is
a link" would pass on one link for a three-marker panel.
- the visit list's condition filter runs in SQL. With one visit in the account
a filter and no filter return the same list, so there are always two.
The bulk arm gets per-entry tolerance over a hundred documents, one unknown id
reported as one failed row with the rest filed, and a foreign visit refusing
the whole batch before anything is written.
The e2e spec proves the incidental-linking promise once, addressing stable
data-slot attributes rather than viewport text: a visit saves with a date and
nothing else, and a document dated inside the window offers that visit,
pre-selected because there is exactly one, and files itself against it.
- 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 VISITS leaf ships in the release that made a visit possible, because a
selectable checkbox that produces nothing is worse than no checkbox. Leaf,
collector and PDF renderer land together, and the leaf carries no LEAF_MODULE
entry — like ALLERGIES beside it — because a visit is core and the whole report
is already gated by doctorReport. The absence is commented at the map so a
later reader does not read it as an oversight.
The rows print the date, the practice with its specialty, the kind, the reason,
the outcome and the conditions the visit was filed against. The two encrypted
columns decrypt at render, fail-soft, and never enter the stored selection. A
condition the person has since deleted is not named: the link row survives a
soft delete, and printing a label they withdrew would put it in front of a
doctor.
No FHIR resource type is added. That deferral is already written at the
Encounter model, and the bundle builder now says so at the point a reader would
look for the missing section: FHIR splits the future from the past across
Appointment and Encounter, which is a serialisation question this release does
not answer.
Two properties are free and worth stating: a stored selection never grows
(unknown ids drop) and a clinician share link's sections are frozen write-once,
so no existing report and no already-shared link gains a section.
The panel also offers a booked visit as the report's reason — one query, one
prefilled window end, one dismissible offer that names its own source and never
applies itself. A window control that moves on its own is what makes an export
panel untrustworthy.
- 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
A lab panel files itself against the visit it came from, and never has to. The
OCR review and the manual form both offer the suggestion through the one shared
field, anchored on the draw date rather than on now, so backdating a reading
moves the offer with it. The commit writes one link PER RESULT ROW, which is
why the join is m:n: a marker re-run on a different day belongs to its own
visit. Best-effort by design — the readings are the write that matters and must
not be lost to a link that could not be made.
The condition side gets a Visits about this card with an action that pre-links
the episode. From the episode side only, and that is a decision: an episode
spans weeks and a visit is a point, so matching the other way would be wrong
often enough to teach a person to ignore every suggestion in the product.
A due checkup gains a second action beside erledigt. Filing the visit as done
satisfies the checkup through the same forward-only, idempotent primitive the
checkbox uses, so doing both is one satisfaction and no error. The two actions
answer different questions — one says it happened, the other says where.
An appointment now appears where a person looks, and NEITHER surface relaxes
the reminder-table exclusion that keeps it out of the preventive-care lists.
Both read the visit table: a quiet line under the dashboard checkup block for
anything inside two weeks, and one rail item for today or tomorrow. A diff in
either that touches origin is the wrong fix.
A visit write now also refreshes the reminder reads. Closing a checkup moves
its next-due date on three surfaces that would otherwise keep showing it due
until their own poll.
OpenAPI regenerated in the same commit.
- 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
The timezone resolver and the dependent-keys bundle shipped ahead of
their consumers, and the dead-code gate rightly went red on the trunk.
The surfaces that need them arrive with the next visit stage and bring
them back together with their callers.
Every arm here is the same shape as the one that already exists for
conditions, because the vault's filing model is settled and a second shape
would be the thing to explain rather than the thing to use: encounterIds on
upload and on PATCH with replace-set semantics, linkEncounter / unlinkEncounter
beside the episode pair in the bulk action, and an encounterId list filter.
Both directions ride ONE table. The visit's sheet writes encounter_document_links
from its end and the document reads it from the other, declared as a second
direction in the link service rather than a fourth table. A visit's kind
crosses the wire as the enum constant rather than a rendered name: unlike a
condition label, which is the person's own words, a kind is one of eight closed
values whose human name the reader's own bundle owns.
The suggestion is offered where the document is being looked at, resolved by
the shared rule rather than by this surface: one candidate pre-selects with an
undo, two offer a picker with nothing pre-selected, none offers nothing at all.
It resolves with no AI provider configured, and it renders nothing once the
document already names a visit.
The bulk verb has a caller — the vault's selection bar files a whole selection
against a visit, mirroring the condition verb and hiding itself on an account
with no visits. Nothing here is one-ended.
OpenAPI regenerated in the same commit.
- 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
/checkups gains a second section rather than a nav entry. The page already
owns the right sentence — when do I have to do what, where — and is
deliberately not module gated, which is the same reason a visit carries no
module key of its own: what a visit may point AT follows the modules that own
those things, so the gate sits on the three link pickers and blanks the block
rather than rendering an empty one.
A visit saves with a date and nothing else. No required practitioner, no
required kind, no required link, and the only thing that can disable Save is a
date that will not parse. The status is derived from the date on a new visit
and only becomes a field on an existing one, because somebody booking an
appointment should not also have to tell the app that it is in the future.
The address book is record content, not configuration, so it sits under
/checkups rather than in Settings — and it inherits that page's route-family
classification with it. Deleting an entry says in one sentence that the old
visits stay, because a person deleting a doctor has every reason to fear
otherwise.
Two defects closed on the way:
- /checkups carried sharedRecord: true and appeared in neither route family,
so a scoped grant could not present it at all. It was invisible while a
whole-record grant admitted every path; the visits section makes it load
bearing, and the page is now listed under both domains whose content it
shows. Deduplicated, or a grant holding both would offer the page twice.
- the address-book search read the name only, so looking a practice up by the
name on the door returned nothing and read as "not in the book".
The practitioner search description in the OpenAPI module moved with it.
A document arriving in the vault and a lab panel being committed both ask the
same question — does this belong to a visit? — and the answer has to be the
same question asked once. The rule lives in one module with one exported
window constant: the caller's live DONE or PLANNED visits within seven days of
an anchor date, resolved into a verdict rather than a list.
One candidate pre-selects, visibly, with one undo. Two or more offer a picker
with nothing pre-selected: pre-selecting the first of two is what teaches a
person to stop reading suggestions, and the return type has no field on that
arm a caller could mistake for a choice. Zero offers nothing.
Cancelled visits and no-shows are not candidates. Neither happened, so neither
produced the letter or the panel being filed, and admitting them would widen
the set with rows that are wrong most of the time — which pushes the honest
single match into the picker branch.
The verdict is served over HTTP because all three moments live in the browser.
Handing the client a windowed list and letting it decide would put the rule in
two places, and the copy in the browser is the one nobody re-reads.
- suggest-window.ts with the window as an exported constant, not a literal
- GET /api/encounters/suggest, profile domain, reviewed into the frozen literal
- OpenAPI regenerated
- fixtures are relative to the run instant; a fixed date slides out of a
relative window and rots the test into a false green
It gates the flag the forecast publishes, so a surface asking whether the
history supports a weekday claim about THIS comparison asks one place.
It does not retro-gate the weekday board the insights page has carried
for releases — that board is built from the entry aggregates and has its
own floors, and hiding it from accounts under ninety days would take a
surface away from people already reading it to enforce a rule about a
different calculation. And it does not justify building a second one.
An appointment reminder rides the same MEASUREMENT_REMINDER event type as a
checkup — one engine, deliberately — so every channel attached its Done /
Later affordance to it: the Telegram inline keyboard and the APNs category
whose actions the iOS app registers. Both resolve the reminder by id, and
every by-id reminder surface refuses an ENCOUNTER-origin row, so each button
could only answer "not found".
The producer knows the origin, so it says the dispatch carries no action and
each channel drops its own affordance rather than re-deriving the rule. The
reminder id still rides the metadata: it identifies the nudge for the
client-managed suppression tag, and what is withheld is the offer to act.
- suppressActions on the notification payload, documented where it is declared
- Telegram builds no measurement keyboard for a suppressed dispatch
- APNs withholds the category and strips the reminder id from the iOS payload
- a negative control keeps a checkup nudge actionable
Two small things the first pass left rough.
The fit's window read had no row cap. A year holds 365 days and the fold
keeps one entry per day, so the cap only bites on an account that logs
many times a day — but an unbounded read in a nightly job is a table
scan waiting for the account that has years of history. The rows now
arrive newest first under a cap, which also puts the loss on the right
end: an account over the cap loses its oldest days, never the recent
ones a forecast is written for.
And the card showed the raw day key. It goes through the account's own
date-order preference now, pinned to UTC like every other day key in the
app, so this card cannot show a different day from the list beside it.
Every delegable operation states, in the contract, that it can refuse a
delegate — and the guard over the document caught this one publishing
only its module gate. One status, two reasons: the module gate's own
sentence first, the shared refusal spliced on the end, which is the
shape every other gated delegable route already uses.
The sweep that retires an appointment reminder no visit claims had no
test of its own. Two now, because the rule has two halves and only one
of them is obvious.
It reaps a live row nothing points at, and it must leave a row whose
only claimant is a soft-deleted visit. That second half is the subtle
one: the sweep matches on the relation and does not read the visit's
tombstone, so a deleted visit still owns its nudge and restoring the
visit finds it intact. A future change that "tidied" the predicate by
also excluding tombstoned visits would destroy that on the next
unrelated write, and nothing else would notice.
The visit is tombstoned directly rather than through the delete route,
which retires the reminder deliberately and would have proven nothing.
The snapshot gains the day's two readings with the age of the second
one, through the same reader the page uses, so the page and the
assistant cannot disagree about what today's comparison is. The
freshness rule is the shared one: a comparison about the day before
yesterday carries the date it is about and an instruction not to state
it as today. Absence is a present field rather than a missing key —
a model told "there is none yet" says so, and a model told nothing
estimates one. The wording rule travels with the values, because a model
handed a number and no instruction writes the sentence its training
suggests, and about a forecast that sentence is a causal one.
The contract publishes the resolved value: the expected figure, the
band, the count, the deviation against the band, the age, and the
contributions that carried weight. Nothing is left for a client to
re-derive — two clients deriving the same figure about somebody's health
record is how they start disagreeing about it. The description carries
the observational wording and the display rule: the count goes wherever
the value goes.
The end-to-end proof runs against real Postgres and reads the table
rather than a return value: nothing written a day short of the
threshold, a banded and counted row on the day it crosses, the
provisional label dropped at sixty days, the rows cleared when the
module goes off, the rows cleared when entries are deleted back below
the threshold, and an older pass's row replaced rather than left beside
a fresh one. A delegate holding only the mind section is refused, and
the assertion is on the response bytes.
Two readings, side by side, never merged. The person's own rating leads
— larger, first, and returned exactly as it was stored — and the
comparison sits beside it with its range and the number of days it was
built from. The deviation is measured against the range, not against its
midpoint: inside it is a day that went as the account's own past days
suggest, and only outside it is worth a sentence.
The sentence is a counterfactual and says so. Never "your mood is 5.4",
always "a value around 5.4 would have been expected". The explanation
names what the comparison weighted for that day, out of what the job
stored for that day, in the words the capture sheet already uses — a
person reads "Workload" here because that is what the slider they moved
was called.
The route declares the whole record rather than the mind section, and
the reason is written at the declaration: the comparison reads the day's
context AND the figures the sleep, activity and recovery modules hold,
and it names them, so a delegate holding only the mind section would
receive a derived view of sections the owner declined to share. A scoped
grant is refused outright rather than served a contribution list with
the linked features quietly removed, which would read as "only the mood
fields mattered" and be false.
The causal-verb ban is a test now rather than a review habit: the copy
guard reads all six bundles and fails on a causal construction in any of
them, and on a statement that quotes a value without the days behind it.
Both halves were watched failing.
A job, never a route. A forecast written as a side effect of opening a
page is a function of browsing rather than of data, and the prognosis
also has to be a value the person cannot overwrite — so one file writes
the table and a guard holds it there.
The cohort is the union of two sets and the second one carries the
weight: accounts with enough recent rated days, plus every account that
already holds rows. Without the second, an account that deleted its
entries would fall out of the sweep and keep last month's answer under a
fresh-looking date, with nothing on the surface to say so.
A pass replaces rather than accumulates. Every account it touches ends
holding exactly the rows that run wrote, or none: below the threshold,
module off, no pattern in the data, no recent day carrying everything
the fit needs — every one of those clears what was there. Per-account
failures are counted and the pass continues; only an all-accounts
failure fails the job.
Runs at 04:35, after the inputs have settled and clear of the geo
backfill on the hour's fortieth minute. The arithmetic is a ridge
regression and nothing else: no provider, no key, no egress, no consent
gate, and the module says so at the top so nobody wires one that does
not apply or assumes one that is not there.
The target is the day's own pleasantness; the inputs are the other four
self-ratings, the numbers and closed vocabularies of the day's context,
and the figures the sleep, activity and recovery modules already hold.
Nothing is imputed. A day missing one of the selected inputs is a day
the fit does not see, and a feature nobody recorded on most days never
becomes a column in the first place — the coverage floor is what keeps
the complete-case rule from quietly emptying the matrix. A day with no
context row contributes no work-status column at all, rather than a zero
that would read as "worked a regular day".
Columns are standardised against the account's own window, so a person
whose stress never passes six does not have six read as the maximum.
Candidates are screened with the machinery the rest of the product
already runs — Pearson at the shared paired floor, Benjamini-Hochberg at
the shared FDR, the shared effect-size floor on the shrunk estimate —
because a sweep over sixty features at raw p < 0.05 finds three things
that are not there.
The fit is a ridge solved in closed form, validated on expanding windows
in date order. The training range ends exactly where the validation
range starts, asserted on the fold indices and on the day keys, not on
the resulting error: a leaking split passes an error check whenever the
leak is small, which is when nobody looks. The band is the central 80%
of the validation residuals, so it widens when the model is bad instead
of staying confidently narrow.
The window read is the linked resolver's own engine, extended to answer
many days in one query rather than growing a second opinion about what a
Tuesday looked like.
Review of the visit work found the appointment arm leaking in both
directions: rows the visit routes could strand, and a reminder surface
that would act on one as though it were a checkup.
An edit could orphan a live reminder. Pointing a still-booked visit at
the checkup it closed walked the foreign key off the visit's own
reminder, and the next reschedule then minted a second one, because the
re-anchor path asked the key what the visit owned instead of asking the
engine. An orphan fires: the cron reads the reminder table, not the
visit list, so it pushed for an appointment nobody could see, edit or
cancel, and deleting the visit did not reach it. The edit arm now
refuses the contradiction the create arm already refused, ownership is
resolved through the relation, deletion retires the row it owns rather
than the row it points at, and a sweep at the end of every visit write
retires anything left armed that no visit claims -- so the state is
unreachable rather than merely unlikely.
The by-id reminder routes never excluded appointments, and that was the
larger hole. Marking one done committed the satisfy and THEN failed on
the mapper, so every appointment push a person acknowledged answered 500
after the write had landed. An edit could give a spent one-shot a
cadence and turn it into a permanent nudge that appears in no list, or
switch a cancelled one back on. The delete is a hard delete, so somebody
holding only the measurements domain could destroy a row belonging to a
visit in the health background. All five refuse in the lookup now -- an
appointment is not addressable there at all -- and the chat buttons that
mark a reminder done or postpone it refuse it the same way. Nor is an
appointment satisfied by a lab result: the auto-satisfy matcher used to
resolve any free-text reminder from any panel, which would have marked
next month's cardiology appointment attended.
A visit is a seam between four domains, and names were crossing it. The
values never did, but the title of a discharge letter is itself the
sensitive part, and a grant that opened only the health background was
never consent for it. Link labels and dates now follow the target's own
domain; the link is still listed, so a delegate learns the visit
produced a lab result without learning which analyte. Closing a checkup
follows the same rule: it re-anchors a preventive-care cadence, which is
a measurements write, so a health-background delegate files the visit
and the closure is reported as skipped rather than performed in silence.
Three smaller repairs. A checkup now closes at the visit's own instant
rather than at the moment it was typed in, which anchors a backdated
entry correctly and makes correcting a mis-filed visit the no-op it
should always have been. A window ending before today no longer widens
back out to today. And the fallback in a push notification was the raw
enum constant, so a lock screen could read a schema value; it reads the
record owner's own language now, in all six.
Five rungs — nothing at all below fifteen entries, descriptive hints to
thirty, a provisional forecast to sixty, a plain one to ninety, weekday
and seasonal comparisons above it — plus the occurrence floor a value
must clear before it may be named at all.
Every floor is an exported constant and every one has a test on both
sides of its own boundary, because the failure here is invisible: a
forecast built from eight days renders exactly like one built from
eighty, and nobody reading the screen can tell. Each assertion names the
threshold it is about, so a red says which floor moved.
Absence carries its reason and the count rather than being a bare false,
so a surface can say how far along the account is instead of showing an
error. Two numbers are deliberately not minted here: the paired-value
floor stays `MIN_PAIRED_N` in the correlations module, and the
occurrence floor is the crosstab's own, taken by assignment. A test
reads the file back and fails if either turns into a local copy.
`MoodPrediction` holds the expected value of a day beside the day's own
rating: one row per account per local day, with a band and the number of
days the fit used. Two tables rather than four columns on the entry,
because the point of the second value is that the person cannot correct
it into agreement with the first — the distance between them is the
finding, and a value the edit form can write is not a second reading.
The table stays out of the disaster-recovery backup and the reason is
written where the completeness guard reads it: every row is recomputable
from the entries, the contexts and the linked modules, and carrying one
would restore a forecast made by a model version that may no longer
exist against data the restore has just moved. It is in the wipe plan,
because a derived row is still a day-by-day statement about somebody.
The write-surface guard lands red on purpose and names what is missing:
`lib/jobs/mood-prognosis-refresh.ts` does not exist yet, so the writer
set is empty where it should hold exactly that one file. The job closes
it. The rest of the guard is green now — no route, page or component
writes the table, and the matcher proves itself against a call broken
across lines, an aliased client, a read and a different model.
/insights/mood was on its 460 KB ceiling. The route paints a calendar, a line
chart, a target card and an assessment; under them sit a dozen breakdown cards
— both tag axes, the influence board, the two crosstabs, the day-context
comparison, four Recharts mini-charts, the correlation cards — and every one of
them was a static import, so a cold mount paid for all of it before painting
anything.
The cluster is its own module now, loaded through next/dynamic with a skeleton,
the way the insights mother page already treats its below-the-hero blocks. It
takes the resolved aggregate as a prop rather than running a second query, so
it cannot draw a different snapshot from the regions above it.
The better-days card follows, for one specific reason: it resolves catalogue
tag icons, and that resolver statically imports about a hundred and sixty
lucide icons. The card sits a screen down and the map now rides the same lazy
group as the breakdowns.
Only mood-only modules moved. Deferring the shared target and assessment cards
was tried and reverted: it bought ten kilobytes on this route and cost
thirty-three on the whole-build total, because both are imported by a dozen
other insight routes and a route-local lazy group duplicates them.
/insights/mood 459 -> 451 KB gz (budget 460, unchanged)
total client JS 3296 -> 3297 KB gz (budget 3310, unchanged)
The payload shape and the section card frame move to a shared module both
halves import; the two structural tests that read the old file now read the
union of the two, so a guard cannot go quiet by pointing at the wrong half.
The six per-section type aliases and the inverse-orientation list had
no consumer; the schemas read the key arrays directly and the workLoad
orientation is stated where the rating fields are declared.
The hook always sent the browser's zone while the route's comment said the
detail view sent the entry's stored one. Nothing was red, because nothing
asserted it: correcting an entry logged in another country looked its day up
under whichever zone the correction was being made from, and the night shown
beside it could be hours out with nothing on screen saying so.
The edit dialog passes the entry's `tz` now. The capture sheet keeps the
browser's and passes no third argument, which is correct rather than an
omission: the entry does not exist yet and will be anchored to that zone.
A legacy row with no zone falls back to the browser's rather than to
Europe/Berlin — the server owns that legacy rule and a guess sent from the
client would override the one place it is written down.
The resolution is a pure helper so both branches can be asserted, and both
call sites are pinned structurally, because the resolution being right is
worth nothing if the dialog never hands it the entry's zone.
The doc comment beside `a1`..`a5` promised that an out-of-range value marks
THAT entry skipped and never the batch, while the fields sat in the batch
schema and answered 422 for all five hundred. The words were right and the
code was not, and a phone draining a year of local history lost the year to
one row.
The five now parse per entry, the same way the day context does; a bad row
reports `invalid_dimensions` and the rest of the batch lands.
The split buys the tolerance and costs a second copy of "0 to 10", so a guard
offers the same value to both schemas and makes them agree at both ends of the
scale and one step outside each.
The resolver hand-wrote the map and got it wrong in both directions. It gated
steps and active energy on `workouts`, which gates workout SESSIONS and not
ambient movement, so the mood sheet hid a figure every other surface shows. It
called resting heart rate and heart-rate variability core with no owner, while
the table has assigned both to `recovery` since v1.30.22 — including the RMSSD
fallback, pinned there so a ring-only account cannot reach through it. An
account with recovery switched off was served the domain it had switched off,
which made the release note's own promise false.
Both directions now resolve through `moduleForMeasurementType()`, the table the
MCP wire, the correlations reader and the Coach snapshot already share.
Steps, active energy and the two vitals also run through the canonical-source
picker now. Without it a phone and a watch reporting the same day summed twice
and the sheet showed a walk nobody took; the vitals answered whichever row was
latest rather than the picked stream's.
A guard pins the three ownership claims, including that the two vitals share
one module — the block carries one availability flag for the pair, so the day
they diverge that flag becomes a lie and the block has to be split first.
The route answered sleep minutes, steps, active energy, resting heart rate,
heart-rate variability and an illness day-log with its symptom keys, and it
declared `mind`. A delegate given only the mind section could walk arbitrary
dates through the one URL and read two sections the owner had declined to
share; no mood entry had to exist for any of those dates.
`record` is the convention for a read that crosses sections, and a scoped
grant never reaches one, so the scoped delegate loses the linked block rather
than receiving a filtered day. A block that silently dropped the sections a
grant does not cover would show a day that looks empty where the truth is that
it is none of the reader's business.
The reason is written at the declaration and in the frozen surface entry,
whose previous justification held only for whole-record grants.
Two requests can probe the same workout, both find no heart-rate series,
and both mean to attach one. The unique index on `workout_id` picks a
winner; the loser has to report the duplicate it turned out to be rather
than claim an attach the database never made. Nothing could catch that
arm going wrong: the unit fake echoes every row back as landed, and the
race suite carries no series at all, so replacing the status with a
constant `enriched` left the whole suite green. Two concurrent posts of
the same enrichment now assert one `enriched` against one `duplicate`
and a single stored series.
The published contract also says now what the counters mean, instead of
leaving it in a comment: an entry reported as `enriched` counts towards
`duplicates`, because the top-level counters count workout rows and an
enrichment writes none. Field descriptions on the response and on the
per-entry status and reason, OpenAPI regenerated.
Marking an older missed dose as taken from the Verlauf pinned the
right slot but stamped the intake with the current moment. The body
builder is a pure helper now, anchored on the slot instant, matching
both the optimistic paint and the skipped-to-taken flip that already
behaved this way.
Two entries carrying the same external id and both a `samples` array
reached the enrichment arm one after the other, and both read the same
landed workout id back, so the response claimed two attaches where the
database had written one. The second entry now takes the duplicate arm:
a workout is claimed by the first entry of the batch that reaches it.
The v1.37 matrix demands an executable driver per admitted mutating
handler, not just a line in the inventory: it runs the real route as a
delegate, asserts the effect landed once, and asserts the audit row
names the acting delegate rather than the owner. Eight admissions came
with the visit API, so eight drivers land with them.
The visit fixture is deliberately in the past. A planned future visit
mints an appointment reminder, and a driver that asserted one effect
while producing two would be checking the wrong thing.
A batch entry matching a stored workout stays first-write-wins for the
workout row. What it can do now is hand over a heart-rate series the
stored workout does not have yet: the series is attached, the row itself
is not written at all, and the entry reports back as `enriched`. A
workout that already carries a series is a no-op, so a client can walk
its whole history as often as it likes.
- The pre-flight probe now answers both questions at once, whether the
row exists and whether it already has a series, and carries the id so
the attach addresses the stored workout without a second round trip.
- The attach rides the same child write as a fresh workout's series.
RETURNING names the rows that landed, so a workout that gained a
series between probe and write reports as the duplicate it is instead
of claiming an attach that never happened.
- A `samples` array that fails validation is refused per entry with
`reason: "invalid_samples"` rather than failing the batch. One
unusable session out of a hundred must not stall a history sweep the
client cannot repair by retrying; every other validation failure keeps
failing the batch.
- The wide event and the audit row tally enrichments. Counts only, the
series never reaches either.
- `enriched` is the one contract movement: additive on the per-entry
status, request shape unchanged. OpenAPI regenerated.
Integration coverage against a real Postgres pins the attach, the
repeat as a no-op, the refusal of a bad series alongside entries that
land, a mixed batch reporting each status, and the stored workout row
compared whole before and after, `updated_at` included.
The page ended on a "Why?" button that opened the coach with a
prefilled question about the session. It leaves on the maintainer's
call; the coach remains reachable from its own surfaces. The two
strings it carried leave all six locales with it.
The capture section and the insights card called the five sliders
"Five scales", a count where a name belongs. They now carry the
state-of-mind wording in all six languages, with Spanish diverging
from the first slider's own label so the block and the mood slider
stay distinguishable.
Query keys land in the central factory before any surface reads them,
because the lint rule that bans a bare array means the first component
listing visits cannot compile without a slot. There is deliberately no
server-side cache invalidator beside them: nothing cached reads a visit
yet, and a sweep over caches nobody reads would look like coverage
without being any. The module says so at the place a reader would go
looking.
The OpenAPI table publishes seven paths with everything resolved — the
practitioner as an object rather than an id, each link with its label
and date, the appointment's next due instant computed. What it does not
publish is the reminder origin enum, which still reads with two members:
the third value exists in storage and reaches no response, because every
preventive-care read excludes it. Keeping that contract closed is why
the exclusion is worth a guard.
Classifying visits under the existing health-background domain widens
what that domain grants. Somebody who shared it so a relative could see
an allergy list now also shares every appointment and the address book.
That is stated where it is agreed to rather than left in a code comment:
the consent screen's sentence names visits and practices, in all six
locales, and the activity feed gains a verb line for each of the eight
new delegated actions so an owner reads what was done rather than
'made a change'.
- the wipe plan names it before its parent, so the confirmation screen's count
is the number of rows that existed rather than zero
- the backup plan classifies it as carried and two-ended, and the round-trip
registry seeds one, which is what makes that claim provable
- the sharing surface admits the linked-day read with the reason it may answer
- CHANGELOG for the release
Nine visit routes and five practitioner routes, all classified under the
existing profile domain and each reviewed into the frozen sharing
literal with the sentence that argues for it. An address book of a
person's own doctors is record content, not account configuration: it
names no credential, no integration and no notification channel, which
is the fence that classification turns on. The eight sharing domains are
unchanged. Link and unlink stay bare, matching document link and unlink,
and the pair is revisited as one decision later.
Nothing is a required field. A visit saves with a date and nothing else.
Every id a body may carry is re-narrowed to the caller before anything
is written, and an unknown link id never blocks a save.
A booked visit mints exactly one reminder on the existing engine with
the new origin, and the appointment half is the part worth reading
twice. Both cadence columns stay null, which is the schema's one-shot,
so the first due instant is stamped directly and the never-nag property
is structural rather than guarded: after it fires, the cadence helper
has no occurrence to return. Rescheduling re-anchors that row and never
mints a second, which the tests assert by counting rows rather than by
checking the date moved.
Filing a visit as done closes the checkup it was filed against, on the
transition rather than on every later edit. Writing the test first found
that: satisfying is forward-only, so re-saving a completed visit was
pushing the checkup's next due date out again as if the person had been
a second time.
An appointment must appear on no preventive-care surface, and that is
four separate reads, one of them in the assistant tooling. Each carries
the exclusion, a guard proves all four by source text with a non-zero
match count, and the reminder mapper refuses such a row outright — so a
read site that loses its filter fails loudly instead of showing an
appointment as a checkup. The published origin enum is untouched.
- /api/sync/changes carries a1 to a5 and the day context under the same keys
the write path accepts, so a client reads back exactly what it can post
- BulkMoodEntry accepts both, all optional; a1 stays server-derived from the
five-point label, so a build that sends only a label still writes a complete
row and needs no change to stay correct
- the batch parses each entry's context on its own: one row carrying a value
the vocabulary does not name is skipped and the rest of the batch lands
- docs/api/openapi.yaml regenerated alongside
- one comparison per context value, reusing the Welch test, the day floors and
the Benjamini-Hochberg family the tag board already runs; a wide sweep
filtered on a raw p < 0.05 would surface one-in-twenty noise as a finding
- two entries on one day count as one day
- every statement names how many days it compared, on the same surface as the
claim, and says it shows a connection rather than a cause
- the assistant snapshot carries the same rows with that constraint written
out beside them
- work, contacts, leisure and one notable event, each a closed accordion with
a count badge, on both the capture sheet and the edit dialog
- sleep, activity, vitals and the illness journal render read-only beside
them, each row naming where its figure comes from and offering a way in to
change it there
- the body line links into the illness module and captures nothing
- six locales for every label and every anchor
Three narrow link tables landed with the visit record, which is a step
away from the one-shared-service direction the polymorphic-linking work
argues for. This bounds that step to a single module: every caller now
speaks one vocabulary — a source, a target kind, a list of target ids —
so replacing the storage underneath later is a change in one file rather
than a sweep across every route that files something.
The document-condition links move behind the same signature, so there
are two families and one call surface. Behaviour is unchanged and the
existing vault tests are the proof; where a test double had to follow
the new query shape, it says why in the diff.
The rules that used to live at each call site now live in the module and
are proven against real Postgres rather than a fake that ignores a
where clause: both ends re-read scoped to the owner before a row is
written, a repeated link is a success that changes nothing, a call is
capped at a hundred targets, and a tombstoned record is not linkable on
either end. Unlinking one deliberately still works after a delete, or a
filing nobody can clear would be left behind.
Reads return a resolved label and date rather than a bare id, so a
client rendering a list never re-derives what the server already knows.
The restore is the one documented exemption and writes the tables
directly: it rebuilds an account rather than filing something in one.
- one resolver reads the day's figures from the modules that own them; nothing
is copied onto the mood row, so correcting a sleep session corrects it here
in the same moment
- absence answers { present: false } rather than zero, because a night nobody
recorded and a night of no sleep are different facts
- a module switched off blanks its whole block instead of filtering down to an
empty one
- keyed by the local day so the capture sheet can ask before an entry exists
- the completeness guard now binds a third model, MoodContext, to a named
select; the four omitted columns each carry the reason the backup is right
to drop them
- the disaster-recovery arm carries the ciphertext, the portable arm the plain
text, and every other column rides in both
- the restore rebuilds the context after its entry, inside the same
transaction, and a file written before contexts existed restores without one
- the round trip asserts the restored row by value, zero included
A person's whole 'which doctor' history was one free-text column on a
reminder: they typed the practice name once and could reach nothing else
that happened there. This turns that string into a record.
Five tables in migration 0320, all additive: an encounter that happened
or is booked, the account's own address book of practitioners, and three
links from a visit to the documents, lab results and conditions it
produced. The encounter is slim on purpose — the diagnosis stays on the
linked condition, the address on the linked practitioner, and the reason
each field was kept or cut is written at the model. No module gate, and
the schema says why rather than leaving the absence to be read as an
oversight.
Every carrier lands with the tables rather than after them. All five are
classified as travelling both ways and are proven doing it: a seeded row
of each goes out through the real export builder, the account is deleted
so the schema's cascade decides what survives, and the rows are counted
back through the real restore route. Two references deliberately do not
survive and are named instead of dropped quietly — the reminder a visit
points at is not in the backup, and a link whose far side a portable
export does not carry cannot resolve. The wipe plan deletes the links
before the lab results and documents they hang off, or the audit row
would report zero for rows that existed. The three encrypted columns are
registered for key rotation, without which dropping a legacy key would
make those notes permanently unreadable.
Widening the reminder origin enum is a type error at the reminder DTO,
which is the right outcome: the mapper now refuses an appointment row
outright rather than relabelling it as a checkup.
- moodContextSchema derives every enum from the vocabulary, so a value it does
not name answers 422 through returnAllZodIssues rather than being dropped
- the context is written in the same transaction as the entry and its links; a
failure rolls the entry back rather than leaving a client retrying a 5xx
into a duplicate
- an entry with nothing filled in stores no row, and a context that says
nothing removes the one that was there
- the create, list, detail and edit reads answer the context with its lists
decoded and its note decrypted; the ciphertext never leaves the server
- one module holds the four sections, their key lists and the rating fields;
the Zod enums, the capture UI and the analytics all read it, so a value the
vocabulary does not name cannot be stored
- labels state facts, never judgements, and the contact circles are groups of
people rather than a score
- a pair guard binds the arrays to all six bundles in both directions, because
the reverse-coverage matcher sees a `mood.context.` template and stops being
able to tell a live label from a stale one
- migration 0311 creates mood_contexts, keyed by the entry, cascading from
both the entry and the owner
- work, contacts, leisure and one notable event; every field nullable, and an
entry with nothing filled in gets no row at all
- notes_encrypted registered in ENCRYPTED_COLUMNS and in the rotation script,
so a key rotation cannot strand it
The determinism guard reads the job that runs the browser, which is now
the shard matrix rather than the gate that only carries the required
check name. The report artifact is named per shard so the two uploads
cannot clobber each other.
The single job passed at about eighteen minutes against a twenty-minute
cap, and the settings geometry measurements pushed it over, so the run
died at the timeout with everything to that point green. Same remedy as
the integration suite, split rather than raise: two Playwright shards,
each with its own database and production build, and a gate job carrying
the exact name branch protection requires that fails when any shard
failed or was cancelled.
The dead-code sweep found the window type exported and unread. It was
unread because the surface that offers the choice carried its own copy
of the same three numbers: the chart could not import the calculator's,
since that module reaches the database client two hops down and a client
component pulling it in would ship Prisma to the browser.
Both now come from the dimension table, which is pure by construction
and which the chart already imported. One declaration of 7 / 30 / 90,
read by the calculator that averages over them and by the control that
offers them, so the two cannot drift.
Three small things the audit named together.
The mood reads spread the whole row, so adding the wire keys shipped
every level-A value twice — once as the column name and once as the key
the write path accepts. A client would have picked one and been stuck
with it. The column spelling is stripped now, and the create response's
key set is asserted so it stays stripped. The published contract is
unaffected either way; these routes are not in it.
The dimension series carried its own copy of the day-key subtraction
that the freshness module already exports. One copy now.
And the reason a stale dimension ships with a notice instead of being
dropped the way the status surfaces drop one is written at the builder:
a one-line card has nothing left to say about a value it cannot narrate,
while an open conversation can say when the value is from — and silence
there would leave no way to tell an unanswered dimension from one nobody
has looked at in a week.
The snapshot stamps every metric block with an asOf built from the
newest reading in it, which for mood is the newest ENTRY on the 1-5
axis. Somebody who taps a face each morning and opens the sliders once
a week therefore carries a block stamped as current and a stress
reading six days old, and a reader taking the block stamp as covering
the dimensions states last week's answer as today's.
The block now names the precedence in one line beside the dimensions,
and each dimension keeps its own age. Present only when there are
dimensions to explain.
An entry always carries a five-point label, so it always implies a
pleasantness value: the create path derived one from a null and the
capture sheet still showed a clear control for it, which meant a button
the server undid on the way to the database.
One rule on both routes now — a null asks for a value that cannot exist
and gets the derivation, on create and on edit alike — and the control
is gone for that field. The other four are genuinely optional and keep
theirs.
Matches what syncVersion, syncedAt and the row stamps already do a few
lines above: a key the file does not carry is a key the file predates,
not a key the file says is empty. Reachable when one file addresses the
same row twice, where the second entry now adds without erasing what
the first one set; an explicit null still clears.
Worth stating plainly, because the audit that asked for this expected it
to protect live rows from an older file: it does not, and it cannot. The
restore deletes the owner's whole mood partition before it rebuilds, so
by the time the update arm runs there is nothing live left to protect. A
test now pins that contract too, so nobody reads the omission as a
guard it is not.
The staleness test rebuilds the block shape from the pure calculator, so
it proves the calculator and the freshness rule agree and nothing else:
hardcoding the freshness flag in the feature builder, deleting the
notice that goes with a stale value, or dropping the whole block left it
green. This calls extractFeatures against real rows instead.
Both failures were watched. With the flag hardcoded true the unit test
still reported four passing while this one named the six-day-old stress;
with the notice removed it named the missing instruction.
The single create's upsert restated all five columns on its update arm,
so a client re-posting an entry with only a label and a timestamp — the
phone, whose build has no sliders — cleared the stress, energy,
connectedness and stability somebody had answered on the web. The batch
and import paths already preserved them, so the two disagreed.
One answer now, and it is preserve: absence in a request means nothing
to say, an explicit null takes an answer back, and pleasantness still
follows the label because the request always carries one. The create
schema accepts the null that makes clearing expressible.
A re-post that omits a dimension is asserted to leave it standing, and
that assertion has been watched to fail against the old arm.
The inverse flag is read by the trend legend and by the assistant block;
the function that flips a value through it had no caller. It comes back
with the surface that needs a combined view, not before.
The pillar reads PHQ-9, GAD-7 and WHO-5 and cites the papers behind
every threshold. A daily self-rated slider is now available on nearly
every mood row, so leaving it out reads as an omission unless the
reason is written down: the two are different kinds of evidence, and
averaging them would weaken the line that makes the figure worth
showing to a doctor. Recorded at the pillar and at the columns.
- the level-A blocks ride the mood aggregate the coach and the briefing
already share, so the ages are fixed once in the data preparation
rather than once per sentence
- every block carries its newest value, the day it belongs to, how old
it is and whether that age still backs a present-tense claim, through
the same freshness rule the vitals surfaces use
- a stale dimension carries the instruction in words as well as in a
flag, because that is the sentence this exists to prevent
- the 1-5 scale line is untouched; the new axis describes itself, with
the anchors resolved from the bundle the person read
- the trend page and the assistant run the same calculator, so a number
in a sentence cannot disagree with the number on the chart
- one engine: the dimension series is a sibling calculator of the mood
aggregate hub and rides the same bounded read, so no second summary of
mood can disagree with the first
- 7 / 30 / 90-day means per dimension, plus the daily series behind them
- a dimension nobody answered is named as not recorded rather than drawn
flat through the middle of the scale, and a zero counts as an answer
- each line keeps the orientation the person answered in; the legend
says which way the inverse one runs
- the card rides the existing module-gated aggregate read and query key
- the five faces are untouched: pick one, save, done in a tap
- a closed section below them opens onto five sliders; picking a face
suggests a pleasantness value and moving that slider takes the
suggestion over, while the face selection stays where it is
- an untouched slider writes nothing, and clearing one takes the answer
back rather than leaving a middle value behind
- the list's edit dialog shows and changes the same five, so nothing is
written on one screen and invisible on the other
- correcting an entry no longer re-derives pleasantness when the label
did not move, which used to overwrite a hand-set value on any edit
- factor ratings are no longer offered on a new entry and nothing was
deleted: existing ratings still render, edit and feed the crosstab
- six locales, and the dimension table's keys are declared dynamic
- Slider wraps the Radix control the way Switch and Progress already do;
no new dependency
- SliderField adds the label, the value and the two scale anchors, which
are what make a 7 mean anything
- a null value renders as unanswered rather than as the value the parked
thumb sits on, and zero renders as the answer it is
- the announced text carries the scale, not only the number
- the backup select and the payload carry all five, on both the portable
and the disaster-recovery arm
- the restore writes what the file holds and nothing else: a file from
before these columns restores them empty rather than deriving them
- the CSV appends them after the existing columns so nothing shifts under
a spreadsheet, and an unanswered value is an empty cell rather than a
zero, which is a real answer on this scale
- the round trip asserts each of the five by value, including a zero
The supply-chain section said the publish workflow runs on every push to
main. That trigger was removed in v1.4.34, when the squash-merge and the
release tag each kicked off their own build of the same SHA; the
workflow has fired on v* tags only since. Pull requests still build the
image, and do not publish it.
The resolver queried the online provider first and read the mounted
GeoLite2 databases only when that missed. Every document describing the
feature said the opposite, including the self-hosting runbook and the
admin notification that tells an operator their login IPs are leaving
the server. So an operator who mounted the databases in order to stop
that egress kept all of it, and nothing said so: the location resolved,
the admin overview filled in, and the only difference was where the
address had been.
Now the local databases answer and the provider only ever sees an
address they could not place. A host with the City database mounted and
a hit makes no request at all — including when the ASN database is
absent, because reaching out for a carrier string would send the address
off the host anyway, which is the thing the mount was for.
IP_GEO_LOOKUP_DISABLED=1 remains the full off-switch: it disables the
online step only, so an offline hit still resolves and an offline miss
returns nothing rather than going online.
The wide-event warning on the fallback path named ipwho.is even when the
operator had pointed IP_GEO_LOOKUP_URL somewhere else; it now names the
host the runtime actually queries. The admin notification copy needed no
change — it fires only when the databases are absent, and then every
lookup does go online.
The two cases in geo-asn.test.ts that pinned the old order are the
watched red for this change.
The admin "Delete all data" action carried its delete list inline. That
list named nine tables, which was most of the schema on the day it was
written, and it stayed at nine while the schema grew to a hundred and
twenty-two models. Laboratory results, documents, cycle logs, journals,
mood, workouts, sleep and Coach conversations all survived the wipe an
operator runs before handing a box on, and the toast said it was done.
The per-account wipe had the same defect and grew a derived plan for it.
The admin route now reads that plan: ADMIN_WIPE_MODELS is WIPE_MODELS
minus a single documented subtraction, so a model added to the schema
lands in both wipes at once.
- AccountGrant is the one difference, with its reason in the plan: it is
the only access path to a managed profile, and this action preserves
accounts by definition. Deleting every grant would leave records
nobody can open, administer or delete.
- The audit history now goes with everything else. It is a record of who
did what from where, which is exactly what clearing a box is for; the
receipt written after the transaction commits replaces it.
- One transaction with the same budget as the account wipe. A
half-applied wipe is worse than none, because the person is told it
succeeded either way.
- The result line reports rows and tables instead of nine fixed names,
which is how nobody noticed: it read the same whether the wipe had
cleared nine tables or ninety.
Guards, both watched red before landing:
admin-data-wipe-completeness.test.ts fails when a user-scoped model
reaches neither the wipe nor a written exemption, and when the route
names a table inline again. The integration guard seeds every table in
the schema for two accounts, runs the route, and asks the database what
is left — drop one model from the plan and it names the surviving table.
- the migration fills mood_a1 from the five-point label for every row
that has none, and leaves the other four columns empty because there
is nothing in a label to derive them from
- guarded on the column being empty, so re-applying the migration is a
no-op rather than an overwrite of an answer somebody gave since
- the test reads the CASE arms back out of the .sql and compares them to
the runtime map in both directions, so neither can drift alone
- rows are asserted by value after the statement runs, and again after a
second run, including one hand-set value that must survive it
Two cycle suites compute their relative day keys with local Date arithmetic
under TZ=UTC while the routes resolve today in the user's zone, falling back
to Europe/Berlin when the fixture sets none. For two hours every evening the
two clocks disagree about the date and every day-of-cycle assertion is off
by one — the suite was red from 22:00 UTC to midnight and green the rest of
the day. The fixtures now carry an explicit UTC timezone so the test's clock
and the route's clock are the same clock at any hour. Reproduced red at
23:07 UTC, green with the pin at 23:19 UTC.
- one helper resolves the level-A values for a write; the five writers
call it instead of carrying their own copy of the derivation
- the create schema takes a1 to a5, and a value the user set wins over
the derived one; the batch request shape is untouched
- both arms of both upserts restate the values, so a re-post cannot
leave a stale reading beside a mood that has since changed
- the JSON import derives from the row's own label rather than the
file's score, which is the one place the two can disagree
- correcting an entry's label re-derives pleasantness the way it
re-derives the score; an explicit null takes an answer back
- a round trip through all five writers against real Postgres asserts
the rows agree and that nothing invents the other four values
Two shards cleared in about fourteen minutes on a quiet day, but with six
pull requests running side by side the larger shard hit the same 25-minute
guard the split was meant to escape. Three shards keep the biggest slice
around sixty files, which clears the guard with real headroom on the
slowest runner observed. The gate job is unchanged.
- migration 0310 adds mood_a1, stress_a2, energy_a3, connection_a4 and
stability_a5, all nullable, no table rewrite
- the schema comment states the four things a later reader needs: score
keeps its 1-5 axis, the columns are queryable plaintext on purpose,
stress is stored literally, and a read delegate sees them by design
- the backup guard is red until the carriers land alongside
- MOOD_DIMENSIONS carries the five self-state dimensions in capture order,
each with its column, its label and anchor keys, its orientation and its
0-10 bounds
- getA1ForMood maps the five-point label onto the pleasantness scale once,
so the quick check-in stays a full-value capture path
- only stress is inverse-oriented, and a test pins that count
- the secret scan reads a dotted message key on a *Key field as a token;
allowlist the dimension keys the way the existing message keys are
A fresh advisory names nanoid below 3.3.17: a custom generator can loop
forever when asked for a zero-size id. It reaches the tree only through
next and postcss, but the audit gate rightly blocks on a known HIGH with
a fix available. Bounded override in the established pattern; the audit
drops from one high to none.
The suite outgrew its runner. The 25-minute guard was sized for 128 files
taking about 13 minutes; the sharing release took the suite to 180 files and
past the cap on CI runners, so every run died at the timeout with every test
to that point green, reported as cancelled. The guard's own rule applies:
split, don't raise.
The files run as two vitest shards, each with its own Postgres testcontainer.
A gate job carries the exact name branch protection requires and fails when
any shard failed or was cancelled, so a half-green matrix cannot satisfy it.
vitest is invoked directly because pnpm passes the -- separator through to
the script literally: the flag never applied through the script form, and
both "shards" ran the full suite. Proven by paired local runs: the script
form ran 180 files twice, the direct form splits 90/90 with 927 plus 689
tests, all green.
Measuring the seven `/settings/layout/<child>` routes was meant to pin the
back-link spacing. It found there was no back-link to pin.
`SettingsHubBackLink` and `settings.sections.layout.backToHub` have existed
since the sub-pages were split out, and the only thing that mounted them was
the shell's slug FALLBACK — the branch that runs when a page passes no
`heading`. These pages pass an explicit one, so the fallback never ran and no
child route has painted a back-link since. The component was written, the
string was written, the reverse-coverage guard saw a call site and was
satisfied, and the way back to the hub was not on the page. Two ends, no pipe.
It now comes from the page, where the heading it belongs to comes from.
The geometry spec grows the seven routes and a second family. A route with a
back-link carries an extra row above the title, so it is taller by
construction; comparing it against a plain route would report structure as
drift. Each family is checked for its own constant heading top, and the step
between the back-link and the title is asserted at 6 px — `PageHeader`'s
documented `space-y-1.5`, which is what the shell's hand-rolled `space-y-6`
would have drifted back to.
Measured, both languages: plain routes 24.0 px, back-link routes 51.0 px,
spread 0.0 px in both families, step 6.0 px on all seven.
The back-link is also painted twice, once per breakpoint, so the measurement
picks the copy with a box — `querySelector` returns document order, which on
a desktop width is the hidden mobile one, and reading it reported no
back-link at all.
Every item here is a check that could pass while the thing it checks is
broken, or a margin the sweep's codemod could not reach.
Guards:
- The access-payload matcher accepted `.level` on ANY object. A chart's zoom
level or a log record's level would have satisfied it and reported a
consumer that does not exist. It now names the bindings the entry actually
arrives under; a third binding is a deliberate edit rather than a silent
widening. Re-falsified: rename both readers, it goes red.
- The `pl-7` sweep skipped any line CONTAINING `//`, so a class string sharing
a line with a URL was waved through. Only the part after a real line-comment
marker is exempt now. Proven by putting `pl-7` on a line with an
`https://` comment: caught.
- `LEGIT_IDENTICAL` in the English-leak guard had no staleness check, unlike
both of its siblings, and carried two dead keys — one from a removal long
before this work, one from the duplicated version fields that came off
`/admin`. Both pruned, the check added, falsified with a made-up key.
- `split-description.mjs` split inside `z. B.`, `d. h.`, `u. a.` and `p. ex.`
and produced a head ending mid-abbreviation. Both halves have to be caught,
which is why the rule is a shape (single letter + stop) rather than a list,
and the merge re-tests its own tail. Exercised against eight cases including
the ones that used to break.
Margins the codemod could not see, because the child lives in a different
component from the card that spaces it: six form roots under managed record
settings (one of which also carried the banned `space-y-5` step), the
low-stock status line, the avatar card's explainer, and the backups restore
report. Each was adding a margin on top of the card's own gap.
Two facts the text diet cut too close, both restored inside the guideline:
that an excluded injection site wins over a medication that prefers it, and
that the privacy card's second button leads to a full health record.
The gate the diet shipped with matched `<SettingsCardHeader` in three
component folders. That is not where every card description lives, so five
real ones were outside its reach — including the full backup's, which the
sweep's own shortlist had named and which stayed at 351 German characters
across three sentences while the guard reported zero violations.
Two ways in were missing. A card header can sit in a folder the walk never
entered (`labs`), and a description can be FORWARDED to the header by a shell
— `ExportCardShell`, `ImportCardShell` — where the prop is on the shell's tag,
not the header's. The gate now matches both, over seven roots, and the
"finds what it is meant to check" floor moved with it.
`SubPageShell` is deliberately not included, with the reason written at the
list: its description renders as `text-foreground text-sm`, the body tier,
where the standards allow prose. Gating it would enforce a rule §3 does not
make for that tier.
The five, all six locales:
- full backup — the twelve-item enumeration was the description. It is now
the card's opening body paragraph, where a list of what is in the file
belongs; the description is one sentence.
- local OCR — the how-it-works sentence stays in the slot; "less accurate,
review every value" is safety copy and reads foreground in the body.
- CSV import, dose-history import — second sentence to the body.
- the ECG page — the "does not interpret the waveform" claim gets its own
paragraph rather than riding the page explainer.
Proven by reverting the full backup's text: the widened gate reports it as
both two sentences and over length, at the call site, in both languages. The
old gate could not see the key at all.
Both ends of the divider removal were green and the pipe between them was
never run. The `<hr>` came out of every integration card, all three unit
tests were updated to expect none, and this browser spec kept asserting nine.
It is the same shape as the failure this project keeps rediscovering: two
correct halves and nothing exercising the assembly.
The cost was not just a red spec. The Pixel-5 horizontal-overflow assertion
sits AFTER it in the same test, so `/settings/integrations` had no proven
mobile-overflow check for the whole sweep — the test never reached it.
The second assertion read a sentence's bytes and broke on the text diet while
the thing it was about — the card rendering its explainer at all — never
changed. `SettingsCardHeader` now names its description slot
(`data-slot="settings-card-description"`) and the spec targets that: the slot
is present, non-empty, and not a raw i18n key. The product name stays
assertable because a proper noun is not prose. The wording itself is pinned
where it belongs, in the unit test that reads the same bundle it renders from.
Every interactive path, every import, MCP and Telegram checked an incoming
measurement against VALUE_RANGES before storing it. The provider sync writers
did not, so a decode slip or a glitched sensor landed a row the application
itself calls impossible, and every mean, median and personal band derived from
it inherited the impossibility. Filtering the read side hid the row. It kept
landing.
The gate is one module reading the domain the app already declares, applied at
two kinds of seam. reconcileExternalMeasurement is the shared identity writer
for Polar, Oura, WHOOP, the Withings sleep leg, the Apple export importer and
the batch endpoint, so gating there means a provider added later inherits the
refusal rather than having to remember a check it never knew about; the new
rejected_range verdict carries no row, which is what makes the compiler force
every caller to handle it. The five writers that hold Prisma themselves gate at
their entry, before the existence probe decides insert versus overwrite, so an
impossible reading can no longer replace a good stored value.
An out-of-band value is dropped. Not clamped, because a clamp invents a reading
nobody took. Not stored behind a flag, because a quarantined row is still a row
and every consumer would have to learn the flag. Not fatal to the batch, because
one bad sample must not strand the run's other readings behind a held watermark.
Absence reads as absence.
A silent drop, though, is indistinguishable from a provider that sent nothing,
so every drop is tallied on the wide event under measurement.range_rejected with
the metric, the provider and the edge it broke. Counts only: the reading itself
is health data and never enters the log line, and the direction plus the
metric's own declared bound is what a diagnosis actually needs. The tally
accumulates rather than replacing, because a provider gates once per resource
leg and the reconciler once per row. One warning per provider lifts the event to
warn so a drop does not hide inside a green line.
A structural guard enumerates all twenty measurement writers with the reason
each is allowed to write, so a new one has to answer the question before it
ships. scripts/diagnose-implausible-measurements.ts reports the rows already
stored, aggregated per metric and source with no value beyond the bucket's own
extremes. It repairs nothing: whether a stored impossible row is a decode slip
or a band that is too narrow is not a call a script gets to make.
Thirty-four routes, both consoles, English and German, desktop and phone.
The claims this program makes are geometric, so they are checked with
geometry rather than by looking at screenshots.
What it found, which is the reason it exists:
- `SettingsCard as="section"` sat one spacing step tighter than its
neighbour on every desktop width. The `<Card>`-backed variant inherits
`gap-4 md:gap-6`; the landmark variant wrote a flat `gap-4`. Seventy-five
of eighty-six cards measured 24 px and eleven measured 16 px, directly
above and below each other. The expected value in the spec is a function
of the viewport for exactly this reason — a constant would have agreed
with whichever half it was written against.
- One German page subtitle wrapped to a second line where English fit on
one. Shortened in all six languages.
After both: heading top 24.0 px on every route, spread 0.0 px, in both
languages and at both widths — that is the no-jump claim, measured. Card
header-to-body distance equals the card's own gap on all 86 cards, and no
card body sits off the card's inner edge.
The header's HEIGHT is pinned only where one line is achievable. At 393 px a
70-character sentence needs about 380 px of column, so most explainers wrap
there; the spec asserts the wrap away at desktop width and only forbids a
third line on a phone. The alternative is truncating the sentence, which is
the defect this program removed.
Seeds an entry with a ticked tag, a rated factor, an encrypted note, a
non-Berlin timezone and a sync counter above zero, exports through the real
builder, restores through the real `POST /api/admin/backups/[id]/restore`,
and reads the row back out of Postgres. Values, not a payload shape: this
repository has already proved that asserting a `moodEntries` key stays green
with the restore call excised.
Two more cases beside it. A hand-written payload in the shape files were
written in before the note, zone, counter and tag keys rode along — the
compatibility floor, and the only way to know it holds is to send one. And a
ticked tag this instance cannot resolve, which has to come back named in the
skip report rather than silently absent.
Seen red three times on the way in: re-adding the tag-link filter loses the
ticked tags, dropping `tz` from the restore write loses the zone, dropping
the note ciphertext from the payload loses the text. Each reverted and the
file's hash checked back to its recorded value.
A lab runs its own method on its own device, so the window a physician
evaluates a value against is the one printed next to it on that report,
not a catalog band averaged over every lab. Reading a report pulled the
value and dropped that range: the extraction asked for it, the commit
handed it to the biomarker resolver, and the resolver only uses it when
it mints a NEW marker. For every analyte the user already had — the
normal case after a first panel — the printed range was read and then
thrown away.
Add `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText`
to `LabResult` and carry the range from the report to the reading. The
text is stored whether or not numbers can be read out of it, because
"bis 5,0" and "negativ" state a window too; a range that could not be
parsed is kept verbatim, never replaced with invented bounds.
`resolveEffectiveReferenceRange` is the one place the precedence lives:
the source window wins for that single reading, and the catalog stays
the net for readings without one. A printed string with no derivable
bound does not displace the catalog. Every surface that judges a
reading routes through it — the API verdict, the doctor report, the
insights blocks and per-marker assessment, the coach snapshot, the MCP
read and the FHIR `referenceRange` — so a value cannot read in range on
one surface and out on another.
The API keeps `referenceLow` / `referenceHigh` as the window in force,
so a client that reads only those two renders the right range and the
right verdict with no change of its own; the catalog band, the source
window, its origin and a divergence flag ride alongside for clients
that want to show both.
Read side: the chart draws the source window as a second, visually
distinct band with the reading it came from named beneath it, the
reading rows name the printed range and the catalog band where the two
disagree, and the doctor-report PDF prints the report's own string with
a footnote naming the saved range. Entry side: one optional field on
the lab form, folded away until asked for, and the printed range is
editable on the scan review screen.
The range parser covers the notations German and English reports
actually print — two-sided, one-sided, comma or dot decimals, thousands
separators, trailing units — reads units without ever converting them,
and refuses to guess on anything ambiguous.
`moodEntrySchema` gains `note`, `noteEncrypted`, `tz`, `syncedAt`,
`syncVersion`, `createdAt`, `updatedAt` and `structuredTags`; every one of
them optional or defaulted, so a backup file written before this parses
unchanged and restores exactly as it did.
The restore writes them. The note follows the measurement pair: ciphertext
verbatim when the file carries it, a portable file's plaintext re-encrypted
on the way in, legacy column null either way. `tz` absent still means the
legacy Europe/Berlin reading, which is what those rows meant. `syncVersion`
comes back at the value the file recorded — restoring at the default handed
the next round to any paired device holding a higher number, and the device
would then overwrite the recovered entry with what it had.
The link rebuild re-creates both arms: rated factors from `factors`, ticked
tags from `structuredTags` with a NULL rating. An unresolvable tag key is
reported under its own `moodTag` family, beside the rated one, because an
operator needs to know which of the two an entry lost.
The disaster-recovery payload dropped the free-text note, the row's
timezone and the last-writer-wins counter, and the tag-link read filtered
`rating: { not: null }` — which is the definition of a BINARY link, so
every present/absent tag a person picked was missing from the file too.
Both arms now mirror the measurement pair: the recovery arm carries the
note ciphertext verbatim plus `tz`, `syncedAt`, `syncVersion`, `createdAt`
and `updatedAt`; the portable arm carries the decrypted note, `tz` and no
ciphertext.
BINARY keys ride in their own `structuredTags` array rather than as a
nullable rating inside `factors`, because a restore resolves `factors`
against RATED tags and names what it cannot resolve — a rating-less entry
there would collide with both. Absent means empty, so a file written
before this parses and restores exactly as it did.
The two link arms partition the links exhaustively: a malformed link comes
back named as unresolvable instead of falling between them.
The mood read was an inline `include`, so nothing could compare it to the
model the way `measurement-backup-completeness.test.ts` compares the
measurement select. Name it — `MOOD_ENTRY_BACKUP_SELECT` and
`MOOD_ENTRY_TAG_LINK_SELECT` — and add the sibling guard that binds both
to `prisma/schema.prisma`.
The mapper's parameter is spelled `moodEntry` rather than `e` because the
guard proves a column reaches a payload by matching `: moodEntry.`, and `e`
is also the intake-event mapper's parameter one section above: a matcher on
`: e.` would match across both and could never fail.
The guard is red as it stands, and names the defect: `note`,
`noteEncrypted`, `tz`, `syncedAt`, `syncVersion`, `createdAt` and
`updatedAt` are selected and written to no payload. The fix follows.
Measured before the sweep: 317 description and hint strings under the
`settings` and `admin` namespaces, median 83 characters, 113 of them two
sentences or more. The rule the design standards now carry is one sentence,
guideline 120 characters, and this is the sweep that makes it true.
Almost nothing was retranslated. A second sentence is already written in
every language — it is just in the wrong slot. `scripts/split-description.mjs`
cuts a description at its first sentence boundary in all six bundles at once
and either drops the tail or files it under its own key; the twenty-one keys
that moved now render in the card body as foreground `text-sm`, which is
where multi-sentence prose belongs and where a reader can actually read it
(the slot is muted `text-xs`). Eight tails were dropped outright, because
"Off by default" under a switch, "Topics and frequency are adjustable" above
the topic switches, and "Only admins can create invitations" on an
admin-gated route are not information.
Twenty-eight keys are gone: the privacy page's intro, which was a table of
contents for the four cards under it; two About descriptions that listed
verbatim the labels and the buttons beneath them; a card description that
repeated its own title; the API-token sentence that said what the paragraph
below it said with the actual instruction; and the eight integration
secondary sentences. Six descriptions were rewritten shorter in all six
languages where trimming was honest rather than lossy.
On the strings that changed: 4391 characters of English down to 2290, and 32
multi-sentence descriptions down to zero.
The rule is now a gate. `card-description-length.test.ts` reads the keys that
actually reach a card header's description slot — statically, from the JSX —
and checks them in EN and DE, because a rule that only holds in English
produces a slot that wraps to two lines in half the locales. Its exception
list is empty and carries a reason requirement plus a test that removes a
stale entry. Verified by putting a second sentence back: it fails.
Most of what Scorecard looks for this repository already does: Actions
pinned by commit SHA, a security policy with a private reporting path,
Dependabot with auto-merge, keyless signing on the release image, a
branch-protected trunk. The value is not the score, it is that the checks
keep running after the person who set them up has moved on, and that
anyone evaluating the project can read the result without being given CI
access.
publish_results is on, which is what makes the score citable. It reads
repository metadata and the workflow files; nothing about the code or its
contents goes anywhere. The token is an OIDC exchange, the same mechanism
the release workflow already uses to sign the image.
No badge in the README yet. The public API has no entry for this
repository until the first run publishes one, and the badge URL currently
renders 'invalid repo path'. It goes in once there is a score behind it.
Two questions the existing scans cannot answer.
The dependency audit runs with --prod, which is the right scope for a
blocking gate but leaves a gap: a dev dependency can still ship. pdfjs-dist
is the case that made this concrete. It sits in devDependencies, and
rasterize-pdf.ts imports it at runtime, so standalone output tracing copies
it into the production image where the prod-scoped audit has never looked.
Trivy reads 627 packages out of pnpm-lock.yaml without --include-dev-deps
and 1455 with it; pdfjs-dist is only in the second set. Trivy's help text
lists the flag as npm/yarn/gradle only, but it works on the pnpm lockfile
too, which is worth knowing before someone deletes it as a no-op.
The container scan builds the image from the current main. Nobody runs
that image. What self-hosters pull is the one built from the last release
tag, and an advisory published after the tag lands against an image
nothing is about to rebuild. So the second job scans ghcr.io/mbombeck/
healthlog:latest instead of a local build.
Schedule only, no push or pull_request trigger, because the change-
triggered half is already covered and would just run twice. The lockfile
job reports into code scanning without failing: widening to dev
dependencies surfaces transitive tool advisories by the dozen, and a red
badge from day one gets muted inside a week. The image job fails on
CRITICAL, after its own report has been uploaded by a separate step.
Static analysis was the one class of check the pipeline had no answer for:
lint catches style, the type checker catches shape, the tests catch
behaviour, and nothing walked the data flow from an untrusted input to a
sink. This is GitHub's default setup written out as a workflow file, so the
query suite, the excluded paths and the schedule sit in the diff rather
than behind a settings toggle.
Pull requests and a weekly run on the default branch. No push trigger:
everything reaches main through a pull request that was already analysed,
and the weekly run is what keeps the Security tab current.
src/generated is excluded. Extracting the 9 MB Prisma client costs minutes
and can produce nothing anyone would act on, because the fix for anything
found in there is to regenerate it.
Twenty-six of twenty-eight admin files already used the Settings card and
header. What made the console read as its own system was everything on top of
that: three hand-rolled cards, a header slot that meant five different things,
and a heading the frame took but never painted.
- `SectionFrame` drops `title` and `subtitle`. Seventeen call sites passed
both into a component that has rendered neither since v1.18.6.1 — the shell
reads the same keys itself. A prop contract nothing honours is a promise the
next reader believes.
- The invite card, the four host-metric headers, and the `/admin` welcome
card stop hand-rolling `bg-card border rounded-xl` shells with their own
padding steps and their own heading weights. `text-sm` as a card title on
the first card of a page, directly above a correct one, was the first thing
the eye landed on.
- The header status slot carries status. The backups run, the invite create,
the danger-zone wipe, and the user filters come out of it; the app-log
refresh stays but reads as the secondary it is.
- Loading states paint their card and header. Encryption returned a bare
spinner row and then a card, so the section jumped when the status landed —
the fix `coach-feedback-section.tsx` already carried, applied where it was
missing. Same for the notification-status card.
- One label/value tile. `StatusItem`, the encryption section's private `Stat`,
the user-management inline panels and the backups upload box were four
fills and three paddings for one role.
- The overview stopped printing the running version, build SHA and build time
twice, thirty pixels apart. The version tile owns build identity — it is the
one that checks for a newer release; the snapshot owns what is running.
- `ai-server-key` had three paragraphs stacked in a muted `text-xs` slot;
one sentence stays there and the two that carry information move into the
body as foreground. The central-codex consent copy moves the same way, and
its disconnect is `variant="destructive"` — which `ConfirmButton` refused to
accept, so every destructive confirm in the tree had been hand-tinting an
outline instead.
- Leftovers: the `-mt-2` correcting the card's own rhythm, two alpha hovers on
text, `text-[10px]` badges, the redundant `space-y-6` wrapper, the empty
state repeating the header's CTA, and the `mt-6` a card added inside a
frame that already spaces its children.
The sweep that follows from the primitives. Nothing here is new behaviour;
every change removes a second way of saying something the surface already
said one way somewhere else.
Headers. The two collapsible security cards stop rebuilding
`SettingsCardHeader` by hand to carry a chevron — the chevron rides inside
the title button, and the description moves out of the collapsed region, so
the first paint is no longer a bare title. The import card shell, the export
and import section headings, and the four free-standing group headers stop
inventing their own type scale: a heading above a GROUP of cards is
`SectionHeading` (`text-base`), a card title is `SettingsCardHeader`
(`text-lg`), and the step between them is what says which is which. The
targets card gets a title instead of a naked icon.
Actions. Every card-level row is `SettingsCardActions`. The primary action
comes out of the header's status slot in six cards (password, web push,
pillar order, coach memory, and the two danger-zone triggers) and out of the
middle of the card in three more; the reset that hung in the dashboard-layout
header joins save as a secondary in the same row. Result and status lines
move ABOVE the row — sources' cross-link, the invite form's two status lines
— because nothing follows a card's actions. Revoking a share link is
`variant="destructive"` rather than an `outline` hand-tinted red in two
different ways. The import row had four variants for four buttons; it has
two. Export downloads are the card's primary, not an outline link.
Integrations. One description sentence with the setup-guide link inline: the
second paragraph was byte-identical across six of eight cards, which is the
tell that it said nothing about the provider it sat under. The `<hr>` under
the header — which existed only in this folder — is gone. So is the hint that
pointed at a guide 40 px above it, and the second format for "not
configured". Connect is one button everywhere: primary, `sm`, in the action
row, instead of primary-and-small in three cards and outline-and-large in
four.
Plus the standards leftovers the audit listed: two `pl-5` list gutters, one
`scroll-mt-20` anchor, a purple header icon, `text-[10px]` badges, four
hand-rolled callout shells that `SettingsInfoTile` already paints, and the
two inline buttons that never had the mobile tap floor at all.
The "looking after" block sat directly under the greeting and above this
account's own day, permanently, for anyone with a single shared record. The
dashboard is this account's day; other people's records are a destination,
and the account switcher in the top bar and the sidebar already is that
door — with the same access level and record kind on each entry.
Drop the card, its test, and the five strings only it used, across all six
locales.
The access-payload consumer guard named that one component as the reader of
`level` / `sections` / `recordKind`, so it went red on the deletion, which is
what it is for. It now names the surfaces that actually adopt those fields —
the presentation resolver and the switcher — and matches the property access
rather than one variable name, so a rename of the binding cannot make it
match nothing and pass. Verified by removing every reader: it fails.
Adds `scripts/i18n-key-tool.mjs` for dropping or rewriting a key across all
six bundles in one go.
The two consoles used the same primitives and still read as two systems,
because everything without a primitive had drifted: the header-to-body
step existed in three competing techniques at once (`mt-4` on the first
body child, `space-y-4` on the card, `mb-4` on the header), the body
started either at the card edge or 28 px in, and both shells hand-rolled
the page title instead of calling `PageHeader`.
Move the decisions into the primitives so a call site cannot re-open them:
- `SettingsCard` is gap-based like `Card` (`flex flex-col gap-4`). The
header-to-body step is the container's, not a margin somebody remembers.
A dense card still overrides the gap on the card itself.
- The body starts at the card edge. `pl-7` is gone — off the spacing scale,
and it mis-aligned action rows against their own forms wherever two
neighbouring cards disagreed about it.
- The card header's description slot loses its `space-y-1`. That was the
licence call sites used to stack explainer paragraphs into a muted
`text-xs` slot; the slot holds one sentence.
- New `SettingsCardActions`: one action row, trailing edge, last thing in
the card. `align="start"` spells the one exception, a card whose only
actions are links out.
- New `StatTile` replaces four hand-rolled label/value boxes in Admin.
- `SectionHeading` moves from `insights/` to `ui/`. It is the heading above
a group of cards, which is what Settings needs; Settings had been using a
card header with no card, which reads as a card that failed to paint.
Both shells now render their heading through `PageHeader`, which gained
`headingAs="div"` for the second copy each shell paints (mobile above the
nav strip, desktop inside the grid) so only one is a real `<h1>`. The
page explainer is muted everywhere: the primitive said foreground, the
standards and every hand-rolled copy said muted, so pages disagreed with
each other. Medications, Vorsorge, the labs / custom-metric / illness
detail pages and the clinician view drop their hand-rolled title blocks
too, including the mid-word truncation on the medications subtitle.
Tests pin the parts that can be checked exactly: the card's gap, the
description slot's missing rhythm, the action row's two alignments, the
`<h1>`-free shells, and a sweep asserting `pl-7` stays gone.
`hasEntryUnitConversion` and `canonicalValueToEntry` shipped beside the
hours-to-minutes conversion for an editing surface that does not exist and has
no ticket. Neither has a caller, so both were only two more things to keep
true, and the dead-code gate is right to say so.
The module comment claimed the inverse was there so a future surface could not
land on a different answer. It says what is actually true now: one direction,
one place. The git history keeps the rest if that surface ever arrives.
The mood sheet opens on the five-face quick check and reveals the rest of
the form, timestamp row included, once a face is picked. The dialog-overflow
spec measured the quick check instead, found no date field, and failed on
its own readiness gate rather than on the layout — a guard that cannot reach
the state it is about proves nothing either way.
It now picks a face and waits for the annotate panel, which is the state the
report describes.
Two things about the card that shows what changed since the last panel.
The comparison had no expiry. Someone whose newest panel is years old was
still reading "since your last panel" about a draw from another year — not
stale UI state but a stale sentence, and one the app kept repeating on the
overview every day.
The window is a year, applied to the data rather than to a per-user
dismissal. A year because routine panels are commonly drawn annually, so a
shorter window would hide a comparison that is genuinely the newest thing
somebody has for months out of every year, while past a year the wording
cannot be defended at all. Doing it on the data means nothing to
acknowledge, nothing to persist, no housekeeping asked of anyone, and one
answer for every client through the existing absent contract — the card
already un-mounts on it, and so does anything else reading that route.
Verified in both directions over the real endpoint.
The delta on the right was muted at the smaller size, the same typography
as the meta line under the analyte, so it optically hung off that lower
line and the row read as bottom-aligned even though it measures centred to
the pixel at every width. A delta is the number the row exists to report,
so it takes the content tier, and the cell states its own centring rather
than inheriting it.
The existing cases pinned their clock at the same time: fixed fixture days
against a relative window is a test that passes until a date in the future
and then fails for reasons unrelated to the code.
The three entries stacked two full-width buttons under the value — a
disabled grey Save above a red-outlined "Remove smoking status" — so each
one read as a slab of controls with the same weight as the field it
belonged to, and scanning them ran the two labels together.
Each entry now ends in one right-aligned action row: the quiet destructive
first, the primary Save last, both at the mobile tap-target floor. The
remove control drops the field name from its visible label, which the
Label directly above already carries, and keeps the full phrase as its
accessible name and in the dialog it opens — so three of them side by side
stay distinguishable to a screen reader. Six languages.
Follows the card action-row convention: primary bottom right, secondary
beside it, nothing after the row.
On a phone the placeholder sat noticeably low. The composer row was
bottom-aligned so the send button would stay beside the last line as the
textarea grows, but that also bottom-aligned a single-line textarea against
a 44 px touch target: 7 px of space above the text and 1 px below.
The row centres its textarea now and the controls keep their own bottom
alignment, so both properties hold. Measured empty, one line and four
lines, phone and desktop: the single-line gap goes 7/1 to 4/4 on a phone
and 2/1 to 2/2 on desktop, and the multi-line case is unchanged with the
send button still level with the last line.
The accepted formats and the size limit were a caption of their own under
the upload buttons, so the card read as an explanation, a control, and then
a second explanation. They now finish the sentence that says what the card
does, which is where someone looks before choosing a file rather than
after. Same two strings, six languages unchanged.
The intro listed all four instruments with their subject in brackets and
then explained itself, which ran to two lines on a desktop header and five
or six on a phone — a paragraph to read before reaching the four cards that
say the same thing. It keeps its opening claim and the one caveat that
matters, and loses the enumeration the cards below already carry: one line
on desktop in all six languages, measured at 1280 and 1440.
The pointer to mood history at the foot of the page is gone. The link on
the mood page pointing here stays, because that direction earns its keep:
someone logging a mood may not know the screeners exist, while someone who
came here for a screener knows where their diary is.
The card told people to add a channel "below". A position is only true
while nothing above or below it moves, and the same card already reads as
the first thing in its group, so "below" says less than the group's own
name does. It now names Delivery channels, which stays true wherever the
card is rendered.
The card's title and its per-channel description were still English in
Spanish, French, Italian and Polish while the strings around them were
translated; they are translated now too. Six languages.
The Coach button showed its unread dot over an empty conversation, and
opening the Coach neither showed anything nor cleared the dot.
Two paths fed one indicator. The nightly sweep flipped a "remind me"
reminder from active to due and stopped there — no conversation, no
message, and `surfaced` had no writer at all. The launcher lit its dot off
a second query that asked only whether anything was due. So the dot
promised content that was never written, and because opening the Coach
clears the message-based signal and not the reminder one, the only way out
was to resolve the reminder in Settings, which nothing on the badge
suggested. A reminder minted for a plan review put a user who had never
typed in the Coach in exactly that state.
The sweep now writes the reminder into the user's Coach thread as a real
assistant message and marks it surfaced in the same transaction: no
message, no status change, and nothing to point at. The badge derives from
that message like every other proactive one, so opening the Coach shows the
note and clears the dot. The launcher's second query is gone.
Reminders an older build left stuck on due are picked up by the same pass,
so an indicator that has been sitting on an empty conversation resolves
into the message it always implied.
No provider call and no push: the body is a deterministic localized
template around the user's own note text, six languages.
Proven end to end against Postgres — the row exists, the badge reads that
row, opening clears it, a second tick writes nothing, and a failed write
leaves the reminder untouched with the badge dark. All five fail against
the previous sweep.
The second indicator in the report, under the bottom bar's overflow entry,
is the active-route marker for that group and not a badge.
Refs #715
The add and edit surfaces could be scrolled sideways and the time picker
sat past the right edge: measurements, mood, the medication intake editor
and the cycle log sheet all showed it, so the cause is in what they share.
Two mechanisms, both in shared primitives.
DateField and TimeField wrap a text input. A flex item's min-width:auto
resolves to a content-based minimum, and for a text input that is its
intrinsic size attribute — 20 characters, about 180px. Neither field could
render below ~230px whatever width the parent offered, so the date + time
row demanded ~470px inside a 400px dialog body and pushed the clock button
58px past the edge. min-w-0 on the field box and on the overlay input
releases that floor; the row now fits at every width from 360px up.
The scrolling body only declares overflow-y. CSS promotes the other axis
from visible to auto, so every deliberate outward bleed in the tree turned
the form into a pannable surface — SheetSection's trigger row was 8px wider
than its own container for no benefit (a focus ring already draws outside
the box), and Switch's hit-target pseudo reaches 13px past the track. The
bodies and the dialog surface now state overflow-x explicitly, the section
trigger no longer bleeds, and the dialog's grid column is pinned so an
over-wide child cannot drag the surface out with it.
Measured before and after on the four reported surfaces at 360 / 390 / 430
/ 768 / 1280 / 1440: the date/time row spilled 83px on every width from
640px up and none of it remains.
An e2e spec asserts the two harms directly — the surface is not a
horizontal scroll container, and no focusable control lies outside its
visible box — with a canary that proves the reachability check can fail.
Entering or leaving a record is a server write followed by
window.location.assign, so the click that starts it returns long before
the document it produces exists. The switcher helper returned there, and
everything a test did next raced the app's own navigation.
Both ways of losing that race are silent. A goto issued into the gap is
cancelled when the assign commits, which Chromium reports as
net::ERR_ABORTED and which is what CI saw twice. Or the goto wins, and
the page it lands on belongs to the caller's own record, where a route
that is ungranted in somebody else's record is simply granted: /coach
opened in that gap issued all four of the protected reads the test
asserts never happen. The second is the worse outcome, because the
assertion was reading the wrong record and could as easily have passed
there.
Neither is visible in the DOM the click leaves behind, which is why
waiting on the banner cannot settle it: the document on its way out
already shows whatever the next assertion looks for, so the wait returns
at once and proves nothing. A marker on window can settle it, because a
document replacement is the one thing that destroys it. If the navigation
never happens the wait fails, which is what a switch that did not land
should do.
Both helpers use it now, so the retry that was added downstream of
leaving a record has nothing left to absorb; it stays, because the claim
there is that the record survives the round trip rather than that one
navigation attempt wins.
Reproduced before fixing, under CPU contention at CI's worker count:
three of six runs flaked, showing both faces. Six of six clean at the
same contention afterwards, then twenty consecutive runs of the file and
the whole sharing set, none flaky.
Two repo-wide guards caught the additions rather than a reviewer, which
is what they are for.
The success toasts reached for sonner directly. The success affordance
belongs to the outcome module, which binds it to the success key and
nothing else, so a surface cannot raise a green toast for a write that
wrote nothing. They go through toastWrittenOutcome now.
The sheet's date header formatted a day key in the browser's zone. A
cycle day key is a calendar day, not an instant, so it is anchored at
noon UTC and rendered in UTC, the same anchor the domain's day
arithmetic uses. Otherwise a reader east or west of the browser zone
could see the sheet name a different day than the one it writes to.
The day count was the length of the labelled run inside the calendar
grid, so the same day read as day 11 over a month and day 23 over three:
the run was cut off at the window's edge rather than at the cycle's
start. That was a latent contract question while every caller asked for
the same ninety days, and it stopped being latent when the month grid
began reading its own month, which is thirty days by construction.
The verdict now anchors on the period start the record holds and falls
back to walking the grid only when no caller supplied one, so the ring's
arcs stay grid-derived (they are about what can be drawn) while the day
is about what the person logged. The overdue ceiling then measures the
real elapsed count too.
Covers D-list edge 15, both shapes: a window that clips the run and a
window that excludes the cycle start altogether.
The constants make ovulation minus five through ovulation plus one, and
the comment above them claimed a six-day window. Wilcox's six days are
the five before ovulation plus the day itself, measured against a KNOWN
ovulation day. Ours is estimated, usually by back-calculating a luteal
length from a median cycle, so a window drawn tight to the estimate
loses its last day whenever the estimate lands early, and the ovum stays
viable for most of a day after release besides.
The direction is decided by who reads the window. It is surfaced to
people trying to conceive and to people avoiding it, and for the second
group a window one day short is a window that misses. The constants keep
the conservative reading and the comment now states it, with the note
that changing either one is a coordination matter because the iOS client
reimplements the same spec.
The width is pinned by a test rather than left to be re-read off the
numbers the code already uses.
Two defects put a record into a state that looks, from the app, like no
period was ever logged. Both are fixed going forward; neither repairs
the rows already made, and neither is visible from the surface it broke.
A closed torso is a live cycle carrying an end date with no live cycle
after it. A cycle is closed by its successor, so closed with no
successor means the successor is gone, and a record whose last cycle is
closed has no open cycle for the engine to forecast from. The signature
is exact and needs no audit trail.
A period with no cycle row is bleeding that only ever existed as a
day-log, from the flow chips, the bulk drain or an import. The engine
reads cycle rows, so the record holds periods and reports none.
The report imports the bleeding-day rule from the write path rather than
restating it, so it and the app cannot drift on what counts as a period
start. Checked against seeded records: it names the torso, names the
three flow-only starts while passing over a day two and a spotting day,
and says nothing about a healthy record.
Nothing is written.
MenstrualCycle rows were only ever written by the one-tap period
boundary. Every other way of recording bleeding wrote a day-log and
stopped there: the log sheet's flow chips, the bulk drain, an Apple
Health export full of menstrual-flow samples. The engine reads cycle
rows, so a person could log every period they had and the module still
held that none had happened. No cycle length, no forecast, an empty
chart, and an offer to log a first period. An Apple Health user who
never found the one-tap button had the whole module dead.
A first bleeding day now opens the cycle it belongs to, through one
shared seam in the day-log write helper, so the single capture, the
bulk drain and the Apple Health fold all infer a start the same way and
the one-tap boundary's own write lands on a date that already has a
cycle, which the rule declines.
The rule is conservative, because a false start corrupts every length
derived after it. Spotting does not open a cycle: it is as often the
tail or the herald of a period as its first day. Bleeding flagged as
between periods does not, since that flag exists to say so. Neither does
a bleeding day within the hard physiological minimum of an existing
start, which is what keeps day three of a period, or a forgotten day
two, from starting a cycle of its own without needing a contiguity
check. A genuinely short cycle is not inferred from flow alone; the
one-tap start still opens one on any date.
An edit is symmetric. Setting a flow opens the cycle; clearing it, or
flagging it as between periods, removes the cycle that day opened and
re-opens the one before it. A cycle that an edit can create and not undo
leaves the record holding a period nothing on the day says happened.
- the period route's start arm and the flow inference now share one
boundary helper, so a start means the same thing however it is entered
- back-dating re-anchors both neighbours, as the one-tap start does
- integration: eleven cases through the real routes, of which five are
about what must NOT open a cycle
The page reads one window anchored on today, ninety days back and half a
year forward, and the month grid navigates freely past it. A month
outside the window came back with no days: the cells still rendered
(they come from the month, not the read) and still opened the log sheet,
and the write behind it still landed, because the period route is built
for back-dating and re-anchors the neighbouring cycles for it. What did
not happen is anything visible. Saving an April flow in August closed
the sheet onto a month the read never carried, so the entry looked like
it had not been made, and entering it again did not help.
The grid now reports the month it moved to and gets its own bounded read
for it. The anchored window is untouched, because every other surface on
the page means today and the route caps a span at 400 days, so a union
with a month two years back would simply be refused.
A write also says so now. Saving, deleting and both period boundaries
raise a confirmation; the sheet's inline strip already named a failure.
Between the invisible month and the silent success there was no way to
tell a save that worked from one that never happened.
The sheet header carries the date it is writing to, and the one-tap
boundaries drop the word today when the day is not today. It always
wrote to the selected date; saying otherwise made a correction read as a
mistake.
The gear beside the log button is gone. It was a second door to the tab
directly below it and no other page carries one in that corner.
- calendar days carry isCycleStart, so the delete dialog can say that
removing this day removes the period start with it
- window arithmetic is a pure module with its own tests, including the
400-day span the anchor cannot be widened past
- integration: a day 111 back is written, is absent from the anchored
read, and comes back complete on a window over its month
- new strings in all six languages
The cold-start gate suppresses the calendar's phase band until three
cycles are observed, because a population-framed phase word off one or
two of them is not earned. The verdict read the day count off those same
labels, so the gate took the count with it: a record holding three
logged period starts resolved INSUFFICIENT_DATA and rendered an empty
ring under an offer to log a first period.
The count is not the claim the gate is about. It is the number of days
since a start the person entered themselves. The verdict now takes that
start from the cycle rows and falls back to counting from it whenever
today carries no phase, with the same overdue ceiling and no phase
claim. INSUFFICIENT_DATA is left for the case that really is one: no
period start on record at or before today.
The sheet's day header was already written for this state and could
never reach it — it renders a bare day number when no phase is
trustworthy.
- verdict round trip covers the still-learning day count and its overdue
side through the real route
- unit tests pin the anchor path, the absent-from-grid path, and that a
future-dated start is not a cycle anyone is in
Opening a cycle closes the one before it: the start write stamps the
previous cycle's endDate and lengthDays, and a back-filled start takes
its own end from its successor. Nothing undid any of that. Deleting the
start left the previous cycle as a closed torso holding an end date from
a cycle that no longer existed, and a record whose last cycle is closed
has no open cycle to forecast from — the prediction went away and there
was no way to bring it back from the app.
Both removal paths now re-derive the boundary from the cycles that
remain: a cycle ends the day before the next one begins, and with no
next one it is open again. That is a re-derivation rather than an undo
log, so it is correct for a history that came from an import or a
restore, and running it twice changes nothing the second time.
Deleting the day-log that opened a cycle now removes that cycle too.
The one-tap start writes both rows and the log sheet's delete is the
only way the web offers to take it back; leaving the cycle standing was
how the broken state survived the undo.
- soft-delete and re-anchor run in one transaction
- the audit row names the neighbour's moved boundary, the only part of
the write the tombstone cannot reconstruct
- round trip through the real routes: photograph the cycle rows, log the
start, remove it either way, compare field by field — including that
the forecast comes back
The engine ended the open cycle's phase window at the predicted next
start, so the day after a missed forecast carried no phase at all and
the verdict resolver read that as today falling outside every known
cycle window. An ordinary three-days-late period therefore reported
INSUFFICIENT_DATA: no cycle day, no phase, an empty ring and the words
insufficient data, while the reminder cron was pushing a period-confirm
prompt for the same day. OVERDUE was only reachable when the engine
itself forecast a cycle longer than the grace window.
The open cycle now runs to the predicted start or through today,
whichever is later, and stops the moment the next start is logged. The
count is bounded on the far side where it already was: past the typical
length plus its grace window the verdict degrades to OVERDUE and says
how late the period is.
The extension pins the open cycle's ovulation anchor to the predicted
length. Left to the span, an unconfirmed ovulation is back-calculated
from the window's own length, so a window that grows with the delay
would walk the estimate and every phase boundary drawn from it.
- buildPhaseCycles, phaseForDate and buildPhaseDayMap take the user's
today; the insights route and the coach snapshot pass it
- verdict round-trip covers the predicted start day, three days past it,
past the grace window, and the case where a new start closes the run
The heart-rate card hid itself whenever the server resolved no curve.
That is right for a session with no heart rate at all, but wrong for the
common case it also covered: a ride logged from a chest strap through a
provider that reports an average and a peak and nothing in between. The
stats grid shows those two numbers, the card next to them renders
nothing, and the page reads as if it has no heart-rate section rather
than as if the shape behind the numbers is not on record.
Three states now. A curve when there is one; a named absence when the
session carries heart-rate figures without a profile; nothing at all when
nothing was ever measured, since there is no absence to report there and
an empty card would be a shell.
The min-to-max envelope shipped as a stacked pair: a transparent base at
the bucket minimum plus a `max - min` delta above it. Stacked series are
measured from zero — recharts derives a stacked axis domain from the
stack offsets, whose baseline is 0 — so a chart asking for `dataMin - 10`
resolved to -10 rather than to the bottom of the band. A session spent
between 120 and 170 bpm was drawn on a -10 to 180 axis and the shape of
the effort, which is the entire point of the curve, squashed into the top
quarter of the plot. It only happened on the dense sessions the envelope
gate lets through, which are the ones with the most shape to lose.
The band is now one range area over the bucket's own `[min, max]` pair.
No stack, so the domain stays on the bpm values that are in the data.
The axis domain is computed rather than inferred, because the session's
peak has to be inside it: recharts discards an out-of-range reference
line instead of clamping it, and a reported peak is a single reading
while the curve plots bucket means, so a peak marker over a data-derived
domain would go missing on most sessions that have one.
Adds that peak marker alongside the average one, both labelled, and reads
the envelope back in the tooltip as the spread it is. The mapping moves
into a pure module so the values the axis sees can be asserted without
mounting recharts.
`durationSec` is a denormalised duration, and not every source reports
the elapsed one. Strava sends `moving_time` while the row's `endedAt` is
`startedAt + elapsed_time`, so a ride with twenty minutes of stops
carries a duration twenty minutes shorter than its own span. The curve
folded against that duration, which dropped every reading after the
moving-time mark: the tail of the session vanished with no gap and no
marker, and the detail page showed a complete-looking profile of an
incomplete session.
Fold over the span instead, with the duration as the floor for rows
whose `endedAt` is not after their `startedAt`. The bucket width tracks
the span too, so a stop-heavy ride keeps the same on-screen density as a
continuous one of the same length.
The reconstruction path also gets an integration suite over the real
route and a real Postgres. Two boundaries decide whether it is honest
and neither was provable against a mocked client: the query pads by five
minutes so a boundary reading still lands in the first or last bucket,
and a reading inside that pad must not extend the curve past the finish;
the measurement read is owner-scoped, and another account's pulse at the
same minute must not enter this curve. Both fixtures place a reading on
the far side with a bpm value no in-window reading has, so a leak shows
up in the numbers rather than only in a count. Verified by breaking each
boundary in turn and watching the matching assertion fail.
pdfjs-dist 6.1.200 and a transitive 5.6.205 both sit in the affected range
of a flaw that lets a crafted PDF run JavaScript inside the renderer. The
document vault feeds it every uploaded PDF, so pin the direct dependency to
6.2.108 and add a bounded override so the transitive copy lands there too.
The override stops short of pdf-parse's 5.4.296, which predates the flaw and
drives the local text-extraction path.
The runtime image ships every pdfjs-dist copy in the store, not just the one
that gets loaded, so an unused transitive copy still counted.
Hoisting in the image picked whichever copy the filesystem listed first, and
more than one copy reaches it, so the version named in package.json was not
reliably the one the renderer resolved. Carry the resolved version forward
from the build stage, select it by name, and fail the build when it is
missing rather than leaving another copy in its place.
The manual sleep field is labelled in hours. The column stores minutes, because
that is what every sync source delivers and because a single stage segment is
only a few of them. The form sent the typed number straight through, so a night
entered as 7.5 was filed as seven and a half minutes and the list showed eight
minutes of sleep.
Nothing rejected it. Seven and a half minutes is inside the column's
plausibility band — a stage really can be that short — so the entry looked
accepted and the loss was silent.
Both ends were already right: the field says hours, the list formats minutes as
hours and minutes. The assembly between them was where the factor went missing,
which is why the round-trip test runs the whole of it — the form's conversion,
the shipped POST, the stored row, and the formatter the list paints with.
The storage unit stays minutes. The conversion sits at the entry boundary in
one place with its inverse beside it, so a future editing surface cannot land
on a different answer, and it rounds to whole minutes rather than storing
437.99999999999994 for 7.3 hours.
The value field also lost "7,5" outright. `type="number"` sanitises a decimal
comma away on the engines that do not localise the field, so the entry arrived
as an empty string with no explanation, and `parseFloat` would have read it as
7 on the engines where it survived. The field now takes the same shape the labs
and custom-metric fields use — a numeric keypad and a submit path that reads
the comma — so every decimal metric on the form gains it, not only sleep.
`scripts/diagnose-manual-sleep-hours.ts` lists the rows already stored this
way. It reports and does not repair: an eight-minute stage and a night typed as
eight hours are the same number in the same column, and only the person who
entered it knows which it was.
The hero line said a pulse was well above its 30-day average today. Nothing
had measured a pulse for five days. The day-signals card flagged the same
reading, and the baseline-drift card said the value was above the usual range,
present tense, on a day that held no reading at all.
One class, and it sits in the data preparation rather than in any of the
sentences. Each surface was handed "the freshest stored value" with no notion
of when it was taken, so each of them assumed now. The age was reachable — the
aggregates carry `newestDaysAgo` inside their coverage object — but it read as
a statistics footnote rather than as the fact that governs the tense of every
sentence about the metric, and none of the three consulted it.
A reading is material for a claim about today when it comes from today or from
yesterday. One day rather than zero: a vital taken at 23:40 and read at 00:10
is the same reading it was half an hour earlier. Two is not, because by then a
whole day has passed with nothing in it and the sentence would be describing a
gap.
The rule now lives beside the data. The vital deviations carry the age of the
reading they were computed from, and the two deterministic consumers apply it:
the day-signals flag counts only current vitals toward its count, and the
baseline-drift card surfaces only current deviations. Stale vitals stay in the
list with their age, so the anatomy view still shows them and nothing silently
disappears; the dated level shifts were never today claims and are untouched.
For the narrated surfaces the same fact is stamped onto every metric block of
the Coach snapshot before anything reads it, so the age travels with the
numbers into the hero, the briefing, the Coach reply and the retrieval tools
instead of each of them guessing. A block narrated off a stale series also
lands in the wide event, which is where it should have been visible all along.
The baseline-drift card reported a pulse in the hundreds of millions against a
usual range in the tens of thousands. Neither figure is a heart rate, and both
came from the same place.
The application declares a plausibility domain per metric, and every
interactive, import, MCP and Telegram write validates against it. The
derivation layer did not ask. So a single stored value outside that domain — a
provider unit-decode slip, or a row from a writer that never had the gate — was
folded into the day mean, carried by the median into the band, and then held
against itself as "currently". The two numbers in the sentence were wrong by
different amounts, which is why the line read as nonsense rather than as an
exaggeration.
Both readers now refuse a value the application has already declared
impossible: the per-day means behind the band, and the latest-day mean the
deviation reads. A day left with no plausible reading contributes nothing
rather than a fabricated point, and the vital drops out of the comparison — the
coverage floor already knows how to report an absent day. The rollup branch
applies the same rule to a bucket mean, since the rollup writer averages
whatever is stored. Everything downstream of `readDayMeanSeries` inherits it,
which is every consumer of the usual range: the band strip, the drift card, the
changepoint detector, readiness, trajectory, the coach read.
The card also printed raw doubles. Each figure now renders at the metric's own
decimal precision through the shared formatter, so a mean reads as a number
rather than as a float and follows the reader's number convention.
Left open, and recorded for a separate pass: the provider sync writers do not
enforce the domain on the way in, so the impossible rows keep landing.
Marking a missed dose taken from the Verlauf ledger showed the row flip to
taken and then fall straight back to missed, with the entry nowhere on the day
it was made for.
The ledger posts the displayed slot's own anchor as `scheduledFor` and means
"record THIS dose". The route resolved the slot by window band from `takenAt`
instead, which answers a different question: which slot the recording moment
falls into. Backfilling an older slot matches no band, so the write fell
through to the standalone insert and landed as an ad-hoc row anchored at the
recording moment. The named slot stayed unserved, the optimistic row was
overwritten by the authoritative refetch, and the dose sat under today with no
sign of where the user had put it.
A named instant that is a real slot of this medication now decides the binding,
validated against the same band anchors the "diesem Slot zuordnen" pin uses, so
an arbitrary client instant still records ad-hoc. A slot that lies ahead of the
take is still refused by the existing forward guard, so a late-morning dose
cannot consume the evening slot. When the naming and the clock disagree the
binding rests on the user's choice, so the row stores it as such and the read
ledger anchors it there rather than dropping back to ad-hoc; an ordinary
on-time take resolves to the same anchor either way and is untouched.
The tests drive the shipped POST and the shipped dose-history GET, and assert
the persisted row and the ledger read back from it.
Maximizing the Coach drawer hands the conversation to /coach and carries
?doc= with it, so browser-Back can rebuild the sheet. That is the one
close-shaped transition whose whole point is to LEAVE the selection where
it is, and the close path has always returned early for it.
The dropped-traversal check added beside that close did not know. It saw
a closed sheet and a selection still in the address bar, which is exactly
the state it exists to repair, and it repaired away the return URL a
quarter of a second after the close had deliberately preserved it.
Whether it won that race was a question about how fast the machine
navigated, so it surfaced as one flaky run on the narrow viewport rather
than as a broken feature.
Both sides read one named predicate now, and the check is skipped when
the close kept the selection on purpose. Proved by driving the grace
window to zero, which is the worst case the timer can have: before, the
maximize never even reached /coach, three attempts out of three; after,
it passes there and the whole documents family passes with it. Twenty
consecutive runs of that family on the narrow viewport are clean.
The service worker writes its data-cache entry after the response has
been handed to the page, so a shell that is ready is not evidence that
the write has landed. Reading the cache once therefore sampled null on a
loaded machine, and the positive control failed saying there was no entry
about a cache that was about to have one.
Polled on the entry itself. It waits for the signal rather than for a
number of milliseconds, and it still fails if the entry never appears,
which is the whole reason the control is there.
The scope mirror is watched so a switch this browser did not begin still
holds every shell until /me reconciles it: another tab on an older
bundle, the referential action behind an account deletion, an operator.
It recognised its own by comparing the mirrored scope against the scope
it was switching to, and that is not a true test. fetchMe mirrors
whatever record the server currently reports and it runs DURING a switch,
both when postSwitchWithReconcile reconciles a lost compare-and-set and
when a second tab reloads beside the initiator. At that moment the server
still reports the record being left, so the write did not match and was
read as somebody else's switch.
What followed was silent. A foreign write publishes a new transition id,
and every terminal call opens by checking the id it was handed, so the
commit that followed was dropped without a trace and the expected scope
was left naming a record no /me would ever confirm. Nothing could release
the hold, and both tabs sat behind the hydration gate until the stored
transition aged out thirty seconds later.
That is the intermittent browser failure that has been carried through
three rounds as a canary and never explained: a peer reload racing a
switch, a gate that never lifts, and a run forty-six seconds long made of
a fifteen-second wait and a thirty-second one. On the selection that
provokes it, eight runs failed three times before and twenty failed none
after, and the runs that used to take fifty-seven seconds now take
twenty-five.
A write is foreign only when nothing of this browser's own is in flight.
Ignoring it while held costs nothing, because a held browser is already
in the state that branch would install. The rule is a named function so
it can be checked in a suite that has no DOM, and the property the guard
exists for is pinned beside it: a response that disagrees with a
committed scope is stale and still must not release.
Both failed on CI and neither failure was about the product.
The loading-state scan held /api/auth/me open with a fixed sleep in the
route handler. Three things follow from that and all three are wrong: the
hold ends 1500 ms after the first request whatever the axe scan is doing,
so the state being scanned is not the state the test names; every refetch
queues another sleeping handler, so how many are live is a property of
the machine; and a handler still sleeping when its request is cancelled
answers route.continue with "Route is already handled", which is what the
runner reported. It now holds on an explicit gate released at a point the
test chooses, counts what it held so a page that was merely slow cannot
pass for a page it held, and asserts the gate goes away on release, which
is the exit control the entry assertion never had.
The labs doorway proved that no OCR probe fires by waiting for that
request with a 500 ms timeout and expecting the rejection. An instrument
whose pass condition is its own failure has to be armed early and awaited
late, and in between it is an unhandled rejection that lands on whichever
test is finishing. Its window was wrong too: the timer started before the
switch was clicked, so on a slow runner it could expire before the labs
page existed. A request listener replaces it, covering everything from
before the click to after the record read landed, with the domain's own
read asserted in the same set so an absence cannot be a statement about a
matcher that stopped matching.
Closing a card-opened document asks the browser to go back, so the entry
the selection pushed is consumed and the reader's next Back reaches the
page before it. A traversal is a request, not a guarantee: one asked for
while another is still settling can be dropped, and nothing raises when
it is. The sheet closes, React stays consistent, and the address bar goes
on naming a document nobody has open, so a reload or a shared link
re-opens the sheet that was just closed.
Pressing Escape straight after the browser's Forward button is exactly
that shape, and it reproduces: ten repeats across both viewports failed
two of twenty with the URL still carrying the id, and none of twenty once
the close verifies itself. The check is guarded by the document id, so a
traversal that landed on a different selection is left alone, and it is
cancelled the moment the sheet re-opens.
The browser test that found it could not say so. It asserted the sheet
was closed through a locator matched on the document TITLE, and the sheet
falls back to an untitled label while its detail read is pending, so the
assertion also passed on an open sheet showing a skeleton, resolved
immediately instead of waiting, and pushed the failure into a URL poll
ten seconds later. It now asserts the dialog role, which is what the
neighbouring deep-link test already does.
The dead-code gate has been red on this branch since the sharing refusal
contracts landed. Neither name is new and neither was noticed, because
knip is not in the local gate.
consentRequiredResponse lost its last caller when every delegable route
started sharing one 403 description through recordRefusal(); the AI
consent sentence it wrapped is still used and stays, with a note at the
constant saying why there is no response object beside it any more.
mutateThenRestore was written with the sharing security fixtures and has
never been called.
No ignore entries: an export with no consumer is the thing the gate is
for.
The operator page still said every grant is read-only, one release after
the write level shipped, and the table of what a grant cannot reach
predates both the manage level and managed profiles.
Rewritten against the frozen behaviour: the eight sections a grant can be
narrowed to and what each opens, the three levels and what each adds, the
step-up that is unconditional on a managed profile and conditional on an
ordinary invitation, and what a guardian may keep for a record that has no
login of its own. The AI row is corrected rather than dropped: a manage
grant reads insights that already exist, and no level causes an outbound
provider call.
Every claim on that page was read out of the code before it was written
down. The one about backups was cut instead, because it was not.
Zod 4's .meta() is immutable: it returns a new schema carrying the
metadata and registers that one. Four request bodies in the sharing
family called it as a statement and dropped the result, so the route
table went on referencing the bare schema, the bodies were emitted
inline and anonymous, and the object-level descriptions never reached
docs/api/openapi.yaml. One of them carries the clause that scopes every
rule on the invitation endpoint to an invited grant, which is the
sentence that stops a reader carrying the step-up and the refusals
across to a guardian grant.
Bind each to a const, reference the const, and correct the level
boundary in the invitation's description while it is being published:
it still carried the blanket denial that stopped being true when the
anamnesis surface opened to a manage delegate.
The wire format does not move. Four named components appear and the four
bodies point at them; the fields, the types and the requiredness are
what they were. A new guard asserts every id this route table declares
reaches the built document, because the failing form looks exactly like
the working one.
The shared-record strip was forced by capturing /api/auth/me and serving
it back with the sharing block filled. That state is now proved across
the payload validator, the fence store and the transition machine at
once, so a single route mock cannot produce it and the two narrow
repairs both stop one step short of the shell.
So the spec stops faking it: its own login jar, a real switch into the
seeded whole-record READ grant, and a place on the list of files that
run in exactly one project, because a second project would drive the
same session row from two browsers. The viewport below md that project
used to cover is measured inside the desktop project instead, where the
shell picks its column layout from the same width-driven rules.
Seven migrations, 0296 through 0302, apply to an empty database in order and
install both triggers. The allocation had been written down as four, and every
later document that quoted it inherited a range that had already moved.
`scripts/verify-v137-adversarial.ts` is the allocation as a runnable artifact
rather than a table somebody keeps in step by hand. It names all seven, refuses
a number allocated twice, asserts each trigger is in the migration that claims
it, and counts every release inventory against a written-down number instead of
against "more than zero" — a list that lost half its entries satisfies a
non-empty check and fails this one. It also refuses to report success when it
ran no checks at all.
It found two things on its first run. Eleven handler entries looked like
duplicates because the key ignored the action, and an `[id]` route carries an
update and a delete as separate contracts; the key includes the action now. The
journey-count leg was then falsified deliberately by expecting nine, which
failed on the count and exited non-zero.
The `push_attempts` principals are declared optional in the schema and NOT NULL
in the database, and that is deliberate rather than drift. The attribution guard
is a BEFORE INSERT trigger, so a write omitting both has them filled from the
legacy mirror before the constraint is checked; requiring them in the schema
would force every established self-delivery writer to name a pair it has no
opinion about, and relaxing the columns would let a partial pair reach the
table, which is what the trigger refuses. `migrate diff` reports the difference
because it cannot see triggers, so the reason is written at the columns and
names the test that proves the accommodation still works.
Measured rather than assumed: applying trunk's chain to its own empty database
reports the same classes of diff — truncated index names, timestamp precision,
foreign keys re-stated. The release adds eight lines to that, seven of them in
classes trunk already carries and one of them the deliberate pair above.
The eight journeys were enumerated and one of them was wired. The other
seven existed as claims in a fixture file, which is the gap the enumeration
was written to close: a browser suite reports what it ran, never what it did
not, and a journey nobody wired reads exactly like a journey that passed.
All eight are wired now, and the list is checked in both directions — an
enumerated journey missing from the wiring fails, not just a wiring entry
naming a journey nobody declared. Before this, a subset was legal, so a
dropped journey and a not-yet-written one looked the same.
A journey names more than one file where more than one file carries it. J1
claims a grant runs through invite, accept, switch, expiry, revoke and a
direct URL; the browser proves the switch, what each level opens and the
direct-URL refusal, and the transitions either side are database facts
proved against real Postgres. Folding that into one file would mean a
browser spec driving expiry through the clock or an integration file
asserting what a page renders, and both are worse tests than the two that
exist.
Three journeys ride Playwright and five ride Vitest, so a green run of this
guard means all eight are claimed and here is which have executed in the
ordinary gate — never that all eight passed. The runner is derived from the
path rather than trusted, so it cannot become a label set to whichever value
made the split read better.
The extractor now reads `it(` as well as `test(`, and the parameterised
`it.each(table)("title")` form alongside the single-call one. It saw neither
before: five of the journeys are proved by Vitest files, every per-file leg
is over a matched set, and an empty match set satisfied them all. The
synthetic spec that proves the parse grew a Vitest half, and it is what
caught the parameterised form being invisible. What the matcher still cannot
see is written into its own doc comment.
The cross-midnight test read `push_attempts` for a synthetic `DEDUP`
channel. Separating the scheduler's bookkeeping from delivery diagnostics
moved the anchor to `notification_events`, and nothing has written that
channel since. The query matched no rows, `findMany` answered the empty
list, and the assertion compared an empty set it had no reason to expect —
a check that could not fail whatever the tick did.
It now reads `notification_events` by record and event type, and asserts
the single row's `dedupKey`. Removing the claim from the tick fails the
dispatch-count leg above it; narrowing the key so it no longer names the
slot fails this leg on the key's own content, which is what makes it a
check rather than a comment.
Four cases in the accountAccess integration test were written against a
contract this release rejected. They asserted an entry of seven fields and a
block of three, both of which gained one; and they asserted that a MANAGE
grant publishes `access: "manage"`.
It does not, by design. `access` is the v1.36.0 name and its union is
`read | write` — a client compiled against that release decodes it as a
closed set. MANAGE therefore publishes as `write` there and as `manage` on
`level`, and the entry schema raises a validation issue unless a manage
entry carries exactly legacy write access and whole scope. The expectations
move; the schema does not.
`recordKind` is required on an entry and on the block. Both are asserted
now, including the block-level value moving from `self` to `shared` across
a switch, so a value stuck on either reads as two cases disagreeing.
Nothing to reconcile in the tree: main's dashboard-hydration fix is already
on this branch as an identical patch, and main's delegated-dose replay guard
predates the branch point. The merge records the ancestry so the release
branch cannot be read as behind trunk.
playwright.config.ts is the one file both lines touched. Main added the second
web server on 3100 that runs the shipped DASHBOARD_SSR_PREFETCH configuration;
this branch added the service-worker project and the per-spec project
exclusions that keep every session-moving spec in exactly one project. The
merged file carries both, and the only difference against main is those 49
added lines.
The floor was described on the one route that cannot reach it and left unsaid
on the two that answer it every time: withdrawing the last Guardian grant and
renouncing it both raise `managed_profile.guardian.required`, and both
published only 'that access had already ended'. Both 409s now name their two
codes, and the annotation on the removal route enumerates all four emitters
rather than three.
A guard closes the class rather than the instance: every route module that
raises the refusal must publish it on its own operation, with the emitters
discovered from the sources.
The freeze guard gains its containment leg — a module becomes delegable by
review, not by discovery, so every file contributing a fenced verb has to be a
member of one of the surface guard's frozen allowlists.
The lifecycle suite took the invitation route's answer as an account-grant row
and reached for `id` and `expiresAt`. It answers with the roster's entry now,
so the id arrives as `grantId` and the expiry is read off the stored column,
which proves it was persisted rather than echoed.
Freezes the authority split the third level ships with. A token exercises a
MANAGE grant, and a Guardian's authority over a managed profile, exactly as a
browser does — proved at the resolver, where neither had a Bearer case before.
A token cannot offer MANAGE and cannot create a managed profile at all, because
both gates resolve through the session cookie; the second had no test until now,
and it runs against a real database with a wildcard token to make the width of
the permission irrelevant.
The claim set is held together by a map naming, per claim, the file and the test
title that proves it, so removing any one of them fails rather than quietly
narrowing what a native client may do.
The fence's client-facing half is frozen beside it: the account payload publishes
the record context on a cookie session and null on a token, and the published
contract says so under the same header names the server matches on.
Runs the legacy whole-record decoder over the entries `resolveAccountAccess`
actually produces, rather than over the fixture objects that describe them: the
existing case proves both ends and never the assembly between them, which is the
shape of test this project keeps rediscovering. Whole READ and WRITE decode,
MANAGE decodes as write and never above the level it confers, a scoped grant is
refused rather than read as the whole record, and a stored scope the server
cannot parse reads as nothing on both ends.
The canonical readers are driven over the same server-built entry, so
`level`, `sections` and `recordKind` are proved to survive the trip to the
capability resolver. The account-payload guard gains the pair for
`recordSession`: a field whose only reader is the transport type is exactly
what that guard exists to catch.
Every route that resolves a record through the sharing fence can answer 403
`sharing.access.denied`, and until now that fact was published on the selector
header and on none of the 120 operations that produce it. All 120 carry it now,
byte for byte, from one constant: the refusal is deliberately indistinguishable
across a missing account, a missing grant, a scope that does not reach the
surface and a read grant on a write, so 120 paraphrases would invite a client to
tell apart what the server refuses to.
Where a delegable route already answered 403 for its own reason — a module
gate, the cycle gate, AI consent — the two share one description rather than one
of them going unpublished, since OpenAPI allows a single response per status.
The guard recomputes the delegable set from the route sources on every run and
classifies per verb, which matters: seventeen modules have a delegable read
beside a write that refuses a switch outright.
The contract already pointed at them: the Guardian roster names
`DELETE /api/managed-profiles/{id}/guardians/{grantId}` as the route its
`grantId` is for, and that route was not in the spec. Creation, deletion,
invitation and removal now are, with the two bodies moved to
`src/lib/validations` so the wire shape has one source.
Three things are stated rather than left to be discovered:
- the two refusals on the invitation that carry no `meta.errorCode`, and the
order a client must resolve in because of them
- the last-Guardian 409 on the removal route, which nothing can elicit through
that route today, published with the reason
- that the sentences on the ordinary invitation describe an invited grant only,
so a client cannot carry its MANAGE rules across to a Guardian grant
- publish the roster's entry from the invitation and `{ grantId, revokedAt }`
from the removal, instead of the account-grant row Prisma returned
- drop the invitation's self refusal: it named a code no request can elicit,
because a Guardian grant's grantor is the profile and never the caller
- name the duplicate grant error rather than catching every grant refusal into
a sentence that would be wrong for any other one
- keep the rate limit and the unknown-identifier 404 free of an error code,
as their siblings elsewhere are, and say why at the call site
- bound profile creation at ten an hour per account, the ceiling its
guardian sibling has carried since it shipped
- distinguish a failed roster read from a slow one, with the way back
- cover the three caller states the roster read has to refuse: an
unaccepted invitation, an expired grant, and MANAGE held elsewhere
- cover what deletion does to a guardian standing inside the record
recordKindLabel chooses between two sentences that mean different things
and nothing could fail it: the journey asserted the data attribute, which
that function never reads, so inverting the arms left the suite green
while a guardian standing inside a managed profile was told it was an
ordinary shared record.
The pending-acceptance note renders unconditionally inside the invite
form, so asserting it after the invitation proved a string existed rather
than anything the journey did. The claim moves onto the pending row,
which the server has to produce. The note keeps its own leg in the unit
suite, where deleting the node makes it red.
- deleting a profile and ending a guardian's access resolve fresh MFA,
and both sit behind a confirmation, so the request that reaches the
server is minutes old and routinely outside the five-minute window
- an account with no second factor is told where to set one up instead
of being asked to confirm one it does not have
- the arm is scoped to those two acts: a 401 on accept, revoke or
renounce is a lapsed session and must not read as a factor prompt
- a refused field gets its own announced node in content contrast; the
standing hint stays muted beside it, rather than being overwritten
- the timezone refusal is the only explanation for a submit button that
will not press, so it leaves the picker's muted hint slot
- drive creation, the switcher entry, the managed banner and the return
trip in a real browser, plus the guardian roster and the floor stated
before an invitation is accepted
- seed a guardian account with a second factor and refresh its step-up
before the journey acts, since no fixture could reach a step-up-gated
route before this
- tie the journey id to its test titles, so a marker that goes missing
fails a check rather than vanishing from a run nobody counted
- give the delegated-action inventory a leg that can falsify its level
axis: the walked module set must strictly exceed the manage-only one,
anchored on a route that never asks for manage
- declare AUDIT_LOG_RETENTION_DAYS and SESSION_COOKIE_SECURE in the env
manifest, so the pre-deploy check reports the two settings operators
are told to set, and hold manifest, compose whitelist and both example
files in step
- list who looks after a profile, invite another guardian and end an
existing access from the profile's own row
- state the last-guardian floor from the real roster before the act, and
say plainly that an invitation counts only once it is accepted
- give each of the invitation refusals its own sentence, branching on
status for the two the route sends without an error code
- cover every call the card can make against the real routes
- confirm before deleting, naming what goes and that it cannot be undone
- state how many people lose the record, taken from the guardian roster
rather than assumed to be one
- say that deleting is the way out when you are the last guardian, since
this route sends no last-guardian refusal
- announce a refused deletion on the profile's own row, retryable
- add GET /api/managed-profiles/{id}/guardians, cookie-only, refusing
anybody who is not an active guardian of that profile with the same 404
an unknown id gets
- disclose the grant id, the party block already shared between grant
parties, the derived state and the two timestamps, and nothing else
- publish the route in the OpenAPI contract
- add a managed-profile card to Settings -> Shared access, between the
invitation card and the standing-state cards
- collect exactly the four fields the create route accepts, with the date
of birth genuinely optional and never derived from a year
- refresh the account payload on success, so the new record reaches the
switcher and the banner without a reload
- assert the section is reachable for an owner and refused for a Guardian
inside a profile and for an adult manager
The privacy dashboard re-implemented the audit-retention resolver with its own
default and its own floor. Two implementations of one disclosure, quoted to the
same person on two pages and by the purge job; it uses the shared resolver now.
The record-content branch in the settings gate matched a classification rather
than a page, so a second manage-writable destination would have rendered the
allergy and family-history managers under its own heading without anybody
choosing that. It resolves through a slug map, and the contract test fails if
the map and the classification disagree in either direction — a destination
cannot join the kind without somebody deciding what it renders.
The managed-profile nav filter assumed a guardian grant is always manage. It is
today, which is why the check was easy to leave out; an entry below that level
would have listed destinations the gate refuses.
Interpolation used a replacement string, and String.replace reads $&, $`, $',
$1 and $$ out of one. A display name containing $' rewrote the sentence around
it — and display names land in exactly the sentences that say who did what in
somebody's health record. A replacer function has no $ grammar at all, which
removes the class rather than escaping the members of it somebody thought of.
Two sentences promised that a manage grant reaches no settings at all. Both
were true when they were written and stopped being true when the anamnesis
destination opened, and both are in the worst places for a stale promise: one
is the access description generated into the public contract and read by the
native client, the other is the paragraph a delegate meets on a page sharing
does not cover.
Both now name the boundary the code draws — record content versus account
configuration — and say which part of the record a manage grant reaches. What
stays with the account owner is spelled out rather than implied: login, the
second factor, provider connections, tokens, notification routing, module and
threshold configuration, and who else has access.
A guard checks the copy against the classification rather than against itself,
so opening a second destination or closing this one moves the sentence with it.
The contract is regenerated.
The revoke dialog tells an owner that "who entered what" stays answerable for
N days, and the activity list prints the same number under its heading. The
query behind that list had no date filter: on an instance whose retention was
shortened it showed rows the sentence said were already gone, and the number
was a caption over an unbounded read rather than its limit. It is the filter
now, resolved once so the bound and the sentence cannot drift apart.
The list also stopped at a hundred rows and said nothing about it, which is the
more dangerous of the two silences — an owner who scrolls to the bottom reads
the oldest visible line as the beginning, and a busy household reaches the cap
in a fraction of the window. The response says which of the two limits it hit,
and the view states it only when it did: a caveat that is always on is one
people learn to skip.
The self-hosting page said an empty list means nobody opened the record. It
means nobody did so within the window. Corrected, with the row cap named
alongside it.
AUDIT_LOG_RETENTION_DAYS is not only enforced, it is published: the
shared-access panel prints the window under the activity list, and the dialog
that ends somebody else's access states how long "who entered what" stays
answerable. Both read the value the server resolved. It was missing from the
compose environment: whitelist and from both example files, so an operator who
set 90 got 365 told to every user, in a sentence about their own data.
That block is a whitelist and Compose substitutes from .env regardless, which
is what makes the variable look configured while never arriving — the same
shape as the session-cookie flag in v1.5.2. A guard now covers the three places
an operator meets such a variable, and its control leg found that the cookie
flag itself is still absent from the production example. Documented there too.
Every delegable route files a breadcrumb when a request does not parse, and it
files it through auditLog on purpose: that helper is what stamps the actor, and
without the stamp a delegate's malformed request would read as the owner's own.
The rows are meant to be attributable. None of them had a sentence, so all of
them rendered in the owner's activity view as "made a change in your record" —
including one a view-only delegate can trigger by sending a query the route
cannot parse. Nothing had changed and nothing could have.
Thirty-three actions now have honest lines. A refusal says it was an attempt
and says it was refused; it never borrows the language of something that
happened. Two of them are filed by library modules the routes import — a
background import's completion row and a medication lifecycle reconciliation —
and a walk over route files alone could not see either.
The inventory guard was asking a different question from the feed. It kept only
awaited audit calls while the feed shows fire-and-forget rows identically, and
it read route files while the feed shows whatever the request reached. Both
criteria now match the feed's, the three rows the feed structurally cannot show
are excluded by name with the reason written down, and the exclusion list is
pinned at three. A leg asserts the shape of the sentence for every refusal, so
a future arm cannot map an attempt onto an ordinary verb and pass on coverage.
The whole round trip is covered against real Postgres: a view-only delegate
sends a malformed query, the row lands attributed to them under the owner, and
the sentence the owner reads is an attempt.
POST /api/allergies and POST /api/family-history have resolved
requireRecordAuth("manage", "profile") since the level shipped, while the only
form in the product that posts to either sat behind a Settings destination
classified unavailable. The write was admitted, audited and owner-scoped, and
nobody could reach it — the one-ended change this repository keeps
rediscovering, with the permission shipped and the surface left as a follow-up.
The destination now carries a classification that means what the routes mean:
reachable by everybody they already admit, which is a guardian inside the
profile they administer and an adult delegate holding manage. The four places
that decided visibility on the guardian-only literal were each updated
deliberately, and the shell that refused every settings path in an adult share
now admits exactly the destinations classified as record content. Record
configuration — modules, thresholds, notification routing — stays with the
owner at every level.
The page inside a record is its own component holding the two managers whose
routes answer for a delegate. Mounting the personal section would have put
three more forms on screen that refuse on submit. The family-history card says
whose statement the row is, which the route comment could not.
Both routes carried a long paragraph arguing the write was deliberately
unreachable. It is gone rather than left to send the next reader back to close
what this opens.
The integration coverage moves from the undeclared-mode refusal to a manage
contract: a manage delegate and a guardian each write and the row lands under
the owner with the delegate named as actor, while read and write delegates get
the insufficient-access refusal. Nothing exercised either route at manage
before.
The shared-record accessibility suite had four tests for five states: success
and empty shared one, and neither of them ever entered an empty state. A
browser run reports what it ran and never what it did not, so a state nobody
wrote a test for produced exactly the same output as a state that passed.
The five states are enumerated in a fixture, each test names its state, and a
fast guard reads the spec back against the list — a state that loses its test,
its anchor, or one of its proofs now fails in the ordinary gate rather than at
the point somebody notices the spinner is silent.
Every state runs four proofs: an axe scan, the document reading order, either
a keyboard reach with a visible focus indicator or a live-region announcement
for the one state with nothing to press, and, where the copy is something a
person has to act on, that it renders as content rather than as muted meta and
clears the contrast floor. The retry control on the read-failure card gained a
stable slot so the keyboard proof targets the affordance rather than its
wording.
The eight release journeys are enumerated in the same fixture.
Accepting, revoking and handing back a grant had no failure rendering at all.
The request came back refused, or never left the machine, and the panel did
nothing: the confirm dialog had already closed, the list did not change, and
the only signal was the absence of one. The two refusals that most need a
reader were the two that arrived most quietly — an invitation that had lapsed
before it was opened, and the last guardian being told they may not hand back
a profile that would then have nobody looking after it.
Each refusal now renders on the row it belongs to, announced, in content
contrast, beside a control that runs the same act again. The sentences are
split by what the person does next rather than by status code, so lapsed and
withdrawn read differently and the guardian refusal names the way out. A
request that never reached the server says so instead of reporting a refusal
nobody issued.
The failed state is read off the mutation rather than copied into local state
from onError: the copy is what goes stale, and the mutation already knows
which row it was for. The invitation form keeps every field it had, and only
a successful send clears them.
Every action a manage-level route files now renders as its own sentence in
the owner's activity view, in all six locales. A write delegate can only add,
so the generic "made a change" was at least the right shape of fact; a manager
changes and removes entries the owner made themselves, and the generic line
withholds the only part that matters — whether a reading was corrected or
deleted, whether a dose history was purged.
The inventory reads the routes and calls the map rather than matching `case`
in its source, so an action that falls through to the fallback fails, and it
follows each resolved key into all six bundles. Every sentence interpolates
the actor's name and nothing else: the audit table is not encrypted and a
value in a verb line would be a health figure in a second store.
The revoke dialog's retention disclosure no longer depends on which of two
requests answers first. `retentionDays` rides the grant list, so a client
holding a row to revoke holds the window it must state, and the branch that
omitted the sentence is gone.
- document srcSet, poster, object data, form action, window.open,
sendBeacon, CSS url() and spread props as shapes this matcher cannot
see, alongside the two trees it walks
- match the imperative `.src =` form, anchored to a real occurrence so
the new leg cannot pass by matching nothing
The detail sheet loaded the original twice on open — the inline preview
and the download href each fetched and decrypted the whole blob — and
for a non-inline document the old href had cost nothing until clicked.
- one loader, lifted to the sheet, and only for a document that renders
inline; everything else fetches at click time
- make the download a real disabled control that reflects a failed
fetch: href="#" with aria-disabled announces unavailable and still
activates, and it ignored the failure entirely
Removing the Vary block left the disk-layer case green: it asserted what
is IN the cache, and the claim is what the cache will SERVE. On the
external-switch path nothing wipes those entries — the app never
initiated the switch — so Vary is the only thing between a cached
delegated response and the next request under another context.
- assert cache.match() hits under the stored context and misses under
the new one; it fails when the Vary is removed
- correct the acceptance map and the client docblock to claim exactly
what executes: no zero-entry count, no offline leg
The suite only passed on one worker: four specs shared the scope
delegate's cookie jar and moved the same session's record selector, so
at CI's two workers they moved the epoch under each other and the
failures were the harness racing itself.
- one login per switching spec, which is a separate session row and
therefore a separate selector; the login bucket is cleared between
them because the ceiling is five and setup now signs in eight times
- each such spec runs in exactly one project, since a second project
drives the same row from two browsers
- clear what the browser suite WRITES, so it can be run twice: the
write journey saves a minute-rounded reading and collided with itself
- wait for adoption after leaving a record, retry a lost switch the way
a person would, and budget the cross-tab hold for machine load
Every one of the three observed nothing and passed anyway.
- AC-04 held /api/auth/me so the record read goes first: /me is unfenced
and adoptable, so the client silently re-adopted before ever sending a
stale assertion
- AC-06 installs its intercept before the request and holds a FENCED
read; /api/auth/me is an actor surface and echoes no context at all
- AC-07 moves to its own spec and a project with the service worker
enabled, and reads the claim off the cache: with workers blocked the
data cache is never populated and its positive control could not pass
- restore the shared session after each test, and start from a known
record, so these cannot fail the specs that run beside them
data-slot rides {...rest} onto DateField's hidden ISO input, so the
invitation spec asserted visibility on an element that can never be
visible and typed into nothing. Use the primitive's data-testid, which
lands on the overlay a person edits, and assert the mirror took the
value.
The batch path had no unique-violation handling at all, so one duplicate
anywhere in the transaction answered 500 — indistinguishable from a
server fault, and what every client retries against. The single path
narrowed with instanceof, the form prisma-errors.ts exists to replace.
A shared cache is one reverse proxy away, and without Vary the first one
to appear would key a delegated record response on URL alone and hand it
to the next request under a different context. Appends rather than
replaces, so a route that already varies keeps what it had.
- the idempotency arm refuses only a wrongly-asserted context; an admin
route behind unfenced requireAdmin still serves a headerless request
- the epoch trigger does not fire on a statement that names record_epoch
without moving the selector; the source guard is what covers that
- keep the record entries until /api/auth/me has answered: clearing the
cache under a mounted observer made the held tab refetch at once
- exclude the account cell from the cancellation, and swallow a repeat
trigger, so a burst of 409s cannot kill the request that ends the hold
- read the account payload network-only; the default staleTime would
have returned the very context the browser was told is wrong
A selector pointed at a record whose grant has lapsed reports the same
disagreement on every /api/auth/me, so routing it to the hydration gate
rendered a permanent spinner with no controls. Expiry reaches this state
on its own: revocation clears the selector inside its transaction and
expiry does not.
- treat an unprovable context as a refusal, which paints the door with
the button back to one's own record
- check the refusal ahead of the hydration gate, so neither trigger can
render behind a spinner again
A browser-issued <img src>, <iframe src> or <a href download> cannot
carry the record-session assertion, so the fence refused all three on
any session that had been inside a shared record — permanently, since
none of them will ever send a header.
- add a structural guard: no client file may name a record-fenced route
in a src=, href= or window.location sink
- fetch the thumbnail, the inline preview and the download through the
app transport and hand the DOM an object URL, revoked on unmount
- make the two API probes in the managed-profiles browser spec assert
the same context the app beside them asserts
- give every acceptance case a stable id and assert the mapping from a
runnable artifact, recording which cases execute in the ordinary gate
and which await the browser stage
- add the browser spec for the cases no cooperative layer can reach:
peers reconciling to an exact context, an aborted switch response, a
closed initiator, a delayed response, and the offline disk layers
- attach the asserted context to every same-origin request inside the
three fetch entries, so no call site classifies itself
- discard a response whose echoed context contradicts the adopted one;
serve one that carries no echo, which is what keeps the public poll
and every actor surface working
- adopt from /api/auth/me and the switch response, and from nothing else
- send the epoch on every switch and retry once after reconciling
- hold and reconcile on the 409 instead of leaving the record
- evaluate the fence in the wrapper above findCached, so a stale request
neither replays a body, learns a cell exists, nor inserts a claim
- return the refusal rather than throwing: one route composes the
wrapper outside apiHandler, where a throw escapes the envelope
- read the claim and the session's own context on one session lookup
- both record resolvers check the fence after authentication and before
the carrier, off the one session row that also decides the scope
- POST /api/account/switch takes an optional expectedEpoch and writes
conditionally, so concurrent switchers resolve to one outcome
- GET /api/auth/me publishes the context a browser bootstraps from
- every response that resolved a record scope echoes it back
- regenerate the API contract
- add an import-free contract module both bundles can name, holding the
header names, the parse and the verdict
- add the server fence: header read, wide-event stamp, and the refusal
- add RecordSessionChangedError as a third sibling on the existing
sharing-error arm, so the 409 needs no new serialisation branch
- add sessions.record_epoch, owned by a BEFORE UPDATE trigger so the
ON DELETE SET NULL path and raw SQL writes cannot skip it
- normalise pre-existing rows so epoch zero means never switched
- project the epoch off the row getSession already loads
`/` runs its reads server-side and hands them to TanStack through a
HydrationBoundary, so the first HTML already carries real tiles. React threw
that away on every cold load. The app shell hydrates first and its mount
answers `/api/auth/me`; the dashboard sits below `loading.tsx`, hydrates later,
and its first client render therefore read an account the server never had.
That disagreed with the streamed HTML, React reported #418 and re-rendered the
whole page. The prefetch paid for its queries and delivered none of its paint.
The account payload is now withheld from the hydration render wherever it is
only rendered. `useAccountOnceMounted()` answers null until the component has
mounted, which is what the server rendered, and hands the payload over on the
next render. The dashboard page, the caregiver card, the getting-started
checklist, the achievements tile and the unit labels read it that way. `useAuth`
itself still reads the cache, because the document vault redirects off
`user.modules` and a first render claiming "no account" bounces it to `/`.
Two tiles disagreed without any account involved. A query reports `isLoading`
true on the client's first render and false on the server, so both now gate on
whether the cell holds an answer.
Nothing in CI ran the shipped configuration: the suite's server turns the
prefetch off so route mocks keep governing the dashboard. A second server runs
it on, and one spec asserts that no hydration error is reported and that the
tile node the server streamed is still the node in the DOM once the page
settles.
Measured over nine cold loads with the CPU throttled 4x: seven bailouts before
and none after, median LCP 869 ms before and 145 ms after. FCP and the time to
the first tile in the DOM are unchanged, because the streamed paint was never
the problem. Keeping it was.
- Wait for a validated account scope before warming record reads
- Abort scoped preloads and discard handoffs across record transitions
- Cover malformed scope reloads and fresh own-record recovery
- Time out stalled provider sends without unhandled late rejections\n- Record a transient timeout outcome for cascade fallback\n- Cover timeout fallback with unit and integration tests
- Exclude and erase egress claims with recipient-side lookup support\n- Serialize data wipes with managed-profile Guardian authorization\n- Cover last-Guardian and queued-claim wipe races
- Commit a final authorization claim under the lifecycle lock\n- Keep provider calls and retry outcomes outside database transactions\n- Retain authorization claims with notification delivery diagnostics
- keep shared navigation independent of actor module preferences\n- open scoped records at their first allowed destination\n- announce record access and kind in doorway names
- prove profile uses a read-only shared route\n- cover scoped navigation independent of actor module flags\n- require action, access, and kind in record button names
- Render a record-keyed status view for managed profiles\n- Refuse shared Settings links before actor controls mount\n- Localize intentional unavailable states
An owner can now narrow a grant to the parts of their record they mean to
share, and can offer the third level. Both decisions are made once, on the
invitation, and neither widens afterwards.
The invite schema takes `scope`: absent or null is the entire record, which
is what every stored grant means and what an owner who does not narrow still
gets. An empty array, a key outside the vocabulary and the whole-record
sentinel are each refused with a 422 naming the field; `inviteGrant` refuses
the same three underneath, and its `invalid_scope` now has an HTTP mapping
instead of falling into the generic refusal.
Offering MANAGE asks for a fresh second factor when the account has one. That
gate resolves through the session cookie and has no Bearer fall-through, so a
token caller is refused first with `sharing.invite.manage_browser_only` rather
than being told it is not authenticated by a route that had just
authenticated it. The consequence is deliberate: management of a health
record is offered from a browser.
The account payload gains `sections` and `level` beside `access` and
`canWrite`, resolved server-side and ordered as the consent screen reads.
`access` was `grant.access === "WRITE" ? "write" : "read"`, which would have
published a grant that can delete entries as read-only; it is a total map
now, so the next level cannot ship silent. The capabilities hook carries both
facts and turns neither into an affordance yet — the edit and delete controls
come back in the release where the routes behind them answer.
The consent surface asks two questions in order rather than laying out a
nineteen-cell grid. The level is three radios, narrowest preselected. What
they can open is two radios, the entire record preselected, and only the
narrow branch expands the eight sections with a sentence each. Both honest
limits are stated above the checkboxes, where the decision is being made: a
section shares everything written in it, and the cross-record surfaces do not
appear for a narrowed grant. Documents carries its own warning, cycle appears
only for an owner who tracks it, and Manage collapses the question to the one
line that answers it.
The Manage sentence says the other person can add, change and remove entries,
including ones the owner entered themselves, and says in the same breath what
it does not reach. The acceptance side gets its own responsibility line rather
than the write one wearing a stronger adjective.
Six locales.
A grant has meant one thing since it shipped: the whole record, to read or to
add to. This lays the words for the two shapes the rest of the sharing work
needs — part of a record, and management of one — while changing what no
existing grant does.
`ShareDomain` is a closed set of eight sections in `src/lib/sharing/scope.ts`,
clustered from the delegable read surfaces rather than invented, plus the
distinguished `record` for the routes that read across them. It never converts
to a `ModuleKey` and never imports one: two masks, two questions, no shared
spelling, because the day they share a name is the day somebody makes one
imply the other, and a module the owner enabled is not a section the owner
shared.
`account_grants.scope_json` is NULL on every row that exists, and NULL is the
entire record — the answer those rows were consented as, so no backfill and no
re-consent. A non-NULL value is read through one fail-closed normaliser: a
blob it cannot parse opens nothing, including a section the blob happened to
name. That is the deliberate inverse of the module preference normaliser,
which fails open on purpose; both do the safe thing with a value nobody can
read, and the safe thing differs because one answers an authorization question
and the other a presentation question.
`MANAGE` joins the level enum, ordered above WRITE so a write grant can never
satisfy it, and carries no scope at all: management of part of a record
promises a boundary that cannot survive an edit, so the invite path refuses to
write one.
`requireRecordAuth` takes the section as a second required argument. The
requirement is the lever — a delegable route without a classification does not
typecheck, so the set of classified routes cannot fall behind the set of
delegable ones. All fifty-seven modules carry theirs. The structural guard
freezes each against the production vocabulary, fails when a module's own arms
disagree, and pins a non-zero count of resolved sections so a matcher that
stopped reading the argument fails instead of agreeing with everything.
`requireGuardianAuth` is exported and reaches nothing yet. It gates on the
managed-profile marker rather than on the grant, so an adult who granted
management of their own record cannot arrive at that record's settings by any
argument about what MANAGE means: widening MANAGE would widen the record
resolver and would not touch this one. Its frozen list lands empty, with the
declaration site as the positive control that keeps "nothing reaches it" a
measurement rather than a renamed symbol.
`users.managed_profile_at` says since when a record is one somebody else runs.
A timestamp and not a boolean, for a reason the schema has to keep possible:
clearing it is the handover, and the grants and the activity trail span it
untouched.
The fix that stopped a delegate overturning a recorded dose through the second
intake route landed on the trunk with no line in the notes. A change that
alters what somebody else may do to your record and says nothing about it is
the shape this project keeps rediscovering, and a release assembled from these
notes would have shipped it silently.
v1.36.1 stopped a delegate changing a dose the owner had already recorded, and
it stopped them on one route. The sibling `POST /api/medications/[id]/intake`
reaches the same rows through the shared slot upsert, where the only existing
protection is against a pending sync echo washing out a recorded dose. An
explicit skip is not an echo: it fell through, flipped the outcome, and the
inventory the take had consumed was refunded on the way. So the refusal I added
was a door with the next one left open.
The rule compares OUTCOMES rather than refusing every write onto an actioned
slot, and the reason is the third case in the new contract. A double tap, an
offline replay and a retry after a partial batch all re-post the same decision,
and a caregiver hits all three; refusing those would make the feature
unreliable exactly when somebody is standing in a kitchen with a pill box. Same
decision applies, a different one is refused.
The check rides into `applyCanonicalSlotWrite` rather than sitting at the call
site, because that is where the existing row is already loaded: no extra query,
and nothing between the read and the update for a concurrent write to slip
through. A refused attempt files under the owner with the same action name the
sibling route uses, so it reaches the activity panel the same way.
Three integration cases, and each asserts the row rather than the status alone
— a 403 that had already written the flip would read as protection while being
the opposite. Watched fail: with the delegate flag forced off, the flip returns
201 while the replay and the owner's own correction stay green.
Seven findings from the adversarial pass before v1.36.1 was tagged. One
blocking defect shipped in that release; these did not, and they are all places
where somebody looking after another person's health record meets a wall with
no explanation, or where a guard cannot fail.
The document vault was the largest. `/documents` is marked as a shared
destination, so the shell offers it under a switch, and every read behind it
resolved the caller instead of the record: the vault painted a query-error card
and the illness view painted another beside it. That is the same failure the
release describes as fixed on the dashboard. Five GET arms move to the record
resolver now, and only the GET arms: the list, one document, its file, its
thumbnail and the vault's usage payload, which the filter bar is built from.
Uploading, retyping, deleting, linking a document to an illness and every AI
verb stay with the owner, and the two reads that spend decryption CPU key
their quota on the person calling rather than the record, the rule
`medications/compliance` already follows.
Logging a value on a metric you track yourself leaves the admitted set. It was
admitted, named on the consent screen in six languages, and unreachable: the
only form that posts one sits on a page a shared record does not open, and the
component that renders it says so in its own comment while returning null. It
is the third route the rule that withdrew allergies and family history fitted
exactly, and it was applied to two of the three. Reading those values stays.
Two guards could not fail, and both were proved by mutation before and after.
The prefetch guard checked rule three with two regexes: one was satisfied by
the doc comment every page carries, the other missed an aliased import, and
the pair stayed green with the dashboard genuinely prefetching off the
switch-blind session. It reads the AST now, resolves aliases through the
module's own import table, and pins the five prefetching pages by name so a
page that stops being detected is loud. The activity verb map decided
membership on a literal `requireRecordAuth("write")`, so putting the argument
on its own line, which is what the formatter does the moment the call grows,
dropped a route out of the scan and turned a genuinely-red run green. Both
counts are of matches now rather than of files walked.
The rest are single instances of rules applied everywhere else. "Take all due"
was withheld on a note saying it rides the bulk intake route; it does not, and
its own docblock says why not, so a helper with five morning tablets tapped
five times for nothing. Marking a dose skipped is offered wherever marking it
taken is. A form could be opened in the frame before the app knew whose record
was on screen and then stood there once the answer arrived, on the capture
sheet and on the dashboard's quick-entry sheets. Two more copies of the intake
toast were still building their own, one of them with an Undo pointing at a
route no delegate may call. The customise link on the mood page led to a page
that exists to say it is not part of what was shared. And the access-level
radios on the invitation screen are hidden by design with no focus ring on
their label, so keyboard focus moved invisibly across the one screen where
somebody grants write access to their record.
Three new structural guards, each watched fail first: the customise link sits
inside a capability branch on every surface a delegate can reach, every intake
surface confirms through the one shared decision, and the batch sweep is
offered at the level that admits it.
Found by an adversarial pass over the release candidate before it was tagged,
and it is the reason the tag waited.
`POST /api/medications/intake` is admitted for delegates because a caregiver
standing next to the patient has to be able to say the tablet went down. It is
an update, though, and the same body can carry two other things. Snoozing
writes `snoozedUntil` on the OWNER's medication row, and the reminder cron
skips that medication for as long as the stamp is in the future. The field
takes any future instant. So somebody with write access could stop another
person's medication reminders for years, and three things made it quiet: the
owner is deliberately not notified of a delegated snooze, the activity feed
renders the whole family as "marked a dose", and the only accurate trace was a
line in the admin audit log.
The second was flipping a dose the owner had already recorded — taken to
skipped and back, which rewrites their compliance history and refunds
inventory. The consent screen promises that changing what is already there
stays with the owner.
Both refuse for a delegate now and stay open to the owner, who is the only
person either was ever for. A refused attempt is filed under the owner and gets
its own sentence in the activity panel rather than borrowing the one for
marking a dose, because folding them together is what made the gap dangerous.
Two more from the same pass:
The consent notice named allergies among the things a delegate may add. That
left the admitted set before this release shipped, and it said so in six
languages on the one screen where somebody decides who may write into their
health record. It also omitted half of what IS admitted. The copy now matches
the frozen list, and the test asserts the withdrawal rather than only the
presence of the rest.
The banner told a WRITE delegate nothing at all — the mode line rendered only
in the read case, guarded by a comment stating that write grants did not exist
yet. This is the release that made them exist. It matters that the banner say
it, because the rule that a refused control is absent rather than disabled
rests on the banner naming the mode globally.
And a third instance of the substitution this release already fixed twice: the
BMI chart divided the owner's weights by the height of whoever was looking.
Inside a shared record it draws nothing now rather than a number belonging to
two different people.
My own bug, and it produced a failure that pointed at the opposite of the
cause. The helper clicked the trigger and then re-read the trigger's
`data-state` to decide whether the menu had opened. Radix marks the rest of the
page `aria-hidden` while a menu is open, and Playwright's role locators skip
anything hidden from the accessibility tree — so a trigger found through
`getByRole` stops matching the instant the click succeeds. Reading its
attributes then does not report "still closed"; it hangs until the test budget
runs out, and the log blames the trigger for not being there.
The check is on the menu now. Both journeys pass locally, and the reason is in
the file so the next person does not have to lose an evening to it.
It kept dying at thirty seconds on the quick-add trigger. The trigger was not
the problem: the dashboard route segment suspends behind its server render and
shows `loading.tsx` until that resolves, and on a shared runner with the whole
suite in flight that has overrun the default test budget. Raising the locator
timeout achieved nothing, because the test itself was the thing expiring.
So the wait is now on the skeleton going away rather than on the network going
quiet — `networkidle` says nothing about whether a suspended segment resolved —
and the budget belongs to the page rather than to the default. Every assertion
about the round trip stays exactly as tight as it was.
`.click()` carries Playwright's 30 s actionability wait. The visibility check
that replaced it ran on the default expect timeout, so the helper made the wait
shorter than the code it stood in for, and a dashboard that was slow to paint
failed on the trigger rather than on the menu.
The add-measurement journey and the two accessibility steps that open a
dropdown all did what the water journey did: wait for the network, click the
trigger, then wait for a menu item. All three clicked markup React had not
wired up yet, and the add-measurement one timed out on the trunk's own runner
for exactly that reason.
They open through the same helper now. The documents bulk-bar menu keeps its
plain click deliberately: it fires after selecting fifty rows, so hydration is
not in question there and a retry would only hide a real failure.
Address4 decoded a leading-zero octet as decimal, so `010.0.0.1` parsed as
10.0.0.1 instead of being rejected. That is precisely the alternate-notation
class `isPublicUrl` was written to catch, and this copy arrives transitively
under the MCP SDK's rate limiter, where nothing of ours screens the value
first. The production tree resolved 10.2.0 while the dev tree already had a
fixed one, so the override names the floor rather than trusting resolution.
The assertion demanded an empty console on `/` and was catching React's
hydration mismatch, intermittently, on this branch. Measured before touching
it: a production build of the trunk with the SSR prefetch on — the shipped
configuration — raises #418 on three loads out of three, on both viewports. A
control worktree on plain main behaves identically, so nothing here caused it.
With the prefetch off, which is what this suite runs against, neither tree
raises it in isolation; it appears only when the whole suite is loading the
machine enough to change the streaming order.
So the check was failing a branch for a defect that ships today, and doing it
unreliably enough that it read as a regression. The one message is exempted,
with the measurement and the mechanism at the filter so nobody has to
rediscover either, and the exemption names its own removal condition. Every
other console error still fails this test.
The defect itself is written down rather than waved through: the server streams
the route-level skeleton while the client's first pass renders the dehydrated
snapshot, and React throws the server tree away. Which means the prefetch that
exists to make this page paint fast is paying its cost and delivering none of
its benefit.
`undici` moves to 8.10.0 and the `fast-uri` override rises to 3.1.5. Both were
published as high after the last run on the trunk, so this is new information
rather than neglect, and both matter here: undici is what backs `safeFetch`,
which is the one documented egress boundary and the place the connect-time
DNS-rebinding pin lives.
The undici advisory is a cross-user information disclosure and a parse-time
crash through degenerate private cache directives. `fast-uri` arrives four ways
through transitive dependencies and the existing override was pinned one patch
short of the fix.
The safe-fetch dispatcher suite passes against the new client, which is the
part that had to be checked rather than assumed: the pin reaches into undici's
connect hook and a client bump is exactly where that could quietly stop being
enforced.
The water-capture journey clicked the dashboard quick-add trigger after
`waitForLoadState("networkidle")` and then waited thirty seconds for a menu
item that never arrived. The failure snapshot shows the page rendered, the
trigger present, and no menu anywhere: the click landed on server HTML that
React had not attached a handler to yet, and the one click the test had was
spent before hydration.
`networkidle` cannot close that window, because the trigger is in the first
HTML and exists long before the client bundle finishes. The gate has to be the
element's own state. `openMenu` clicks and confirms the trigger reports itself
open — Radix stamps `data-state`, the hand-rolled mobile capture button carries
`aria-expanded` off React state — and spends another click if it does not,
because the problem is a lost event and waiting longer for a swallowed click
achieves nothing. Out of attempts it fails naming the state the trigger was
actually in, rather than letting the caller time out on a menu item that was
never going to appear.
This is a race the spec always had; it surfaces when the machine is loaded
enough to push hydration past the click, which is why it reads as a
branch-specific regression when it is not one. The tour passthrough spec keeps
its single un-retried click on the same trigger: proving that exactly one click
reaches the button through the overlay is that test's entire point.
MedicationSideEffect was classified as backed up and was not carried: the
export wrote nothing and the restore had nothing to read, so a restored
account kept the drug and lost the reason someone may have stopped taking it.
Delegated write access sharpens that — a side effect is one of the things a
second person can contribute, and it was the contribution a restore dropped.
The rows ride inside their medication, like the schedules beside them. A
top-level array would have to carry `medicationId` and survive a restore that
mints a fresh id for a portable file, which is the remap the intake events
already work around by drug name; a nested create has no id to remap, because
Prisma binds the child to whatever id the parent row actually got.
The note follows the measurement contract for the same dual-column
arrangement: a portable export carries the decrypted note and no ciphertext, a
disaster-recovery payload carries the ciphertext verbatim, and the restore
re-encrypts a legacy plaintext row rather than writing plaintext back into the
column.
Move the model from COVERAGE_PENDING to TWO_ENDED_MODELS, and settle the claim
where a written verdict cannot: the round trip seeds a side effect, exports
through the real builder, empties the account and reads the row back out of
the real restore route with its category, entry, severity and note intact.
Both halves were excised to check the test can fail — the restore branch turns
the count red, the payload turns the field values red.
Ten routes on `/` refused with `undeclared_mode`, which made the first page
of account sharing the only page in it that does not work. Each one now
declares a mode, per route rather than as a class.
Eight are record reads: the dashboard snapshot, the Today digest, the badge
grid, the Coach nudge signal and reminder ledger, the medication low-stock
thresholds, and the two presentation blobs. The old objection to admitting an
aggregate — a summary can carry a finding from a module the grant does not
cover — does not hold while a grant is whole-record and all-or-nothing. Every
admission site names that, so the day per-module scope lands the aggregates
are findable.
The layout routes split their verbs. Reading the owner's arrangement is right
and, for the dashboard, forced: the snapshot already carries the layout and
the client seeds the same cache cell from it, so an actor answer would put two
people's layouts in one key. Writing it is not a caregiver's job, so the PUT
and DELETE stay bare and refuse. Same on the medication list, where the stored
order names the owner's medication ids and means nothing anywhere else.
Two are actor surfaces: the UI language belongs to the person reading the
screen and must not be transplanted onto the record, and the feature-flag
matrix reads the settings singleton and no user row at all.
Also fixes a second instance of the delegate-data-under-owner-banner defect,
on the same page: the dashboard's client-side band fallback derived blood-
pressure targets and the weight range from `/api/auth/me`, which answers about
the caller, and painted them over the record's charts. Under a switch it now
derives nothing and waits for the snapshot's own bands.
The integration suite drives every admitted route with both accounts seeded,
proves the two answers differ, and then demands the owner's back.
Taking `POST /api/allergies` and `POST /api/family-history` out of the
delegable set moved one end and left the other: the integration suite still
asserted that a delegate holding a WRITE grant gets a 201 from both, so ten
cases went red on a decision that was deliberate.
They are pinned the other way round now, with the reason in the file. A
delegate is refused at either grant level, nothing lands under either
account, and — the leg that matters most and that nothing else here covered —
the owner's own unswitched write still succeeds. Withdrawing a delegated
write by breaking the ordinary one would have been the worse bug.
The dose-history purge was the one destructive verb in the intake family
that removed rows outright. Both siblings, the single-event DELETE and the
bulk delete, stamp `deletedAt` and bump `syncVersion`; the purge called
`deleteMany`, so a mis-tap was final and a client offline at purge time kept
every row forever, because a row that no longer exists produces no tombstone
for the sync feed to carry. It tombstones now, scoped to live rows so a
second purge counts zero. No inventory refund: a purge wipes a history as
bookkeeping, and refunding every dose ever taken would inflate the current
container count instead.
The manual water quick-add and the lab-result un-delete both wrote health
data and reached only the wide event, which expires. Months later nothing
could answer who put a day's total there or who brought a reading back, and
with a second person able to act on a shared record that question has a
second possible answer. Both write a durable row now, named after the
neighbours they sit beside.
The refused-delegation audit comment claimed the row was written for the
owner's benefit. It is not: it is filed under the caller, it is only written
for a header refusal, and a header names an account the caller merely
asserts. Filing under that claim would let anyone write rows into any
account's activity feed by naming it. The comment says what the row is now,
and points at `recordDelegatedAccess`, which is the half the owner does see.
Two comments in the biomarker surfaces still claimed the `SetNull` foreign
key left the readings behind when a marker is deleted. That stopped being
true when delete became a "drop this and its history" action and the
confirmation copy was rewritten in six locales to say so.
The purge route gains a call-shape assertion rather than a count assertion,
because a count comes back identical from a `deleteMany`. The lab restore
route had no test at all and now has one.
The activity feed maps an audit action to a sentence. The web client posts to
/api/medications/[id]/intake, which has audited as `medication.intake` since
v1.0.0, but the map only knew `medications.intake.update` — so a delegate
recording a dose showed up in the owner's feed with no verb at all. Both
spellings are recognised now, plus `measurement.create.batch`.
A new test reads the audit actions straight out of every route that calls
requireRecordAuth("write") and fails when one has no case in the map, so the
next route to be opened for delegates cannot land a silent gap.
The delegate notification is fire and forget now, like every neighbouring
side effect. The dispatcher awaits each channel in the cascade, so one
unreachable channel added its whole timeout to the request of the person
standing next to the patient waiting for a dose to register. The integration
test polls the ledger rather than relying on the await, so nothing about the
proof weakens.
Every test in e2e/delegated-writes.spec.ts had been skipping since the
day the invitation form shipped its level control. The skip guard looked
for data-slot="grant-invite-level"; the control landed as
"grant-invite-access-option". The file said out loud what to change and
nobody changed it, so a quiet skip and a passing suite read the same in
a CI summary.
Two assertions inside it had never been executed and were both wrong:
the header button reads "Add", not "Add measurement", and the form opens
on blood pressure, which has no `value` field. Fixed against stable
attributes rather than viewport text, with data-slot="measurement-add"
added to the control the journey clicks.
Adds the deep-link leg the SSR suite cannot hold: a query parameter is
the same affordance as the button, and ?add= / ?new=1 open exactly what
the resolved level admits.
Three surfaces record a dose and all three had to reach the same two
conclusions inside somebody else's record: name the record, and drop an
Undo the server refuses. Two learned it and the dose ledger did not,
because the ternary was written out three times. It is written once now,
in the file the other two already share, with its own test.
POST /api/allergies and POST /api/family-history leave the frozen
delegable write set. The argument that admitted them still stands — an
allergy is the single most useful thing a caregiver can contribute — but
the only surface that posts to either lives in Settings, which a switch
closes, so no delegate could reach the form at any level. That is a
permission frozen ahead of the caller for it, and the list is built the
other way round on purpose.
Both delegable READ arms stay. A caregiver reading the allergy list is
what the feature is for; only the contribute step waits, and it comes
back in the same diff as the surface that offers it.
Three of the newly gated surfaces are query-backed, so the suite gains a
query client and a cache seed rather than a hook mock: the component
under test stays the real one. Each leg asserts the rail, the card and
the reminder row are still THERE — only the mutating control is gone.
Breaking each gate in turn puts every leg red.
Nine surfaces offered a delegate an action the server declines, and the
switch had never been asked about any of them.
The Today rail's dismiss and the coach check-in's keep / let-go write
through routes that resolve the caller, so both refuse under a switch;
they now ask canManage and are absent at either grant level. A priority
action with no href and no handler is no longer rendered as an inert
button at all — the type carries that rule now.
The Vorsorge dashboard card offered the mark-done that /checkups already
withholds, and the checkups list branch inlined its own ungated copy of
the button the cards branch gates, so which view a browser last chose
decided whether the action appeared. Both bind canManage.
The chart overlay cog persists through a route that resolves the caller
and stores a preference belonging to the person rather than the record.
Gated once in the control so the three chart wrappers cannot drift.
Three query parameters opened sheets past a gated button: ?add=<TYPE> on
measurements, ?new=1 on medications, and ?edit=1 on a medication detail,
the last of which opens a wizard refused at both levels. A deep link is
the same affordance as the control that produces it.
The efficacy retarget dial rewrites a setting the owner chose. The
episode documents card offered a link and an upload the vault gates on
the same endpoint throughout. And the dose ledger built its own success
toast, missing both the Undo suppression and the "saved to" receipt its
two siblings already carry — the one a delegate met on every dose.
The Coach drawer is not mounted inside a shared record, so every button
calling askCoach() opened nothing. The launch provider now publishes no
value there and each entry point's existing null check does the rest;
the documents sheet, the one that rendered without checking, now checks.
The coach positive control navigates to the dashboard to prove the launcher
renders for a delegate in their own record. It left the browser there, and the
next step clicks Accept, which lives on the access page. The click waited out
its timeout against a page that does not carry the button.
An assertion inserted mid-journey has to restore the state it borrowed.
A server-prefetching page dehydrates a health record into the HTML and
seeds it into the client's query cache under the key the client cell
reads. That copy is only right when the account the page read is the
account the client's route would answer with, and under an account
switch it never is: `getSession()` answers who is CALLING, deliberately
and permanently, so all five prefetching pages seeded the delegate's own
rows into a document opened on somebody else's record. The dashboard was
the worst of it — snapshot, digest and widget layout ride routes that
refuse the refetch, so the delegate's numbers stayed on the owner's
dashboard, while the chart row refetched to the owner and left the page
showing two people at once under a banner naming one.
Resolve the session through `getUnswitchedSession()` instead, which
answers null while the browser is acting on another record. The prefetch
steps aside and every cell fetches through its route, which is where the
grant check lives. Resolving the owner here instead was the other
candidate and is worse: the grant check has exactly one home, half of
what these pages prefetch rides routes that declared themselves not
delegable, and re-deriving it in an RSC would be a second program
deciding whose record this is.
Close the two shell-level doors beside it. The Coach launcher, its
drawer mount and the module tour hang off `<AuthShell>` rather than off a
route, so dropping Insights and Coach from the navigation under a switch
left them painting on every page in the record and on the unavailable
panel too — one tap from a drawer whose every action refuses, and a
`POST /api/insights/coach/seen` fired by opening it at all. All three
bind `useRecordCapabilities()`, the same resolved grant the banner and
the switcher read. Absent, not disabled.
The prefetch rule is structural: the guard walks the app tree, so a sixth
prefetching page is covered the moment it lands rather than the moment
somebody remembers. The pages stay on the session-surface allowlist with
their reasons rewritten, because a wrapper one directory deeper is the
easiest way for a surface to walk out of a guard.
The activity view told an owner that a delegate acted on their record. With
delegated writing it can say which act: added a reading, recorded a lab result,
marked a dose. The mapping already existed as a pure module with its own test
and a fallback for anything unmapped; this is the wire it was missing.
The component suite runs without a DOM renderer, so it proves what a page
paints and never what a button does. That is the right shape for this work,
because the property that matters most is a control being absent — but it
leaves the other half unproven: a form that renders is not a form that
submits, and a write that reaches somebody else's record is exactly the thing
worth watching happen.
The journey stands down while the invitation form has no level control, since
minting a WRITE grant behind the UI's back would prove a path no person can
walk. It names the control it is waiting for, so a run that skips while the
control exists reads as a bug here rather than an absence upstream.
Sharing lived in a settings panel and a menu three levels deep. That is where
a grant is managed and not where it is used: somebody who opens this app every
morning to check on a parent does not go through settings to do it, and the
design standards draw the same line, settings being account utilities rather
than feature destinations. So the dashboard carries one compact card listing
the records shared with this account, each row a one-tap switch through the
existing flow.
It is a doorway and not a preview. No numbers, no last reading. A slice of
somebody else's record on the delegate's own dashboard would sit outside the
one frame the design leans on, the banner naming the record you are inside.
An account nobody has shared with sees nothing at all.
The other half is the receipt: a reading, a lab result or a dose recorded
under a switch now says which record it landed in, because "Saved" alone is
the confirmation somebody acting for another person does not need. The undo
on the dose toast goes at the same time, since a delegate cannot remove what
they recorded and an undo they can see is one the server refuses.
Also here: the lines the owner's activity view owes each admitted verb, as a
translated mapping with a fallback, so a later release filing a verb it has
never heard of still renders a sentence rather than a blank row.
Marking a dose taken or skipped is the verb the delegation was written for:
somebody looking after a parent confirms the morning tablet. Adding a
medication and noting a side effect come with it. The card menu keeps only
what survives and disappears entirely when nothing does.
Four detail tabs leave the registry inside somebody else's record, because
their whole content is owner work: the schedule editor and its plan history,
the supply ledger, the external-token row, and the advanced surface holding
the wizard, the import and the destructive zone. A stale deep link into one
lands on the landing tab, the way an inapplicable tab already does.
The take-all-due sweep goes too. It rides the bulk intake route, which the
delegation does not name, and a control that half-works is worse than one
that is not offered.
Every page a switch can reach now renders the controls the grant covers and
none of the others. Readings, lab results and illness entries keep their add
path at WRITE and lose edit, delete and selection at both levels. Mood,
screeners, cycle days, checkup reminders and the document vault lose every
mutation control at both levels, because no grant admits those verbs and the
routes behind them refuse a switched session outright.
Absent, not disabled, throughout. The banner already says whose record this
is, so no chip repeats it beside each missing button; the one exception is
the measurements empty state, where somebody who may add will look for the
edit control beside the row they just entered, and one muted line says where
the limit is.
Two dead ends went with them: the custom-metric section on the measurements
page linked every row into a route the shell turns a delegate away from, and
the wrench glyphs point at settings pages sharing does not cover.
Three controls carry most of the app's mutation surface between them. The row
delete is spelled once for measurements, lab results, biomarkers, allergies,
family history, custom metrics and medication supply; the selection bar is
the only bulk delete; the capture picker is the fastest path to four entry
forms on a phone and bypasses every page header.
Deleting belongs to the owner at every grant level, so the first two are
absent inside somebody else's record rather than greyed out: a disabled bin
still claims the row is the delegate's to remove. The picker keeps the two
kinds a delegation admits and drops a mood entry and a glass of water, which
the server refuses under a switch; when nothing survives, the bar's plus
button goes with them instead of opening an empty sheet.
The surface guard grows a write leg. Membership is decided by the argument
a module passes to the record resolver, not by the identifier, because a
delegable read and a delegable write name the same symbol and mean opposite
things. The call matcher resolves aliased and namespace-qualified calls
back to the original name, and proves it can against synthetic modules.
Two assertions carry the decision not to add a provenance column to eleven
tables. Every module in the write list has to call auditLog, which is the
only writer that stamps the acting account, and none of them may reach the
audit table through the client instead. That decision rested on the trail
existing; it now rests on a test.
The prose on the read list said the write arms keep refusing under a
switch. Eleven of them no longer do, so those sentences are rewritten
rather than left to lie.
The integration suite drives the shipped POST exports through the real
handler, resolver, grant table and Prisma. Five legs per verb: the write
lands under the owner and nowhere else, a read grant is refused with
nothing written, a caller who was never granted is refused the same way,
the request after a revocation is refused with no re-login, and a caller
who never switched is indistinguishable from before. The grant is minted
through the invitation flow rather than planted, and the audit assertion
reads both ends of the row.
Account sharing shipped read-only, and everything that consulted the grant
lived in the application chrome: the nav bars, the banner, the switcher. No
feature surface asked. A delegate opened somebody else's record, saw the add
button on every page, filled the form, and was refused by a server that was
right to refuse. Nothing leaked; it taught people the product was broken.
This is the answer every mutation affordance asks for. Two booleans, because
a grant at WRITE admits a short closed list of verbs and nothing else:
`canAdd` for an entry of an admitted kind, `canManage` for changing what is
already there or adding what the list does not name. `canWrite` arrives
resolved from the server and is bound, never recomputed; what this decides is
only which class of control a resolved level covers.
The absent case is the permissive one. Failing closed would blank every add
button in the app for every account on every cold load, to spare a delegate
one frame between the reload and the account query landing.
Four medication verbs resolve the record: adding a medication with its
nested schedule, recording a side effect, and both forms of marking a dose.
Editing and deleting a medication stay refused under a switch, which is the
sharpest asymmetry in the feature — a delegate can create a medication and
can never correct the typo in its dose afterwards.
The side-effect rate bucket keys on the acting account rather than the
record. That was the condition on which the verb was admitted: on the
record key a delegate could exhaust the owner's allowance and lock them out
of their own log, and could collect a fresh one by switching to another
record. Everyone who has not switched lands in the bucket they always did.
Both intake routes tell the owner who marked their dose. Everything else
downstream stays addressed to the owner and is correct there: the
cross-device sync wakes their devices, the web-push clear closes their
pending reminder and recounts their badge, the compliance rollup recomputes
their day.
Two validation breadcrumbs move to auditLog(), the only writer that stamps
the acting account.
An allergy, a family-history entry, an illness episode and a custom-metric
value now resolve the record rather than the caller.
Two of them carry a consequence worth naming. The family-history row
describes the record's relatives, so it is the one admitted write where
third-party health information is present by design; it is stored as the
record's own, exactly as the read arm already serves it. The illness module
gate runs against the record before the write, so a delegate cannot open an
episode inside a record whose owner switched the module off, whatever they
set on their own account.
The custom-metric entry reaches its metric through the ownership hop, which
now resolves against the record: a delegate can only feed a definition the
owner made, and their own metric of the same name is unreachable from in
there. Its validation breadcrumb moves to auditLog().
POST /api/labs and POST /api/biomarkers resolve the record. The free-text
lab path mints its biomarker under the record's catalogue rather than the
caller's — a result added to somebody's record whose marker landed on the
helper's account would leave the owner with a reading they cannot chart.
Adding a marker the record already tracks stays the ordinary 409 from the
same per-account uniqueness the owner would hit themselves. The PUT and
DELETE on the sibling routes are unchanged and still refuse under a switch.
Both validation breadcrumbs move to auditLog(), the only writer that stamps
the acting account.
Both POST arms of /api/measurements resolve the record rather than the
caller. Every `userId: user.id` in the handler already meant "the record",
so the bodies are untouched: the safety-floor check, the reminder
satisfaction, the rollup recompute and the cache eviction are statements
about the record and stay addressed to it.
The two validation breadcrumbs move from a bare client create to
auditLog(). That helper is the only writer that stamps the acting account,
and a delegate's malformed payload filed without it reads as the owner's
own typing.
The sync-changes fixtures seeded rows at a literal calendar date. The route
serves only what sits inside the tombstone retention horizon, which is sixty
days plus a fifteen-day margin, so the fixture was inside the window when it
was written and fell out of it exactly seventy-five days later. Three cursor
round-trips started failing at midnight with no commit behind them, and would
have failed on every run from then on.
The rows now anchor five days before the current time, so they cannot age out
of the window they are meant to be inside. The mood entry's own date string is
derived from the same anchor rather than written out, because the two drifting
apart is the same defect one level down.
The payload type still said the level is read throughout and `canWrite` false.
Both were true of the shipped release and neither survives the invitation
gaining a choice. Also says what `canWrite` does not mean, since the word
invites a larger reading than the thing it grants.
A delegate retrying with one `Idempotency-Key` across two people's records met
itself in one cell. The cache tuple was `(caller, key, method, path)`, so the
second record's request replayed the first record's cached 201: the handler
never ran, no row was written for the second person, and the caller was told it
had been — with the other person's id in the body. Nothing errored and nothing
logged a conflict, and five of the write verbs about to become delegable are
wrapped here.
Fold the claimed record into the `key` component, centrally, once. Not into
`userId`: that column is a foreign key to `users` with a cascade behind it and
cannot hold a composite. It keeps naming the caller, which is what makes two
delegates on one record two cells for free. The separator is a byte the key
validator refuses, so a caller cannot hand-craft a key into a cell belonging to
a request they are not making.
A request with no acting account keys byte-for-byte as it did before, so a
client that never heard of sharing cannot be affected.
Accepted and pinned by test: the fold uses the CLAIM, before the grant check,
so within the 24h window a delegate whose access has ended can still replay a
key they had already completed. They receive bytes they already had, nothing
new is written, and a fresh key from them is refused because that request
reaches the handler.
Both frozen surfaces move in this diff. The claim reader gains a guard leg
freezing its consumers, since an unchecked claim is exactly the value a future
caller must not mistake for permission.
The module header still described a permission to read, and the grantee field
still said "invited to read it". Both are half the sentence once the level is
a choice.
The rule that decides what a request says it is acting as — session stamp on
the cookie transport, selector header on Bearer, a header on a cookie request
naming nothing — sat inside the resolver that acts on the answer. A second
caller needs the same question answered before any auth context exists, and
two implementations of "whose record is this" drift; the copy that drifts is
the one nobody reads.
Move the decision, the header name and the two predicates into
`auth/acting-carrier`, as a pure function of what the transport carried. The
resolver keeps what only it may do: turning a claim into a data scope once a
live grant says so. No behaviour changes — the resolver's arms are the same
arms, and its suite passes untouched.
The header-name guard follows the constant to its new file.
The invitation form gains the choice, and the option labels say what actually
ships rather than the word "write": a delegate can add readings, results,
observations, a medication and a marked dose, and can edit or delete nothing,
including what they entered a moment ago. That gap between what "write access"
sounds like and what it does is where a support case lives, so it is on the
card rather than in a reply.
The delegate reads the same thing before they accept. A pending write
invitation states its level and one sentence about what accepting means, on the
screen where they accept it, because acceptance is where the two consents meet
and it happens once.
Ending an access says what stays: the entries remain and the owner can remove
any of them, and the activity view keeps saying who entered what for as long as
the instance's audit window reaches. That number comes from the server, so an
instance running ninety days says ninety, and a browser that has not been told
says nothing rather than a made-up year.
The browser journey now invites at the wider level and reads the posted body
for it. That is the only check in the tree that can: the component convention
is server-rendered only, so no test under src/ runs the form's submit path, and
every one of them stays green with the control wired to a constant.
A grant has always had an access column and `inviteGrant` has always written
READ into it. That was the right way to ship the first version: "this release
never grants write" was a property of the file rather than a promise in a
comment. Remove it deliberately now, so somebody sharing their record can offer
reading or reading plus adding.
The level is a required argument to `inviteGrant` and is defaulted in exactly
one place, the invite schema, where an omitted field becomes READ. An older
client's payload therefore stays valid and stays narrow, and the domain module
never guesses a level nobody named.
There is no upgrade path and that is the decision, not a gap. A grant is READ
or WRITE from the moment it is offered and stays that way until somebody ends
it. Widening a live one would change what the delegate agreed to without asking
them again, and their consent is half of what makes a write grant legitimate.
The way up is a new invitation, accepted again.
The audit row and the wide event carry the level: "who was given what, and by
whom" is the question that row exists to answer.
The intake routes call notifyDelegatedIntake with both account ids and
the helper decides. Keeping the comparison here rather than at the call
sites means a route added later cannot notify somebody about a dose they
marked themselves, and a snooze is answered in one place as what it is:
moving a dose, not resolving one.
The medication name is read scoped to the owner, because that name goes
onto the owner's lock screen. Nothing addresses the delegate: the
dispatcher takes a single account id, and under record substitution that
id is the owner. The person who marked the dose learns what they did
from the response they are already awaiting.
The integration test asserts the ledger row before the sender mock, so a
dispatch nobody recorded cannot pass as a dispatch.
The matrix derives each row's label from the event name, so a new event
type gets a row whether or not anybody wrote the strings for it. That is
how SECURITY_ALERT has been rendering its own raw key since v1.23: the
forward i18n coverage test only sees literal call sites, and this key is
assembled from a runtime value.
Move the derivation out of the page so a test can compute the keys the
page computes, assert both resolve in all six bundles, and name the
missing one. Write the sign-in row's strings, and the strings for the
delegated-dose row and its push body.
The owner of a shared record gets an alert when a delegate marks one of
their doses. It is the one exception to the rule that bookkeeping stays
in the activity view: whether the tablets were taken is a fact about the
owner's own body, and they should not have to go looking for it.
Default ON, against the opt-in precedent of PERSONAL_RECORD, because the
event only exists once the owner has invited somebody and given them
write access. The per-channel row in the matrix is the valve for anyone
who finds it noisy, and both positions of that preference are covered.
The destructive-control registry carried this card because a detector read
its hardcoded `expiresAt: null` as a field being blanked, and an entry
explained at length why blanking was harmless here. The explanation was
right and the cause was not: the null was there because nobody could set a
date, not because none was wanted.
With the control wired the detector no longer fires, so the entry no longer
matches anything and the registry's own staleness check asked for it back.
The invite route has accepted expiresAt, stored it and annotated on it since
the grant model shipped. The card sent a hardcoded null, so the column existed,
the validator accepted it, the wide event reported on it, and no person could
set one. The release note said an invitation could carry a lapse date.
The form now offers the date, floored at tomorrow because a grant that lapses
today confers nothing. A chosen day maps to the END of that day in the
browser's own zone, so "until the 30th" means the 30th rather than the
midnight that starts it, which is the reading anybody picking a date has in
mind. Empty stays the default and still means no lapse date.
Both ends are pinned rather than one: the control is asserted in the rendered
form, and the mapping is exported so its contract holds without a click, which
is this repository's convention for component tests.
An unrevealed chart cell is `invisible` and `aria-hidden`, so the chart row
is not in the accessibility tree at all until the reveal gate fires. Waiting
on the tile strip left it to chance whether the row was read: locally the
cells were still hidden when the strip appeared and revealed six seconds
later, while the runner that reported the broken heading order had already
revealed them. The row is the largest surface on the page, so a scan that
sometimes skips it is not a check of the page.
Gating on a revealed cell makes the coverage the same on every run. With the
heading level put back to its old value the case now fails 16 out of 16
repeats across both themes and both viewports, where the same defect
previously surfaced twice in several full runs.
A dashboard chart card called itself an h3. Nothing contained it. The cards
beside it in the same grid — "Recent unlocks", "Recent workouts" — are h2,
and so are the target and usual-range cards on the metric subpages, so the
chart card was both the odd one out and a level skipped straight from the
page h1. The rendered dashboard ladder was h1 → h3 → h3 → h3 → h2 → h2, and
/insights/bmi was h1 → h3 → h2.
A heading level is a claim about nesting. Someone moving through the page by
headings hears a subsection announced where there is no section, and screen
reader rotors build their outline from exactly this. Promoting the title
keeps the same text, the same classes and the same pixels; only the claim
changes.
The unit guard fails in the fast run; the browser scan catches the same
regression through axe, but only in the e2e job.
A child model rides its parent through the backup, so the check looks
for the relation field name as well as the delegate. It looked for the
bare name anywhere in the declared files, and `symptomLinks` is declared
twice — on `IllnessDayLog` for the illness link, on `CycleDayLog` for
the cycle one. Deleting the illness branch from the restore route left
all twelve checks green, because the cycle write in another file was
answering a question about a different table.
A name that points at exactly one model is still evidence anywhere. A
name shared by several counts only in a file that also operates on the
declaring parent's delegate, and a check asserts the cycle file proves
the cycle link and refuses to prove the illness one.
That leaves the illness link unprovable from source: its include is a
shared constant with no query beside it, and the parent that would
disambiguate it is reached through the episode in another file.
`STRUCTURALLY_UNATTRIBUTABLE` names it with what defeats the matcher,
and naming a model there is not a free pass — it has to be seeded in
the round trip, which is checked here.
The plan says which models a backup carries and a source-text check says
each one has a reader and a restore branch written for it. Neither can
tell a restore branch that runs from one that only exists: wrap the
nutrient restore in `if (false && …)` and every check stays green, which
is the correct-green-and-inert shape this project already shipped once.
So settle it end to end. Seed one row of every two-ended model, export
through the real payload builder, delete the account so the cascade
empties what a hand-listed sweep would miss, restore through the real
admin route, and count each row back out. A model that never travels
comes back at zero and says which one it was.
The two-ended list is a literal tuple now, so the seed registry can be
keyed by it: adding a name without seeding and counting a row for it is
a compile error rather than an unproven claim.
Also names `src/lib/illness/dto.ts` as a backup writer. The illness
day-log's symptom include is a shared constant living there, not a
literal in the records writer — the same "the caller delegates" shape
that already caught the measurement pager.
Several accessibility scans ran against a page that had not finished
arriving, so axe reported violations the app does not have. The worst of
them was the medications case, which waited for a level-one heading and
then checked that a level-one heading exists. A page that really lost its
heading would have waited out the timeout under a misleading name instead
of failing the rule, which makes it a check that cannot fail rather than a
flaky one.
The filter rail was the other bad sentinel. It renders beside the loading
silhouettes rather than after them, so it was on screen while the rows were
still grey rectangles. Under an 8x CPU throttle the rail and the loading
marker resolved in the same 40ms poll and the rows a poll later.
Every case now gates on a marker the real component carries in its
resolved, non-empty branch, and on a component other than the one the scan
asserts. Both the desktop table and the mobile card list carry the same
marker so the wait holds at any viewport. No sentinel names a heading, the
app frame, a busy marker or the filter rail any more.
The two cycle entries in the frozen list claimed the gate reads `gender` off
the resolved auth context. It does not: `requireCycleEnabled` names that
parameter `_gender` and ignores it, then re-loads gender and the profile by
the resolved user id through the module gate. The answer is the same — the
owner's — but a reviewer checking the claim would have found a dead argument
and no explanation.
The profile route is the exception and now says so: it passes the auth-context
gender to `isCycleEnabled` itself to compute the flag it returns.
A frozen-list entry is only worth the reviewing it invites, so it has to
describe the mechanism that is there rather than the one the call site looks
like it has.
The strip is new this release and had not been checked against the
design standards. It carried the shape §11 names as the anti-pattern: a
centred flex-wrap row whose gap-y drops the action onto its own line
once the name is long enough. The maintainership strip is the sibling
that already had an action and already had the right shape, so the two
now read the same — sentence on min-w-0 flex-1, way out on shrink-0,
beside it rather than under it.
The exit control was a 32px tap target. Leaving somebody else's record
on a phone is the one interaction in the whole banner stack, and it now
carries the standard's floor (min-h-11 sm:min-h-9).
The three strips also spell their icon size three ways for identical
CSS; they all use size-3.5 now.
Text tiers are unchanged and were already right: the person's name is
foreground because it is content, the read-only qualifier is muted
because it is meta.
The offline, shared-record, demo and maintainership strips lived inside
the content column, so each one pushed the top bar down while the
sidebar's logo band stayed at the top of the window. Both bands read
SHELL_HEADER_BAND and are therefore the same height with the same
border, and the single line they draw across the top of the app still
came apart: measured on the French maintainership strip, the two borders
sat 66.5px apart at 768px and 61.0px apart at 1280px, and the full
three-banner stack put them 184.5px apart. After the hoist the step is
0.00px at every depth and both widths.
A banner is a statement about the whole window, not about the content
column, so it belongs above the row. The outer wrapper keeps the h-dvh
and the row below takes what the banners leave, which means the stack
comes out of the viewport budget instead of being added on top of it:
the document still does not scroll at any depth, and <main> stays the
one vertical scroller at both breakpoints.
The existing seam guard could not see this class. It asserts that both
bands carry the shared band tokens, which stayed true the whole time,
and the e2e spec beside it measures a shell with nothing above the
header. The new spec forces the banners on through the conditions the
components actually read — an unmaintained locale, a patched account
payload, a real offline context — and measures the same two borders at
four stack depths. Reverting the layout change turns it red on the first
banner with the measured offset in the message, while the older seam
spec stays green.
The maintainership strip gains a data-slot so the shell's chrome can be
enumerated from one attribute.
`GET /api/measurements` was the resolver test's example of a route refusing
under a switch. It is delegable now, so that arm was asserting the opposite
of what ships and went red on the migration — which is the arm doing its job,
not a defect in it.
Split in two. The measurements route keeps both of its real claims: unchanged
un-switched, and the owner's rows and not the delegate's once switched. The
refusal moves to `GET /api/share-links`, which is non-delegable permanently
and by design — a delegate who could mint a clinician link would hold a door
that survives the owner revoking their access. A route that can never join
the frozen list cannot break this arm the same way twice.
Ten routes: the cabinet list, the medication detail, its cadence, schedule
revisions, side effects, inventory, phase config and intake history, plus the
account-wide intake list and the batched compliance read.
`GET /api/medications/compliance` is the one delegable read that spends a
quota, and its rate-limit bucket now keys on the ACTOR while everything below
it scopes to the record. Both halves matter: a delegate hammering the route
must burn their own allowance rather than lock the owner out of their own
cabinet, and switching records must not hand the same caller a fresh one. The
test reads the `rate_limits` row rather than inferring it from a 429, because
a bucket keyed on the wrong person still answers 200.
`GET /api/medications/[id]/api-endpoint` stays refused. It returns two scalar
fields and no secret, and it tells a delegate whether the owner holds a live
ingest credential and for which medication — in the same file as the PUT that
mints one that would survive revocation.
The two intake validation breadcrumbs move onto `auditLog()` for the actor
stamp. The POST arm's breadcrumb stays as it is: that arm cannot be reached
under a switch.
Four routes: the cycle history, the day log, the profile and the custom
symptom vocabulary.
All four gate on `requireCycleEnabled(user.id, user.gender)`, and both
arguments now come off the record. That is the correct reading and the one
that had to be verified rather than assumed: whether cycle tracking applies
is a property of the record, so a delegate who tracks nothing still sees the
owner's cycle, and a delegate who tracks their own sees nothing when the
owner does not. Both directions are asserted.
Eight routes: the two sleep reads, the nutrient overview and its daily
series, the illness episode list, detail and day logs, and the workout list.
The illness day-log read is the one worth reading twice. Its queries are
episode-scoped rather than user-scoped, and what makes them safe is the
`loadOwnedEpisode(id, user.id)` hop above them. That hop now resolves against
the record, so a delegate cannot reach their own episode from inside somebody
else's account — asserted, because it is the kind of thing that would look
fine and behave wrong.
`GET /api/workouts/[id]` stays refused. It differs from the list beside it by
one conditional include that decrypts a paragraph written about the owner.
The switch is stamped on the session row, not held in the tab. Every
authenticated spec opens its context from the same storageState file, so
the sharing journey switching that jar into the owner's record switched
it for whatever spec happened to be mid-navigation on the other worker.
The failures land nowhere near the cause: /settings/ai painting "Not
part of shared access" instead of the AI panel, an axe scan reading a
banner its spec never asked for, a waitForResponse waiting on a request
the refused surface never sends. Which spec loses depends on scheduling,
which is why it moved between runs.
Log the delegate in a second time and hand that jar to the journey. Same
account, its own session row, no reach outside the file. The three login
captures collapse into one helper while the third one is added, and the
fixture's login-bucket reset already covers the extra attempt.
The guard matches the two things a switching spec cannot avoid naming,
the switcher entry it clicks and the endpoint that entry calls, and
checks both that the match set is non-empty and that the shared jar is
still what the ordinary specs read. Verified red by pointing the journey
back at the shared jar.
The credits page named CC BY-SA 4.0 alone. MaxMind stopped distributing
GeoLite2 under that licence by itself: the databases ship under the
GeoLite End User License Agreement, which incorporates CC BY-SA 4.0 by
reference and controls wherever the two disagree. Name both, in that
order, and link the EULA. The attribution string was already there and
is unchanged; the e2e page check now asserts both licences rather than
the one that no longer stands alone.
Reconcile the self-hosting guide with the same wording, point it at the
current EULA URL, and say why the `/about` page must stay reachable — it
is where an operator's own attribution lives.
Correct the project licence line while on the page: it still claimed
AGPL-3.0, which the page's own footer contradicts. The trunk has been
PolyForm Noncommercial 1.0.0 since v1.15.19.
Six routes: the mood-entry list and detail, the custom-metric list, detail
and entries, and the personal-record list. The custom-metric entries read
establishes ownership on the metric before it queries by metric id, and that
hop now resolves against the record — a delegate cannot reach their own
metric from inside somebody else's account.
`GET /api/mood/tags` stays refused. It looks like plain record vocabulary and
one select in the middle of it pulls the owner's tag layout, which is a
presentation preference and belongs to the person rather than the record.
The mood list's validation breadcrumb moves onto `auditLog()` for the actor
stamp, the same change the measurement reads took.
Every credential-shaped row is left out on purpose — API tokens, trusted
devices, step-up elevations, known devices, clinician share links, and the
grants behind shared record access. The per-model reasoning already lives in
backup-plan.ts with a structural test behind it, but an operator planning a
recovery does not read that file.
The consequence differs by target and the difference is the part worth knowing.
Restoring onto the same instance changes nothing: the restore replaces data
tables and does not touch grants, so shared access carries on as it was.
Restoring onto a fresh instance brings none of it, so both people invite and
accept again before sharing works. That is the fail-safe direction, and it is
better planned for than discovered.
Vitest is a knip plugin, so every file under `src/**/__tests__/**` is an
entry, and a module whose only importer is its own test counts as used
for as long as the test survives. Planting a module with a single test
importing it left `pnpm knip` at exit 0 — the gate reports green on code
production stopped reaching.
Knip can answer the question, just not in the pass CI runs: production
mode drops the plugin's test entries and keeps the entry and project
patterns suffixed with `!`. Mark them, which is inert in the default
pass, so `pnpm knip --production --include files` is one command away.
It stays a review tool rather than a second gate, and the config says
why: twenty-nine files today, some test-only by construction and some
unreachable from production on purpose, plus at least one false positive
where a script's `@/` import does not resolve. Baselining that set
unread would encode noise as an invariant.
Move the config to JSONC so all of the above lives next to the settings
it describes instead of in a commit message nobody will run `git log`
for.
Ten routes: lab results and their detail, the biomarker catalogue and its
detail, allergies, family history, the mental-health screener history, and
the health-profile facts. All GET arms; every write arm in the same files
keeps `requireAuth()` and refuses under a switch.
The family-history payload is the one admitted read that carries third-party
health information by design — it describes the owner's relatives — and it is
admitted because a caregiver reading a family history is what the feature is
for and the rows are the owner's own.
The mental-health module gate resolves against the record, not the caller.
That is the recorded decision: the module map says which parts of a record
exist, so a delegate who enabled the screener on their own account gains no
screener view inside somebody else's. The integration test asserts both
directions.
git, eslint and vitest each carry an exclusion for local development
worktrees, and all three name /.worktrees/ while the convention actually in
use is /.wt-*/. Every one of them was therefore walking eleven full copies of
the source tree.
The consequences differed by tool and none of them announced itself. pnpm lint
took ten minutes instead of one and reported warnings from whatever commits
those copies happened to sit on. vitest collected duplicate suites that drag in
a second React instance, which fails in ways that have nothing to do with the
working tree. git left them merely untracked, one git add -A away from
committing the repository eleven times over.
With the true warning count finally visible, teach no-unused-vars the
underscore convention this codebase already follows and drop the five
disable directives that convention made redundant. pnpm lint is clean.
A non-underscore unused argument still warns; that was checked rather than
assumed.
A consent record has to answer who consented and when, and the grant row
already does. It does not have to answer from where, and an address kept for
as long as both accounts exist is personal data held past the point it
evidences anything. Nothing read the column, which makes it exactly the
write-only field scripts/audit-column-readers.ts exists to surface.
The acceptance stays evidenced: audit_logs records it with the caller's
address under that table's own retention. The column never reached the wire,
so the grant view and the OpenAPI contract are unchanged.
Written before the feature shipped, so migration 0295 drops an empty column.
Six routes move from `requireAuth()` to `requireRecordAuth("read")`: the
measurement list, the single reading, the two series reads, and the two
reminder reads. Each is admitted in the delegable classification with its
five answers written; each enters the frozen list with the argument for why
a delegate reading through it cannot extend their own reach.
Only the GET arms move. The POST, PUT and DELETE arms in the same files keep
`requireAuth()`, which refuses outright while a switch is active, so a file
entering the list admits a read and nothing else.
The two validation breadcrumbs on the list and series reads now write through
`auditLog()` rather than a bare `prisma.auditLog.create`. That helper is the
only writer that stamps `actorUserId`, and without the stamp a delegate's
malformed query lands on the owner's record looking like the owner's own.
Integration coverage drives the shipped GET exports against real Postgres:
the owner's rows reach a delegate holding a live grant, the delegate's own
rows do not, a caller without a grant is refused `sharing.access.denied`, the
request after a revocation is refused with no re-login, and a caller who
never switched sees exactly what they saw before.
`fs.globSync` never matches a leading dot, so every guard built on
`**/*.{ts,tsx}` walked past `src/app/.well-known` — the two OAuth
discovery documents and the apple-app-site-association handler sat
outside fourteen frozen-set sweeps. An unlisted `getSession()` planted
in the authorization-server route left the session-surface guard at nine
passed; it fails now.
Move those fourteen onto one shared walker built on `readdirSync` with
`recursive`, which has no dot rule, and give it a floor every caller has
to state, so a walk that comes back too small throws instead of
reporting a clean tree.
Give the same floor to the guards that compared a discovered set against
a frozen list with nothing asserting the set had been read: gutting the
walker in the inventory-write guard left its invariant test green,
because zero offenders reads exactly like no offenders. Raise the
migration-count floor off one, and assert the locale bundles are found
before the per-locale cases are generated from them.
Document, at each walker that prunes on purpose, what is pruned and that
dot-prefixed directories are not.
An operator page for the feature, written in the register the
reproductive-health page established: say the narrow true thing rather
than the reassuring one.
What the person given access can reach, and the table of what they cannot
— credentials, sessions, tokens, connected services, notification
channels, exports, share links, the sharing itself, and the AI surfaces.
The AI row gets its own paragraph because it is the one that looks
arbitrary: server-managed processing runs under the consent the record's
owner gave for their own use.
Revocation is described honestly. It lands on the next request rather
than the next login, it puts a browser that was inside the record back
into its own account, and it cannot take back what was already seen. The
product can close the door; it cannot reach through it, and the page says
so rather than implying otherwise.
The activity view's window is described as the operator's setting, not as
365 — an instance that shortens the audit retention shortens this view,
and an empty list means nothing was recorded within the window rather
than that nothing ever happened.
The three trust facts stay where they were: the operator could always
read the database, encryption at rest is instance-keyed rather than
per-user, and AI processing stays under the owner's own consent.
The data-protection note is deliberately absent — its stance is a
maintainer decision, and the page reads complete without it.
Two browser contexts, two seeded accounts, one database: the owner invites,
the delegate accepts and opens the record, the owner takes it back. Every step
through the shipped UI, because the half of this feature that can go wrong is
the half a person touches.
The stale-paint check is a negative one and deliberately not "the new record's
data arrives" — a check that waits for the new data says nothing about what
was on screen while it arrived. An init script arms a MutationObserver before
any page script on every navigation, including the reload the switch performs,
and latches the delegate's own marker weight. The observer's positive control
runs first on a page that definitely shows it, so its later silence is not the
silence of a broken observer.
Then it was broken on purpose, and the result is written into the spec rather
than assumed. Removing the switch-time cache wipe, the persister's restore
guard and the record-scoped hash — all three — leaves the spec GREEN: the
restore already refuses to clobber a query the live cache holds, and the
dashboard's query is created during render, so the snapshot never reaches that
route's screen at all. Removing the reload turns it red on the banner rather
than on the marker. So this suite is a regression net over the journey, not a
proof of any one control; the controls are proven where they can be broken and
observed, in the unit suites, and the spec's header says so.
The nav assertion turned up a real gap while it was failing: the avatar block
still linked into the account settings from inside a shared record. Those
routes refuse under a switch, so the link led only to an explanation of why it
did not work. It renders as a plain identity block now, and the mobile user
menu drops the same utilities.
Setup gains a second account, a marker weight in each record, a grant reset,
and a clear of the login bucket — this setup signs in twice now, and the
ceiling is five attempts a quarter-hour, so three local runs used to end with a
429 from the fixture rather than a failure from the product.
Three additions to the contract, all of them things a native client cannot
work out on its own.
`GET /api/auth/me` enters the spec for the first time, and enters it loose:
`accountAccess` is specified in full, the remaining preference fields are
declared open rather than half-enumerated. The endpoint has never been in this
document and its payload carries thirty-odd keys no contract has ever named;
specifying a subset and closing the object would publish something the server
does not keep.
`X-HealthLog-Account` is published as a reusable parameter with no route
referencing it, which is the honest state of the contract — the routes that
read another account's record arrive with the surface migration and will
reference it as they land. The header is live in the resolver today, and a
client needs the description before it can be built against a tagged spec: the
value comes from `accountAccess.accounts`, it is a selector and never a
permission, and it must never ride the sync engine, device registration, or an
auth call.
`GET /api/account/activity` carries `retentionDays` in the response and says
why: the window is operator-configurable, so a client that assumed 365 would
tell somebody their record was never opened when the honest answer is "further
back than this reaches".
The parameter is defined in the sharing route module rather than the shared
components block, so the boundary guard keeps holding the header's name to two
files — the resolver that reads it, and the one place that publishes it.
Each behaviour here was verified by removing the code that provides it and
confirming the test went red, then putting it back. Two of them did not, and
both were rewritten rather than kept:
The restore-side persister check asserted that the delegate's own dashboard
could not be read back inside a shared record. Deleting the guard left it
green, because the record-scoped hash already makes a snapshot written in one
record unreadable in another. It now counts what landed in the cache, so the
guard it names is the thing it measures — and the weaker line is kept beside
it with a note saying why.
The switcher's account rows were asserted from server-rendered markup. Radix
portals do not render server-side at all, so an earlier draft was measuring
the renderer. Those rows moved to the browser suite; what stays here is the
gate, which fails sharply — with the `canSwitch` bail removed the two cases do
not return different markup, they throw.
Two more shapes worth naming. The cache dimension is exercised through a real
QueryClient built from the app's own defaults, because "the hash string
changed" and "one record's data cannot be read while inside another" are
different claims. And the invalidation is a plain function over a client with
three live entries seeded, with a test that asserts they start valid — without
it, a client that failed to seed and one where everything was invalidated look
the same from every other assertion.
Settings gains one section for one concept: who else can open this health
record, whose this account may open, and when they were opened. Split across
two pages it would be two half-answers.
An invitation confers nothing until the other person accepts it, and the card
says so above the field rather than after the submit — that is the part
somebody needs to know before they type a name. Refusals are named from the
stable errorCode, because "no account with that name" and "that person already
has access" are different situations with different next steps.
Ended grants stay in the list. The row is the consent record, and "who had
access, from when to when, and who ended it" is the question the panel exists
to answer; a list that dropped them could not. Revoking asks once and then does
it: reducing access must never be harder than granting it was.
`GET /api/account/activity` answers the owner's other question. One row per
person per day with a count, not one per request — a hundred identical lines
is a way of answering nothing. The window is published with the rows rather
than assumed: audit history is purged on a schedule the operator sets, so the
view prints the number the server resolved from the same setting the purge job
reads. An instance running ninety days says ninety.
Six locales ship with it.
Switching is three acts in a fixed order, and the order is the safety
argument. Ask the server, so a refused switch leaves the browser where it was.
Empty the caches a reload cannot reach, awaited rather than fired, because a
wipe racing the navigation is a wipe that sometimes does not happen. Then
reload the document, which is what discards the in-memory cache, the rendered
React trees, and every request already in flight against the old record.
The banner is the feature, not decoration. Someone who forgets they are
switched will log their own blood pressure into another person's record, and
there is no undo for a reading that was never theirs. So it states whose
record is open, that it is read-only, and carries the way out — in the one
visual register reserved for "something about this screen is not normal", at
the top of the shell above every other strip. A subtle tint is what people
stop seeing on the third switch.
The switcher lives in one component mounted in both user menus. A way in on
the laptop and no way out on the phone is how the two menus have drifted
before.
Nav entries that sharing does not cover drop out, and a page reached by
bookmark says so instead of painting a row of refusals. Both are paint: the
server refuses those routes on its own, from a frozen list this code cannot
reach. Settings, connected services, notifications and the AI surfaces are the
account around a record rather than the record itself.
A grant can end while a browser is inside the record. Revocation clears the
delegate's sessions in the same transaction; expiry does not, and leaves a
session stamped with an account it may no longer act on — correct refusals
under a screen that lies. One cache subscriber notices the stable errorCode
and leaves, rather than asking two hundred call sites to remember.
Every cached read in this app is about one health record, and nothing in the
key factory said which one — there was only ever the signed-in account's. Once
a browser can act on somebody else's, `["dashboard","snapshot"]` names two
different answers and the cache cannot tell them apart. That is this repo's
known cache-poison class, on health data, across two people.
One line at the root rather than a dimension threaded through twenty-three key
modules: a `queryKeyHashFn` that folds the record id into the cache identity of
every entry. Key arrays are untouched, which is the part that matters — prefix
invalidation and the import-time dependent-key bundles both match on the array
and keep working. Prefixing the arrays would have broken every bundle silently.
The hash is defence in depth. Switching is a full page reload, so the
in-memory cache is empty on the other side of it; the layers that survive a
reload are the IndexedDB snapshot and the service worker's data cache, and
those are handled by refusing to write either while a switch is on, and by
skipping the restore. Nothing of the owner's record ever reaches the disk of a
delegate's device, so a stale scope marker can cost an empty restore and never
another person's data.
The persister's per-account binding read the account payload's id, which under
a switch is the delegate in both states — it could not tell the two apart. It
now asks the record scope, which can.
`GET /api/auth/me` grows an `accountAccess` block: the accounts this caller
may act on, the one the browser is inside right now, and the booleans a UI
binds directly — `canSwitch`, and `canWrite` per account. Every value is
resolved server-side. A client that worked out what it may do from grant rows
would be a second program deciding one person's access to a health record, and
the two answer differently the first time an expiry or a revocation appears.
`canWrite` is false for every grant in v1 because invitations write READ only.
It is resolved through the grant state machine rather than hardcoded, so the
day a WRITE grant exists the answer changes without this file being edited.
The route becomes an actor surface. It has to be: the switcher, the banner
naming whose record is open, and the way back out all read this payload, and
under a bare `requireAuth()` the whole app shell would refuse the moment a
switch was on. Every field it returns is still the caller's own.
The active record is published as a full entry, not an id to look up. A banner
that had to join two fields could render unnamed the moment they disagreed;
here the active entry is one of the listed ones, selected by the pass that
built them. A stamp left behind by a grant that lapsed reads as not switched —
the same answer the resolver gives the next delegated request.
Not published: the other account's avatar. Those bytes are owner-scoped, so a
URL here would resolve to a refusal and paint a broken image. Clients keep the
initials fallback.
The first entry in either frozen list. It is the way back out of a
switch: every other route refuses while one is active, so a browser that
could not reach this one would be stuck inside a record it entered. The
entry names why it may say what it says — one session row, the caller's
own, nothing of the owner's rendered and nothing granted.
The entry count moves with it, so an addition has to be made twice
rather than once.
The lifecycle surface under /api/account. An owner offers read access to
somebody already on the instance, the delegate accepts, either side ends
it, and a browser session points itself at a record it has been granted.
Ending a grant clears the acting-account carrier on every session of the
delegate pointing at that record, in the same transaction that stamps the
row. Split apart, a revocation would leave a browser inside a record it
can no longer read: every request refused, the banner still naming the
owner, nothing on screen able to explain it.
Revocation has no step-up and no confirmation step. Reducing access must
never be harder than granting it was.
Grant management is non-delegable by construction rather than by a check:
every route here resolves through bare requireAuth(), which refuses while
the caller is acting on another account, so no code path through them
runs as somebody else. The switch endpoint is the one actor surface, and
it is the way back out. A delegate already inside a record renounces
after switching out, one request, rather than the fail-closed default
growing an exception that later gets widened.
The state machine is not re-implemented anywhere: routes call the grant
module's predicate and its transitions, and the session carrier has one
writer.
Adds AuditLog.actorUserId (migration 0294) and threads it centrally, out
of the wide-event context the acting-account resolver already stamps. No
call site passes an actor, so no route can forget one.
NULL keeps meaning "acted for themselves" — the reading every existing
row carries. The threading fills the column only when a request resolved
an acting account AND the row is being filed under that account, so a
self-action, a refusal, and a delegate's row about themselves all stay
NULL rather than acquiring an id that would rewrite what the history says.
Delegated reads coalesce to one row per owner, delegate and day, through
a partial unique index and an insert that expects to lose the race. The
alternative is a row per request on the busiest table in the schema and
an activity view made of identical lines.
The column carries no foreign key, against the grain of user_id beside
it: SET NULL there erases an account from its own history, while here it
would turn "the delegate opened this record" into "the owner opened this
record". An id that no longer resolves reads as a deleted account; an id
that silently became NULL reads as a lie.
Both resolvers land with empty allowlists. That is the correct start and
also the shape that hides a broken matcher: a scan that finds nothing
agrees with a list that holds nothing, and the two look identical from
outside. So every leg pins a count that does not start at zero. The scan
asserts it found route modules, the two frozen legs assert the matcher
still finds the declaration site, the co-occurrence leg asserts each
cookie-only helper is present in the tree it reads, and the two
out-of-band Bearer surfaces assert they still resolve a token of their
own before asserting they reach no resolver.
Membership is decided from the parsed module rather than the source text,
so a route may explain in a comment why it is not delegable without being
enrolled by saying so, and an aliased import cannot hide one. The
synthetic modules at the top prove both halves of that.
Enumerates with a recursive readdir rather than a glob: globSync skips
dot directories, so it drops the three route handlers under
src/app/.well-known and leaves the sweep looking healthy at 444 modules
instead of 447. One of the three is pinned by name.
Scans 447 route modules and 2338 source files, and finds nothing.
The unit file walks the arms with the grant module deliberately unmocked,
so the resolver consuming the one "is this grant active" predicate is a
property under test rather than a claim in a comment. Its Prisma fake
honours its own `where` clause for the same reason: a fake that answers
any query with any row turns every scoping assertion into a check that
cannot fail.
The integration file proves the two things a unit suite cannot, because
both are about which rows come back. Substitution: the owner's
measurements are returned and the delegate's own, seeded alongside, are
not, so a resolver that quietly served the caller their own record shows
up as the wrong values rather than as an empty list. Never cached: a
revocation lands between two requests inside one test and the second one
403s.
The do-no-harm arm runs against a real shipped route, and the cookie-only
helpers are asserted against a real session row carrying a real switch,
because the one edit that would break them is `getSession()` itself
substituting the owner and no mocked session can catch that.
The structural guard freezes the set of files allowed to touch the
carrier column and the selector header, and asserts a non-zero match
count before comparing against the allowlist. A matcher that finds
nothing agrees with an emptied list and reports success; this repository
has shipped several guards that did exactly that.
One resolver, both transports, on every request. The cookie path reads
the carrier off the session row it has already loaded; the Bearer path
reads the `X-HealthLog-Account` selector header, because stamping switch
state onto a long-lived token would make the credential itself ambient
authority. Two carriers converge on one function for the same reason
`resolveBearerToken` lives in one file for two wires.
Three declared modes. `requireRecordAuth("read" | "write")` honours a
valid switch and substitutes the owner as the data scope while keeping
the delegate as the actor. `requireActorAuth()` always serves the
caller's own rows and keeps working under a switch, which is what the
switcher and the banner need.
Bare `requireAuth()` refuses to run under a switch at all, with a stable
`sharing.not_permitted` code. It does not fall back to the caller's own
account, and that arm is the point of the whole design: a delegate who
believes they are reading the owner's record and is handed their own is
the same data mixing pointed the other way. A route nobody has reviewed
is inaccessible while switched, never wrongly accessible.
The grant is loaded from Postgres on the request that uses it. Nothing
memoises the verdict, so a revocation lands on the delegate's next
request rather than their next login.
Refusals do not enumerate. The denial takes no constructor arguments and
carries a fixed message, and neither branch looks the named account up:
an account that does not exist and one that granted nothing produce the
same empty row from the same query, so the two answers are identical in
bytes and in timing rather than by careful wording. The reason reaches
the audit row and the wide event and nothing else.
`getSession()` still answers who is calling. That is what leaves
`requireAdmin`, `requireCookieAuth` and `requireFreshMfa` structurally
out of a switch's reach; the row's carrier rides out beside the session
id as a value to be checked, never as a decision already made.
The selector header is a Bearer carrier only. Sent over the cookie
transport, or to an actor surface, it is refused rather than ignored. A
client that names an account on a route which never reads one has a bug,
and a correct response to a wrong expectation hides that bug until it
arrives as a support case about data that looks wrong.
Five test files gain the session shape their fixtures always claimed to
have; they were building an auth context without one.
Migration 0293 adds `sessions.acting_as_user_id`. NULL, which is what
every session has carried until now, means the browser is acting as the
account it signed in as.
It is a selector and not an authorization. The resolver joins it against
a live grant on every request, so a value stranded here by a grant that
has since been revoked or expired confers nothing. Nothing sweeps the
column for correctness; the sweeps that exist are housekeeping, so a
delegate's browser lands back in its own account instead of on a wall of
403s.
ON DELETE SET NULL, not CASCADE. The two ends mean opposite things:
deleting the session's owner should take the session with it, which is
the existing `user_id` cascade, while deleting the account being acted on
must leave the delegate's browser session alive. A cascade on this side
would sign a delegate out of their own account because somebody else
deleted theirs.
A revoked grant with no revoker, or a revoker with no revocation, are
both rows that cannot answer the question the table exists to answer.
The migration already enforces the pair; this asserts it against real
Postgres rather than trusting that the domain module is the only writer.
The delete-all-data guard counted an account's surviving rows with a
literal `WHERE user_id = $1`, which is the same assumption the wipe loop
itself used to make. Account grants relate two accounts and carry no such
column, so the guard raised `column "user_id" does not exist` and stopped
before it could assert anything.
The predicate now comes from the wipe plan's own owner-column
declaration, so the guard asks the question the route answers instead of
its own version of it.
An account grant is one account's standing permission to read another
account's record. The row is also the consent record for the act that
created it: there is no second receipt table, because two records of one
act drift and then nobody can say which is true.
That shapes the table. Revocation stamps revoked_at and revoked_by
instead of deleting, so the record can still answer who had access, from
when to when, and who ended it. The uniqueness constraint is therefore
partial — one live grant per (owner, delegate), with history accumulating
underneath — otherwise a re-invitation would either be refused or would
overwrite the first grant's acceptance. Both foreign keys cascade: a
grant naming a user who no longer exists is an authorization nobody can
resolve and nobody can revoke.
The state machine lives in src/lib/sharing/grants.ts and only there. The
acting-account resolver and the grant surfaces both read these rows, and
if each carried its own idea of "active" the drift would be silent in
exactly one direction: the resolver admitting a grant the panel shows as
ended. Pending confers nothing, expiry is checked against the clock of
the request that reads the row, and every transition is a conditional
update so two concurrent accepts cannot both win.
The access column carries READ or WRITE and only READ is ever written.
It exists from day one so that delegated writes become enforcement work
rather than migration work.
Three lifecycle paths, each proven against the real route:
- Delete All Data removes grants from either side. The wipe loop asks
every model for "this account's rows", and a table relating two
accounts has no user_id to answer with, so wipe-plan.ts now declares
the owner columns and translates. Wiping only the grantor side would
leave the account still holding access to other people's records.
- Account deletion removes them in both directions.
- An admin restore leaves them exactly as it found them. Grants are
classified NOT_IN_BACKUP: rolling data back is a decision an admin
makes, but rolling an authorization back is a decision nobody makes,
and a grant revoked on Tuesday must not come back alive out of
Monday's file. After a restore, sharing is off and both people
re-consent.
The backup classification guard was reading a user relation by field
name, so a model naming its account relations grantor and grantee read
as instance-scoped and was excused from having a verdict at all. It now
reads the type. That surfaced InviteToken, classified here too.
The server names every unresolved catalogue key in its response and in the
audit row, and the integration suite holds that end. This is the other one:
a report nobody sees is the same silence the naming replaced.
The panel moves into its own exported component so it can be rendered
against a report directly. The SSR smoke tests in this directory cannot
drive a state transition, and a render proven only by reading it is not
proven. Pinned: every key is printed rather than summed away, each key
carries its own count and catalogue, an empty report renders nothing at
all, and the copy resolves in a second locale.
Three of the restore's lookups resolve a key against a seeded catalogue:
cycle symptoms, illness symptoms, mood factors. Each threw on a key it
could not resolve, inside the one transaction that wipes and rebuilds the
account. So a single renamed symptom rolled everything back and answered
500. No measurements, no doses, no notes, nothing. A year-old backup read
by a newer instance is the ordinary case for a backup, and the catalogue
drifts between those two dates by design.
The unresolvable link is dropped and named now. What it costs is one
association between a row and a symptom; the day-log, the mood entry, the
flow, the temperature and the encrypted note all come back. The count and
the exact keys ride the response, the audit row and the wide event, and
the admin surface shows them in a panel that stays put instead of a toast
that leaves. A quiet drop would be the older failure of this same seam
wearing a success message.
A dangling `cycleId` or a dose recorded against a medication the file
never carried still throws. Those name rows the file was supposed to hold,
so a missing one means the file contradicts itself. A catalogue key names
a row the instance owns, which is why it gets the other answer.
`IllnessSymptom` carries no owner column at all, so a key that will not
resolve there can only ever be drift; the file was never able to supply it.
The restore does not mint the missing catalogue row back. That would
preserve the link at the price of reference data colliding with whatever
the next migration seeds under that key, and a label nothing can translate.
The top bar's bottom border and the sidebar logo band's bottom border draw
one horizontal line across the top of the app, and they sat a pixel apart.
Both declared h-16, so the number was never the problem. Every box in the
app is border-box: the top bar carried `h-16 border-b` on one element, which
puts the line inside the 4rem at 63 to 64 px, while the sidebar put `border-b`
on the wrapper and `h-16` on the link inside it, which puts the line after the
4rem at 64 to 65 px. Same number, different line.
The height and the border now travel together as SHELL_HEADER_BAND in
shell-metrics.ts, next to the sub-shell scroll floor that exists for the same
reason. The element carrying that class is the band; neither call site
restates a height, and the sidebar link stretches with h-full. Border colour
stays per-surface, because the two chrome surfaces are tinted apart by design
and only the geometry has to match.
Two guards. chrome-header-seam.test.tsx asserts the structural property, that
each band renders every token of the shared constant and declares no height of
its own; a second height class on either element replaces the shared one
through twMerge, so both halves fire on the same edit. The e2e spec measures
the painted result at 768, 900, 1024 and 1280 px in both the expanded sidebar
and the collapsed rail, and checks each band actually paints a border at the
same weight first, so two missing lines cannot pass as two aligned ones.
`settleBeforeMeasure` ends with a `waitForLoadState("networkidle")` whose
rejection is swallowed and a `page.evaluate` awaiting
`document.fonts.ready`. Both are written to end on their own terms, and
neither had a bound: the config leaves `actionTimeout` and
`navigationTimeout` unset, which Playwright reads as no limit, so a
single sub-resource that never completes consumes the whole test budget
rather than the wait.
The way it surfaces is the worst part. The swallowed timeout hides the
stall, the run reports whichever call was still pending when the fixture
closed the page, and the message reads
Test timeout of 30000ms exceeded.
Error: page.evaluate: Target page, context or browser has been closed
which names neither the surface nor the resource. Raising the budget
changes nothing, because no budget bounds an unbounded wait.
Route one font request to a handler that never answers and the current
code reproduces that message exactly; with the bound in place the same
run reaches the spec's own assertion in twelve seconds.
Six seconds is an order of magnitude above the slowest settle measured
across the suite (1.1 s, /insights at a mobile viewport).
The `/insights/workouts` case gated its axe scan on `#main-content
[role="status"]`. That role is not a content marker: the busy skeletons
carry it too, and the one the streamed route shell paints first is a
`chart-skeleton` with `aria-busy="true"`. The sentinel therefore went
green on a document the page client had not touched yet. A frame trace
under a 20x CPU throttle puts that skeleton on screen at 408 ms and the
page's own `<h1>` at 4119 ms, so for most of four seconds the scan could
land on a tree with no level-one heading and report
`page-has-heading-one` against `html`. That is the failure a loaded
runner has been showing, and the page it accused has the heading.
Gate on the two states the page itself renders instead, the list or the
empty state. The overview case above already excludes `aria-busy`
skeletons the same way.
`/coach` had a smaller version of the same gap: `coach-page` is the
sizing wrapper around a `<Suspense fallback={null}>`, on screen 241 ms
before the conversation under it exists. Gate on the conversation.
Neither sentinel waits for a heading. Removing the `<h1>` from
`SubPageShell`, `CoachHero` and the conversation's screen-reader title
still turns the matrix red with four `page-has-heading-one` violations
per theme.
The nine-rung verdict ladder was written for the opt-in dashboard hero.
That hero was retired when the digest-driven Today rail became the single
promoted hero, and its i18n copy was dropped across every locale in the
same change, but the resolver stayed behind. Since then it has had no
caller outside its own tests: nothing renders it and no string exists for
any of its variants.
Every rung it computed already has a live owner. Dose states ride the
digest's dose_window item, an elevated-at-rest window rides tension_window,
the score delta rides the digest score block the Today hero paints its chip
from, weight drift and sleep deficit and a silent stretch are the coach
nudge cron's own triggers, and a crisis blood-pressure reading is the
safety-floor engine's. Wiring a renderer back on would rebuild the second
hero the retirement removed.
- Delete the resolver, its ladder suite, and the client-safety import walk
that pinned its bundle graph.
- Fold the two shared nudge thresholds back into the cron. The leaf module
existed only so a client component could read them without dragging the
server graph along; there is no such component now.
- Repoint the score-config guard off the verdict onto the digest fields the
Today hero actually reads, so it still fails on the assembly it was
written for.
- Repoint the clinical-floors notes off the retired surface.
Three integration cases pinned the old behaviour: the sync handshake stamping
the checkpoint, and the calendar read persisting the prediction. Both are the
faults this release removes, so the cases were asserting a bug.
Each file now asserts the read writes nothing AND that the other end does. The
second half is not decoration: without it, "nothing was written" would pass
just as well against a column nothing writes at all.
The cycle case seeds its own history and its own profile row rather than
reaching a route to get one, because reaching the route is exactly what the
prediction must no longer depend on.
The assertion in the module-gate suite could not fail: that fixture's mood
badges carry no completion date, so the result it drove had no pending
unlock and "nothing was written" was true either way. Restoring the INSERT
left it green.
The case moves to its own file with the builder faked so the result always
carries a pending unlock. Undoing the fix now turns it red. The
disabled-module invariant the old case reached for lives with the write, in
the sweep's suite.
A GET must not have side effects — api-handler.ts says so next to
READ_HTTP_METHODS, where an MCP-audience token is admitted on the safe
methods precisely because they are assumed to write nothing. Three routes
broke that: the sync handshake bumped User.lastSyncedAt, the achievements
read inserted unlock rows, and the cycle calendar fired a discarded promise
at the forecast cache so a failed write left no trace anywhere.
Each write moves to the place that causes it.
The sync checkpoint is now stamped by the batch ingest endpoints for
measurements, mood and intakes. The handshake reports it. That also fixes
the watermark: the checkpoint used to advance past a window the client had
not drained, and a client trusting it would skip that window. It can only
trail a real sync now.
The unlock rows move to a nightly achievement-unlock-sweep. The badge grid
never needed them — unlocked comes from the live metrics and the completion
date is derived — so the payload is unchanged; the rows pin that date and
carry it into the backup, and the sweep reaches accounts that never open
the page.
The cycle forecast moves to a nightly cycle-prediction-refresh. The
CyclePrediction row is not a cache of a read: it is the index the reminder
cron scans, so which reminders an account received depended on whether
somebody had opened the calendar. persistPredictionCache no longer
swallows its errors either — the job reports them.
The two calendar-day cases built their due instant with setHours on the
process clock, which vitest pins to UTC, while the component floors the due
date in the profile zone. Those two disagree about the date for the two hours
before UTC midnight, so both cases went red every night in that window and
green again by morning.
Deriving the date parts from the display zone removes the disagreement rather
than hoping the suite never runs at night. Noon is chosen deliberately: at any
offset the zone uses, 12:00 UTC lands on the same calendar date there, so the
instant cannot slide either way.
Verified inside the failing window: red before, green after, and putting
setHours back turns it red again.
Eight test and fixture files hardcoded scoreVersion: 2, a value the
constant has not held since v1.35.0. Now they import SCORE_VERSION and
use it, so a future bump cannot leave a fixture pinned to a stale
number.
The one genuinely historical case (record.test.ts, proving the input
fingerprint moves when the algorithm version moves) now derives both
its "previous" and "current" markers from SCORE_VERSION instead of two
unrelated literals, so the pair keeps its meaning after the next bump.
The Bearer guard demanded the literal `apiToken.findUnique(`.
`lib/idempotency.ts` writes the same call fluent-style, with a newline
between `prisma.apiToken` and `.findUnique({`, so the matcher never saw
it. It has been resolving a raw Bearer value to a user id since the
idempotency work landed, outside the frozen set, while the guard stayed
green on five files.
Tolerate whitespace around the member access and name the file with what
it does: it decides which user's idempotency cell a replay lands in, and
authorises nothing, because the handler still runs its own requireAuth.
Add the non-zero count assertion the other guards now carry. Narrowing
the regex back to its old form turns this test red, which is the proof
the broadened matcher is what finds the file. The mint-site matcher in
T3 was checked for the same defect and has no hidden members.
The Bearer guard freezes one mechanism: the files that turn an
Authorization header into a user. It reads like coverage of "who can
resolve a caller" and it is not. Two other families do the same job by
other means and no guard watches either.
Fifteen files under src/app read the session cookie directly instead of
going through requireAuth: eight API routes and seven pages. Nine more
authenticate on a token carried in the URL, including the clinician
share tree, which serves one person's whole health record to an
anonymous caller holding a path segment. A sweep that greps for
getSession sees none of the second family.
Freeze both as literal lists with a reason per entry, matched by five
independent legs: the session-deriving helpers, a direct read of the
session cookie name, a dynamic route segment carrying a credential,
share-token resolution, and a query-string credential. Legs overlap, so
each asserts its own membership and its own non-zero count. A leg whose
matcher stops matching then fails instead of letting the union carry it.
`<main id="main-content">` scrolls the authenticated shell, but it was
`position: static`, so an absolutely-positioned descendant with no nearer
positioned ancestor resolved against the initial containing block. That
block sits outside the scroll container: `overflow-y-auto` never clipped
those boxes, and their static position extended the DOCUMENT's scrollable
overflow instead. The result is a second vertical scroll surface beside
`<main>`, which UI-STANDARDS §9 forbids.
The escapees are library markup, so no per-component wrapper would have
caught them. A Radix `<Switch>` outside a `<form>` renders a hidden
`position:absolute` bubble input on its first pass and drops it only once
the button ref resolves, and the root `loading.tsx` chart skeleton carries
an `sr-only` announcement that lives until the real content streams in.
Both are gone within a few hundred milliseconds of hydration, so the page
carried a full-height document scrollbar for the whole pre-hydration
window and looked clean immediately after. On /settings/modules at a
390x844 viewport the document measured 1952 px against an 844 px viewport
while the switches were still bubble-inputs.
Anchoring the scroll container closes the escape route for every such box
at once. The over-scroll guard grows the matching structural assertion:
`<main>` must not compute to `position: static`. The height read it
already had can only see an escapee that happens to be mounted at measure
time, which is why it went red on a loaded CI runner and green on the same
tree twenty minutes earlier; the computed style is timing-free and fails
on every route and both viewports the moment the anchor is removed.
The MCP due-list answered "is this checkup overdue" by comparing the two
instants, so a checkup booked for nine in the morning read overdue from ten
past. The screens read the same reminder as "today" until midnight. One
reminder, two verdicts, and the model reading the tool result repeated the
one nobody could see.
Same counting as the screens and the briefing payload now, on the account's
own clock rather than the server's. A checkup stays due for the whole of the
date it belongs to, and is overdue from the date after.
The briefing payload states how far off a checkup is as a bare number the
model narrates back: "due in N days", "overdue by N days". It got that
number by dividing the hour gap by twenty-four, which answers a different
question than the one the sentence asks. A checkup booked for nine this
morning came out "overdue by 0 days" by the evening. One due Sunday at
01:00, read late on Friday, came out one day off — Saturday, on a calendar
where it is two. A clock-change day is twenty-three or twenty-five hours
long and rounded whichever way its length pushed it.
The checkup screens had exactly this and it was fixed there, so the counting
already exists. Lift it out as `calendarDaysUntil` and have both the phrase
and the number come off it, rather than leaving the payload with a second
body that can drift again. It counts by differencing two integer day indices
read on the person's profile clock, so the answer is exact rather than
rounded, and `extractFeatures` resolves that zone and hands it down — the
number now agrees with the screen the person is looking at.
Today's checkup moves off the overdue list, where the hour gap had put it as
soon as its appointment time passed. The hour it was booked for may be
behind, but the day it belongs to is not.
The horizon that bounds the read stays an hour cutoff on purpose. It is a
query bound, not a statement to anyone about which day something falls on.
Apple Watch ECG recordings reach the record straight from the app instead of
only through an export archive. One recording per request, gated by the same
switches that already govern reading them.
A recording is now identified by what it is rather than by what a source calls
it, so the same strip arriving through both doors converges on one row. The
migration that adds the constraint resolves the duplicates already stored: it
keeps the row holding the waveform over one holding only a verdict, and moves a
removed row's event link onto the row that is kept. The losing rows are deleted.
It asserted the stored ciphertext does not contain each sample rendered
as a string. Base64 is full of digits, so a short sample value like 3
matches by accident: the check failed on a mutation that had nothing to
do with encryption, and would equally have passed while leaking. It now
asserts the column holds the keyId.base64 envelope and nothing else,
which a serialised sample array cannot satisfy.
The mobile project read the day as final on a table with no sleep in it.
The rows were gone; what was left was the dashboard snapshot the desktop
run built from them. It carries the sleep-freshness the digest reads, it
lives in the analytics SWR bucket at sixty seconds fresh and an hour of
stale-serve, and a DELETE straight into Postgres does not touch it. So
the second project asked whether last night's sleep was in, and a cached
answer from the first project said yes.
The purge now goes through the bulk-delete route. That tombstones the
rows the way the management list does, recomputes the rollup buckets the
snapshot reads through, and hard-evicts the bucket, so the next read
rebuilds from the table rather than from the run before it. The
delete-by-external-id route names the rows the way this spec does, but it
invalidates without evicting, and a marked-stale cell is served while it
revalidates - the next read would still have been the stale one. The ids
come from SQL because the measurements list collapses overlapping sources
to one row per day and can hide a row that still counts.
The purge stays where it is rather than moving into the cleanup block:
the eviction only happens when the delete removes something, so tidying
up at the end of each run would leave the next one with nothing to delete
and the same stale snapshot.
The starting state is asserted, not assumed, and it stays that way.
A round trip through the real handlers: post a recording, read it back
through the list and the detail routes, and check what a client actually
receives rather than a fixture rebuilt at each end. Post it twice and
the second call says updated, not inserted, and the table does not grow.
Feed the same strip to the export-archive importer afterwards and it
says skipped; feed it to the importer first and the route says
duplicate. Both directions leave one row.
The migration test drops the index, manufactures the collisions the
resolution step exists for, and runs the migration file through the same
executor the deploy uses. It checks which row survived each tiebreak,
that the salvaged event link followed the keeper, that rows differing
only by user, source or sampling rate were left alone, and that the
constraint rejects a new twin afterwards. Applying the migration to the
empty testcontainer proves none of that.
The Withings ECG tests now hand the transaction the same delegate the
shared writer uses, so they still watch the real write instead of a stub
standing in for it.
POST /api/insights/ecg takes one recording per request: the stable
recording id the client already has, the on-device timestamp, the
sampling rate, the waveform as integer micro-volts, the lead, the
average heart rate, and the device's own verdict. Same gating as the
reads (auth, the insights module, the insightStatus surface), userId
from the session, APPLE_HEALTH the only source a client may claim.
sampleCount and durationSeconds are derived here, and nothing about the
waveform is ever interpreted.
The write body now lives in one place, src/lib/ecg/persist-recording.ts,
shared by the Withings sync, the export-archive importer and the new
route. Three writers with three persistence bodies is how the two ends
of one rule drift apart.
Migration 0291 adds the second unique index on
(user_id, source, recorded_at, sampling_frequency). The archive importer
identifies a strip by a hash of its content and a live client by the
device's own sample uuid, so the same physical recording arriving
through both doors carried two different ids and would have landed
twice. One device does not record two different strips at the same
instant at the same sampling rate, so the constraint is sound and the
convergence stops being a convention.
The table already holds rows on deployed instances and two writers have
been filling it since v1.19.0, so the index cannot just be created: a
pre-existing collision would abort the migration. The migration resolves
duplicates first, keeping the row with the most samples (a stored strip
beats a verdict-only row), then the one linked to its event
measurement, then the most recently written, then the lowest id so the
choice is total. The losers' event link is salvaged onto the keeper
first, so resolving a duplicate never costs the recording its place in
the event timeline.
If that resolution is wrong on some instance, the failure is visible at
deploy time and not later: db:migrate:deploy runs on boot, so the
container crash-loops with the migration named in the logs and the
release never serves traffic. A deploy that finishes in under thirty
seconds did not run it.
The GET list and detail routes and the export-archive path are
unchanged; this is a second door, not a replacement.
The changelog is meant to read as my own notes, but a stack of entries
narrated me in the third person ("the maintainer's complaints", "per
maintainer directive", "26 of the 27 maintainer findings landed") and
carried internal review choreography no reader can use: numbered review
rounds, finding counts, item codes, QA severity labels, a local path to
a design handoff.
Every one of those now states the change instead of who asked for it.
Where the only content was the attribution, the line is shorter or gone.
No entry's technical claim changes. "maintainer" stays wherever it means
the operator role of a self-hosted instance.
The flip from an authored recipe back to the defaults was written with the
retired pillar as the thing left out. With that pillar gone, both halves of the
case sent the same full list, so the first read false where it expected true and
the flip was never exercised at all.
It now takes out a pillar that still exists, and says at the assertion why the
two halves have to differ.
The spec watched the day flip from provisional to final on a hero that
only its own subject was keeping on screen: the arrival marker was the
last thing in the calm-degrade guard holding the hero up for the bare
fixture account, so removing the arrival chip took the hero with it and
left the spec nothing to observe.
Putting the arrival back into the guard would bring back a worse
problem. An arrival-only digest lands in the compact all-clear
composition, which would tell someone with no readings at all that
everything is clear - a claim about their health made from nothing, and
made only on the days something happened to sync. The guard stays as it
is; the fixture was what was wrong.
So the spec now seeds its own precondition: one integration needing a
reconnect, which is an ordinary Today for anyone whose readings come
from a device. It is read fresh on every request rather than through the
dashboard snapshot cache, so unlike a seeded score or briefing it cannot
lose a race with a cached snapshot, and the rail item admits no module
gate and no dismiss path. The row is removed again afterwards.
While the spec was open: it now pins the state it starts from. It only
ever asserted that the pending-sleep note was absent at the end, which a
note that never rendered also satisfies. It asserts the note is up and
the day reads provisional first, so the two halves are one assertion
with the number flipped.
The component side is pinned in unit tests, since the spec needs a built
app and a database to run.
The cycle ring stops working out its own verdict and reads the server's. The
constant that made that possible is deleted rather than deprecated, because a
constant left in client code is a constant that gets copied again, which is how
the other client's ring froze in the first place.
The score settings page takes the shape the other settings cards have, loses a
pillar that could never score, and turns green where the app's other two scores
already did. A saved selection now comes back as it was sent.
Five gaps in the published specification are closed, including a route that
shipped this morning and was never described at all.
Two branches met here. One retired the fitness pillar and renamed the test's
pillar list accordingly; the other, written against the eight-pillar world,
referred to it by its old name and its old count. The merge kept the new
declaration and the old references, so nothing compiled.
The name carried the count, which is why the collision was visible at all.
There are seven now.
/api/auth/me/health-score-config shipped in v1.35.0 with a settings page
in front of it and no entry in the contract at all — no path, no schemas,
nothing. A client had the surface and no way to generate against it. This
is publication only; the route does what it already did.
Both verbs are registered off what the route actually returns rather than
off a neighbour's shape. The asymmetry matters and is now written down: the
request speaks the positive selection, the pillars that should count, while
the response carries both sides resolved plus the recipe version. The column
stores the complement, which is storage and no client's business.
The response's updatedAt is documented as optional because it genuinely is
absent until a selection exists — an account that never chose has nothing to
guard a write against, and a decoder that required the field would break on
that read.
The PATCH 422 is written out rather than reusing the shared base-token
response, because this route has four of them and a client branching on
meta.errorCode needs all four: the breadth refusal with the sentence to show
the person, the validation failure, the unparseable base token, and the
non-JSON body. Naming only the token would have been a contract that omits
the refusal people actually hit.
The route test now drives the real route to produce each of those responses
and asserts the published contract names every code that comes back, so a
response the route can emit but the spec does not describe fails the suite.
That closes the base-token ledger: nine routes emit invalid_base_updated_at
and all nine now document it.
GET /api/auth/passkeys was absent from the contract entirely — no path
entry, no schema — while the client teams had been pointed at a published
schema for it since v1.32.8. It is now registered with the shape the route
actually serves: id, name, credentialDeviceType, credentialBackedUp,
createdAt, and the nullable lastUsedAt that started this. The route itself
is unchanged; only the record of it was missing. PasskeyInfo is deliberately
separate from WebauthnMfaCredentialInfo — one lists sign-in credentials, the
other second-factor security keys, and merging them would blur two surfaces
that behave differently. The route test now compares the projection the
route asks Prisma for against the published property set, so neither end can
move without the other.
`invalid_base_updated_at` existed only in route tests. It is now on all
eight published endpoints that accept a `baseUpdatedAt` body token, and the
field description says the thing implementers keep getting wrong: an
unparseable token, `null` included, is rejected with 422 rather than falling
back to the unconditional write. Omitting the key is the only way to get
that write.
/api/auth/me/health-score-config also returns this 422 but is not in the
spec at all; publishing that route is a separate piece of work.
The `type` query parameter on GET /api/insights/derived carried the eleven
VITALS_BASELINE vitals and described itself as being for that metric only.
SAME_TIME_BASELINE takes a `type` too, and its four cumulative day metrics
were absent from the enum, so a client generated against the spec could not
send them at all. The parameter now publishes the union of both registry
sets and the description says which values belong to which metric. A guard
reads the enum back out of the assembled document and pins it to the
registry, so a type added to either engine reaches the contract with it.
The idempotency wrapper's two 409 arms sent `error` as `{ message }`. That
was the only object-shaped error the application emitted; every other route
sends the documented `{ data, error: <string> }` envelope, which is also
what the spec has always promised. The code was wrong, not the spec. Both
arms now build their response through one helper that sends the string, so
every route under withIdempotency serves the envelope its clients decode.
The hero score-ring comment still described MED_COMPLIANCE as pooled 7-day
adherence. It has been today's dose progress since v1.27.8.
iOS green-lit removal on 2026-07-30 (their decoder treats both as
optional, confirmed at ShareLinkDTO.swift:105-106) and confirmed no
shipped build breaks. Drops RETIRED_FHIR_SCOPE_FIELDS and its spread
from the create and list responses, removes the two properties and
their required-list entries from ShareLinkCreated/ShareLinkList in
the OpenAPI source, and regenerates the spec. The request side was
already closed off by .strict() in an earlier pass; this finishes it
on the response side.
Route tests now assert the fields are absent from both responses
instead of asserting the constants they used to carry.
The app carries three scores and meant two things by a green dot.
Readiness and the sleep score both call 70 green; the health score alone
wanted 75. One app cannot do that, so the outlier moved. The yellow floor
stays at 50: raising the bar for a reward and lowering the bar for a
warning are different decisions, and only the first one was on the table.
The band is still not a function of the number. The composite takes the
worse of the mean's band and the worst counted pillar's, so a composite
carrying a red pillar stays red however high the mean sits - the stored
band on a recorded day exists for exactly that reason. Only the mean-side
thresholds moved.
The method version moves with it. A day already recorded keeps the band
it was actually shown under, and the notice key carries the version, so
each account is told once that the rules moved. The week-over-week
comparison is left alone on purpose: the number itself does not change,
only the colour it is drawn in, so a subtraction across the change still
compares two numbers worked out the same way.
Under a number, 'Just in' did not say what had arrived - the score? a
reading? - and the clock face beside it changed nothing a person would
then do. The line is gone, and with it the only wall clock this surface
rendered.
What it was there for survives: the day still flips in place when last
night's sleep lands, and the pending-night note still says the number is
reading an incomplete day. An arrival on its own is no longer a reason
to paint a hero at all, so an account whose only news was the arrival
now gets nothing rather than a bordered empty card.
The digest still carries the arrival, which the morning line and the
wide event both read.
Save moves out of the header and into its own row at the bottom right,
where every other settings card that saves a form puts it. The body
starts on the title's reading edge instead of the card edge, so the text
lines up with the cards above and below it.
The rules between the rows are gone; the gap between groups does the
grouping. Every row said 'Being recorded - Shown in the app' under the
pillar name, which is the modules screen's business and was the same two
words on every line - the sentence at the top of the card already says
it once. The paragraph explaining that heart, glucose and cholesterol
are three pillars in one area is gone too.
The area headings read in the content colour now rather than the muted
one, so they stop looking switched off beside the rows they head.
It could never score. The schema records VO2max but cannot tell a
measured test apart from a device estimate, so every fitness row reached
the scorer unproven and was refused - the pillar has never produced a
value on any account. Listing it on the score settings page as a thing
you can choose, with a line admitting it does nothing, was chrome over a
switch that changed no number.
So it leaves the catalogue: no pillar id, no domain, no scorer, no row,
no wire field. A stored selection that still names it reads back fine -
the resolver has always filtered stored ids against the catalogue and
drops what it no longer knows, so nobody's saved recipe throws or is
discarded over it.
That also fixes the page claiming unsaved changes forever. The save
stripped the fitness pillar out of the selection on its way to the
server, so the answer that came back could never match the form that
sent it, and 'All pillars' left the save button lit no matter how often
you pressed it.
The spec hand-builds a calendar response, and the wheel now reads the
resolved verdict from it. Without the field the ring paints empty, so the
fixture derives the verdict from the same phase plan it derives the day
grid from and the two cannot drift apart.
Three checks. The engine tests pin the overdue boundary from both sides:
day 42 of a stated 28-day cycle still counts, day 43 stops counting and
reports fifteen days later than usual.
The round-trip test calls the real GET handler, reads the response body as
text and parses it, so a field that both ends handle but the wire never
carries fails here. It runs the same instant twice for two accounts in
different timezones and gets two different answers, which is what the
account timezone is for. It also checks the ring's cycle day against the
phase engine the coach read uses, so the two cannot tell someone two
different days.
The guard reads the syntax tree of every file under components, hooks and
app, and fails on a grace-period constant, on an overdue verdict computed
rather than read, and on a cycle-day count compared against a sum. It
follows local bindings, so lifting the threshold into a variable above the
comparison does not hide it. A companion suite tries nine disguises against
the same detector and pins five innocent shapes it must ignore, including
an overdue medication dose.
The cycle ring's day count, its phase arcs, and the judgement that a period
is late all used to be worked out in web client code, including the
fourteen-day grace window that decides when the count stops being an
observed fact. Anything else that wanted the same answer had to reproduce
that rule, and a second copy of it is how a ring ends up showing someone a
cycle day their record does not hold.
GET /api/cycle/calendar now carries a verdict: the state, the cycle day,
the ring's arcs, the cycle start, how many days until the next period, and
when the period is late, how late. It is resolved against the user's own
timezone day, which a browser in another zone cannot do. The web wheel,
the wellness-strip tile, the insights teaser and the temperature chart all
render it, the grace constant is gone from the components directory, and
the spec says the field is required.
The overdue caption now names the number of days instead of only saying
later than usual.
A medication on a rolling interval had its expected dose anchored on the moment
the person actually injected, and was then checked off against the moment it was
planned for, with six hours of tolerance. Those two are allowed to sit days
apart, because a dose taken late still counts, so the dose someone's own
injection created could never be marked done. It stayed open, went overdue, and
the app asked for it again.
No editing was needed to reach it. Taking a weekly dose a few days late was
enough, on any account. Push reminders read the plan rather than the entries and
were never affected, so this was on screen, not on a phone.
The shell renders the heading pair twice under distinct ids, one column
hidden below the medium breakpoint and one hidden above it, so exactly one is
ever on screen. The spec asserted the desktop id alone: green on the wide
project, red on the narrow one against a heading that is hidden by design.
It now follows whichever of the two the viewport reveals, and asserts that
exactly one is visible, so a real duplicate would still fail.
CI died at the 4 GB default heap while checking types. The build was given its
own ceiling earlier this month so a local run, CI and the published image all
invoke it the same way; the type check was left on the default and has now grown
past it. Same treatment, same reason.
Also raises the aggregate bundle cap from 3260 to 3310 KB, and only that one.
Every per-route ceiling is untouched and every measured route sits within a
kilobyte of its previous value, so nothing a person downloads grew. The rise is
the new score-settings route, a mutually exclusive chunk that the whole-build sum
counts even though no single visit pays for it, plus its strings across six
locales. The reasoning is recorded in the budget file beside the number.
The new page wrote its own success toast. The outcome module owns that
affordance so a saved thing looks the same everywhere, and a structural check
catches anyone reaching around it. That check offers an allowlist entry as a
way out; taking it would have left two places deciding what a success looks
like, which is the thing the module exists to prevent.
Two branches met here. One made `configured` a required field so a caller
cannot stay silent and publish "not authored" for an account that chose its
own composition. The other wrote these cases before that existed, so they
compiled against the old shape and stopped compiling against the new one.
Filling the field in to satisfy the compiler would have thrown away the point
of making it required, so each case now states what it actually is: the two
with a hand-picked selection are authored, and the one whose own comment says
the account counts all eight is the default.
The Health Score stops being a by-product of switches meant for something else.
It gets its own page, its own recorded history with the recipe that made each
day, and a comparison that refuses to draw a line across two different
definitions.
An account that upgrades keeps the composition it already had, so the number
does not move on its own. What changes is that the composition is now visible
and yours.
The composite's coverage counts distinct areas of health, not pillars.
The learning line called them pillars, so an account recording blood
pressure, glycaemia and cholesterol read that it had one eligible pillar
while three sat in front of it. The count was right and stays; the noun
was wrong.
The meter beneath the score carried the same confusion further. Its
fraction is the three-area minimum the score requires, and a score only
exists once that floor is met, so it rendered as full coverage for every
account that has one. The arithmetic is untouched. Below the floor the
fraction is a real number and is shown as one; at or above it, the floor
is stated. The dots and the percentage were always honest, they track how
much history is behind the number, and they stay.
Both are held down by an AST guard rather than a text search, with a
counter-test for the neighbouring lines that are legitimately about
pillars and legitimately about a real fraction.
The flag was proven end to end over the real snapshot, digest and derived
wires, which is the right place to show that it arrives and the wrong place
to be the only proof of what it means. That suite needs a container runtime,
so on a machine without one the definition stood unguarded: replacing the
default side of the comparison with the unnarrowed catalogue, the exact
mistake the docblock warns about, left the whole unit suite green.
The definition now has its own fast test, and that break takes two of its
cases red.
A dedicated settings page lists every scorable pillar grouped by the area
of health it speaks to, so the fact that blood pressure, glycaemia and
lipids are three pillars of one area is visible rather than buried. Each
row shows the three things that were never separable before: whether the
pillar is being recorded, whether it is shown, and whether it counts
toward the score. Only the last is writable here, and the page says so.
A pillar whose tracking is switched off is not listed at all, because it
is not waiting for anything. A pillar this build cannot score yet is
listed, disabled, and says why. Wellbeing under safety signposting is
named as safety guidance and never as a mistake in the selection. The
refusal for a selection too narrow to produce a score comes from the
server and is shown in the reader's own language.
The score's foot disclosure gains one link into the page.
Two sentences about the Health Score stopped being true the moment
someone could choose which pillars count toward it.
The one people read said the score averaged "every pillar that has
enough recent data". For an account that took a pillar out, that
describes ground the number does not cover, and it reads as a
reassurance rather than a description. It now says the average is over
the pillars that count, that a pillar without recent data is still left
out rather than zeroed, and that which pillars count is the reader's own
choice, so the combined number is not itself a clinical standard. All
six locales.
The one nobody reads named the WHO HEARTS technical package flat, as
though a standards body had signed off on whatever selection the account
holds. HEARTS names the risk factors the pillars are drawn from; it
prescribes no average over them. The entry now says what it is cited
for. While checking where that citation surfaces it turned out it
surfaces nowhere: ProvenanceExplainer takes the prop and renders only
the method caption, which v1.22 did on purpose when the info popover
went away. That is written down in the file now, because the map's own
header implies otherwise and the next person would trust it.
The registry entry for HEALTH_SCORE lists all eight pillars with a
minimum of three, which reads like the recipe and like a threshold. It
is neither. The published coverage and confidence are built in
computeComposite over the resolved composition, so a person counting
four pillars is not measured against a denominator of eight, and the
real floor is the breadth rule, which counts distinct domains rather
than inputs. Both facts are now stated at the entry instead of being
inferable only by following the call chain.
Tests: the published denominator is never the eight-pillar catalogue; a
pillar somebody took out of their score is never listed back as missing
coverage; a pillar they kept and have no data for still is; the registry
list stays equal to the score catalogue; the choice sentence is present
in all six locales.
Five accounts, each writing its selection through the real PATCH route
and reading the flag back off the dashboard snapshot, the daily digest
and the derived-metric payload. Nothing is hand-built between the ends:
a builder that forgets to carry the field returns undefined and the
assertions fail.
The four negatives are the ways an account reaches the default
composition without having authored it, including one that kept every
pillar (hasSelection is true, the flag is not) and one that took out a
pillar its modules had already withdrawn. A sixth case walks the same
account through a save and an undo, so a value cached under a key that
does not include the recipe cannot pass.
One line under the method version, shown only when the server says the
composition is the person's own. It states the fact and stops there:
which pillars they took out belongs to the settings surface, and
working it out here from a configuration blob would put a second
decider behind one sentence.
The dashboard snapshot copies it off the composite, the daily digest
copies it off the snapshot, and the derived-metric payload already
carries it inside the composite value. Optional on both DTOs like the
score identity fields beside it, so older cached bodies stay valid.
The digest's score object is additionalProperties:false and had never
declared deltaReason, scoreVersion or composition, all of which it has
been sending. A generated strict decoder would have rejected the real
payload, so they are declared here too rather than left standing next
to a field that documents itself correctly.
One boolean on the composite: true when the account's resolved
composition differs from the composition its defaults would resolve to
today, both narrowed by the same modules. Decided once in the score
reader, from the same config and module map the composition came from,
so no client interprets a config blob to answer it.
This is not hasSelection. An account that opened the surface and kept
every pillar has written a selection and is not configured; an account
whose modules alone narrow the set has authored nothing and is not
configured either.
The promise is that nobody's number moves the day they upgrade, and the
way it holds is easy to lose: there is no backfill and no migration
step, because a null column reads as version zero, version zero counts
every pillar, and every pillar narrowed to what the account's modules
record is exactly the set the module switches were producing before.
The inheritance is the resolver's behaviour, so the next person to touch
the resolver needs to know they are holding it.
Also records what the first save sets in motion, once each: the delta
suppressed while the comparison window still straddles the change, the
next stored day marked as a break so the line is drawn in two segments,
and the notice raised under a key carrying the recipe version.
The recipe boundary opened with an early return for a missing date and
another for a date it could not read. Deleting either one changed no
answer any test or any caller could observe: both cases parse to NaN,
and every comparison against NaN is already false, so the refusal was
falling out of the arithmetic underneath them. An if that cannot change
an outcome is a comment wearing a keyword, and it invites the next
reader to trust a lock that is not attached to anything.
The behaviour is unchanged and now has one statement instead of three,
with the reason written where the next person will look for it.
The seam had the same shape in miniature: a day naming no recipe was
already broken away from a day that named one, by plain inequality, so
the arm only ever mattered when two consecutive days both named none.
That case is now covered, and removing the arm makes it fail.
The recipe boundary also required a version of at least one before it
would suppress. That reads like a second lock and is not one: the
resolver returns a version below one only on the same path that returns
no change date, so nothing could ever reach the condition and no test
could make it fail. Removing it leaves the guard asking the one question
that has an honest answer, which is whether the recipe moved inside the
window.
The invariant that makes the removal safe is now pinned instead: a
resolved configuration never carries a version without the date beside
it. A resolver path that produced one would wave that account's recipe
change straight past the guard.
Also pins the order of the two rules that can fire together. A change
that moved the composition trips both, and the person is told they
changed what counts rather than that the included pillars changed, which
would describe the consequence and leave out the act.
The named failure now has a test that drives the whole chain a request
takes: fixture rows through the real reader, the real score record
write, the real dashboard snapshot, the real verdict rung, and the real
digest that gives the morning push its sentence. Only the IO around that
chain is faked. A version built on a hand-written snapshot literal would
have stayed green through a release where the assembly between the two
ends dropped the field, which has happened here before.
Every case runs twice on the same data. Once with the recipe changed
three days ago, once with it changed a month ago. The second run is what
proves the fixture really does produce an eleven-point fall, so the first
run's silence is the guard working rather than an account with nothing
to say. The decline is gradual on purpose: a cliff would raise each
pillar's own noise floor and the delta would be suppressed for a reason
that has nothing to do with the recipe.
Beside it, the boundary's edges, the notice key's two dimensions and the
seam's shape, each with a case proving the guard stays quiet on innocent
data. Against real Postgres, a row now has to come back carrying the
recipe version and change date the account's stored configuration
supplied.
Changing which pillars count is an act the person performed, not
something that happened to their health. Until now the week-over-week
comparison could not tell the difference: both windows are computed in
one request under whatever recipe is in force right now, so the pillar
sets agree, the subtraction looks legitimate, and the dashboard could
announce "your score dropped 14 points" to someone who had just come
back from the settings page.
The recipe now carries its own dated identity, and the delta guard
compares against it the same way it already compares against the global
method boundary: a recipe that changed between the two windows
suppresses the delta with a new reason, config_changed, and the surfaces
that gate on an unexplained delta stay quiet. The score record stores
the recipe version and its change date on every write, resolved from the
same stored blob the composition came from, so a row can say which
recipe produced it. A new series module reads those versions back, marks
the first day after a change as a break, refuses to join the composite
line across it, and keeps the per-pillar rows running through it: a
pillar is graded against its own reference band and stays comparable
when the average of the pillars does not.
The notice key folds the person's recipe version in beside the method
version, so a change in either raises the existing dismissible notice
once and dismissing one never dismisses the other. An account that never
chose keeps its old key untouched.
The module-to-pillar mapping had no test of its own. It lived in
ternaries, so the only way to notice it had changed was for someone's
score to change.
The new file drives the real reader against a fake database and pins
three things. The composition for every module combination with no
stored recipe, compared against the old expression written out verbatim,
which is what makes it a before-and-after rather than a restatement. The
difference between a pillar that is selected and has no data yet, which
keeps its row, and a pillar whose module is off, which has no row at
all. And the breadth refusal at read time, whose reason has to be the
shared rule's verdict on the set the reader resolved, so a second copy
of that rule would show up as a disagreement.
Until now the set of pillars behind the Health Score was assembled from
the module switches alone, in a stack of ternaries inside the reader. A
person who turned the sleep module off lost the sleep pillar from their
number as a side effect of a setting about recording data, and there was
nowhere else to say what should count.
The reader now reads the stored recipe and narrows it to the pillars a
module is actually recording. The mapping from module to pillar moves
out of the ternaries into a named map beside the reader, where it can be
read and tested. Glycaemia stays fed by either glucose or labs, which
was the one entry a one-module-per-pillar map would have lost.
Nobody's number moves on upgrade. An account that never opened the new
surface resolves to every pillar, and every pillar narrowed by module
availability is exactly the set the ternaries built, so the inheritance
holds by construction with no backfill.
A pillar the recipe selects whose module is off produces no row at all,
rather than a "waiting for data" line. Turning a module off is a
deliberate act, and asking someone for data they chose to stop recording
would be nagging them in the voice of their own settings.
The audit script prints the annotation verbatim, so a note wrapped across
three schema comment lines came out with the comment markers folded into
the middle of the sentence.
The delete-all-data guard seeds every table straight from information_schema
so a table added next year is asserted without anyone remembering. A CHECK
constraint defeats that: the generic seeder has no way to guess a value the
constraint accepts, and the whole suite failed on the new table. A Postgres
enum carries the same closed set, the seeder reads it from pg_enum, and
Prisma now types the column instead of trusting a string.
Dropped the day-key format and non-empty-composition checks with it. The
writer builds the key through the timezone helper and refuses an empty
composition before inserting, and the restore validates both against the
wire schema before the transaction opens, so a third copy in the database
bought nothing and cost a hand-written line in a fixture whose point is not
having one. The composite range check stays: free to check, and a number
outside nought to a hundred renders as a score nobody can read.
The scorer and the stored composition both had to answer what a valid
pillar list looks like, and both had their own copy of the answer. One
function next to the catalogue now, used by both.
Four suites hand-roll a Prisma stand-in for the backup builder, so a new
section reaches them as an undefined delegate rather than as an empty
result. Each gains the delegate, and the admin restore round trip gains an
assertion that the day comes back with its composition and per-pillar
scores, not just its headline: a row restored without those looks restored
and answers nothing.
A new user-scoped table is outside all three by default, and the two
structural guards said so the moment the model landed: the wipe plan and
the backup verdict list both named it as unclassified. It now joins the
wipe list, rides the backup payload as its own spread section, and comes
back through the restore route.
The classification guards only prove a helper exists in a declared file, so
they would pass an unreferenced restore helper. The integration round trip
goes through the real admin restore route instead, with the table emptied
first so a restore that carries nothing cannot pass by leaving what was
already there.
PATCH /api/auth/me/health-score-config takes the pillars a person
wants counted and stores the rest as the deselection list. A selection
that cannot produce a score is refused with a sentence naming what is
missing, using the same rule the scorer applies at read time. The
write evicts the score caches, and so does the modules route, which
changes the same composition and never did.
The fingerprint carried the composition twice: once as its own key and once
as the ordered pillar ids inside the per-pillar triples. No input could make
those two disagree, so the term could not be broken and the test that
claimed to cover it passed with the term removed. Dropped the duplicate and
replaced the test with one that pins what the ids are for: a different set
of pillars producing the same numbers in the same order is a different
standing, and now reads as one.
Reads the stored deselection list into the pillar set the scorer and
the settings surface both work from. An absent list means the person
never chose and keeps counting everything; a present list is an
authored recipe and is never widened. Ids outside the catalogue are
dropped on read rather than failing the whole blob, so one unknown
string cannot discard a recipe someone wrote.
Every surface that shows a score now goes through one entry point that
computes it and writes the day down, rather than three surfaces each
remembering to. The write is an insert that yields on conflict, so a second
computation of the same day leaves the first row alone, and a failure is
annotated rather than thrown: a score that renders must not disappear
because a bookkeeping insert did.
The fingerprint covers exactly what the stored composite and band depend on
- the algorithm version, the composition, and each counted pillar's score
with its scoring-mode identity. A pillar that was graded but not counted
stays out of both the fingerprint and the row, because it did not
contribute to the number being stored.
The composite needs three distinct domains and one physiological
pillar. Until now only the scorer could narrow the pillar set, so the
rule lived inside it. A settings write can narrow it too, and a second
copy of the rule would drift, so both layers now call the same
function over the same pillar-to-domain map.
The score is computed live from today's rows, so once the readings behind
a past day change the number the person actually saw that day is gone.
Nothing wrote it down. This adds the table that does: one row per account
per local day, holding the composite, its band, the algorithm version, the
composition that produced it, every counted pillar's own score, and a
fingerprint over exactly what those depend on.
Append-only by construction. A unique index on account plus local day gives
the writer something to conflict against, and there is no updated_at,
because a later computation of the same day must leave the earlier row
exactly as it stood.
A nullable JSONB column on users carrying which pillars the person
chose to count toward the composite score. Stored as a deselection
list so a pillar shipped later counts by default and cannot be
confused with one the person removed. NULL keeps its own meaning:
never chose, which is not the same as counting nothing.
The over-scroll guard measured a settings page while two of its cards were
still showing their loading state, and read a height that no longer existed a
moment later. The page was blamed for a second scroll surface it did not have.
The gate waited on the layout shell, which is present long before the data
under it, and its own documentation says a sentinel has to be something the
measurement depends on. It now also waits for the loading placeholders the app
marks in the markup, and gives up quietly rather than failing if one legitimately
stays, since a timeout would be a worse failure than the one being prevented.
The check compiles the app's stylesheet the way the app does, and reaches
postcss through the Tailwind plugin's own resolution rather than taking a
dependency on it. The dead-code gate reads the module name and asks for a
declaration; the borrow is deliberate, so the name is listed as one the gate
should leave alone.
The panel picked up a green +N chip beside its label when the score moved
back into the band, and the repo-wide affordance guard counts that colour
wherever it appears outside the outcome module. The chip reports a week that
went up rather than an action that succeeded, so it is recorded here at
exactly one use rather than routed through the outcome table.
The sideways check could not fail for the case it was written for. A panel
wider than the phone kept it green: the hero band wears overflow-hidden for
its glow, so an over-wide panel is clipped rather than scrolled and the
document never widens by a pixel. The person loses the right-hand end of
every row and nothing reports it.
It now compares the panel with the room the band actually offers, which is
the comparison the clipping makes meaningful.
The reserve and the row's label column were carried as comments with pixel
figures nobody could reproduce: the tool that produced them was never
committed. This adds one that is, and the figures change.
The reserve did not hold anything. Its declared minimum was 30rem while its
own blocks added up to more than that, so the height was whatever the blocks
happened to be and the constant was decoration. The blocks are back under the
floor and the floor is 28rem, which sits between the shortest and the tallest
panel a real report produces: three scored rows come to 410 pixels at the
352 pixel column, four 438, five 466, six 494, and the reserve is 448. A short
report is no longer pushed down by 74 pixels when the score lands.
The label column goes back to the 7rem the design specifies. It had been
widened to 8rem on the grounds that 7rem clipped the longest label in five of
six languages and 8rem cleared them; measured, the longest label overruns both
widths in five of six, and the only difference is English, by a margin smaller
than the error introduced by measuring in a substitute typeface. The extra
width came off the fill bar for nothing.
The new check renders the real band with the compiled stylesheet and reads the
boxes back in a browser: the reserve against the panel it stands in for, every
row in all six languages on one line with an ellipsis rather than a push, and
no sideways scroll at the 352 pixel column or at 360 pixels stacked. It states
plainly that it cannot know the shipped typeface, since the font is fetched at
build time and no font file is committed, so every assertion in it is one that
holds in any typeface.
Four things a person notices if they break: a pillar never shows up twice
anywhere in the panel, a failed read never lands in the not-scored list, the
panel does not push the page sideways at 360 pixels, and the whole thing is
there with the briefing switched off. Every one of them was broken on purpose
first to check the test actually goes red.
The label column is 8rem, not 7. Rendered in all six languages, 7rem clipped
the longest label in five of them, two of those by two pixels, which reads as
a bug rather than a truncation. The reserve is 480 pixels, measured against
the built panel rather than guessed: three rows come to 440 and six to 524.
The overview fixture now carries a health score, so the phone-width guard
measures a band with the panel in it instead of an empty column.
The stand-in for the discovery engine took an argument it never read, so
the lint run flagged it. Give the mock a function type instead: the
argument stays inspectable, which is the whole point of the probe, and
nothing is declared and then dropped.
The grader returns the score and its basis together and the pillar builder
copies both across, and each of those ends already had its own test. Neither
sees what happens between them: the envelope hand-off, the reader's narrowing,
the pillar list, the JSON that finally goes out. A field can be produced
correctly, consumed correctly, and never reach anyone, which is exactly how a
payload field shipped earlier this month and carried nothing on the wire.
These tests read the assembled response and assert the basis that arrives, so a
future hand-picked key list on that path fails here rather than in someone's
browser.
Three lines now sit above the reference band: which axis set the number
and how far it was from its ceiling, what the scale means at the ceiling
and at 100, and that the pair shown is a recency-weighted average rather
than a reading anyone took. In all six languages.
The guard read a parameter as required whenever it carried no question
mark and no default, so `Locale | undefined`, `Locale | null`, a locale
widened to `any`, and a default sitting on the destructuring pattern all
walked straight past it. Each of those lets a caller say nothing and the
sentence come back in English, which is the bug the guard exists to stop.
A counter-case pins the other direction: a plainly required locale still
reads clean, so the guard stays quiet on the shape the chain is meant to
have. The correlation read now also asserts the language it was asked in;
nothing else in that file would have noticed it going back to English.
The score popover now opens with three sentences before the reference
line: which axis set the number and how far it sat from its boundary,
what the scale means at that boundary, and that the pair behind it is a
recency-weighted average rather than a single reading someone took.
The lines read the basis the grader already publishes, so nothing here
recomputes anything. A pillar that arrives without a basis contributes no
lines at all instead of an empty one.
The score is the worse of the systolic and diastolic sub-scores, and
until now nothing said which one it was or how far over its ceiling the
reading sat. The grader now returns that basis together with the score,
as one value, and it is carried as one value all the way to the pillar
so an explanation can never belong to a different grading run than the
number it explains.
The score returns to the right-hand column of the hero band, where it sat
for eleven weeks: the number at the top, then one thin line per pillar that
actually counts toward it. The card that was pinned below the greeting is
gone, so the score has one place on the page again.
Each contributor line now takes its own colour. A pillar scoring 92 used to
be painted red just because the overall score was red.
Anything not contributing is one quiet line instead of a block: a failed
read is named once for all the pillars it took down and carries the retry,
safety guidance is named in full, and everything simply not recorded yet is
counted, with the names a tap away under the score.
Three things are held down here. The route hands the reader's language to
the strip builder, for a cookie and for a stored preference. The pattern
sentence comes back in German for a German reader, asserted word for
word, through the real chain rather than the engine on its own — the
engine could always write German, it was simply never asked, so an
engine-only test would have stayed green the whole time it was broken.
And a cached strip stays with the language it was written in.
The guard reads the declarations rather than grepping them, so renaming
the argument or moving the fallback into a variable does not slip past
it. Its counterpart runs the same detector against those disguises and
insists each one is caught, because a check that cannot fail reads like
proof and is not.
On a German metric page the strip printed its own summary in German and
the pattern line under it in English. The pattern line is a finished
sentence the server writes, and the route never told the server who was
reading, so it wrote English.
The route now resolves the reader's language the same way the
correlations and analytics routes do and passes it down. The language is
a required argument the whole way through — it used to be optional with
an English fallback, which is why a caller could stay silent and still
get a sentence back. The cycle page's lagged rows had the same gap and
are fixed with it.
The strip's browser cache is keyed by language too, so switching
language no longer re-uses the sentence written for the previous one.
Every fix in this release is the same shape underneath. Two places in the code
were each deciding something they both had to agree on, and nothing made them
agree: two copies of the due-day rule, two clocks on one card, two server caches
behind one page, two ideas of where a nap belongs.
The version anchors, the changelog and the published specification move together
as usual.
The nap fill and a wake bout measured 1.52:1 in the dark theme and 1.16:1
in the light one, so on any night with a wake bout the two bands read as a
single block. That defeats the point of pulling the nap out in the first
place.
No fill colour can fix this. To clear the 3:1 asked between neighbouring
shapes, a nap colour would have to beat all five stage colours and the card
behind them, and in both themes those two demands point in opposite
directions with nothing left in between. The band now carries a card-
coloured outline instead, which is the usual answer for stacked bars and
leaves the stage colours exactly where they were. The tightest pair it
leaves is 4.81:1.
The legend and the tooltip already name the nap, so the distinction never
rests on colour alone.
The reading behind the pin was taken under `next start`. The container runs
`node .next/standalone/server.js`, and a fix that is green everywhere and
inert in production is the failure this project has paid for before, so the
same probe was re-run under the standalone entrypoint with static assets and
public copied the way the Dockerfile copies them.
No difference: three module instantiations either way, one shared registry
with the pin, separate registries without it, and a measurement write made
through a route handler reaches the page render's cache only when the pin is
there. The note says which entrypoint the claim rests on so the next person
does not have to wonder.
Three things are worth not regressing: a big afternoon nap must not land in
the night's stage buckets, a day without a nap must come out exactly as the
old per-wake-day path produced it, and the two surfaces must name the same
session as the night for the same rows. The last one drives the real
/api/sleep/night handler rather than re-deriving its answer.
The chart's row assembly moved into a plain function so the nap band can be
tested at all; Recharts draws nothing under server rendering.
The stage-composition chart totalled each wake day, and a nap ends on the
same wake day as that morning's night. An afternoon nap was added straight
into the night's stage buckets, so the column read longer and shallower
than the night actually was.
The composition now asks the same question the night view already asks:
which session was the night. The night's stages are the night's, and the
nap sits on its own band above them, in its own colour, with its own line
in the tooltip. Days without a nap look exactly as they did.
`dashboardSnapshotCacheKey` documents itself as the single source of truth
for the key, and the invalidators sweep by it, but the read spelled the same
string out by hand. The two matched, so nothing was broken today — edit one
side and the other stops matching silently, and the only symptom is a
dashboard that keeps painting the body from before the write.
The read now calls the function. The guard warms the cell through the real
read and then asserts the entry sits at the composed key exactly, so a
suffix change on either side is caught rather than forgiven by the prefix
sweep; two more cases cover a snapshot invalidation and a recorded dose.
The header on the shared read claimed both entry points ride the same cell.
That claim now holds, but only because the cache registry is pinned to the
global, so the header says so.
The admin panel offered a toggle for the Health-Score explainer and
described it as the ? next to the delta line. That ? had already been
taken off the insights surface a release earlier, and the caption that
replaced it went away entirely when the health score was replaced by
the reference composite. Since then an operator could turn the switch
off, get a saved confirmation, and change absolutely nothing.
The toggle, its wording in all six languages, its place in the flag
matrix the app and the iOS client both read, and its column on
app_settings are all gone. Nothing is left to promise a control that
does not exist.
A guard goes in with it: every remaining assistant sub-flag has to be
read by some surface, and the panel and the plumbing that carry all of
them by construction do not count as proof.
A production build instantiates a module imported from both a Server
Component and a route handler once per bundler layer. The cache registry
sat in plain module state, so the home page's server render held its own
set of Maps and every API route held another. Recording a dose evicted
the route handlers' copy; the home page then rendered from a copy nothing
had touched and painted the dose as still due until the entry aged out on
its own, two or three minutes later. Because that render is dehydrated
into the client query cache, it also overwrote the fresh value the browser
had already fetched.
Measured on `next build && next start`, single process: the page render
and the snapshot route reported different cache instances, and after an
interactive write the route handlers' copy dropped to zero entries while
the page's copy stayed full.
The registry now lives on globalThis under a Symbol.for slot, which is the
only scope both layers share. db.ts guards its own global assignment with
a non-production check because it is only defending against dev reloads;
this one has to hold in production, so it is unconditional.
Moves the host zone under the function to prove the answer does not follow it, since the suite otherwise runs pinned to UTC and would not have noticed. Adds the two days a year that are not twenty-four hours long, where counting hours rounds the evening into tomorrow. The structural check that the shared body never reads the device clock was looking for the wrong shape and let a deliberate break through; it now looks for the word itself.
The baseline step submitted what you filled in and ignored the answer.
The profile write is field-independent: it stores everything that
validated and reports back what it declined. Nobody read that report
here, so a value the server refused vanished between the input and the
next screen and you moved on believing it was saved.
It now says which field did not make it, using the name that sits next
to the input you typed into, and keeps going with the rest that did
save. When nothing could be saved at all, the step stays put and names
the field that blocked it instead of a shrug. The wording and the
field-naming come from the same place the account form in Settings
uses, so the same refusal reads the same wherever you hit it.
The phrase read the device clock while the date printed under it read the profile clock. Set a laptop to another zone and the card could say a checkup was tomorrow above a date that was today. The zone is now something a caller has to state, and both screens state the one their dates are printed in.
Two screens once carried their own copy of the same date arithmetic and quietly disagreed for a year. This fails the moment a second one is written, whatever it is named, and says where the real one lives.
The dashboard starts its snapshot request the moment the router commits to
"/", and parks the response in a slot outside the query cache until the
mounted cell asks for it. The cell only asks on its first fetch, so on every
return to the dashboard — where it already holds data and does not refetch on
mount — the parked response simply sat there unclaimed.
The next thing to call the query function was then whatever came along: the
two-minute poll, a focus refetch, or the invalidation that follows recording
something. All three exist to fetch data newer than what is on screen, and all
three were handed a response that had been requested before the entry was
written. So a value recorded within a minute of arriving on the dashboard
could keep reading as the old one until the following poll replaced it.
Only preload when no snapshot cell has produced data yet. That is the cold
first load the preload was built for, and it is the only moment the mounted
cell will claim the response promptly.
The cases that used to read wrong: an evening look at this morning's checkup, a Sunday early-hours checkup seen late on Friday, a Saturday late-night one seen just after Friday midnight, and one from yesterday that only slipped past a few minutes ago. Each is a case where counting hours and counting dates give different answers.
The dashboard summary card and the checkups page each worked out "today",
"tomorrow", "in N days" and "overdue by N days" from the same due date, and
each did it with its own copy of the arithmetic. The page compares calendar
days. The card subtracted one instant from the other and divided, so a checkup
due Sunday at 01:00 read "tomorrow" when you looked on Friday evening, one due
Saturday at 23:59 read "in 2 days" when it was in fact tomorrow, and one due at
09:00 quietly stopped saying "today" as the afternoon wore on.
Both now read from one place, so they cannot drift apart again.
A cycle symptom can be recorded with a 1-4 intensity. The log-day sheet
writes it, the day-log reads it back, and the backup never carried it:
the file held the symptom keys and nothing else. Export a day recorded
as "cramps, and they were a 4", restore it, and you get "cramps". The
symptom is there, the number is gone, and nothing fails, which is why
nobody noticed.
The file now carries `symptomSeverities` alongside `symptomKeys` — one
entry per symptom that was actually rated. `symptomKeys` still decides
which symptoms a day had, so a release that only knows that field reads
every symptom out of a new file unchanged, and the two lists can never
disagree about presence. A link nobody rated is simply not listed: no
entry means never rated, which is not the same as rated zero.
A file written before this restores exactly as it did, with every
intensity absent rather than invented. An intensity outside 1-4, from a
hand-edited file or some later scale, costs that one number and leaves
the symptom and the rest of the restore alone.
The overnight reminder tests handed the check every intake row the
fixture held, whatever date range it had actually asked for. That left
the suppression assertion unable to fail. Narrow the read back to the
day the check happens to run in and the 23:45 dose logged at 23:50 drops
straight out of it, yet the test stayed green. The fake now honours the
scheduledFor range it is given, so the assertion answers the question a
person really asks: does it stop pestering me about a dose I already
took.
Two cases were missing at the other end of the same boundary. A dose
genuinely missed at 23:45 still has to escalate in the small hours, and
the missed-dose row it leaves behind has to name the evening it belongs
to rather than the morning the check runs in. Both are pinned now, next
to the case where the dose was taken and no missed row may be written at
all.
The last-attempt lookup runs on the zero-rows path, which is exactly the path a
fresh account takes. When it threw, the whole nutrients read returned 500 — so an
account with nothing to show got an error instead of an empty page, and the
feature that exists to explain an empty surface was the thing preventing it from
rendering.
Wrapped so a failed ledger read degrades to the plain empty state and is recorded
for the operator rather than shown to the person. The route's shape assertion now
carries `lastAttempt: null` explicitly, so a field cannot join this response
without someone deciding it should.
Thirty-four changes, most of them one shape: something the server or the screen
already knew and did not say. A closed schema that omitted three fields it sends.
A fence around sensitive data that the contract never described, so the native
client dropped an affordance rather than risk crossing it. An ingest whose only
durable trace fired on success, leaving "did the phone even post" unanswerable. A
profile save that discarded valid fields to punish an invalid neighbour. A card
titled "that day" that received the day and threw it away.
The correlation-discovery engine always sets shrunkR (the
sample-size-shrunk effect) and tier (the phrasing-confidence class)
on every surfaced pair, but the closed DiscoveredCorrelation schema
never declared either — same defect class as the dashboard snapshot:
additionalProperties: false rejecting a field the runtime actually
sends. Both are required, matching src/lib/insights/correlation-discovery.ts.
GET /api/dashboard/snapshot is additionalProperties: false in the spec
but the runtime emits three blocks the schema never declared: the
band/target math (targetBands, always present), the score-card
narrative (healthScore.tension / healthScore.returnToBand, additive
optional fields), and the briefing recall/forward-look
(briefingMemory). A strictly generated client rejects the real
response as a result.
Adds all four to dashboardSnapshotResponse with the exact runtime
type, optionality, and nullability, matching DashboardSnapshot in
src/lib/dashboard/snapshot.ts and the DTOs in
src/lib/dashboard/score-narrative.ts. No route behaviour changes.
Five assertions slept a fixed 100 ms and then read the audit ledger. The write is
fire-and-forget, so on a busy runner the read lands first and the assertion fails
with "expected [] to include 'consumed'" — which reads as a broken refusal when
the refusal worked and only its breadcrumb was still in flight. CI failed on
exactly that while nothing in the branch touches step-up or MFA.
Replaced with a poll that returns the moment the reason appears and, on timeout,
asserts against the real list so a genuine regression still says what WAS
written rather than reporting a bare timeout.
The existing test encoded the behaviour this release deliberately changed: the
breadcrumb used to fire only when a row landed, which made the one durable trace
of an ingest conditional on its success. It failed for the right reason.
Rewritten to assert the new intent with the reasoning kept, and it goes red again
if the condition returns.
GET /api/nutrients now looks up the most recent nutrient.batch.ingest
audit row when the window is genuinely empty, and surfaces it as
lastAttempt only when that call landed nothing (inserted === 0 &&
updated === 0). A real success elsewhere is left alone, so an empty
window for an unrelated reason never gets reframed as a failure.
The lookup runs on the zero-rows path only, never on the happy path,
and stays counts-and-reason-codes only - no user-typed content, no
per-entry health values. The reason code never reaches the page as
text: each of the four closed skip reasons maps to its own human
sentence, and the two that have no useful action for the account
holder (unit_mismatch, upsert_failed) say plainly that the entries
couldn't be stored rather than inventing advice. day_invalid is the
one case worth naming a real cause for - a clock or time zone mismatch
on the sending side - without claiming it was a phone, since any
token holder can call this route.
This depends on AuditLog retention: cleanupOldAuditLogs prunes rows
past 365 days by default, so a dormant account's last failed attempt
can age out and the explanation silently reverts to the plain empty
state. Documented at the lookup for whoever next touches that job.
The Settings > Account save handler read json.error straight off the
wire and rendered it as-is - the exact path a raw validator message
would have reached a person's screen through, and the only place the
report's symptom actually reproduces (baseline-form already goes
through the errorCode-aware localizedApiError helper).
It now resolves meta.errorCode into a localized sentence, the same
convention used everywhere else in the app, and never touches the
top-level string. A partial save (some fields landed, one didn't)
gets its own message naming the rejected field via the same label
already shown next to that input on this form, and reseeds the
form from what the server actually persisted so a field that was
silently skipped can't read as saved.
describeRejectedProfileField lives in account-section-utils so both
the warning and the failure path share one field-name-to-label
mapping instead of duplicating it.
The 409 email-conflict branch had a safe, specific message already,
but no errorCode - the account-section fallback would have had nothing
to translate it through once it stopped echoing json.error verbatim.
Give it profile.update.emailInUse so it renders in the active locale
like every other profile-save outcome, and propagate errorCode through
both route handlers even on the (non-issues) conflict path.
Also updates the /api/user/profile route test for the reported
scenario directly: a valid timeFormat now lands even when gender in
the same PATCH is unsupported, instead of the whole request failing.
apiErrors.profile.update.{nothingSaved,invalidBody} let the web client
resolve applyProfileUpdate's new errorCode into a localized sentence.
settings.profilePartiallySaved / profileNothingSaved carry a {field}
placeholder so the account settings screen can name the specific field
that blocked or was skipped, in the active locale, instead of a raw
validator string. All six locales.
A profile PUT/PATCH validated as one safeParse and wrote as one
transaction, so a single rejected field took every sibling change down
with it - editing height and gender together lost both, and onboarding
lost a whole baseline step to one field. The schema has no cross-field
dependency, so applyProfileUpdate now re-validates each present field
against its own schema and writes whatever validates; only a body where
nothing validates is refused outright, naming the field that blocked it.
The top-level error string also stopped being a raw Zod message
(issues[0].message) that could read "Invalid option: expected one of
"MALE"|"FEMALE"|"OTHER"" on a person's screen. It's now a person-safe
sentence plus a stable errorCode for the web client to localize; the
full per-field detail is relocated (not deleted) into details.issues,
matching the multi-issue envelope the iOS contract already expects from
other write routes.
SubPageShell insets its own heading to the card body edge rather than the
container edge, so a section heading or a meta line rendered flush left sat
16 px to the left of everything above it. The day-context heading, the sport
chips and the count line now take the same responsive inset.
The list hook already returned the canonical total and the page threw it
away, so the surface never said how much history it was showing. Render it
as a meta line above the rows.
The route and the hook already accepted a sport, with nothing to reach it.
Add the chip row. Its vocabulary comes from the unfiltered read, which is
the same query the list itself runs when no sport is chosen, so the row
costs no extra request and choosing a sport can never empty the row it was
chosen from. A filtered view falls outside the server prefetch and pays one
client fetch; a filter that matches nothing says so instead of borrowing the
first-run empty state.
The bpm range under each effort zone sat at an arbitrary text-[10px]. It is
a micro-label on a card, not a chart axis, so it takes the text-2xs token
the standards mint for exactly this.
"How does this compare to last time?" is the most likely next question
after reading a workout, and answering it meant going back to the list and
scanning for the one before.
Resolve the previous session server-side, through the same canonical picker
the list uses, so the link can never land on a duplicate row recorded by a
second watch, and render it beside the own-history average line. Null when
nothing older exists, and then no link is shown.
The stats card's hand-rolled heading moves to the icon-less TileHeader
while the file is open.
The "That day" card took the workout, dropped it, and offered three chips
to Pulse, Sleep and Mood. None of those pages accepts a date, so every chip
landed on an all-time trend surface with the day nowhere in sight.
Replace it with the day itself: the intraday pulse chart pinned to the
session's day, the night the person woke from into it, and that day's mood
entries. All three reads are addressed by the server-resolved dayKey, so
nothing here decides which day a workout belongs to.
The two outbound links that remain are undated and say what they lead to,
Sleep trends and Mood history. Sleep and the intraday chart mount only when
their module is on, so a switched-off module cannot paint as a failed read,
and a query that has not answered yet shows a skeleton rather than claiming
the night was empty.
The detail page also drops the description it borrowed from the list.
The detail payload carried the session's start timestamp but nothing that
said which local day it belongs to. Anything that wants the day around a
workout had to slice the ISO string, which is the browser's day, not the
person's: a 20:00 UTC session reads as the 15th in London and the 16th in
Auckland.
Resolve the day server-side from the user's timezone and ship it as
dayKey. Additive, so a native client that does not read it is untouched.
The score had two mounts: a compact face in the hero band and a
nine-block stack further down the overview. The stack also rode inside
the daily-briefing branch, so an instance with the briefing switched off
lost the whole breakdown.
Fold both into one progressive-disclosure card pinned between the
greeting and the customizable region. Collapsed it reads as the score
face: the ring, the band sentence, the weekly delta or the honest reason
there is none, Rest Mode, the tension and return-to-baseline lines, and
the coverage meter. Opening it adds a three-across pillar grid, the
named list of pillars that are not scored yet, the personal weight goal,
and the method footer with its cited standard.
The card is deliberately not a section in the insights layout registry:
an id a saved layout has never seen merges in as default-invisible,
which would have deleted the score for every account that has ever
customised the page.
A pillar the server gated stays visible by name with its reason instead
of being drawn as a zero, safety signposting leads that list, and a
pillar whose read failed gets an error row with a retry rather than
silence. The hero band goes back to the full-width greeting, the shape
it already had for an account without a score.
nutrient.batch.ingest only reached the durable AuditLog table when
inserted + updated > 0, so a batch where every entry was rejected
(unit mismatch, out-of-range value, invalid day, a write failure) left
the exact same empty trail as a phone that never called the endpoint
at all. That made an empty nutrient_intake_days table unanswerable
from the durable record - the question this surface exists to answer.
Record the call unconditionally, the same shape measurement.batch.ingest
and workout.batch.ingest already use (counts only, no per-entry health
values, no user-typed content), plus an aggregate skip-reason breakdown
so an operator can tell, from the row alone, that the phone posted and
why nothing landed.
HTTP status and the per-entry response shape are unchanged - a batch
endpoint returning 200 with per-entry outcomes on a partial failure is
the correct, documented contract and native clients already decode it.
The page's overview query shares queryKeys.nutrientIntake(30) with
MicronutrientsCard, which already renders its own QueryErrorCard on a
failed read. But the page's empty-state check ran first and treated
undefined data (the failed-read state) the same as a genuinely empty
window, returning "no nutrient data yet" and short-circuiting before
any of the three cards - including the already-correct one - ever
mounted. A self-hoster with HealthKit permissions granted and a read
that actually failed saw the same blank invitation as someone who
never logged anything.
Add the isError branch above the empty-state check so a failed read
surfaces its own QueryErrorCard with a retry, and cover it with a test
that renders the actual page (not just the card in isolation) so a
regression here can't hide behind a passing unit test again.
GET /api/medications/intake/dose-history-import/{jobId}/status reads
through the same readMedicationIntakeImportJob projection as the
per-medication route, so both 200 responses now point at one shared
MedicationIntakeImportJobStatusEnvelope object instead of two
independently generated copies.
Audited every other route description for a schema named by id that
never resolves to a components.schemas entry. Found one more:
CoachPrefs is named in the coach-prefs PUT description, but every
actual consumer is a .extend() of it, so the base never carried
forward to a $ref. Force-registered it the same way as the
dose-history fatal-reason enum. The remaining unresolved meta ids are
request/query schemas zod-openapi inlines rather than hoists — none of
them are named by id in another schema's prose, so nothing points a
reader at a component that silently isn't there.
Documents POST /api/medications/intake/dose-history-import and
GET /api/medications/{id}/intake/import/{jobId}/status, including the
full 16-value MedicationImportSkipReason set the v1.33.0 shape widened
to, read straight from MEDICATION_IMPORT_SKIP_REASONS so the two never
drift again.
Also force-registers MedicationDoseHistoryImportFatalReason into
components.schemas: the 422 description names it, but nothing else
$refs it, and zod-openapi drops unreferenced schemas silently.
Documents POST /api/oura/sync, /api/polar/sync, /api/strava/sync,
/api/nightscout/sync (all four share the resolveSyncOutcome shape) and
GET /api/google-health/sync/status, matching the routes' own
sanitising/clamping logic field for field.
Documents the saved doctor-report scope endpoint, reusing the real
savedReportProfileSchema so the spec's leaf cap and version literal
stay tied to the source of truth.
The field has been served on both routes since v1.28.13 but was
missing from ShareLinkCreated and ShareLinkList, which are
additionalProperties: false.
GET /api/meta/capabilities dropped group membership at the wire: it
shipped a flat group-id list and a flat leaf list with no boundary
between them, so a native client could not tell which leaves live
under which group, or which group is the fenced sensitive tier. That
made it unsafe for the client to offer a "select all" affordance for
any group, since it could not mirror the server's fence against
bundling more than one sensitive leaf behind a single control.
Add SHARE_GROUPS to the report-selection catalogue, derived from the
existing REPORT_GROUPS/MEASUREMENT_LEAF_GROUP/STRUCTURED_LEAF_GROUP
maps: each group now carries its member leaf ids, and the group whose
id equals SENSITIVE_GROUP_ID carries sensitive: true. share.groups in
the capabilities response now ships this shape instead of bare ids;
share.leaves is unchanged. Update the OpenAPI schema and regenerate
docs/api/openapi.yaml.
Add an invariant test asserting every leaf in the flat leaves set
appears in exactly one group and no group carries a leaf outside that
set, plus a check that exactly one group is flagged sensitive.
`next build` needs more than Node's 4 GB default: the TypeScript pass after
compilation runs the whole project through the checker and dies with
"Ineffective mark-compacts near heap limit" at around 4.07 GB.
CI and the production image already knew that. `e2e.yml` and the Dockerfile both
invoke the build with `--max-old-space-size=8192`. The bare `pnpm build` did not,
so the one place that ran with the default was a local gate — which then failed
for a condition that exists in neither environment that matters, and would have
kept failing every time somebody ran the documented gate.
Three places declared how the build is invoked and one of them disagreed. The
script now carries it, so a local run, CI and the image all get the same ceiling.
The existing prefixes in the workflow and the Dockerfile stay: they are harmless
now, and removing them would put the knowledge back in more than one place.
GET /api/insights/rhythm-events was never in the spec, so a client had
nothing to generate a type from but the three-value ECG classification
next door — reusing it would drop VERY_LOW / FIRED. Register the route
with its own RhythmEventClassification carrying all six
RhythmClassification values; the ECG enum stays untouched at three.
On a phone the composer stacked its controls under the textarea, so an EMPTY
input was two rows tall: a text line, a 44 px tap-target row, and padding. That
is roughly a tenth of a phone screen spent before anyone has typed anything, and
it is the first thing on the screen when the Coach opens.
The auto-grow was never the problem. The textarea already starts at one line and
grows to six; it was the layout around it that reserved the second row up front.
The stacking existed to protect keyboard focus order. That argument does not
hold: focus follows DOM order, and the DOM order is textarea, actions, Send in
both layouts. Nothing jumps. `items-end` keeps the controls beside the last line
as the textarea grows, so the send button stays where the thumb left it.
One component serves both surfaces — the conversation view and the hero, which
re-parents the same live composer — so this is one fix rather than two.
The test that asserted the stacked layout now asserts the row, with the reason
written down, and it fails when the stack comes back.
The header fix needs two declarations to hold together: the second grid row is
conditional on a description, and CardAction spans the full row set. The second
is the trap. `row-span-2` against a single explicit row mints an implicit second
row and brings the phantom gap straight back for every header that carries an
action but no description.
Reverting it broke nothing. Nothing in the suite asserts a `row-span` class, so
the regression is DOM geometry the unit tests cannot see, and it would have come
back silently at the next tidy-up. This is a literal-class assertion in the shape
`assessment-spine-order.test.ts` already uses, because the question has an exact
answer.
Also finishes the compact-card tier while it is open: three cards on the metric
surfaces still carried `gap-1.5` where the assessment cards carry `gap-2`. One
tier, one value, no `gap-1.5 py-3` left in the tree.
grid-rows-[auto_auto] was unconditional, so a header with a title and
no CardDescription still reserved an empty second row track and drew
the gap-2 for it — 32px instead of the intended 24px between heading
and body on desktop. Make the second row conditional on a description
being present, and widen CardAction to row-span-full so it still
tracks the header's row count when an action lands in a
description-less card.
Also correct two assessment cards that had drifted onto gap-1.5
instead of the sanctioned gap-2 compact tier.
The navigation-geometry spec failed on a merge commit whose tree was
byte-identical to the branch head that had passed minutes earlier, so nothing had
regressed. The spec resolved the element it measures with
`main.firstElementChild`, independently in each of its two `page.evaluate` calls.
Any node mounting ahead of the wrapper between the two reads made it measure two
different elements, which is why the failure was 64 against 131.92 rather than a
two-pixel drift. A timing problem gives a small difference or a null; this was a
substitution.
The shell's content wrapper now carries a stable `data-slot`, both reads select
it by that, and the wait gates on the element rather than on scroll state alone.
The same positional pattern in the overscroll spec is fixed with it, so the class
is gone from the measurement paths rather than from one spec.
Re-running the job made it green, which is a workaround. This is the fix.
The `app` service declares 62 environment variables. The commented-out
`app-worker` example declared 14, missing every SMTP, VAPID and APNs value plus
the AI-host allowlist and the offline geo directory. The worker is the process
that sends notifications and runs the AI jobs, so anyone following the documented
split got a worker that could not email, push or send APNs, and nothing failed
loudly. It just did nothing.
Hoists the shared block to a YAML anchor both services merge, so copy-paste
cannot drift again. The resolved configuration is unchanged: 62 variables on
`app` and 3 on `db` before and after.
Also:
- The commented GeoLite2 mount pointed at a directory the default GEOLITE2_DIR
does not read. Uncommenting the example as written did nothing. The two now
agree.
- Bounded log rotation on both services. The default json-file driver grows
without limit and fills the disk on exactly the small hosts this file targets.
- `cap_drop: [ALL]` on the app, and a written reason why the database does not
get it: the Postgres entrypoint starts as root and needs CHOWN/SETUID/SETGID
to hand off to its own user. Dropping capabilities there breaks startup, which
was confirmed rather than assumed.
- A memory limit on the app, and a written reason why the database has none. A
bounded app container restarts clean when the kernel takes it; a database
killed mid-write does not.
`read_only: true` stays off for now: the app writes a runtime cache and that
needs testing before it can be turned on.
The digest-pinning section explained how to set HEALTHLOG_IMAGE_REF and never
said what happens if it is left behind. It stays until someone changes it, and
every deploy in between reports success while the running instance holds the
previous release.
Adds the warning, the two post-deploy checks that would have caught it (the
version endpoint AND the running container's image, because the first alone can
be served from a cache), the note that a suspiciously fast deploy is a signal,
and the duplicate-key trap: the platform accepts two rows with the same key
without warning and does not define which wins.
The card carried a four-sentence non-medical notice at the top of the cycle
on-ramp. It goes, at the maintainer's decision.
The goal-specific caveat on the prediction surface is untouched: a profile set to
avoid pregnancy still gets the stronger "not a contraceptive method" copy, which
is chosen server-side by goal and shared with the native client, so nothing about
that path changes here.
Removes the key from all six locales and the now-unused icon import.
Carries the four fixes that landed after the v1.34.1 tag plus the two found while
reviewing them.
The workout collapse that shipped in that batch only worked on the workout list,
because it keyed a session on a field three of the five callers never fetched.
Those three kept averaging one session into a person's baseline more than once.
The service-worker fallback version had drifted a release behind with nothing
checking it; both halves now have a gate.
The service worker derives its cache names from the version injected by
`sw-version.js`, and falls back to a literal compiled into `sw.js` when that
import fails. The prebuild step rewrites both from `package.json`, so both were
correct as long as a release rebuilt. One did not, and the literal trailed the
release by a version while every test stayed green.
Nothing checked it. The generated file is compared against `package.json`, and
the behavioural suite deliberately reads the literal out of the source so its
cache-name assertions cannot drift from the literal they exercise. Reasonable
apart, and together they left the literal itself unverified.
The effect is confined to the degraded path and is still worth closing: a client
that cannot load `sw-version.js` computes the previous release's cache names, so
it keeps serving the old shell and its cleanup treats the current caches as the
stale ones.
Re-anchors the literal and adds the equality as a gate, since it is a question
with an exact answer. A version bump committed without a rebuild now fails
locally instead of shipping.
The canonical picker keyed a session on sport, start, end and duration. Only the
workout list selected the end timestamp; the Coach narration, the per-sport
baseline and the deep-link cluster passed rows without it, so those rows carried
no session identity at all and the duplicate survived. One record counted once in
the list and two or three times everywhere else, including in the averages a
person is compared against.
Drop the end timestamp from the identity. On a workout with pauses it measures
wall-clock time while the duration counts only moving time, so two re-sends of
one session can differ in that field alone and the stricter key rejected a
genuine match. It also bought nothing in exchange: nobody starts a second
workout of the same sport in the same instant with the same duration.
The deep-link cluster now selects the duration it needs. A structural guard
freezes the call-site set and records which file owns each caller's select, so a
new surface cannot join the set without fetching the identity, and the identity
cannot grow a field only some callers fetch.
The badminton mapping added the enum member in the Zod registry and left the
generated spec behind, so the drift gate failed. One line, `- badminton`.
Third failure this branch revealed in sequence: the formatter hid a red test,
the red test hid this. Each gate only runs once the one before it passes, so a
single unformatted line kept two real problems invisible.
The spacing pass on this branch tightened `insight-status-card` to `gap-1.5`
and left `mood-insights-sections` at `gap-2`, so two sibling cards that are
meant to read as one family stopped matching. `assessment-spine-order.test.ts`
exists to hold exactly that contract and went red as soon as the formatter
stopped masking it: `format:check` failed first in the job, so the unit tests
never ran and the divergence shipped unnoticed onto the branch.
Bring the mood card to the same value rather than widen the other one back.
The direction of travel is the tighter rhythm, and both allowed steps are on
the sanctioned spacing scale.
The test's expected string moves with the contract, not around it: it still
asserts that both cards carry the SAME value, which is the whole point of it.
The formatter joins one JSX line that was left wrapped. No behaviour change.
It is the only thing standing between this branch and a green gate: typecheck,
tests, e2e, integration and both image builds already pass, and `format:check`
fails on this file alone.
- preserve prior rolling identity independently of local-day flooring
- scope exact resolution by schedule and era while advancing resolved grids
- include open rolling slots for display without widening compliance bands
- select active canonical recurrence slots instead of rebuilding from tick day
- scope intake suppression and dedup metadata to the occurrence local date
- lock and rotate refresh families inside one database transaction
- revoke CAS losers and insert successors before commit
- sign refresh artifacts only after committed state
- Normalize IPv6 literals before transition-prefix inspection
- Reject mapped, compatible, 6to4, and NAT64 private embeddings
- Share the verdict with pinned public and operator dispatchers
- document public-only install and data-free offline behavior
- record scoped current and legacy cache eviction
- capture green final-boundary and session-hook verification
- delete current and legacy HealthLog static page and data caches
- preserve unrelated CacheStorage namespaces during activation and logout
- keep IndexedDB query-cache removal in the session-end boundary
- remove the dashboard root from immutable precaching
- evict same-version root entries left by older workers
- retain public logos and favicon for offline support
- document passkey enrollment and reset recovery RED boundaries
- record focused expected failures and green parity evidence
- preserve production, schema and scanner-owned files untouched
- document expected cache-isolation and transition-form failures
- record preserved public and redirect behavior
- capture atomic task commits and verification evidence
- execute the operator CLI against real PostgreSQL credential tables
- require complete target revocation and unrelated-admin isolation
- force a mid-transaction database failure to prove rollback
- require password, passkey, TOTP and WebAuthn proof choices
- order fresh-factor completion before challenge creation
- keep successful enrollment in one cookie-bound ceremony
- refuse standard embedded-private IPv6 resolver answers pre-connect
- preserve public IPv6 to IPv4 fallback connectivity
- prove followed redirects repeat the pinned DNS verdict
- Remove the user-facing private-host bypass contract
- Require redacted operator approval guidance for legacy failures
- Preserve usable public and approved-private connected states
- require fresh existing-factor proof on both cookie-only legs
- bind registration challenges to user, purpose, session, expiry and one use
- cover bearer, ambient-cookie, stale, foreign and replay attacks
- cover mapped compatible 6to4 and NAT64 private embeddings
- exercise both literal URL and raw DNS address verdicts
- preserve public IPv4 native IPv6 and transition forms
- exercise signed-in service-worker install at CacheStorage boundary
- require data-free offline navigation and immutable public assets
- require current and legacy session cache cleanup
The 15-minute reminder tick computed windowDuration ONCE per schedule
as windowEnd - windowStart, then applied that SAME span to every
timeOfDay slot in the schedule. For a single-dose schedule that span
genuinely is the one slot's window, but for a multi-dose schedule
(e.g. windowStart=08:00/windowEnd=18:00 with timesOfDay=[08:00,18:00])
it silently gave the 08:00 slot a 600-minute grace before even
entering ORANGE phase — ten hours nobody configured — while an
identically-intended single-dose schedule with a narrow legacy span
(08:00-09:00) correctly escalated on schedule but then had only a
30-minute radius for a logged-but-late dose to suppress its reminder.
Same misapplied field, opposite-looking symptoms: one medication
silently never reminded, the other kept escalating despite being
logged as taken.
resolveSlotWindowDurationMinutes resolves each slot's duration
independently, checked in priority order: an explicit per-dose
doseWindows entry for that exact timeOfDay (the actual per-dose rule
set in the dose-window editor) > the schedule's reminderGraceMinutes
(documented for exactly this purpose but never actually read by the
reminder tick until now — the write path already reads it via
graceToleranceMs) > the same default an unconfigured "point" dose
gets everywhere else in the app, bounded by the gap to the nearest
sibling slot so two doses can never share a window. A single-dose
schedule with neither override keeps the exact legacy windowEnd -
windowStart behaviour, unchanged, for backward compatibility with
schedules that predate the per-dose doseWindows system.
The core property this restores: a dose's resolved window never
depends on how many sibling doses share its schedule. Two
identically-configured doses now resolve identically regardless of
whether one of them happens to have a second dose elsewhere in the
day.
Full suite (18k+ tests) passes with no regressions; 13 new tests
cover both real-world cases that surfaced this (a single 08:00-09:00
schedule and a twice-daily 08:00/18:00 one) plus the sibling-
independence property directly.
AGENTS.md duplicated the project description, version, stack table and a tree
of the source layout, then went untouched for about thirty releases. It
announced v1.4.0 as current and called the UI Dracula-themed, both false for a
long time. An instruction file that is confidently wrong is worse than none.
Replace the copies with a pointer to CLAUDE.md and keep only what is genuinely
specific to running as an agent here: the generated-client read ban, the gate
including TZ=UTC, reading output rather than exit codes, where working notes
live, and how the worktrees are used.
v1.33.1 claimed custom cycle symptoms were carried in backups. They were not,
and the same release turned the old silent drop into a hard failure, so the
accounts the fix was written for went from losing one symptom link to being
unable to restore at all.
Both ends of this defect had passing tests for the whole time it was broken.
The section builder returned the symptoms, the wire schema declared them, the
restore read them, and each of those three was covered on its own. Nothing
covered the object literal in between, which copied section keys out one at a
time and never listed `customSymptoms`.
So the test spans the seam and asserts on rows. It populates an account with a
symptom of its own on a day-log, builds the file through the real
`buildFullBackupPayload`, serialises it, parses it back through the real
`parseBackupPayload`, and drives the real admin restore. Asserting that the
payload carries the field would only re-test the writer that was already green.
Deleting the symptom row before the restore is the case the field exists for,
not a contrivance: `restoreCycleData` clears day-logs, cycles, and the profile
but never the symptom catalogue, so a restore onto the same instance still
finds the row sitting there. That is why the older round-trip test stayed green
through the whole thing.
Mutation check: hand-pick the cycle section's keys again and the restore
answers 500 on `Unknown cycle symptom keys: custom:sacroiliac-ache`, which is
the production failure verbatim.
The assembly picked `manifest` out of the records section by hand, one line
under the comment explaining that both sections are spread precisely so nobody
has to pick their keys. It resolved to the same value the spread had already
placed, at the same position, so the emitted file is byte-identical either way.
Left in, it is the pattern that lost the custom cycle symptoms sitting next to
the note about how that happened.
The sync function was widened to answer with what it wrote rather than a bare
count. This mock still resolved to a number, so the handler destructured
`imported` off it, got undefined, and reported `measurements_imported: NaN`.
Nothing in the type system could see it: `vi.fn().mockResolvedValue(0)` carries
no relation to the real signature, so a widened contract drifts past every mock
in the tree unnoticed. This was the only sibling; the class is worth a sweep of
its own.
`IntradayCumulativeProfile` shipped classified as backed up and carried by
nothing. The drain folds the per-sample rows into twenty-four running totals
and deletes the originals in the same transaction, so past the grace window
the table is the only copy of a day's curve: a restore rebuilt the account
without it, reported success, and left the same-time baseline reading "still
learning" for a fortnight.
Builder and restore sit together in one file, the way the profile and cycle
pairs do, because the restore route delegates and a grep of the route alone
lies. The section spreads into the payload rather than being copied key by
key. Two curves for the same metric and day, or a curve that is not a whole
day, throw naming the day instead of writing a row every reader drops.
The pair is proven end to end rather than at each end: the round-trip suite
builds a real payload from a populated account, serialises it, parses it back
through the real parser, drives the real restore, and asserts on the rows
written. Remove the read and it goes red.
Two parallel lines of work reached the same conclusion independently: a
baseline band over a quantity that cannot go negative must not report a lower
edge below zero. One of them answered it locally with a `Math.max(0, …)` at the
call site; the other gave every metric's numeric domain a single home and made
`buildBaselineBand` take the metric so the floor comes from the declaration.
Keep the second and drop the first. The required parameter is what surfaced
this at all: the new call site could not compile without naming its metric,
which is the whole reason it was made required rather than defaulted.
The section builder returned them, the wire schema declared them, and the
restore has read them since v1.33.1 — including the throw that replaced the
silent drop. The payload assembly copied its keys out one by one and was never
extended, so every backup written carried an empty list. An account with a
custom symptom did not get the silent drop that release removed; it got a
restore that failed outright on a key nothing had written back.
Spread the cycle and records sections instead of hand-picking their keys, so a
field added to either section interface reaches the wire by construction. Declare
labelEncrypted on the cycle section, which the builder already emitted.
The drain's existing transaction mock had no profile delegate, so the fold
could have thrown inside the per-day error boundary and every assertion would
still have passed. Cover the merge directly: the upsert lands before the
deleteMany, the stored curve matches where the samples actually fell, and a
serialised tick that finds the rows already drained writes nothing.
The rail gains a same_time_baseline kind and a server builder, which is the
growth path that type documents for itself. It speaks only when today is
actually outside the typical band: a card that reports doing exactly what you
usually do teaches people to stop reading the rail. Dismissing it is keyed by
day and metric, not by the hour, so it stays dismissed instead of coming back
every sixty minutes.
The Insights grid gains a section of four tiles, one per cumulative metric,
each honestly quiet until the history accrues.
The two-trajectory chart it needs did not exist, so DeltaSparkline gains a
second mode rather than a second chart family: the comparison curve rides the
same container, the same fixed height and the same shared y-domain as a dashed
muted line behind the live one, which is the only way the two can be read
against each other.
Six locales. The wipe and backup plans learn about the new table: the profile
cannot be rebuilt once the samples are gone, so it is carried, not derived.
The total shown after a backup upload skipped the nutrient day totals, so a file
that carried and restored them reported fewer records than it held. Same defect
as the payload gaps this branch closes, on the reporting side. Counts the
nutrient days, the health profile and the custom metrics with their readings.
The targets reference panel graded weight against the WHO BMI band even
for an account that had entered its own target, so the panel under the
weight chart contradicted the chart above it. An override now wins and
the card's source reads Custom, the same posture the glucose builder has
always taken with a user-pinned band. The BMI classification stays
WHO-derived: naming which category a reading falls in is a reference
fact, not a goal.
Thirty-three suites read a registrar as source text and matched
`boss.work` next to a queue constant. They now match `createAndWork`,
and the seven schedule tuples that grew a `cronIsTheRetry` third element
assert that element rather than accepting any arity — a guard that
accepts more than it did before is a guard that notices less.
Two negative assertions were tightened while they were open: the
"has no cron" checks on the arrival and workout-insight queues ended at
a literal `]`, so a cron added WITH send options would have slipped past.
The behavioural repairs carry the real change. Handlers that resolved to
undefined now resolve to their outcome, so the assertions name the counts
instead of asserting nothing. The reconcile tick's test said it swallows
a failure instead of throwing; it reports a failed outcome now, and the
test says that, with the comment pointing at the retryLimit that carries
the old anti-retry-loop concern.
The classification guard proved every model has an opinion and would have
passed unchanged while a model classified as carried was read into the payload
and never written back — which is what happened to the nutrient day totals, the
custom mood tags and the custom cycle symptoms, three releases running.
Split BACKED_UP into the models with both ends and the models without, each of
the latter carrying what an account loses meanwhile, so the debt is a register
rather than a silence. The check reads declared writer and restore files rather
than grepping for a route, because the route delegates: a review that grepped
only the route concluded the cycle data was never restored and had to be
retracted. Declaring the file list caught its own first omission — measurements
are read through the pager, not directly.
Adds a round trip that builds a real payload, parses it through the wire schema
and drives the real restore, asserting on the rows written rather than on the
payload. Dropping the cycle section spread makes it fail the way production
did: the restore throws on a symptom key nothing wrote back.
Covers what the required parameters now make impossible to forget: the
scorer takes the band whole and never widens it, a missing target scores
the bare trend, the resolved yardstick rides the weight component, the
shared builder refuses the reference band that sits under an override,
the snapshot gates its mood pillar on the module map, an explicit target
reaches both the score and the dashboard bands, and weight/BMI record
direction follows the user's own goal.
F1 adds the golden the score has never had: the rollup read branch and
the live read branch scored against one series encoded both ways. On the
canonical one-weigh-in-a-day cadence they agree exactly. On an uneven
cadence they part company, and the test pins that the whole difference is
the per-day mean collapse - the rollup branch equals the live branch fed
those same daily means - with a companion assertion proving the fixture
is not a no-op.
runJob's claim is behavioural, not typed: an ok:false outcome has to
reach pg-boss as a failed job, reported once, with the handler's own
facts and the thrown error's stack rather than runJob's frame. That is
what the retention cleanups now rely on, so it is pinned.
The rule's tests carry its blind spots in the valid list, where a reader
will trip over them: an aliased receiver is not detected, and neither is
a handler that returns ok:true after swallowing.
One guard per defect, each verified by breaking it on purpose first.
A non-negative metric's baseline `low` never goes below zero — swept over
every type in the catalogue with a series whose spread exceeds its
median, plus the reported step shape rendering an edge of exactly 0. The
axis assertion sits in the same file, because the point of the fix is
that the two read one fact.
No insights page passes an English word as a unit — the page sources are
scanned for `unit=` / `yAxisUnit=` literals and held against the set of
language-neutral symbols, and every `unitKey` is resolved in all six
locales. That second half matters: a key passed as a prop is invisible to
the `t()` call-site coverage sweep, so nothing else would notice a
missing translation.
An integer-valued metric never renders a fractional part — the step page
is rendered in German with no precision prop at all and must produce no
comma-decimal, on the Min/Max card and on the band sentence below it.
Two types were missing from the plausibility map, so the first guard also
asserts the map covers the enum.
All 104 bindings across the five registrars now go through createAndWork,
whose handler type is (jobs) => Promise<JobOutcome>. The forty-two inline
handlers return what they did. A handler that falls off the end no longer
compiles, and the healthlog/job-handler-outcome rule refuses a bare
boss.work( anywhere under src/lib/jobs except the one module that owns
the wrapper. The rule's header states its own limit: it catches the
omission, not a handler that claims ok:true after a swallow.
Retry policy, reviewed queue by queue because the rethrow changes what
runs, not only what is visible:
- Twenty-one cron passes carry cronIsTheRetry (retryLimit 0). They are
the retention DELETEs, the OAuth-state prunes, the hourly maintenance
sweeps and the nightly per-sample drain — every one of them a
statement that will fail identically a second later. pg-boss defaults
to two retries with no delay, so converting them without this would
have run the same doomed statement three times a night. The next tick
is the retry and it arrives with the whole gap as backoff.
- The policy rides on the schedule, not on createQueue: create_queue()
ends in ON CONFLICT DO NOTHING, so a queue-level default would be
inert on every already-running instance.
- Everything else keeps its retries deliberately. A TLS probe, an
off-host upload, a restore drill and a provider poll all fail
transiently, and the reminder ticks only fail at job level when the
pass itself could not run.
Six integration cards and the lab-OCR dialog keyed their tone off `res.ok`.
A sync that imported nothing because every reading was refused rendered a
green tick and a count, and a re-scan in which every confirmed row turned out
to be a duplicate raised a green toast saying zero readings were saved.
Each surface now renders the outcome the route resolved: a tick only when
everything landed, a warning when some of it did not, an error when none of
it did, and a neutral note when there was nothing new. The lab-OCR toast
picks its variant the same way.
Replaces the directory-scoped import guard with one that covers the whole
component tree and pins per-file occurrence counts, so a listed file cannot
quietly add a second tick. Its docstring states what it cannot catch.
Both were classified as backed up when the backup plan was written and carried
by nothing. The custom metric is live data loss: a series the account defined
itself is the one kind no integration can re-sync, so a restore rebuilt the
account without the metric and every one of its readings, and reported success.
Builder and restore live in one file beside each other, the way the cycle pair
does — a grep of the restore route alone does not find either, because the route
delegates to both.
Readings nest inside the metric that owns them rather than referencing it by
name, so the restore has no parent to look up and no lookup to miss. A duplicate
metric name and an undecodable ciphertext field both throw and name the value.
Intake events whose medication did not restore now throw with the unresolved
reference instead of being filtered away into a dose history that comes back
short while the restore reports success.
Compare today's running total at the last completed local hour against this
person's own typical total at that same hour, over a trailing four weeks of
stored intraday profiles. Same estimator as the vitals baseline — median for
the centre, median plus or minus k times MAD for the band — so a reader who
understands one understands the other.
It refuses to speak below fourteen usable days and says so through the
existing insufficient arm rather than a band built on three days. It ships no
projected day total: extrapolating a partial day is a guess, and this exists
to replace guesses.
Steps first; active energy, distance and flights ride the same engine.
Three defects on one card of the step sub-page, all of them a surface
answering a question that belongs to the metric.
"Your usual range is -5,595.6-13,387.6 steps", under a chart whose own
y-axis refuses to draw a negative step. Same app, same question, two
answers: the axis clamped and the sentence beneath it did not. Both now
read the same fact. `VALUE_RANGES` already declares every metric's
plausibility floor for ingest; `value-domain.ts` reads it and answers
"can this metric be negative" for the padded y-domain and for the median
+/- k.MAD band alike. The band's lower edge floors at zero for a
non-negative metric; `spread` stays the unclamped dispersion so a
deviation-in-sigma calculation is unaffected. `buildBaselineBand` now
takes the metric it is describing as a required argument, so a new caller
cannot quietly reintroduce a negative edge. Two types carried no
plausibility range at all, which let any number through ingest and read
as "unknown" on the negativity question: walking steadiness gets the same
0-100 percent band as its two gait siblings, the loud-listening event a
floor of 1 with a generous ceiling.
"13,387.6 steps" also said steps to a reader of German. Five sub-page
units are English words, not symbols - steps, flights, falls, years, and
a "count" that was a poor label in any language. They now travel as i18n
keys through a new `unitKey` on the metric-page scaffold and resolve in
all six locales. The thirteen real symbols (bpm, mmHg, %, m/s, kg/m2,
...) stay literals; translating a symbol would be the opposite mistake.
And the Min read "104.0 steps". Precision was a per-page prop defaulting
to one decimal, so every page had to remember that its metric is a count
- and the step page did not. It follows the metric now: a discrete metric
(tallies, category events, closed integer instrument scores, ordinal
levels) resolves zero decimals wherever it is rendered, and a page only
passes the prop to override that.
The weight pillar has been graded against something for two years and
never said what. The score card's weight row now carries one line: the
user's own target band when they set one, or a plain statement that the
row scored the trend with no target at all. The band rides the analytics
DTO in canonical kg and the page formats it into the reader's display
unit, so an imperial account reads pounds.
The weight sub-page shades the same yardstick: its chart bands follow an
explicit target when one exists and fall back to the height-derived band
only when it does not.
A cumulative metric has no meaningful latest reading — a partial day's step
count is a fragment, not a value — so the only honest comparison is against
the same person's own typical total at the same local hour. That needs an
hourly substrate and there was none: the nightly cumulative drain collapses
per-sample Apple Health rows into one daily total after the grace window and
hard-deletes the originals in the same transaction. Today's curve was
derivable; every older day's was already gone.
Fold the rows into twenty-four running totals and write them down first, in
the same transaction and before the delete. The merge is additive so a day
drained twice keeps both halves, and the rows are re-read under the advisory
lock the drain already takes so an overlapping tick folds nothing.
One table, one nightly range delete to bound it.
Sixty-two handler functions across twenty-four modules now resolve to a
JobOutcome instead of undefined. The counts they already wrote to their
wide event ride out in `did`, so a run that deleted nothing and a run
that could not run are no longer the same shape.
Three rules decided each conversion:
- a catch that swallowed becomes jobFailed, keeping its warning line;
- a catch that already rethrew keeps its throw, because the throw
already fails the job and rewrapping it buries the stack;
- a fan-out pass is judged on the pass. One account's revoked grant
belongs on that account's integration ledger, not in a queue-wide
failure that retries the whole cohort, so per-leg failures ride out
as a users_failed count — visible for the first time.
The retention cleanups are the behaviour change worth naming: every one
of them used to warn and return, so a purge that had not run for weeks
looked like a purge with nothing to do. They fail now. Their registrar
sets retryLimit accordingly in the commit that converts the bindings.
JobFailure carries the thrown error's stack; without it every one of the
101 queues would report the same runJob frame.
The section builder returned them, the wire schema declared them, and the
restore has read them since v1.33.1 — including the throw that replaced the
silent drop. The payload assembly copied its keys out one by one and was never
extended, so every backup written carried an empty list. An account with a
custom symptom did not get the silent drop that release removed; it got a
restore that failed outright on a key nothing had written back.
Spread the cycle and records sections instead of hand-picking their keys, so a
field added to either section interface reaches the wire by construction. Declare
labelEncrypted on the cycle section, which the builder already emitted.
The Health Score graded the weight pillar against 22 x height squared,
expanded by 2 kg either side: a target nobody set, nothing disclosed,
and the only producer of the pillar's band. For anyone with a height on
file it pre-empted both the honest trend-only path the module header
promised and the null-redistribution behind it, while the target the
user had actually entered sat unread in User.thresholdsJson.
computeHealthScore now takes the band whole plus the yardstick it came
from. Both callers resolve it through one shared builder next to
buildHealthScoreBpInputs, which hands over a band only when
getEffectiveRange reports a real user override; every other account
scores the bare trend. defaultWeightTargetFromHeight is gone.
Same pass, same defect class elsewhere: the dashboard bands, the
weight-drift verdict rung and the weight/BMI record direction all
ignored an explicit target too, and the snapshot never resolved the
mood module, so a mood-off account got a mood pillar in the hero ring
and none in the Insights card. moodEnabled and the weight yardstick are
required parameters now, so a caller that forgets one fails to compile
instead of quietly scoring against a default.
Six provider syncs returned a bare count, which narrowed their failures away:
a Nightscout window in which every SGV row was refused by a constraint
returned the same 0 as a quiet night, and an Oura or Polar run whose
collections all failed returned 0 too. The routes then answered 200 with a
count and the settings card drew a green tick over it.
Every sync now reports what it imported AND whether any part of it did not
land, and the route resolves the outcome server-side so the web card and the
native client cannot disagree. Google Health keeps its 502 for a run that
failed and wrote nothing, but a run that failed after writing some resources
is now a partial rather than a discarded result. The labs OCR commit carries
the same resolved outcome.
Behavioural cases first: the Google mapper keeps the raw `exerciseType`
when the sport falls back to "other", reports a missing one as null
rather than an empty string, and the ingest writes it under
`metadata.googleExerciseType` on both the insert and the update arm — the
update is what a full re-sync goes through, so a row written by an older
build repairs in place. The key is omitted rather than nulled when a
session carries no type, so a re-sync cannot wipe what an earlier one
recorded.
Then the structural guard, because the behavioural ones only cover the
provider that was reported. It freezes the set of sport-label mappers and
the set of files creating Workout rows, and asserts every mapping ingest
carries its raw label into metadata. Fitbit has the same gap and is
excluded on purpose — it ships on its own, small and isolated, per the
standing rule for that provider. The exception is an assertion, not an
omission: when Fitbit gains the write, the guard fails and forces the
entry to move. An exception nobody has to retire is how this gap survived
in the first place.
The rule that a write which wrote nothing must not read as a success was
scoped to the import panel. Provider syncs need the same rule, so the
classifier moves to src/lib/outcome/written-outcome.ts and the tick plus the
success colour move to one presentation component the whole tree renders
through. Adds the sync shape: a run reports what it imported and whether any
part of it failed, and the outcome is resolved from both.
A pg-boss handler signalled success by falling off the end of a function.
Every binding in this tree resolved to undefined and nothing read the
value, so a handler that caught an error, logged a warning and returned
was indistinguishable from one that finished its work: pg-boss marked
both completed and the failed-job readers saw nothing.
JobOutcome makes the completion a value the handler has to construct.
runJob turns ok:false into a reported, rethrown failure so the queue's
retry policy applies. createAndWork is the one binding path; its handler
type is (jobs) => Promise<JobOutcome>, so a handler that returns nothing
no longer compiles.
The type cannot make a handler honest — jobDone() after a swallow still
compiles. It converts omission into commission, which is the smaller and
far more reviewable class.
An unmapped Google `exerciseType` resolves to the literal "other", and
until now that was the end of the label: nothing else carried it, so a
workout Google typed as a sport the map does not know became
indistinguishable from a genuine `OTHER_WORKOUT`. Carry the trimmed
upstream value through `GoogleHealthMappedWorkout.sportTypeRaw` and write
it to `Workout.metadata.googleExerciseType`, the shape Polar, Strava and
WHOOP already use. Insert and update share one payload, so a full sync
repairs rows written by an older build.
The comment above the type map claimed an unmapped type "still persists"
because the column is free-text. It does not, and it did not when the
comment was written. Prose that asserts what the code does not do is the
same defect as the missing write, so correct it here rather than later.
The v1.33.1 credit block named a reporter for the dashboard layout while
the body of the notes described nothing of the kind. The fix was in the
release; only its description was missing, which leaves a credit pointing
at nothing and a reader unable to tell what changed on their own instance.
Eight workstreams, two migrations, and a pass by an independent reader that
found four things this release was claiming about itself that were not true.
Two of the fixes change what people receive. Medication reminders went out
every fifteen minutes because the dedup key could not tell one dose slot from
another, and "handled by the app" was silencing every channel rather than the
one it names. Both are called out in the notes, because a user who set that
flag as a general mute will notice.
Two came from reports: an OpenAI-compatible gateway that the provider list
had no room for, after an earlier answer from us said otherwise, and four
places where a timestamp followed the browser instead of the profile.
The backup half is the one worth reading. Three record types were carried and
could not be restored, or could not be carried at all, and each failed in a
different direction: one dropped silently, one stopped the restore outright,
one was discarded while the restore reported success. What a backup holds is
now declared in one file with a written reason for everything left out, and
the roughly thirty types that are still carried by neither end are named there
instead of waiting to be discovered by someone who needed them.
The Del chip added beside the document card measured 4.06:1 against the card's
background, under the 4.5:1 floor, and axe failed the vault on it. So the
change that made the destructive key visible introduced a barrier of its own.
It uses the full foreground token now. The design rule says muted for meta
text, and a key hint is meta — but it is the one kind of meta that has to be
readable at a glance, so here the contrast rule wins and the reason sits next
to the class list rather than in a commit nobody will read again.
Found by running the e2e suite locally before pushing rather than after.
The guard tested whether the argument list contained the substring "timeZone".
`timeZoneName: "short"` contains it. That option appends "GMT+1" to the output
and leaves the zone as the browser's, so a formatter written that way sailed
through while being exactly as wrong as a bare one — and looking more careful
than a bare one, which is worse.
Tightening it to a colon-terminated property then reported nine correct call
sites, because the shorthand `{ timeZone }` is what most of this tree writes.
The honest response to that would have been to allowlist all nine, which is
how a guard stops guarding. It accepts both forms now, and a test pins both
directions rather than leaving the regex to be trusted.
Two exclusion reasons in the backup plan aged inside their own release and are
corrected: push attempts now also hold the reminder dedup anchors this release
introduced, and the thumbnail's "rebuilds from the stored bytes" only holds on
the disaster-recovery path, since a portable export carries no bytes to rebuild
from.
Four things an independent read found, three of them things this release said
about itself that were not true.
A custom mood tag travelled without its label, its icon, its sort order and
its SCALE. The label is the smaller loss: the person who named a factor
"Migräne" would have got a key back. The scale is the real one — the inverse
flag marks a factor where a high value is bad, and dropping it does not lose a
label, it silently reverses the meaning of every rating already recorded
against that tag. Custom cycle symptoms lost their label the same way. Both
now carry the ciphertext verbatim, like every other encrypted column in a
disaster-recovery payload.
The plan file claimed, in the present tense, that a guard requires both a
payload reader and a restore branch for every carried model. No such guard
exists. The commit that introduced the file said so honestly; the file itself
said the opposite, and roughly 31 models have neither end. BACKED_UP means
"must be", not "is", and both the plan and its test now say that plainly. The
wrong sentence is recorded rather than quietly swapped, because it is the same
defect the file was written to prevent.
The export route still told operators that labs, allergies, illness episodes
and workouts could be exported but never restored. The restore route
re-creates all of them, and since this release nutrient day totals too. The
scope now points at the plan instead of repeating it beside the code, which is
how that sentence came to be wrong.
And the export card promised "every record you own". It does not, so it no
longer says so — six locales.
The resolver is online-first, so mounting the databases fills the ASN
and carrier but does not by itself keep login IPs on the host. Point at
the IP_GEO_LOOKUP_DISABLED section that does.
The hint said switching a widget off here turns it off in the mobile
app. This page writes the flag; whether the client applies it on its
next read is the client's business, and there is an open report that it
does not. Say where the setting lives instead of promising a result
this surface does not control. Six locales.
BMI renders on the native dashboard and was configurable from nowhere.
Two individually-correct decisions produced it: `bmi` is a catalogue id
with no web render path, and the web layout editor filters those out
because a toggle over something this page cannot draw would be a silent
no-op. The widgets PUT accepts and persists them all the same, and the
native client writes them, so an account ended up holding rows it never
set and could not change from any surface.
The sortable list stays exactly as it was. Below it, the ids the web
does not draw but the layout carries get their own labelled group with
the same two flags and no reordering — placement belongs to the client
that draws them. An account with no native client has nothing there and
sees nothing. The module gate applies as it does above, fail-open.
Refs #581
A focused card deleted on Backspace as well as Delete. The timeline's
roving tabindex gives a card focus the moment someone tabs into the
grid, Backspace is the reflex "go back" key, and a card carries no
delete control at all — so the reflex press removed a medical document
with nothing but a transient undo toast behind it.
Delete alone now deletes. The key resolution moves into a pure helper
in vault-utils so the contract is testable without a render harness,
and the card stops keeping that contract in a code comment: the overlay
button carries aria-keyshortcuts, and a kbd chip appears while the card
holds focus. The label is per-locale (Entf / Suppr / Supr / Canc).
The switch was described three times as applying to what you upload
next. A genuine off-to-on flip also enqueues a pass over the documents
already stored without a summary, capped at 200 per flip — scanned
medical documents going to a third-party provider, from copy that reads
as forward-looking only.
Drop "newly uploaded" from the sub-label and give the disclosure the
sentence it was missing, including the cap. Six locales. The behaviour
is right and stays as it is; only the sentence changes.
A guard pins the number in the copy to MAX_ENQUEUES_PER_RUN, so moving
the cap without touching the copy fails.
The runtime has always read GEOLITE2_DIR, but the variable was absent
from the compose `environment:` whitelist, from both env examples, and
from the docs. A variable that is not on that whitelist never reaches
the container even when the operator's `.env` sets it, so the override
existed in code and was unreachable from the documented path.
Put it on the whitelist, document it in both examples and the manifest,
and add `docs/self-hosting/geolite2.md`: fetch the databases with a free
MaxMind account, mount the directory read-only, point the variable at
it. The compose file carries the bind-mount line commented out beside
the app service.
The admin notification told the reader to set MAXMIND_LICENSE_KEY in
this repository's Actions secrets — something nobody running the
published image can do, about a fallback that sends their sign-in
addresses to a third party. It now names the mount, names the online
provider it is actually querying, and links the runbook. Six locales.
It also no longer claims the carrier column stays empty; the online
tier has filled the carrier from the ISP field since v1.25.8.
A blank GEOLITE2_DIR now resolves to the default instead of the process
working directory: compose substitutes an unset variable to the empty
string, which is not nullish.
Refs #659
The Bestand tab was 1,139 lines of one client file holding two concerns that
only meet at the row shape: the list of containers a person holds, and the
three dialogs that change it. Registration, per-item correction, packaging
economics, the summary and the row rendering all shared a scope, so reading
any one of them meant scrolling past the rest.
Move-only. The dialogs go to `inventory-dialogs.tsx` with the day-input
conversions their fields produce; the row shape and the cache-invalidation
they both need go to `inventory-shared.ts`; the section keeps the summary and
the list at 458 lines. Every export the tab offered is still offered here, so
no caller changes. No copy changes, no new keys.
Two suites pin these contracts by reading the file's own source — the register
dialog's unit conversion and the correction dialog's sparse patch. Their
`readFileSync` now names the file the code moved to. The assertions are
unchanged, byte for byte; only the path they read is.
The retention-purge comments still cite migration 0277 for indexes that landed
in 0276 — the renumbering moved the directories and left the prose behind. The
schema is trustworthy because its comments are, so a wrong number is not
cosmetic.
The import worker's `already_recorded` reason claimed the skip covers a dose
recorded on any other intake surface. It does not: `source` is part of the
live-row unique, so a hand-logged dose and an imported one at the same slot are
different keys and both are written. Say what the mechanism actually does, and
say plainly that cross-surface dedup is not there.
The per-medication import and the account-wide dose-history import queued
their work through ninety near-identical lines each: row lock, stale-job
sweep, active-job refusal, job row, pg-boss send, and the fallback that fails
the row when the queue is not there. Five steps written twice drift, and the
drift shows up as an import that hangs on one surface and not the other. The
status projection moved out of these routes for the same reason; this is the
write half.
Scope is the only real difference, and it is one argument. What the two
surfaces legitimately say differently — the 409 wording, the envelope, the
kickoff audit line — stays at the call site.
The replay key had the same shape of problem in smaller form: the export
reader owned a function for it and the hand-listed body inlined the same
template. Both now call one function, with a test that pins them equal for
the same medication and instant.
The supply tab carried its own copy of the two container kinds whose first
opening starts a degradation clock, with a comment saying it mirrored the
state machine's list. Nothing enforced the mirror — the state machine types
itself against the generated Prisma client, which the browser bundle cannot
carry, so a copy was the only way to say it.
Move the list into a client-safe module with no imports and let both sides
read it. The state machine re-exports it under the Prisma enum, so a renamed
or dropped container kind is now a compile error rather than a member that
quietly stops matching.
The resolver reads the profile's sex for the body-fat and pulse bands, and
nothing asserted it. A resolver that stopped reading the field, or read it and
defaulted to one side, passed the whole suite. Pin both recorded sides and the
neutral band that OTHER and an unanswered profile share.
`binaryReferenceSex` was already the declared owner of the shrink from the
three stored values to the two a sex-split reference table has rows for, but
eight sites still narrowed inline — including a byte-for-byte private copy in
the effective-range resolver and a ternary 37 lines above a `toProfileSex`
call in the same file. Each of those is a place the fallback can drift.
Delete the copy, replace the ternaries, and name the pair once as
`BinaryReferenceSex` so a signature that needs it stops re-listing the two
literals. No behaviour changes: every site already resolved OTHER and "no
answer" to the same neutral or absent value, and the existing suites pass
untouched.
A migration container starts empty, so applying 0281 there proves the
statements parse and nothing else — the backfill's WHERE matches no rows and a
broken expression passes green. Every self-hoster runs it against rows that do
carry a leg, and those are the only rows it exists for.
Seed the pre-migration shape, run the migration's own UPDATE read out of the
committed file, and assert an attributed row comes out still knowing which leg
broke, an unattributed one is left alone, and a second run does not nest the
array.
The batch route has accepted a `syncTrigger` since v1.32.8 and put it on the
wide event, where no human reads it. Half of what was promised got built: the
value is written and appears in three places in the codebase, none of which is
a screen anyone looks at.
Persist it on the account, alongside the instant of the most recent batch that
arrived without the app being opened, and publish both from
`GET /api/integrations/healthkit`. The card now names how the last sync arrived
and when data last came in on its own. `healthKitLastSyncedAt` alone reads
identically whether the phone delivers by itself or only while someone has the
app open, which is why "is background delivery working" could not be settled
from either side.
A batch that declares no trigger writes null rather than leaving the previous
one standing — the field describes this sync, and an older client's silence is
not evidence about it. A foreground batch leaves the background timestamp
alone; overwriting it would erase the only answer the card has.
Also here: WHOOP, Fitbit and Google Health join the workout-freshness table.
All three write workouts, and without an entry their cards could report every
measurement type fresh while the workout leg had been silent for weeks. And the
status pill drops a generic `aria-label` that replaced its own accessible name,
so a screen reader announced the same words for a healthy connection as for a
broken one.
`failing_leg` was one slot, so two legs of the same provider failing in
overlapping windows overwrote each other. The row remembered whichever failed
last, and that leg recovering then cleared an error the other one was still
causing — the ledger forgot a live failure and painted the card green over it.
The slot becomes a set. A failure adds its leg, a success removes its own, and
the error state, the per-kind buckets and the streak anchor clear only once the
set is empty. The array is ordered oldest failure first, so the last element is
the leg that wrote the message `last_error` holds; when that leg recovers while
others are still failing, the message is cleared rather than left describing a
leg that is now healthy.
An unattributed failure adds nothing and removes nothing. A single-leg provider
therefore behaves exactly as before, and a multi-leg provider no longer loses
what its other legs already reported.
The column never reached a client — it is absent from the status serialiser and
from the OpenAPI contract — so it is replaced rather than shimmed.
The module-gate branch cited the client-managed skip as its precedent for
advancing past a cycle. That skip no longer exists in this file, and the
reason the gate advances is its own: a disabled module has nothing left
to deliver.
`apns-collapse-id` is a replace directive: a push carrying an id
supersedes the earlier undelivered push with the same id. Every
medication reminder carried the literal string MEDICATION_REMINDER, so
three medications due at 08:00 arrived on the phone as one notification
naming one of them. The user takes that one and misses the others with
nothing on screen to suggest anything was dropped.
Key the collapse on the per-slot tag the Web Push sender already
coalesces on, falling back to the event type when a payload carries none.
Grouping stays on `threadId`, which is what grouping is for. Discreet
mode keeps the generic id — no record-identifying string on the lock
screen — and a tag over Apple's 64-byte ceiling falls back rather than
costing the send.
The per-phase "already sent today" guard read the Telegram message
ledger, and that table is written only when a Telegram send succeeds. A
user on email, APNs or ntfy never had a row, so the 15-minute tick
re-derived the same overdue slot and sent it again on every pass — around
fifty mails for a single missed dose.
Anchor the guard in `push_attempts` instead, the ledger the daily
briefing and the illness escalation already use. Two properties are
deliberate: the anchor is stamped for every dispatched slot regardless of
what any channel returned, because keying it on a delivered push would
re-open the flood the moment a channel starts failing; and it is stamped
before the dispatch, so a crash costs one phase's notice rather than a
repeat every quarter hour.
The key carries the schedule id and the slot's real HH:mm alongside the
phase and the local date. Both are load-bearing: two legacy
single-window schedules on one medication share the empty first-class
time-of-day, so a coarser key would collapse the evening dose into the
morning one. The tests assert the other direction too — the next phase
still escalates, and the next local day starts clean.
The Telegram ledger keeps its message-tracking duties; it is no longer
the dedup key. Anchor rows carry a sentinel channel and stay out of the
operator's notification health and diagnostic reads, which count
deliveries, not decisions.
`medication.clientManaged` and `measurementReminder.clientManaged` mean
the iOS client schedules the reminder locally, so the server should not
push it. That is what the OpenAPI description, the settings copy and the
iOS contract all say. What the crons did was skip the dispatch entirely,
so a user who flipped the switch also lost Telegram, ntfy, webhook,
email and Web Push — channels they may well read on a desktop while the
phone holding the local schedule sits in a drawer.
Move the gate into the dispatcher's channel loop, where it applies to
APNs and nothing else. The skip is not counted as an attempt, so
`dispatched` keeps meaning "something reached the user", and it stays
countable: the same wide-event keys the crons emitted are emitted here,
plus a `skipped` row in the push-attempt ledger.
The Vorsorge path is the sharper half. It suppressed every channel and
then advanced `nextDueAt`, which dropped the reminder out of the
preventive-care card and out of the daily digest: a reminder that never
reached anyone was recorded as handled. The advance now happens only
where it always belonged, after a dispatch that a channel actually
delivered. When nothing delivers, the reminder stays overdue and visible.
The restore resolves a mood entry's rated factors by key and asks only for the
seeded catalogue. A tag the user created is not in it, so the lookup came up
short and the whole restore threw 'Unknown mood factor keys'. The file was
complete; it simply could not be read back.
That is the same cause as the cycle symptoms two commits ago, with the
opposite ending. There, an unresolvable key was filtered out and the loss was
silent. Here it stopped everything. One shape of missing definition, two
answers, neither of them the right one.
The account's own tag definitions travel now, get written before the factors
resolve, and the lookup accepts the seeded catalogue or the owner's own. The
seeded rows stay out of the file for the same reason as before: a restore must
not rewrite another instance's reference data.
Four more test mocks needed the new delegate, which is again the compiler and
the suite naming the call sites rather than me hunting for them.
The export has written nutrient day totals since v1.29. The canonical schema
never declared the field, so `.passthrough()` carried it through parsing and
no reader ever looked at it: every water and vitamin total in the file was
discarded on restore, and the restore reported success.
Declared in the contract, cleared and re-created beside its siblings, and
counted in both places that count — what the file holds and what the restore
replaced. A count that stops at the domains someone remembered is how this
stayed quiet.
restoreCycleData was loud about one missing reference and silent about
another, three lines apart. An unknown cycle reference threw; an unknown
symptom key was filtered out of the list, so the day-log came back with one of
its symptoms quietly gone and the restore reported success.
The cause was upstream: the seeded catalogue is reference data every instance
has, but a symptom the user created lives only in their account and was never
carried. The key had nothing to resolve against, and the filter turned that
into silence.
Both ends now. The account's own definitions travel with the backup, the
seeded rows deliberately do not — shipping those would let one instance's
restore rewrite another's reference data. And the resolve step throws on an
unresolvable key rather than filtering it, so the same function answers the
same question the same way twice.
Widening the builder's client type made four export tests fail on a missing
delegate, which is the sweep working: those are the call sites, named by the
compiler instead of found by reading.
The mirror of the wipe plan, for the same reason and against a worse failure:
a list that was right when written and stops being right every time a model is
added. On the delete side that leaves data behind; here it means data does not
come back, and nothing says so until someone is restoring a backup because
something already went wrong.
The list is longer than the wipe list by sixteen models, and that gap is the
point. A wipe may lean on the database — deleting a medication cascades to its
schedules, dose changes and inventory events — but a restore has no cascade,
so every child has to be named here.
Every exclusion carries its reason in prose, and the guard enforces a length
floor on it. That floor is not pedantry: the first draft of this file said
'Same as WithingsConnection' four times, which is a pointer rather than a
reason, and a pointer silently inherits whatever the thing it points at
becomes. Writing the decision where it is made is the only way it stays
reviewable.
The guard found two things in its own author's work on the first run: a model
with no verdict at all, and a reason too thin to be one.
The restore side of the guard — that every carried model also has a restore
branch — lands with the payload work it will police.
A formatter with no timeZone follows the browser, which is a different
question from the one this app answers. Four surfaces had drifted apart that
way before anyone noticed, and one of them printed a shared report a day off
from the page it came from.
The admin host chart went with them rather than onto the allowlist. Server
samples are real instants, and an operator reading their own instance should
not find a spike at 14:20 here and 15:20 two screens away.
Two deliberate limits, both written into the test. It does not match
toLocaleString, which in this tree is overwhelmingly a number formatter, and
burying four real hits under every thousands separator is how a guard gets
silenced. And it cannot tell a Date built from calendar arithmetic — a month
label, a weekday header, no instant behind it — from one parsed out of stored
data, so those four sites carry a written reason instead. The value is the
ratchet: a new bare formatter has to be argued for before it ships.
The bucketer hard-coded Europe/Berlin for day, week and month boundaries, so
a reading taken on the last evening of a month landed in the wrong month's
bar for everyone who does not live there. Two smaller siblings did the same
thing: the Today chip asked the browser for a time instead of the profile,
and the score card's tooltip formatter carried a locale but no timezone.
The boundary timezone is a required option rather than a defaulted one. A
default would have left any caller that forgets to pass one still wrong and
still silent; required makes the compiler name them, which is how the three
call sites and the eight test sites in this diff were found rather than
guessed at. The tests were always asserting Berlin's calendar without saying
so, and now they say so.
Two fixture mistakes worth recording, both caught by running the tests rather
than trusting them: summer time is already in force in Berlin on 31 March, so
the first instant chosen was April on both sides; and 31 March 2025 falls in
the same ISO week as 1 April, so the week case needed its own instant where
one side is still Sunday.
The page at c/[token] resolved the owner's timezone; the PDF download beside
it resolved nothing and fell through to the display default. Same link, same
reading, two dates — and the one a practice files is the one that was wrong.
Resolved in the shared download gate rather than in the route, so the PDF and
the FHIR bundle cannot drift apart the way the page and the PDF did. The
owner's clock preference travels with it for the same reason.
The fixture is deliberately west of UTC: Berlin is the fallback, so a Berlin
fixture cannot tell a resolved timezone from a forgotten one.
The rotation-coverage guard greps the script for a quoted column name.
Listing the new column in the header comment satisfied that grep without
the script rotating anything, which made the guard pass on a script that
would have left the column behind. The comment now names it unquoted and
says why.
Names LiteLLM, OpenRouter and vLLM, states what it does not share with
the OpenAI provider and why, and keeps the Local provider's own section
intact — it still speaks the same wire and remains the right pick for a
model on your own machine.
The properties this provider has to keep: it resolves against the base
URL that was saved for it, the pinned OpenAI arm cannot see that URL and
still posts to api.openai.com, and the OpenAI key never travels to the
gateway. Each is asserted at the wire, not on internals.
The SSRF floor is checked through the same alt-notation classes the
Local provider is checked against, and the pinned tags are shown to keep
requiring a public host even when the operator opened every private one.
The AI settings connect-src is pinned to what it already allowed: the
gateway is called server-side, so no CSP change is needed, and a future
change that needs one will turn this red.
Its own form with its own three fields, its own entry in the provider
dropdown and in the fallback-chain editor, and copy in six locales that
says plainly where the OpenAI key does not go.
The chain editor offers the entry but the default chain does not carry
it: it resolves only against a base URL somebody saved.
The read endpoint reports the gateway's base URL and model, and the
bearer as presence only. OpenAPI regenerated.
The chain gains a seventh entry. It builds the OpenAI client against the
base URL the user saved, with the bearer they saved for it and the model
they named, and it reads none of the OpenAI columns — the pinned
api.openai.com arm stays pinned.
Host policy is the Local provider's, unchanged: a public host always, a
private one only where the operator allowlisted it, which is what a LAN
LiteLLM or vLLM needs. Egress keeps riding safeFetch.
The JSON-mode dialect the Local client learns per endpoint moves into a
shared module and now serves both clients, so a gateway that rejects
response_format is detected once and retried without it.
The entry stays out of the default chain: it can only resolve against a
base URL somebody configured.
Three dedicated User columns — base URL, optional bearer, model — for a
provider whose endpoint the user chooses. They are separate from the
OpenAI columns on purpose: the OpenAI arm pins api.openai.com, so it must
share no column with a user-supplied host, and a gateway must never
receive the key issued for OpenAI.
The bearer joins the encrypted-column registry and the rotation script;
the three columns join the account-wipe reset set.
Four specs navigated and then read the DOM once, with nothing in between
that waits for anything but the network. An idle network says nothing about
whether React has rendered, so the read can sample a tree that is still
mounting and the assertion then blames the application for a race inside the
test. Two such reads went red on consecutive CI runs, on different specs.
The settings over-scroll guard also slept a fixed 500 ms, which is the wrong
shape twice over: too long when the page is ready, too short on a loaded
runner, and it never says what it is waiting for.
The structural guard runs in the unit suite rather than in Playwright,
because e2e runs at PR-CI only and flaky counts as failed there — one
unguarded read otherwise costs a whole pipeline before anyone sees it. It
refuses to count a wait whose rejection is thrown away, since that reads
like a guarantee and is not one, and would leave the check defeatable by
appending a catch. The one deliberate swallow is on the allowlist with its
reason: a genuine regression there still fails at the width assertion below
it, which reports the measured number instead of a timeout message.
A release asset is served with content-disposition attachment and nosniff, so
an image embedded from that URL renders as a broken image in the notes. The
raw path serves it as an image, which is the only thing that actually works.
A chart shows the shape and hides the numbers. An appointment needs the
numbers, and so does anyone moving readings into a spreadsheet.
The table reads the same expression the chart hands Recharts, after the range
slice, the week/month bucketing and the display scale, and it formats through
the chart's own value and date formatters. Nothing is re-derived, so a cell and
the tooltip cannot print two numbers for one point.
Collapsed by default, with the point count in the trigger so the size reads
while it is closed; the region scrolls in its own box rather than growing the
page, and the table primitive already scrolls a wide row set sideways. The
caption names the grain, because a weekly mean labelled as a daily reading
would be a lie, and the grain now has one source shared with the bucketing.
Derived overlays get no column: a moving average is drawn from the points, not
measured. An empty window renders no table at all, since the chart's own empty
state already says there is nothing there.
Opt-in per mount. The metric sub-pages take it; the dashboard tiles do not,
because a table under every tile buries the dashboard it summarises.
Refs #636
The tag crosstab already ran a with-versus-without Welch test per structured
tag, with a D to D+1 pairing mode whose stated example was an evening tag
against next-day recovery. It only ever had one channel at that lag, and a
recovery score is a wearable composite plenty of accounts never record. The
rated-factor board next to it has tested next-day resting heart rate and HRV
since v1.14.0, but a rated factor is a continuous score, so a binary tag never
reached either vital.
Add both to the tag map. Same engine, same per-side day floors, same
Benjamini-Hochberg family, same card. The fetch filter derives from the map, so
the channels come along without a second query.
The delta read-out goes neutral to match the rated-factor board: with resting
heart rate on the board, a positive delta is worse on one row and better on the
next, and a green plus sign would claim a verdict the association cannot carry.
Refs #616
The preamble already says once what the two states mean and that neither is
absence; repeating it per row would have doubled a manifest that rides every
turn.
The unit suite mocks Prisma, so it proves the discriminant and nothing about the
query underneath it — a wrong column would have left every test green and every
account answering no_data_unconfirmed in production. Seeds the reported case and
the four other shapes: an empty record, another account's rows, tombstoned rows,
and a history that mixes units.
Rounds the out-of-window mean to one decimal, like every other mean the snapshot
surfaces, and annotates a failed manifest probe rather than letting it degrade in
silence.
The mutation check found both holes: an addendum test that only looked for the
new reason codes passed with the old declare-absence-and-move-on sentence still
in place, and a block-failure check whose window was wide enough to see the
neighbouring block's annotation.
The fixture is the report: a fortnight of glucose readings from April 2024 on an
account that displays them everywhere else. The clock is pinned so it cannot age
out of the window it was written to sit outside of.
Covers the four states an empty window read can resolve to, that a never-recorded
metric and a historical one differ in the SAME run, that a mean is withheld
across mixed units rather than fabricated, that the manifest advertises the range
instead of "absent", that both instructions branch on the reason code, and that a
miss carrying figures reaches the prose verifier's authoritative set. Adds the
medication-compliance page to the failed-read sweep and pins the narration
blocks' failure annotation.
A Coach retrieval tool that read the window and found nothing returned
`reason: "no_data"` — the same code for two situations that have nothing in
common: the record holds nothing for that metric, and the record holds a
substantial history for it, all of it older than the window that was searched.
Nothing downstream could tell them apart, so the Coach said the honest-sounding
sentence for the wrong case, and then, having been told there was nothing there,
changed the subject. Someone with a year of imported glucose readings was told
the readings did not exist, on the same account whose glucose page was showing
them.
The window cap stays. It exists because a multi-year history does not fit a
prompt budget, and widening it would only move the same failure. What was
missing is the ability to say a different true sentence.
- `tools/availability.ts` — one grouped aggregate per miss, no rows
materialised, resolving four states: `no_data` (probe confirmed empty),
`outside_window` (rows exist, older than the searched window; carries the
count, the date range in the user's own timezone, and a bounded per-series
mean/min/max), `unavailable_in_scope` (rows exist inside the window and the
domain still produced none — a module is off), and `no_data_unconfirmed` (the
probe itself failed; absence was never established, so it is not asserted).
- Every empty read in the executor routes through it. A bare `no_data` can no
longer be returned without the record having been checked.
- The DATA INVENTORY carries the same distinction, so a domain the window
cannot reach is advertised with what it holds instead of as absent — the model
no longer skips the fetch, and a vague follow-up keeps the subject.
- Both instructions that told the model to report absence and move on now
branch on the reason code, and forbid answering about one metric by reporting
another.
- The out-of-window aggregate joins the authoritative set for the prose
verifier, so the sentence the fix exists to make sayable is not elided.
- A cross-cutting narration block that throws is now counted rather than
vanishing into a `null` that looks like honest absence.
- The medication-compliance page tells a failed read apart from an account with
no medications, instead of inviting someone to re-enter what it already holds.
Refs #648
`Partial<typeof item>` inferred the literal types off the base fixture, so an
override could not swap the container kind or null a date. The fixture takes
an explicit row shape instead.
Hardcoding the container kind in the batch-import consumption path or in the
refund path left the whole suite green — neither had a scenario that read a
clock-less container. Both now do: a back-dated import chunk against a tablet
pack stays IN_USE with no deadline, and a refund that brings units back to a
long-opened pack reads IN_USE rather than expired. The pen counterpart of the
import case is pinned alongside.
The fake client's `updateMany` learns the `id: { in: [...] }` form the batch
claim uses; it only understood a single id before.
The no-runway arm of the low-stock evaluation now has its written-off count
asserted too.
The comparison now includes `updated_at`, so a second application that
rewrites an already-correct row is visible even when the value it writes is
identical. Without it the assertion passed whether or not the guard that
skips untouched rows was there.
The first pass of migration 0276 restored the rows the in-use clock had
already flipped, but every clock-less container still IN_USE carried the same
clock-derived `expires_at`, most of them with the deadline still ahead. The
daily expire scan would have worked through them over the following weeks and
reproduced the whole defect one row at a time.
A clock-less container's `expires_at` is now set from its printed expiry —
usually nothing, which is how it stays out of that scan. Pens and ampoules
keep theirs; their clock is real.
Submitting the correction dialog is out of reach for the SSR component
tests, so the two behaviours that only show up on submit are pinned against
the module source: a date rides along only when it changed, and a cleared
field sends null rather than an empty string. The route side of the same
contract is covered by a real request test.
The row printed the opening date and the printed expiry with nothing between
them. It carries the same middle dot the unit figures use.
The docblock now says out loud what the enum cannot distinguish: AMPOULE
covers both a punctured multi-dose vial, where the post-opening window is
real, and a carton of single-use glass ampoules, where it is not. The kind is
never auto-selected, so picking it is a statement about an opened container,
and the opening date is editable when it turns out not to be.
The supply tab rendered a container's kind, its unit count and a state badge,
and nothing else. A row could read "Expired" with nothing on screen to say
which date caused it, and the correction dialog only ever offered the
remaining-unit count — so the opening date, which the intake consumption hook
writes without the user ever typing it, could not be taken back.
Each row now carries the two dates it has, and the correction dialog edits
both alongside the unit count. Each field rides along only when it actually
changed, so saving a stock correction never rewrites a date left untouched,
and clearing the opening date sends null, which the route reads as "this was
never opened" rather than "leave it alone". Beside the opening date the
helper text says whether the kind carries a 30-day window at all.
The headline stops contradicting itself. "0 of 0 doses remaining" next to
"+ 54 units expired" read as "nothing was ever recorded" beside "stock was
written off"; when the available pool is empty and only expired stock is
left, one line says so instead. The low-stock push follows: no usable supply
with written-off units on hand is a different errand from an empty pack, and
`expiredUnits` now reaches the payload, the notification metadata and the
wide event.
Migration 0276 reverses the rows that reached EXPIRED only through the
post-opening clock on a container that never had one. It moves a row back to
IN_USE when an opening date is recorded and ACTIVE when none is, and sets
`expires_at` from the printed expiry — which for these rows is usually
nothing at all, because they recorded none.
The predicate is exact rather than approximate: nothing but the printed
expiry and the in-use clock can write EXPIRED, so a clock-less container
whose printed expiry is absent or still ahead can only have got there through
the clock. Pens and ampoules keep their EXPIRED, so does anything whose
printed date genuinely lapsed, and so does a drained container. No date is
invented and no printed expiry is fabricated.
The integration test seeds the before-state, applies the migration SQL from
disk, and asserts both what moves and what stays — the testcontainer starts
empty, so the migration running at boot proves nothing on its own.
A supply container went EXPIRED 30 days after its first use no matter what
kind of container it was. That window exists because puncturing an injection
pen exposes its contents and the product information caps how long the rest
stays usable; a blister of tablets has no such limit, every tablet sits in
its own sealed cavity, and pressing the first one out says nothing about the
rest. The state machine never read `containerType`, so a tablet pack opened
five weeks ago had its remaining stock written off.
The clock now keys off the container kind. PEN and AMPOULE carry it: both are
a single sealed reservoir that the first dose breaches. BLISTER, INHALER,
BOTTLE and OTHER do not: their units stay individually sealed, or the
arrangement is simply unknown, and a fixed 30 days would be a number nobody
entered. The printed expiry still applies to every kind, so an unknown
container is not an unbounded one.
`computeExpiresAt` takes the kind too, so a clock-less container with no
printed date now persists no `expiresAt` at all and the daily expire scan
never sees it.
The per-item PATCH gains a way back out: `markAsFirstUseAt` accepts null and
clears the opening date, which is the correction a container needs after the
intake hook auto-opened it from a back-dated dose.
Also drops `daysRemainingInUse`. It had no caller anywhere in the tree and
its docblock described a chip that does not exist.
The wrap assertion gated on the section heading, but the signals card paints a
skeleton heading first and swaps it for the resolved one — so the element could
detach between the visibility check and the measurement, and the skeleton
heading carries no action slot to measure in the first place. Gate on the
caption itself and read both rects in a single evaluate.
The Insights overview panned sideways at every phone width. SectionHeading
pinned its trailing action slot with shrink-0, which sizes a flex item to its
max-content width. That slot was designed for a compact control, but every
caller passes prose today — the provenance method caption, the device-score
subtitle, the sleep-quality subtitle. A sentence at max-content measures
1229 px in English and 1464 px in German, and since the row sits inside the
AuthShell <main> — whose overflow-y:auto makes overflow-x compute to auto —
that width turned the page into a horizontal scroller. min-w-0 lifts the flex
min-width:auto floor so the caption wraps to the row instead; the parent's
flex-wrap still moves the slot to its own line before anything is squeezed.
The existing width guards could not see this. Both measured
documentElement.scrollWidth, which never budges when <main> absorbs the
overflow into its own scroll area, and the insights case ran against an empty
mocked payload that fits any viewport by construction.
- Add e2e/utils/horizontal-overflow.ts: asserts the document AND the <main>
scroll container, and names the widest offending element on failure.
- Add e2e/mobile-horizontal-overflow.spec.ts: sweeps /insights, /, /measurements
and /medications at 360/390/393/402/430 px against populated fixtures, plus a
canary proving the measurement can see through <main>, and a check that the
insights fixture really populates the page.
- Rewrite the cycle spec's empty payload as a populated one and sweep every
phone width; its /insights case moves to the new spec.
- Point mobile-density.spec.ts at the shared assertion — it carried the same
document-only blind spot.
Three legs, none of which stands alone.
`destructive-control-guard.test.ts` is the structural one. A registry
names every destructive control, what it takes, and how recoverable that
is; three detectors then make FORGETTING to register the failure mode.
The obvious guard — every trash icon must sit near a dialog — fails both
ways and fails quietly: it misses the control that destroyed the most
here, a PUT carrying two empty strings with no trash icon involved, and
it cries wolf on the trash icon that drops a row from an unsaved draft.
Both classes are live in this tree, and a guard suppressed on half its
hits teaches people to suppress it. So the judgement lives in the
registry, where it is written down and reviewable, and the mechanism only
guarantees that a new control cannot arrive unnoticed and a removed one
cannot leave a lie behind. What it cannot do is on the record in the
docblock: it cannot prove the registry complete, and it cannot prove an
entry true. Skipping a confirmation is possible, but it costs a written
argument, and the length floor is enforced.
`confirm-button.test.tsx` proves the dismissal contract once, where it
belongs: the trigger carries no click handler, the caller's callback
reaches exactly one node, the dialog action, and cancel never sees it.
An earlier draft also asserted that a closed dialog renders no confirm
control; that passed with `open` hard-coded true, because Radix portals
do not render server-side at all. It was measuring the renderer, so it
is gone rather than left green.
`about-me-clear-scope.test.tsx` covers the finding itself: the request
carries no allergies key at all, the clearing call reaches the control
only as `onConfirm`, and the allergy field stays editable so the targeted
way to clear it survives.
The existing settings suite grows to name every newly guarded control by
slot, plus the threshold override switch, whose off position performed
the same irreversible delete as the Reset button beside it.
Every assertion was mutation-checked.
Settings → AI "About me" had one Clear control wired to
`save.mutate({ aboutMe: "", allergies: "" })`. One tap, no dialog, and
the allergy line went with the note — while the toast it raised said
"About-me note cleared", which is what the control looks like it does.
The allergy line is the first thing a clinician reads and the column is
overwritten in place, so there is nothing to restore afterwards.
Scope the control rather than only gating it: it now writes `aboutMe`
alone and omits `allergies`, which the PUT leaves untouched by contract,
so the payload cannot carry the allergy line away even if the dialog is
ever dropped. Clearing allergies stays possible in the allergies field
itself, which is a deliberate act on the thing being emptied. The
confirmation on top guards the note.
The same one-tap shape ran through thirteen other controls that
overwrite a column or drop a credential with nothing to restore from.
They route through the shared confirmation now: both Codex disconnects
(personal and the instance-wide shared account), the AI server key, the
profile photo, the threshold reset — all, per metric, and the override
switch, whose off position performed the same irreversible delete from a
control that reads as a display toggle — the source-priority order, the
dashboard layout, the Insights overview, a single target range, a
remembered Coach fact, a past Coach plan, a logged cycle day and a custom
cycle symptom.
`ConfirmButton` moves to `components/ui`, since it is no longer a
settings-local helper, and gains `ConfirmDialog`: the same dialog without
a trigger, for destructive controls that are not buttons. A switch has to
stay on when the dialog its off position raised is cancelled, and a
control that could not reach for the primitive reached for nothing —
which is a fair part of why those were the ones that shipped unguarded.
Copy lands in all six locales.
The description promised that null or an empty string clears any field.
An empty string clears the free-text ones and now gender, but email is
validated as an address and 422s on it, and the display preferences are
not clearable at all. Name them rather than let a client find out.
Adds the cases that were missing everywhere except the FHIR conformance
suite: an empty string clears without taking its siblings down, OTHER
round-trips through the profile and onboarding writes, an unsupported
value is still refused and says which field it is about, the Strain
model scores an unanswered account on neither side, the dashboard bands
give OTHER the neutral fallback, the snapshot payload carries the value
instead of flattening it, and the doctor-report cover omits the row
rather than claiming a value the account never chose.
Refs #645
The profile validator has accepted MALE, FEMALE and OTHER for a long
time, but the published contract documented two, and a client written
against that contract had no way to know the third existed. The profile
PATCH is one write, so a rejected gender took every sibling field with
it - a time-format change lost to a field the person never touched.
Gender now clears on an empty string, the same contract every other
profile field documents, and an unsupported value is refused with a
message that names the field and its accepted values instead of the
validator's own prose. Onboarding validated against a narrower enum
than the profile schema and 422'd the whole submission; its form could
not record the third value at all.
The rest is the same defect in other clothes. The Strain model scored
an unanswered or OTHER account on the male Banister coefficients; it
now uses the midpoint of both published models, matching every other
sex-split formula here. The dashboard snapshot emitted null for an
account that answered OTHER, which is indistinguishable from no answer.
Three casts asserted a two-value type over a three-value column. All of
them now carry the stored value and narrow once, at the boundary of a
reference table that genuinely has no third row, through one named
helper in src/lib/profile/sex.ts.
Refs #645
The envelope was assigned after the loop, so the loop's IN_BED skip had
no observable effect - removing it changed nothing, which makes it a
dead branch rather than a guarantee. Assign the envelope first and let
the skip protect it.
A device can write the same period twice: an original envelope and a
corrected one, same start, later end, distinct external ids. Both rows
belong to the same writer, so the writer collapse keeps both, and the
per-stage totals added them. One night served a 10 h 13 min in-bed
headline next to a 20 h 17 min in-bed legend on the same screen, and
the hypnogram drew the night twice over.
Every per-stage total is now the measure of the union of that stage's
spans rather than the sum of the rows, and the same merged spans feed
the hypnogram segments and the time-asleep headline. Stages merge
independently: two stages describing one minute is a contest, and the
writer collapse is this file's mechanism for that - arbitrating again
would collapse the summary-shaped writers, which stamp every stage
total on the sleep end instant and nest by design.
stages.IN_BED is now the in-bed envelope itself, the same union
inBedMinutes reports, so the legend cannot contradict the headline and
a night whose envelope only a losing writer recorded still reports it.
The seven documented statuses are four kinds of thing, so they get four
rulings instead of one.
Taken and Skipped state a decision about a dose and each maps onto something
already recorded here — a row with a take time, and a row marked skipped. That
second mapping was checked rather than assumed: skipped is documented as a
deliberate skip and held apart from autoMissed, which is the marker for a dose
nobody ever acted on, and the compliance re-fold counts a skipped slot in its
own column and never as taken. So a deliberate skip arrives as one.
Not Interacted, Not Logged and Unspecified are absences rather than
statements. Written as taken they would invent a dose; written as skipped they
would invent a decision, because not answering a notification is not choosing
not to take something; written as pending they would put outstanding doses
from years ago in front of someone today. Snoozed describes what happened to a
reminder. Notification Not Sent is about the software. None of the five
becomes a row.
All five are refused, but each under its own name, because "the file says
nothing about this dose" and "this row is about a reminder" are different
things and a person acts on them differently. Three reasons where there was
one, each with its own sentence in six locales.
An inactive medication's history imports and the medication stays inactive.
Preserving what happened and putting a medication back on the active list are
two acts, and only the first belongs to an import.
Three things the tests said were covered and were not.
A column flipped from honoured to ignored moved both sides of its own
assertion, because the check read the verdict off the table it was checking.
The verdicts are now written out as literals, so changing one is a decision
somebody states twice.
The preview block was unreachable from an SSR-only test, so nothing would
have noticed it rendering through the outcome view — which would classify
what a preview *would* write as a success and put a tick on a run that never
ran. It moves into its own component that takes the verdict as a prop, the way
the CSV result view already does, and the preview and the archived-row report
are asserted directly.
The account-wide import narrows its in-progress check to account-wide jobs, so
an import running on one medication's page does not refuse it. Nothing pinned
that; now something does.
Also correct two comments about the earlier replay-key fault. The repeated
value in the reported file was not a dose quantity — it was copied from the
example this dialog itself displays. The field never carried identity, and the
submission was never at fault.
The dose-history import gets its own card in Settings, because the file
covers a whole regimen and the per-medication dialog on a medication's page
covers one medication.
Preview before writing. The dry run parses the file, matches every row
against the record, and answers with the whole verdict: rows read, rows that
would land, rows refused grouped by reason, and the medication names that
match nothing — verbatim, so they can be added and the file re-run. Nothing
is written until the second button.
The column rulings sit on the card before a file is chosen, one line each,
naming whether the column is used, reported, or not read and why. A column
read and dropped in silence is indistinguishable from a column honoured, and
that is how somebody comes to believe an importer looked at something it
never did.
Outcomes render through the same primitive the other three import surfaces
use, so a run that wrote nothing cannot carry a success tick. The preview
borrows the reason lines and deliberately not the outcome sentence: a preview
has written nothing, and classifying it by the counts it would produce would
put a tick on a run that never ran.
The fixture is the export a self-hoster attached, de-identified as it
arrived: 3,395 rows, sixteen medications, three and a half years, two UTC
offsets, three statuses, two units, blank cells in four columns. A
hand-built fixture would have carried one status and one offset and proved
nothing about the file that prompted the work.
The integration pass runs it through the real derivation into a real
Postgres, because the claim is about rows: 3,387 land, 8 are refused because
the file records no decision about them, and imported plus refused equals
every row read. 2,799 of the doses were taken after they were due, which is
the fact that survives only because the scheduled slot and the take are two
columns.
Structural cover so the verdicts cannot rot: every column of both shapes has
exactly one ruling, and every parse-time refusal is spelled the same as a job
skip reason — a reason that exists in only one vocabulary renders as "reason
not recorded", which is a refusal nobody can act on.
The intake importer took a hand-written list of dates and times for one
medication. A self-hoster with a ten-column export covering sixteen
medications and years of history was left translating it by hand, which is
where the scheduled time and the time a dose was actually taken get
flattened into one — the distinction the record wants most.
The export keeps them as two columns, so read the file's own shape.
`MedicationIntakeEvent` already models that pair as `scheduledFor` +
`takenAt`; an imported dose now populates both instead of anchoring itself
on the take. `Skipped` becomes a deliberate skip with no take time, which is
how every other skip path writes it. A dose whose actual amount differs from
the amount the schedule expected carries the difference as a per-dose
override; an unremarkable amount stays absent, which is what absent means
there.
Timestamps carry their own UTC offset (`+1030` and `+0930` on adjacent rows
of one file) and it is read literally: the epoch is computed from the stated
offset rather than handed to the platform's lenient date parser, and a value
without an offset is refused instead of assumed to be the server's zone.
An export covers a whole regimen, so the job is account-wide and each
payload entry names its own medication — migration 0276 widens
`medication_intake_import_jobs.medication_id` to nullable for it. The
per-medication route is unchanged and keeps naming its medication on the job
row. A medication is never created from an import file; unmatched names come
back verbatim so they can be added properly and the file re-run.
Every column gets a verdict, and `?dryRun=1` returns the whole one before
anything is written.
The intake import built its replay key from the row's optional `zaehler`
field. That reads as a per-row counter — the documented example showed one —
but nothing required it to be unique and nothing checked. A reported export
carried the dose quantity there instead: 1 tablet, on all 28 rows. Every entry
queued under the same key, one dose reached the ledger, and the other 27 came
back reported as duplicates of a dose they had nothing to do with.
The key is the instant now, and `zaehler` is out of the contract rather than
accepted-and-ignored. Nothing is lost by dropping it: the live-row unique on
`(user_id, medication_id, scheduled_for, source)` already makes an imported
dose unique per instant, so a re-import of a file first imported under the old
key still dedups.
The telling is the other half. Two different facts were counted as one number:
entries of a submission that collide with each other, and doses the ledger
already holds. They are separate reasons now, grouped with a count, and the
dialog renders them through the same classifier and the same outcome line as
the CSV and JSON import cards — so an import that wrote nothing can no longer
answer in success tone. That rule shipped for the settings import panel and
this surface was not part of it; the guard now lists every import surface by
path, so a fourth one has to be added deliberately. `skippedInvalid` is gone:
it was hardcoded to zero on every path and reported a capability the importer
never had.
Alongside it, the other half of the environment report — a scheduled job that
fails every run has to say so. `recordError()` increments one unnamed counter,
so the admin status page could show a number and not which queue produced it,
and the module's own screen showed a zero day count with nothing to
distinguish "nothing stored yet" from "every run failed". pg-boss has held the
answer the whole time: a job that exhausts its retries keeps its queue name,
its error and its timestamp. The admin status now names the failing queues
with counts and the last error, the environment overview reports its own
account's failed runs, and neither needed a change at any of the thirty
`recordError()` call sites — a visibility fix that has to be remembered per
call site is the same failure one level up.
Refs #650, #646
Migration 0217 created the type as `environment_location_source`; the
datamodel declared `EnvironmentLocationSource` with no `@@map`, so the client
compiled every write against `public."EnvironmentLocationSource"`. Postgres
answered 42704, the upsert rolled the transaction back, and the module sat at
"0 days recorded" from the day it shipped — one enum out of forty-nine, and the
only one without a map.
No migration follows. `prisma migrate diff` against a database built from the
committed migrations reports the enum rename and the `environment_contexts.source`
column rebuild before the map and nothing after it: the database was never the
side that drifted, and renaming a live type to match a datamodel typo would be
the wrong direction.
Two guards, because the two questions are different. The unit guard replays
CREATE TYPE / ALTER TYPE RENAME / DROP TYPE across the migration set and holds
every enum to a type that survives it. The integration guard compares all three
name classes Prisma emits — enum type, table, column — against a real migrated
Postgres, which is the only place a name mismatch is observable at all: it
survives generation, typecheck, and every mocked test untouched.
Refs #646
Mutation-checked against the production paths they cover: the escalation
verdict, the leg guard in `recordSyncSuccess`, the streak anchor the verdict
dates itself by, the status envelope threading it, and the pill's relative
timestamp.
Also give the four legacy per-provider status routes the verdict the
consolidated envelope already carries. They answered `connected: true` plus a
`state` string and nothing else, so a client reading one of them saw a live
connection over a pipe that had delivered nothing for weeks.
A sync failure the classifier bucketed as `transient` had no exit. The park
test read `kind === "persistent"` and the persistent streak anchor was only
ever stamped by a persistent failure, so a streak could run for a thousand
attempts with the persistent counter at zero, the state frozen at
`error_transient`, and no ladder it could climb. `transient` is a hypothesis
about retryability, and the classifier defaults every timeout, 429, 5xx and
unrecognised response to it precisely because the wire cannot tell a reboot
from a permanent break. That default is right for one attempt and wrong for a
thousand: an unbroken streak retried hourly for a full day has refuted it by
experiment. Record it as `persistent` from there, which feeds the park ladder
that already exists — 48 h of unbroken failure end to end before an
integration stops attempting.
The counters could not accumulate anyway. `IntegrationStatus` is keyed
(user, integration) while Withings runs four legs on four crons and WHOOP four
resources on four more, and a success cleared the error, every bucket, the
streak anchor and the state whatever leg had recorded them. The ECG tick at
:41 wiped what the sleep tick recorded at :15 — including a reauth park — so
the strike ladder was reset several times an hour and the card painted a green
"connected · 12 minutes ago" over a data class that had stopped arriving. A
success now clears only what it is entitled to clear.
Two columns carry it: `failing_since_at` dates the streak, `failing_leg` names
its owner. The migration backfills the anchor for rows already in an error
state so a connection broken for weeks escalates on its next attempt rather
than restarting its clock.
The defect is about rows that survive, so a mocked client proves nothing. The
new integration test seeds a row into every table in the schema, driven by
information_schema and pg_constraint rather than a fixture, runs the wipe and
asks the database what is left. A table added later is seeded and asserted
without anyone remembering to.
It holds all four halves: nothing in the wipe list survives, everything in the
exemption list does, everything reached only by a database cascade is gone,
and the account row comes back reset but able to sign in. A second file pins
the allergy and checkup-reminder deletes actually removing their row.
The reproductive-health note gains a Deletion section. It taught people to
reason carefully about a host being imaged and never said what pressing
delete does; now it says which of the three deletions removes the row and
which only hides it.
The nightly tombstone purge asked for `deleted_at IS NOT NULL AND deleted_at
< cutoff` and the audit-log purge asked for `created_at < cutoff`. Neither
predicate had an index behind it: every deleted_at index in the schema is
partial on IS NULL, the live-rows predicate, and every audit_logs index leads
with user_id or action. Both purges planned a sequential scan and then paid
index maintenance per removed row, in one unbounded statement, on a
connection carrying statement_timeout=60000. Past a certain backlog the
statement aborts, the transaction rolls back, nothing is purged, and the next
night runs the identical statement against the same rows. The mechanism that
gives deletion a horizon could not close on the instances that needed it.
Migration 0277 adds the indexes the predicates ask for; the three tombstone
ones are partial on IS NOT NULL, so on a healthy instance they cover only the
tombstones and shrink as the purge drains. The purges now walk the backlog in
batches of 5 000 by primary key, capped at 200 000 rows a run.
Both handlers used to swallow every error into a wide-event warning and
return, so pg-boss recorded a successful job either way: a purge failing
every night looked exactly like a purge with nothing to do. They now rethrow,
and a run that stops at the cap records that a backlog remains.
The wipe deleted thirteen tables against a schema that carries more than
eighty user-scoped ones. Mental-health assessments, lab results, the
menstrual record, allergies, family history, the document vault, every AI
conversation and live clinician share links all survived a confirmation
that promised to permanently delete all health data. So did
MeasurementRollup, the per-day reconstruction of the measurements the same
transaction had just removed.
The list now lives in src/lib/data-wipe/wipe-plan.ts next to its own
exemption list, and a structural guard enumerates schema.prisma and fails
when a model belongs to neither. Adding a model can no longer widen the set
of things that survive a wipe by default. The same guard covers the User
row's own columns, which had drifted the same way.
The boundary is explicit: everything the account owns goes, except what is
needed to sign in again. The confirmation and the toast say so before the
person types the confirmation rather than after.
Checkup reminders and allergies promised permanence over a soft delete with
no restore path; both now hard-delete, so the dialog is true. The lab
biomarker dialog was checked and is already accurate — it maps to the
hard-deleting /api/biomarkers/[id] route, not to the soft-deleting lab
result route.
Every entry in the display-transform registry declares the decimals its
unit is read at. That number was carried to the render layer and read by
exactly one screen out of all the ones that show a converted value, so
everywhere else the raw product of a kilogram and 2.20462262185 reached
the surface. Pounds came out with a dozen digits it never needed.
Move the rounding into `applyDisplayTransform` and
`applyDisplayTransformDelta`, so a surface gets a correctly-scaled number
whether or not it remembers to ask. Patching the weight cell would have
left the same trap behind inches, Fahrenheit, miles per hour and miles.
Two boundaries stay exempt and are documented as such. A transform that
does not rescale is the exact identity and is never rounded, so a stored
78.45 kg keeps rendering as 78.45 on the metric branch and every
untransformed metric is byte-identical. `invertDisplayTransform` — the
entry boundary — keeps full precision, because a value rounded on the way
out and inverted on the way back would drift the stored canonical number
a little further on every save; the entry surfaces quantise to their own
two-decimal canonical dialect instead.
`applyDisplayTransformUnrounded` is the one documented escape hatch, for
arithmetic whose result is not rendered. The target adapter needs it: its
guardrails round inward, and rounding the 30 kg floor to the nearest
tenth of a pound first gives an edge that inverts to 29.98 kg and the
server rejects it.
The weight page and the HealthKit scaffold were folding the factor into
their stat strips by hand. Both now convert through the helper, so the
raw multiplier is only ever handed to the chart, which owns its own
series and formatting.
Formatting is unchanged. Precision is fixed at the conversion and the
surfaces keep the formatter they already used, so switching preference
changes the unit and nothing else about how the number looks.
Refs #627
The scope picker arrived with the standard doctor's report already ticked,
so someone who opened the panel and pressed Generate sent material they
never chose. Nothing is selected now until a person selects it.
The template keeps its definition and loses its automatic application: it
sits on a button at the top of the picker, named, on both surfaces that
mount the picker, and it stays there so the standard set can be restored
after experimenting. One click still gets a complete doctor's report.
Generate and the share-link create refuse an empty scope, with the scope
line beside them saying what to do rather than reporting a failure after
the press; the line also describes the control, so the disabled state is
not silent for a screen reader.
The root layout shipped the active locale's catalog as `<script defer>` and
counted on it executing ahead of Next's hydration scripts. It does not: Next
emits its own chunks as `async` tags AHEAD of anything the layout renders into
`<head>`, and an `async` script runs the moment its fetch settles. So the
370 KB catalog was racing the app bundle, and on a cold load it lost about a
third of the time — `load-locale` initialised with an empty cache, every `t()`
in the hydration render returned its raw key against the server's real text,
and React tore the tree down with a text hydration mismatch. The user saw a
frame of `nav.skipToContent` before the provider's backfill landed.
Register the catalog through `next/script` at `beforeInteractive` instead.
Next's own bootstrap awaits those scripts before requiring a single app module,
and pairs the registration with a `<link rel=preload>` so the download still
starts with the document rather than after it. The CSP nonce rides along, so
the posture is unchanged.
Fold the boot global into the cache lazily rather than snapshotting it at
module evaluation. The snapshot made correctness depend on an ordering the
module cannot see; reading on demand reduces the invariant to something local
and testable — whatever the boot script assigns is visible to the first `t()`
that asks, whenever it lands.
Measured on the dashboard render spec, 30 repeats per project against a
production build: 11 of 30 failed on desktop before, 0 of 60 after.
Everything on that page is server-rendered, so a row is visible long before
the client boundary that owns it is live. A chevron click landed on inert
markup about one run in four, and a `boundingBox()` occasionally came back
null because the node had just been replaced.
The page is one client boundary carrying the export panel and the sharing
card, so the sharing card's own query is a sound signal that the surface is
wired. One helper opens the page and waits for it; every case on that route
goes through it, including the disclosure case that was already doing this
by hand.
Three of the picker assertions resolved to two elements and failed strict
mode; they now name the export mount, and a new case pins that each mount
carries exactly one of every id the picker renders.
The included-data disclosure case asserted a collapsed first render. The
panel opens on a first run on purpose — that is the only place the standard
template is applied, and a template behind a closed disclosure is opt-out
wearing a hat — so the case now asserts the contract that replaced it: open
on first render, and still a disclosure, closing and reopening under the
same control.
Settings → Gesundheitsakte mounts the one scope picker twice: the export
panel at the top, the share-link create form under `#sharing`. Sharing the
component is deliberate, but it also meant every test id inside it resolved
to two elements on that page, so nothing in the picker could be addressed
without guessing which one you got.
The picker now takes a required `surface` and every id under it ends with
that surface — `report-group-row-vitals-export` and
`report-group-row-vitals-share` are two controls and read as two controls.
The summary line beside each picker follows the same rule. A third mount
picks a value and stays addressable by construction.
The capability descriptor named `fhir:read` as the scope a narrow token needs
for the REST face. No code path mints a token carrying it — every mint grants a
wildcard, the medication ingest scope, or an MCP scope — so the face is
reachable by the owner's cookie session or a wildcard device token and by
nothing else. Advertising an unobtainable credential invites a client to go
looking for one. The constant stays where it belongs, as the fail-closed check
each route makes; the descriptor now reports the fact instead.
End-to-end coverage for the scope picker: the first run opens it expanded with
the fenced tier rendered and no group control over it, a group row clears the
44 px floor at 390 px, and a leaf grid opens from its chevron. All on stable
test ids rather than text a breakpoint can hide.
Migration 0273 revokes every live record share link minted before the selection
model, because their frozen scope was "everything except mood and cycle" — a
scope nobody chose. The owner has to see why the link stopped and be able to
replace it without retyping what they already decided, or a deliberate closure
reads as a fault. The list now names that state, says why in one sentence, and
offers a replacement that carries the label, the window and the lifetime
across, leaving exactly the decision that link never carried.
Writing the test for it found a defect in the picker. The group count and the
group checkbox read the catalogue's group rather than the leaves the surface
actually renders, so on the share-link form — where the insurance leaf is
hidden because the server refuses it — the identity row read "1/2" and drew
itself half-selected over a leaf the owner could neither see nor reach, and its
checkbox would have tried to add it. The three helpers take the rendered leaves
now, so a control cannot describe more than it offers.
Two downloads under the token — the FHIR document Bundle and the clinical PDF —
behind the same unlock cookie the document route enforces, serving exactly the
link's frozen selection. That is what the removed FHIR toggle promised, in the
form a practice actually uses: open the link, unlock it, save the file. No new
credential type, no new scope, no mint path that does not exist.
The gate is the document route's, step for step: the same live check, the same
token-scoped cookie, the same blunt 404 for every miss class so a probe cannot
tell "no such link" from "revoked" from "documents-only". It differs in one
number, because generating a bundle is far more expensive than serving a stored
blob and must not share a budget with it. Each download writes its own audit
row. The scope cannot exceed the page's: both resolve the same frozen leaf list
through the same resolver, and the insurance leaf is refused at create, so the
insurance number cannot reach this path at all.
The MCP doctor-visit summary stops claiming presence it cannot support. An
account whose owner never saved a report selection resolves to the empty one on
that surface, and an empty scope produces an empty payload — which now reads as
{ present: false, reason }, the same sentinel the other reads use, instead of a
record with every section quietly missing.
Mutation-checked: removing the unlock check, the rate limit, the documents-only
guard or the audit row reddens its own case; pointing the bucket at the document
route's key reddens the bucket assertion; making presence unconditional reddens
the absence case.
The saved profile's shape moves out of the module that reads the row. The panel
is a client component and parses the blob from the /me payload; importing the
reader dragged the database client into the browser bundle, which typecheck and
the unit suite both pass and only `next build` catches.
Five layers, because the defect had five places to hide.
The catalogue guard checks what the compiler cannot state: every leaf resolves
a label a human can read, every group resolves one, the fenced tier stays last,
and the shipped template intersects the fence at nothing.
The selection guard pins the three minting functions against each other. A
stored blob this build cannot read resolves to the empty selection, never to a
default set — that is what makes the revoked legacy links revocable rather than
silently re-scoped. A request naming an id the server does not know is refused
by name rather than dropped, because dropping it would narrow a scope the
caller did express.
The egress guard freezes the set of aggregator callers with the source of each
one's selection written down. The required brand-typed parameter already stops
a new path compiling without an answer; this stops one being added without a
human writing the answer down, which is the moment a reviewer asks the
question.
The behavioural sweep aggregates every leaf in and out against a fixture that
carries a row for each, and asserts the payload differs. It is parametrised
over the catalogue, so a leaf added later is covered the day it is added. Its
positive control asserts the all-on payload is genuinely populated first, so a
"no difference" result means the gate is inert and not that the account is
empty. Beside it: an unmapped measurement type is excluded rather than passed
through, no table behind an unchosen leaf is queried at all, and an empty
selection produces an empty record.
The artefact layer runs the real route and reads what comes out — the FHIR
bundle, the PDF text and the zip's contents — with a positive control beside
every exclusion.
Mutation-checked: making the gate admit everything reddens the sweep for every
leaf; passing an unmapped type through reddens the fail-closed case; dropping
the mood query's condition reddens the zero-read case; returning the BMI
unconditionally reddens the sweep naming BODY_MASS_INDEX; adding a fenced leaf
to the template reddens the purity guard; removing the panel's seeding block
reddens the first-run and repeat-run cases; dropping the insurance refusal
reddens the share-link case; adding a caller outside the frozen list reddens
the egress guard naming the file.
The panel suite grew past the practice name it was written for and is renamed
for what it now covers: the three states, the fenced tier having no group
control, and the scope line naming its sensitive inclusions.
Integration coverage moves to the leaf model, including the case this release
exists for: an account with data in every domain and a caller that named
nothing gets an empty record.
The OpenAPI contract catches up with the routes — the required selection, the
refused keys, the share summary's new fields, the moved `Patient/$everything`,
the Coverage search and the five typed reads.
The export panel rendered twenty flat switches behind a collapsed disclosure,
and the share-link form rendered none at all — it posted whatever the create
schema defaulted to. Both now mount the same picker: twelve named groups, a
count on each, the leaf grid one chevron away, and a fenced tier below a
separator.
The fence is mechanical, not a label. The sensitive tier has no group
checkbox, so no single control anywhere in the picker can turn on more than
one of mood, cycle, family history, PHQ-9, GAD-7, WHO-5 or SCI at a time.
There is no select-all. The shipped template contains none of them. And the
scope line under the button restates every sensitive inclusion by name rather
than folding it into a count, because a number cannot be checked at a glance
and "incl. Mood, PHQ-9" can.
First run opens expanded with the named standard template applied and the
fenced tier visibly empty, so the user presses Generate having seen twelve
named groups. Every later run opens collapsed carrying their own last
selection, which makes the waiting-room path two interactions.
Only leaves are ever stored. A group's checkbox is derived from its leaves, so
the two cannot disagree, and switching a group off and back on inside one
session restores the pattern instead of flooding it.
The cycle export card is deleted. It posted empty groups under a docblock
claiming every other section was off, and empty groups resolved to the
defaults — so a "cycle only" download carried weight, blood pressure, pulse,
oxygen saturation, body fat, body composition, the medication list, adherence,
labs, allergies and family history. Cycle is a fenced leaf in the one picker
now, reachable for the PDF as well as for FHIR, which is more than the card
offered.
The panel gains a 30-day preset and a custom window, and its error line says
what went wrong instead of interpolating a status code.
The Checkbox primitive learns the indeterminate state it already had the Radix
support for. A partly-selected group drawn as unchecked would say "none of
this is included", which on this surface is a false statement about what
leaves the instance.
The report resolved an absent key to "included". A caller that sent no
sections object, or an empty group inside one, received the full record at
server-chosen defaults: roughly fifty metrics plus the medication list, labs,
allergies and family history. The web panel sent every key explicitly so it
looked fine from the browser, but the share link, the FHIR REST face, the MCP
doctor-visit resource and the cycle-only export card all went through paths
where absence meant yes. No arrangement of booleans fixes a default that means
yes, so the shape goes.
A selection is now an inclusion list of leaf ids. A leaf is the smallest thing
that can be released or withheld: every measurement type, and thirteen
structured sections — identity, insurer, the glucose panel, labs, the drug
list, the administration ledger, adherence, the GLP-1 block, allergies,
illness episodes, family history, mood, cycle. Membership is inclusion,
absence is exclusion, and there is no defaults constant anywhere to drift.
Three locks keep a metric added later off a report chosen earlier. A stored
selection is a list of what was chosen, so a leaf that did not exist then is
not in it. The catalogue is keyed over the measurement enum, so adding a
member without deciding its group fails typecheck in the same commit. And the
aggregator's filter inverts: a type the catalogue does not know is excluded and
annotated, where it used to be passed through.
The aggregator's third parameter is now required and brand-typed, which turned
the three surfaces that had no scope at all into compile errors. Each one had
to name a source, and each now replays the owner's saved selection — the same
object reviewed in Settings. An account that never saved one gets nothing
there rather than a guess.
Identity and insurance become selectable, and a share link refuses the
insurance leaf outright. The share view never decrypted the number; that is
structural now instead of incidental. The KVNR is not even read unless the
leaf was chosen.
Two surfaces gain a record they always implied. A share link serves its frozen
selection as a FHIR bundle and as the PDF, behind the same unlock cookie the
document route uses, with its own rate bucket, its own audit row and the same
blunt 404 for every miss. That is what the removed FHIR toggle promised, in
the form a practice actually uses.
The AI summary leaves the report. Its text came from the cached briefing with
no relation to the selection, so its scope could not be proven, and prose whose
scope cannot be proven has no place on a document whose whole point is a proven
scope.
The PDF table stops being a nine-type whitelist. It prints every selected leaf
that has data, sub-headed by group, in the same order the panel shows — so
selecting resting heart rate no longer means seeing it in the bundle and not in
the PDF. The clinician view is grouped the same way and reads real labels
instead of "Phq9 score".
Migration 0273 adds the saved-selection column, drops the orphaned prefs column
and the two retired share-link scope columns, and revokes every live record
share link minted before this model. Their frozen scope was "everything except
mood and cycle", which is a scope nobody chose; narrowing it would still be a
scope nobody chose. Documents-only shares are untouched. The sharing list shows
each closed link in a pick-again state with its label, window and expiry kept.
The retired allowFhirApi and resourceTypes stay on the create and list
responses as the constant false and empty list. The request refuses them. They
come off the response once the native client confirms it does not decode them.
Six files are split at seams this change already touched: the aggregator into
its gate, its dense-bucket query, its series, its glucose panel, its medication
ledger and its clinical records; the export route's three emitters into their
own module; the clinician view into its sections, its document list and its
downloads.
Two layers, because the defect had two halves and each needs its own kind of
proof.
The structural guard walks the panel's control shape, folds every control
through the wire schema and the resolver the route uses, and fails when
flipping one changes nothing in the resolved selection. It also checks the
panel renders a row for every control it declares, and that every resolved
flag is read downstream, either by gating measurement types or by name in the
aggregator. That is the link nobody had written: the dead switches were dead
for several releases and no test connected "the panel renders this" to "the
export honours this".
The behavioural half runs the real export route and reads what comes out. The
previously-dead measurement switches are asserted against the FHIR bundle and
the PDF table, with a positive control beside each exclusion so nothing passes
on an empty fixture. The screening totals are asserted absent for a caller
that names other sections and for a caller that sends no sections at all, then
present when asked for by name. Allergies and family history are asserted
absent from the bundle and never read from the database, and the package case
opens the zip and checks the bundle inside it agrees with the PDF beside it.
Mutation-checked: reverting the allergy gate, the medication-list gate, the
screener entries or the measurement entries turns the matching cases red and
leaves the rest green; removing a control's fold entry or its aggregator entry
turns the guard red with the control named.
The remaining changes update fixtures the new shapes touch: prefs objects now
resolve the added flags from the defaults, and the share-link fixtures drop
the retired FHIR scope, with a case pinning that the create route now refuses
those keys instead of ignoring them.
The export panel rendered twenty data switches and eight of them changed
nothing. The grouped selection folded down to twelve flat flags and dropped
oxygen saturation, body fat, body composition, resting heart rate, HRV, VO2
max, steps and distance on the way, so a user who switched any of them off
still exported the readings in the PDF, in the FHIR bundle, in the package,
through a clinician share link and over MCP. Each one now resolves to a flag
the aggregator reads, and the rows are excluded at the query rather than
fetched and dropped.
Three more controls were untruthful in their own way:
- The medications switch documented itself as excluding all medication data
and excluded the adherence figures only. The drug list and the per-dose
administration ledger rode along regardless. Both follow the switch now;
the rows are still read, because compliance and the GLP-1 block are computed
from them, and they no longer leave the aggregator.
- Allergies and family history were gated for the PDF and fetched
unconditionally for the FHIR bundle, so a package could carry a compliant
PDF and a violating bundle in the same zip. The reads are gated, and a
deselected section is not read at all.
- The PHQ-9, GAD-7, WHO-5 and SCI screening totals had no per-report control.
They rode the mental-health module alone, which has been default-on since
v1.29.1, so depression and anxiety screeners reached FHIR bundles, share
links and the MCP doctor-visit resource on accounts that never chose to send
them. They get their own section key, defaulting off, with the module gate
kept as the second lock, and the stale privacy-by-default comment that
argued otherwise is corrected.
The share link's FHIR promise is removed rather than implemented. The create
form offered a toggle and a resource-type picker, the values were persisted
and badged on the finished link, and the API description called the toggle a
REST reachability switch, while no route has ever answered a share token with
FHIR or any other machine-readable format. Serving one is a feature with real
security surface and does not belong in a correctness fix. The toggle, the
picker, the badge and both claims are gone; the create schema rejects the two
keys instead of accepting and ignoring them; the columns stay, unread, so no
data is destroyed to undo a promise.
Two files are split along seams this change already touched. The
measurement-type gating maps and the filter that reads them leave the
aggregator for their own module, so the query gate and the output gate cannot
disagree; the export panel's control shape and the selection it builds leave
the component, so the selection contract can be extended without the
rendering. Three accepted-and-discarded wire keys (activity energy, GLP-1,
side effects) are dropped: nothing rendered them and nothing read them.
A spec that arms `page.route(...)` in a `beforeEach` and never drops the
handler races its own teardown. The context closes, an async handler is
still awaiting, its next call rejects with "Test ended.", and Playwright
attributes that to the test that had just passed. With `retries: 2` plus
`failOnFlakyTests`, which spec bites is a matter of timing.
`e2e/setup/test.ts` re-exports Playwright's `test` extended with an auto
fixture that unroutes every page and the context after each test, and
clears the page event listeners a spec subscribes to; `expect` rides
along so a spec needs one import. Auto means a spec author cannot forget
it and does not have to know it exists. Every spec now takes `test` from
there, and `e2e-route-cleanup-guard.test.ts` keeps the import boundary
closed with an allowlist that carries its reason.
The per-file `afterEach` on the cycle spec goes away — leaving both would
read as not trusting the fixture.
Not covered: a page inside a context a spec builds itself. Only
`share-documents.spec.ts` does that, and it owns those contexts.
A HealthKit blood-glucose sample carries no context either — the mapping has
no field for one — so the batch ingest was building the same row the old
CHECK refused. Fold that source into the case that pins the contextless
shape.
Refs #640
The example CSV is the file people copy. It showed a glucose row with a
FASTING context and nothing else, which is the shape a meter produces, not
the shape a continuous sensor produces. It now carries both.
Refs #640
Same shape as the CSV and JSON cards: reaching the done state rendered the
success tick whether or not a single row was upserted. The archive's count
picks the tone now, and an archive that wrote nothing says so.
The clinical-record count stays out of that decision. Skipping
electrocardiograms and lab documents is a documented, deliberate exclusion,
not a refused row, so it must not read as a warning.
Refs #640
A CSV import of a continuous-sensor export wrote nothing and rendered a
green success state. Three faults compounded, and the reporter took all
three apart before filing.
The parser refused every BLOOD_GLUCOSE row with a blank glucoseContext.
Behind it, the CHECK constraint from migration 0021 read both ways at
once: a glucose row MUST carry a context, every other type MUST NOT. The
second half is right and stays. The first was written when the only
glucose writer was the entry form, where a person picks fasting or
post-meal at the moment of the reading. Every machine writer that
arrived since has no such answer per sample, so the Nightscout pull, the
MCP log tool, the Telegram capture and both bulk importers were all
building rows the database then refused.
A missing context now stores as NULL, never as a fifth enum member.
Every per-context reader selects by equality against the four members,
so the reading stays out of the doctor report's fasting rows, the
matching FHIR observations and the per-context targets, while the chart,
the all-time summary and the clinical panel keep it in full. Hand entry
still asks for a context, because at that moment you know it.
The import cards now read the counts rather than the status code.
Nothing written is a failure, a mixed file is a warning, and the refused
rows are grouped by reason with a count instead of fifty identical
lines, with the per-line detail one disclosure away. Both cards render
through one outcome table, so a tick cannot reach a result that wrote
nothing.
Refs #640
`medication_intake_events.idempotency_key` carried a GLOBAL unique from
0001_init. The value is client-supplied, so two people running the same
client can mint the same key — and on a global unique the second one is
simply refused by the constraint. Their dose is not written and nothing
tells them so. The bulk drain made it worse: its replay probe read the
key with no user scope, resolved the first user's row, and reported the
second user's entry as a duplicate carrying a stranger's row id.
Migration 0273 replaces the global unique with `(user_id,
idempotency_key)`, the grain every other client-supplied identity on the
table already uses. Both unscoped reads in the bulk drain now key on the
composite; the other four reads were already narrowed by user.
On existing data this is a relaxation, so nothing needs reconciling: the
old constraint is strictly stronger than the new one, and no pair of rows
can violate an index the previous one already forbade. The migration
still carries a reconciliation step for a database whose global index was
dropped by hand at some point. It never deletes or rewrites a dose — it
clears the dedup HINT on the later row of a colliding pair and keeps
every intake event, timestamp, attribution and inventory stamp intact.
The new index is created before the old one is dropped, so a failure
aborts with the old protection still in place.
A structural guard freezes the read side: no `medicationIntakeEvent`
query may filter on the key without narrowing by `userId`.
The dedup key on the medication intake surfaces is not only `externalId`.
`MedicationIntakeEvent.idempotency_key` is a `@unique` column persisted
with the dose row forever, with no TTL and no sweep, matched by plain
equality on the per-medication POST, the bulk drain and the external
ingest bridge. That makes it an identity, not a replay token, and it
carries the same failure mode: a key that rotates per client launch never
matches its own earlier post, so every retry logs the dose again. The
blank case is sharper still — the column is unique table-wide, so an
empty key resolves to whichever unrelated row stored it first. Refuse
both shapes at the schema on the single-entry paths and per entry on the
bulk drain.
The MCP write path hashes a host-supplied key into the `externalId` half
of its dedup key, so the shape is laundered before anything downstream
could see it and the check has to run on the input. That closes a live
one: every tool handler falls back to `""` when the host omits the
argument, and `""` digests to a constant, so each keyless write after the
first reported `already_logged` and was silently dropped.
The HTTP `Idempotency-Key` header stays out, and now says why in the
guard: `IdempotencyKey.expiresAt` bounds it and the cleanup job sweeps
it, so it is a windowed replay token and rotation is its design.
Extend the structural guard with a provenance rule for identities that
never pass a Zod request schema — a spreadsheet column, an MCP tool
argument. Inside the import and MCP trees, a module handling an identity
must apply the floor itself or import a module that does. That is a real
chain rather than a list of known offenders: a new importer that mints an
`externalId` from file input and imports nothing checked fails it, with
no allowlist to escape into.
Harden the structural guard so it reads the file body with its imports
stripped: grepping the raw source cannot tell a file that APPLIES the
check from one that merely imports it, so deleting the call while
leaving the import slipped straight through.
Add the partial-success case on a second batch envelope — the mood bulk
ingest echoes the offending entry back with its index, status and
reason while the good rows still upsert.
An external id is half of every ingest dedup key — (userId, source,
externalId), (userId, externalSource, externalId), (userId, type,
source, externalId). Re-posting the same triple is idempotent and
returns the existing row, which holds only while the id is stable
across client launches. A client that serialised an opaque platform
object with its default object description sent a per-allocation
memory address instead, so no sweep ever matched its own earlier row
and each one minted a fresh record: a live instance grew 23 phantom
medications in a day, none of which could collect a dose, because the
dose side resolved through the same rotating string.
Add one shared classifier for identifiers that provably cannot be an
identity — an angle-bracketed object description carrying a hex
pointer, a bare 0x address, and a blank value — and wire it at every
surface that accepts a client-supplied externalId.
Single-entry creates refuse through the schema, so the client gets the
standard 422 multi-issue envelope with a message naming the shape it
sent and what to send instead. Batch and bulk surfaces classify per
entry and report the offending row as skipped with reason
unstable_external_id, leaving the rest of the payload to land: one bad
row must not stop a sync. Every refusal carries pinned wide-event meta
(count, shape, surface) and never the id itself.
A structural guard freezes both routes into the floor: a request-side
externalId declaration either applies the field check or carries a
marker naming the file that checks it per entry, and the guard verifies
that file really does. The backup-restore payload is the one exemption,
with its reason recorded — it replays ids the server itself wrote.
Whether a column has a reader anywhere in the tree is a whole-program property,
and grep is the only cheap approximation. Grep is unsound both ways here:
generic names match unrelated identifiers and read as consumed, while
consumption through a select-less read plus whole-row serialization reads as
unconsumed. A gate built on that would drift towards green and stop meaning
anything, which is the failure this whole line of work exists to prevent. So it
stays a review tool with a human reading the output, run from the deep-audit
step and never in CI. The script says so itself and prints the false-positive
sources under every run.
It also carries the convention that makes the tool useful: a change that adds a
column, payload field, or preference contains its consumer, or the schema
comment carries `@internal` for something deliberately never surfaced or
`@pending-consumer(#issue)` for one still coming. The second marker is
re-listed on every run, so a follow-up that never happened surfaces at the next
release audit instead of never. Recorded in the contributor notes alongside the
two questions that are exact enough to be gates.
Every finding behind this work is a two-ended change whose ends shipped in
different releases with nothing holding them together, and every gate the repo
runs proves one end: typecheck proves the export exists, knip proves it is
imported, the OpenAPI check proves the schema matches. None of them notice when
the second end never arrives.
Two questions here can be answered exactly, so they become tests.
The account endpoint rides every app boot and carries the user's preferences.
Its field names are read out of the response literal and each one must be read
by a client module — property access or destructuring, comments stripped,
because a doc comment claiming a consumer is what the remembered practice name
had for its whole dead life. That field would have failed this on the day it
shipped. One field is allowlisted today with its reason written out: the
insurer institution number has no web surface at all, which the entry keeps
visible rather than papering over.
The measurement backup select is now a named constant, compared field by field
against the model in the schema. Both sides are literals, so the comparison is
exact — no name heuristics, nothing that can drift towards accepting. A column
added to the model and forgotten by the backup fails at the commit that adds
it, which is how the aggregation provenance went missing. Scoped to the one
model; extend it as each further model becomes backup-relevant.
The reverse-coverage guard asserts that every key in the bundle is reachable
from a call site. It had stopped doing that for most of the bundle. A template
whose only static anchor was a namespace root registered that root as a subtree
base, so a single per-provider OAuth key marked every key under `settings.*`
reachable, and the same shape did it for `measurementReminders.*`. A cache-key
string that happens to start with a namespace word had quietly done it for
`insights.*` too. The guard stayed green throughout, which is the part worth
noticing: a pair guard degrades into a one-end guard without any signal.
Extract the matcher into a helper with its own unit tests, and change what it
accepts. A template carrying both a literal prefix and a literal suffix now
records the pair and requires both ends to match, so the OAuth template covers
the provider keys and nothing else. A template with no suffix whose anchor is a
bare namespace no longer whitelists anything — it fails the guard by name, with
the two honest remedies in the message. The same door is used by the template,
assignment and concatenation arms, so none of them can reopen it. Template
bodies are read by scanning forward from a known opening backtick rather than
by pairing backticks across the tree, which was drifting and dropping whole
files out of the scan.
Three call sites build their keys whole instead of interpolating onto a
namespace root: the two measurement-reminder due helpers, the mental-health
option labels, and the OAuth error copy, which now names its per-provider base.
Two audit-log action names that merely look like keys carry a written
exemption; the API error catalog is a runtime lookup and is allowlisted with
its reason.
With the matcher honest, 149 keys had no call site left. They belong to an
explainer popover, a hypnogram, a passkey table and an insights hero that were
all rebuilt or removed, and they are deleted from all six locales. The
explainer guard stops pinning its title leaves, which no surface has rendered
since the popover went.
The disaster-recovery backup selected every Measurement scalar except the
aggregation fields, so a restore brought every `stats:` aggregate back with no
provenance and the Apple Health authority ladder came back flat: an export.xml
source-day estimate could then overwrite a native HealthKit statistic.
`aggregationProvenance` now rides the select, the disaster-recovery
serialization, the payload schema and the restore mapping, following
`externalSourceVersion` exactly — including staying out of the reduced
portable shape, which carries the human-readable subset only. The schema field
is optional, so every backup written before this parses unchanged.
Restoring one of those older backups no longer recreates the ambiguity
migration 0263 repaired: an Apple Health row under a `stats:` identity with no
provenance comes back as LEGACY_UNKNOWN, the sentinel the cumulative drain
already writes in that situation, so a native sync or archive reprocess can
repair it. The gate is scoped as 0263 scoped its backfill — other providers
mint `stats:` identities for their cumulative dailies and have never carried
provenance, and ordinary point rows keep NULL.
Migration 0263 added three columns beside the Apple Health `stats:*`
authority ladder. Only `aggregation_provenance` is ever read back: the
external-measurement reconciler selects it and refuses a lower-authority write
against a higher-authority canonical row, and the cumulative drain reads it
too. That column stays.
`aggregation_contributor_count` and `aggregation_selected_source_hash` are
written by three call sites and read by none. No select, no raw SQL, no export,
no backup, no API contract, no repair path. The import-time selection keeps its
deterministic tiebreak, which compares an in-memory map key computed per flush
rather than a value read back from the table.
Both are plain nullable scalars with no index, unique, check or foreign key, so
the drop is a catalog-only operation that touches no measured value.
The doctor-report contract has always described the practice / clinic name as
a stored preference, and `/api/auth/me` has returned
`User.lastReportPracticeName` since it shipped, but nothing ever wrote the
column. Every report asked for the clinic name again.
The export route now persists the sanitised name after the requested artefact
has actually been produced, so a generation that throws cannot overwrite the
remembered value with a half-typed one. Clearing the field persists null, which
keeps the cover line cleared next time. The export panel seeds its input from
the auth payload during render, gated per user id, so a late resolve never
clobbers a field the user is already typing into, and a finished export
invalidates the cached payload so a remount reads the new value.
Interface housekeeping. The overview sections drop their hand-rolled card
frames for a shared shell with one inner rhythm, failed reads on dense metric
pages get a named row primitive that puts the message in the content tier, and
the last off-scale spacing and text sizes go back onto the scale.
The tag chip and the sub-twelve-pixel text step are primitives now, so neither
can be hand-copied at a slightly different value again.
The stage-composition chart card was the last insights tile with a
hand-rolled title row. It now uses the canonical header primitive; the
subtitle and the window toggles keep the shape they had, since the toggles
have to drop to their own row on a phone.
Also sweeps the last off-scale spacing: the two dense tiles inside a
recommendation move onto the sanctioned inner-tile step, and the not-found
button onto the scale. The banned twenty-pixel step is now absent from the
tree. The chart body is untouched.
Seven overview sections had hand-rolled the same card literal, and the copies
had already drifted on the one thing the literal did not pin: the space
between blocks. Three used 12 px, two 8 px, one 16 px, one under the other in
the same column.
`InsightSectionCard` composes `Card`, so the frame comes from the card
contract, and fixes the inner rhythm at 12 px. The daily briefing moves onto
it as well, and the lab-changes list keeps its edge-to-edge rows through the
documented flush variant.
Visible change on the overview column: two bodies open by 4 px, one tightens
by 4 px, and all of them pick up the shadow their card neighbours already
had. Chart bodies are untouched.
The HealthKit metric sub-pages and the vitals dashboard had each hand-rolled
the same failed-read row, byte for byte. The tall centred error card is the
right shape for a list surface but dominates a dense metric strip, so the row
stays — as a named primitive rather than a third copy.
`QueryErrorRow` carries the shipped shape with one correction: the failure
message renders in the foreground tier. An alert is the content of a failed
surface, not meta about it, and both copies had it muted. The retry glyph
matches its tall sibling. Both converted surfaces pass their existing slot
names through, so their hooks are unchanged.
Three integration cards carried the same hand-copied chip literal in the
card-header accessory slot, each free to drift on the next touch; the size
itself was an arbitrary value repeated at three slightly different spellings
across the tree.
- Mint `--text-2xs` (0.6875rem with a matched line-height) as the one
sanctioned step below `text-xs`.
- Mint `TagChip` and adopt it on the Fitbit, Google Health, and Nightscout
cards. It stays distinct from `Badge`, which carries status meaning.
- Sweep the arbitrary micro sizes: the stepper label and the two dose-ledger
outline badges move onto the token; the muted target caption and the muted
ledger meta row move up to the `text-xs` muted floor.
No visible change beyond the two muted rows gaining a fraction of a pixel.
Arm the response waiter before navigating, so a response that lands during
the initial load is still observed instead of timing out on an event that
already happened, and poll for the returning refetch rather than sleeping a
fixed span that a loaded machine can outrun.
Mood has been a first-class module here for a long time, with entries,
structured tags, rated factors and notes. The bridge to the external moodLog
service had nothing left to add, and its Settings card was withdrawn in
a56e6fe61 on the promise of a dedicated removal. This delivers it.
Removed: the hourly `moodlog-sync` queue, cron and handler binding; the
inbound webhook and its shared-secret handling; the reverse push and its four
call sites; the pull-sync module; the per-user credential routes and the
instance-wide toggle; the entry on the `/api/integrations/status` envelope,
the `IntegrationKey` union, the cadence map and the display-name table; the
boot-time secret rotation pass; 22 message keys per locale.
Migration 0271 drops the five `users` credential columns and
`app_settings.mood_log_global`, clears the ledger row for the provider, and
deletes the retired pg-boss schedule so no cron keeps enqueuing work no
handler drains.
Mood data is untouched. `mood_entries` is not named anywhere in the migration
and cannot be reached from it: the dropped columns carry no index, constraint
or foreign key, and `mood_entries.user_id` references `users(id)`, not any of
them. Rows the bridge imported keep `source = 'MOODLOG'`, and every read path
- list filter, source badge, exports, backups - still resolves that value.
`src/lib/validations/moodlog.ts` held the native mood-entry schemas alongside
the bridge's own; it is now `validations/mood.ts` and carries only the former.
The load-bearing one is the panel-coverage test: render the panel with an
envelope carrying every integration key and require each configured one to
surface as a card or as a fallback row. Drop the fallback row and it goes red,
which is the whole point — the previous shape of this bug was a provider on
the wire that no screen rendered, and no card-shaped test could have caught it.
Alongside it: the ladder table with the month-silent fixture that resolves to
stalled, the 24-hour and 7-day boundaries asserted against literals rather than
the constants they check, the per-metric stale flag firing only under a healthy
provider, and a structural guard that the verdict-to-pill mapping is defined in
exactly one module.
The card suites migrate with the semantics: a transient failure now expects the
warning pill everywhere, and the Apple Health suite trades its bespoke badge
states for pill assertions. The e2e mocks gain the verdict shape, the
nightscout entry, and an assertion that the card-less row is on the page.
Three `pillStateFor` implementations disagreed with each other, so the same
ledger state painted red on one card and amber on the next, on one page. All
three are gone; the client projects the server verdict once.
Red now means "your action fixes this" and nothing else. A transient upstream
failure carries the warning tone: telling a user to reconnect against a 503
sends them clicking at something they cannot repair, and the detail belongs in
the inline error line anyway. Three new pill states carry the rest — no recent
data, sync stopped, waiting for first data.
Per-metric freshness has been computed on every status call since v1.27.30 and
rendered nowhere. Every connected card now opens a disclosure listing each data
type the connection has delivered and how long ago, with a quiet one flagged.
Collapsed by default so a 25-type card keeps its shape, but the collapsed
summary already names the count, or the signal hides behind a click. A type a
provider never delivered simply is not listed.
The panel iterates the envelope and renders a plain status row for every
configured entry no card claims. That is the structural half: a connection can
no longer be running, failing and absent from the page at once.
Apple Health drops its bespoke badge for the shared pill, gains the divider and
body inset every sibling carries, and says something when a week has passed
with nothing arriving. Nightscout reads the envelope instead of its own status
round-trip. The OAuth card uses the shared error primitive, all nine cards
carry the divider testid, and the four original connectors gain the "view your
data" link the newer ones already had.
The ledger records what the last attempt did. It has no way to say what is
no longer being attempted: a row frozen at `error_transient` for a month
reads exactly like one retrying every hour, because nothing anywhere read
the age of `lastAttemptAt`.
`sync-verdict.ts` derives the missing reading as a pure read-side
projection: eight verdicts down one ladder, no writer, no cron, nothing that
can desynchronise from the row it reads. `stalled` is the new one, and it
deliberately outranks `failing` — an integration nobody is calling is not
failing, it is not running.
Every entry on `/api/integrations/status` carries the verdict, computed
inline from data the route already holds. That placement is the point: a
card-level verdict misses a card-less provider by construction, which is how
a dead sync stayed invisible. Nightscout joins the envelope so its card can
stop fetching its own status.
The freshness module gains the workout leg (Strava writes no measurements at
all) and a source-parameterised core so the Apple Health route can reuse it.
Its per-metric `stale` flag fires only while the provider itself reads
healthy: that combination is the dead-pipe signature, and when the whole
connection has stopped every metric ages together anyway.
MCP reports the verdict beside `connected`, which only ever meant "not
explicitly disconnected".
Sweep every feet + inches pair the fields can offer and every whole-inch
height across the canonical range, asserting the value comes back
unchanged and lands inside the server's window. Assert the metric branch
with toBe so a re-derivation of a stored height cannot slip through.
Render both entry surfaces under each preference: the metric case keeps
the single centimetres box with the canonical guardrails, the imperial
case shows the feet + inches pair seeded from the stored value. Extend
the structural guard so a surface that drops the adapter, hardcodes a
centimetre guardrail, or hardcodes a unit label trips a tripwire.
Height is a profile column, not a MeasurementType, so the display
transform that carries the metric/imperial preference across every
reading surface never reached it. Add the adapter that closes the gap.
With imperial selected both entry surfaces render a feet + inches pair
instead of a centimetres box; with metric selected the field is what it
was. The adapter converts to canonical centimetres at the entry
boundary, so storage, the wire, the Zod bound, and every consumer of
the value stay unchanged.
Imperial entry quantises to whole inches, which map to centimetres
without a remainder, so save-then-reopen returns the typed pair. The
offered feet window rounds inward against the canonical 50-300 window
so a height the fields allow is never refused on save.
The unit select shares the Settings card with the height field, so a
flip re-expresses the in-flight draft through the new branch rather
than stranding an empty field.
Stable per-segment sleep keys plus the replace-by-window sweep, a one-shot
repair over the past year, Retry-After handling, and the unchanged-value write
skip. Migration 0270 records which connections the repair has been through.
Mirrors the Google sleep suites name for name: the mapper keeps its externalIds
across a re-score that re-classifies and renumbers segments, the sweep stays
bounded to the night's window and never touches the fresh keep-set, a null
window issues no delete at all, and a cleanup failure never fails the sync.
The end-to-end fixture runs one night stored under the old keys through
`syncUserSleep` and pins the ordering the heal depends on — sweep first, then
the rescue re-keying the tombstoned rows in place, no parallel insert — plus a
second pass that writes nothing at all.
Also covers the repair's verdict gates (bounded 365-day window, marker stamped
only on a clean run, watermark untouched, dead token and reauth park returning
without a stamp), the unchanged-value skip including the re-classified segment
it must not swallow, and both Retry-After paths.
Fitbit re-scores a night after the fact, and the per-segment externalId carried
a positional index plus the stage label. Both moved on a re-score: renumbered
segments minted fresh keys, the previous scoring's rows stayed live because
nothing swept them, and the night total counted both copies from then on.
Key the segment on the stable session anchor plus its own start instant
(`<logId>:sleep:<segment-start>`), and add the replace-by-window sweep every
other segment-writing provider already has. The two ship together with the
natural-key rescue that has been sitting in the Fitbit upsert unused: the sweep
tombstones the old-keyed rows first, so the rescue re-keys those very rows in
place instead of inserting a parallel copy or losing the insert to
`skipDuplicates` against the natural unique index.
A one-shot repair queue re-reads the bounded sleep history once per connection
so no user has to do anything; it stamps `sleepRepairedAt` (migration 0270) only
on a clean run, never touches the sync watermark, and reports the rows it
removed. Nights older than the 365-day backfill horizon keep their old keys and
whatever duplicates they already hold — they can no longer gain new ones.
Alongside: `fitbitGet` now honours a `Retry-After` inside a 120 s cap by waiting
once and retrying once, and fails fast above it so a long hold cannot starve the
rest of the poll cohort or outlive a job's expiration. The measurement upsert
skips a write whose payload is unchanged, which takes the hourly overlap off the
delta feed for every Fitbit resource.
A route with no caller is an API detail, not a feature. The Oura, Polar,
Strava and Nightscout cards get the same action WHOOP already had, in the
same row as Test connection, rendered only once connected: post the sync,
show the count or the failure, invalidate the reads that depend on
measurements. No full-history dialog, since these providers have no such
arm. Keys added across the six locales.
These four were the only connected providers with no way to ask for a
sync; they ran on their scheduled pass and nothing else. Each gets a POST
sync route on the hardened Fitbit and Google Health template: session auth,
a 5-per-minute per-user bucket, and a fixed 502 on failure. No body and no
full-sync flag, because none of the four entry points has a full-history
arm to call. Polar goes through the shared legs entry point so both legs
run with the attribution rules built for them.
The Nightscout error arm is the one that matters: a base URL carries the
API token as a query parameter, so the caught message can hold the user's
secret and never reaches the response body. The classified failure is
already on the ledger, recorded with the provider's own redaction.
Connecting also starts the first sync now. The poll handler has accepted a
single-user payload since it was written and never had a producer, so a
fresh connection sat empty for up to an hour waiting for the schedule. The
Oura and Polar callbacks and the Nightscout connect route enqueue one
targeted job each, best effort, with the hourly tick still the safety net.
Strava needs none: its callback already enqueues the history backfill,
which covers the recent window.
The four poll-queue names move into their own module so a request-path
caller can name a queue without dragging the whole worker handler graph
into the request bundle, following the backfill-queue precedent. Creation,
scheduling and handler binding stay in the registrar, and the dead-queue
guard follows the declarations.
The lookback was pinned at seven days, so a sync that stayed broken for
longer than a week came back with a window too narrow to reach the gap.
The quieter case did the same damage with every signal green: a ring left
unsynced holds its nights in Oura's cloud until the app is opened, every
hourly tick succeeds with empty arrays, and when the days finally arrive
they sit outside the window.
Anchor the window on the newest live Oura measurement instead, clamped
between the existing seven-day floor and a new thirty-day ceiling, plus a
day of overlap. Both failure modes leave the same fingerprint, so one
anchor closes both, and the watermark cannot drift out of step with the
data because it is the data. Tombstones are excluded: the sleep sweep
soft-deletes rows a revised hypnogram orphaned, and one of those must not
pass for a live reading.
The ceiling is thirty rather than the ninety days Oura's guidance would
allow, because the whole batch still rides one transaction. With data
flowing normally the clamp floors at seven and the tick is unchanged.
A connection with no rows at all takes the full month.
Cover the adapter end to end: an imperial seed, a guardrail projected
into the display unit, a save inverted back to canonical, and a reopen
that shows the number the user typed, swept across the whole editable
window at the input's own step. Temperature carries the affine case,
where a difference must take the factor without the offset.
Assert the metric path is the exact identity rather than merely close,
so a stored value cannot drift by a rounding pass.
Extend the structural guard to sweep the three target and threshold
surfaces, and add two tripwires: each must call the preference hook and
convert through the shared adapter. Reintroducing a hardcoded unit or
swapping the hook for a literal now fails the guard.
Target ranges stayed canonical while every reading surface moved to the
metric/imperial preference in the previous release, so an imperial
account saw a weight chart in pounds above a target reference in
kilograms. The panel is a coupled display and edit unit: converting only
its display would have corrupted the edit seed and the save round-trip,
which is why it was held back.
Add one adapter that owns every direction of the conversion and route
all three surfaces through it. The reference panel converts the
canonical payload once and hands the converted pair to the range bar,
the status pill, and the editor seed. The editor seeds from the stored
value converted to the display unit, validates against guardrails
projected into that same unit, and inverts the typed number back to
canonical before the PUT.
Guardrails round inward, so the 30 kg floor projects to 66.2 lb rather
than 66.1 lb, which would have inverted below the floor the server
enforces. Absolute values carry the affine offset, differences take the
factor alone. Input step comes from the transform's own precision.
Storage is untouched: thresholds stay canonical SI, and for a metric
account the adapter is the exact identity in both directions, so a
stored value is unchanged bit for bit.
Surface a self-hoster's report that the unit preference was stored but read by
almost no display. Weight, body composition, waist, and temperature now follow
metric or imperial at entry and everywhere they render, storage stays canonical
SI, and the MCP logging tool converts a known unit hint or refuses an unknown
one. OpenAPI diff is version-only; the wire stays canonical.
The crosstab, vitals, and cycle-summary renders are static server renders with
no QueryClient; the new preferred-unit hook reads through the auth query, so
mock it to a real metric-preference implementation (identity values, canonical
symbols) that matches the render those suites assert. Register the new unit
resolver on the MCP write-tools module mock so the preview path resolves.
Add affine-correctness tests for the transform registry (°C→°F absolute vs a
delta that must stay offset-free, lb/in factors, inverse round-trips), a
resolver test for the inbound unit aliases, a preferred-unit entry-form label
test, and MCP cases for converting a lb hint to canonical kg and refusing an
unknown unit. Add the structural guard that trips if any dashboard or insight
surface reintroduces a hardcoded transformed-type unit or value scale. Ship the
diagnose/convert script pair for legacy non-canonical measurement rows.
Route the weight and temperature displays in the mood-factor and cycle-phase
crosstabs and the GLP-1 plateau note through the unit transform: absolute phase
averages take the affine conversion, deltas stay factor-only, and the unit
symbol follows the preference. Thread a unit token into the plateau evidence
copy across the six locales so the sentence reads in the user's unit.
Wire the display transform into the chart scaffold, the chart itself (with an
affine offset for the temperature conversion), the dashboard trend tiles and
weight chart, the vitals grid, the coach read strip, and the device-score
tile. Each surface resolves its unit and scale from the user's preference at a
single choke point and stops hardcoding the canonical unit, so an imperial user
sees a consistent value, axis, band, tooltip, and range hint while a metric
user's render stays byte-identical. Retire the local celsius display shim now
that the transform owns the symbol.
Add the affine display-transform registry (kg↔lb, cm↔in, °C↔°F with the
temperature offset carried as an absolute-only shift), a single client hook
that reads the preference, and a shared inbound unit-alias resolver. Wire the
manual entry form to label, prefill, and convert at the boundary so an imperial
reading persists in canonical SI, and route the record list value cells and
edit dialog through the same choke point. Close the one write path that could
persist a non-canonical unit: the MCP log_measurement tool now resolves a unit
hint to canonical or refuses it, reusing the resolver the CSV importer already
relied on.
A 31st new mirror create at the cap returns 422 with the limit_exceeded
errorCode; an idempotent re-post of an existing mirror at the cap still returns
200; a native create at the cap is unaffected and never runs the count. The
list read exposes externalSource as APPLE_HEALTH for a mirror and null for a
native row.
A client that mints a fresh externalId for the same source concept on every
sync sweep never matches the (userId, externalSource, externalId) idempotency
triple, so each sweep would otherwise create a new mirror row without bound.
Add a growth guard on POST /api/medications: once an account already holds 30
medications mirrored from an external source, a genuinely new mirror create is
refused with 422 and the errorCode medications.mirror.limit_exceeded. The guard
sits after the idempotency lookup, so an idempotent re-post of a medication the
account already mirrors still returns 200, and native creates never run the
count.
Echo externalSource on the medications list read so the web UI and an operator
can tell a mirrored row from a native one, and document both in the OpenAPI
contract.
The diagnose script reports the provenance, external-id values, and intake/schedule
counts of medications by name. The cleanup script deletes only name-matched
Apple-Health mirror rows with zero intakes and zero schedules, dry-run by default,
replicating the delete route (category cleanup, token revoke, audit, cache bust).
Per-collection isolation for the Polar vitals sync: one unavailable data type
no longer blanks the rest, a failing collection is named on the integration
status, and an unexpected response shape is logged instead of reading as no
data. Bump version, regenerate the OpenAPI version string, add the changelog
entry.
The collection fetchers degrade an unexpected 200 body to an empty list, so a
Polar-side envelope rename would read as "the user has no data" and stamp a
clean sync while importing nothing. Emit a fixed-shape polar.envelope.drift
breadcrumb (verb, HTTP status, top-level key names only, never values) at each
silent-empty site in the collection fetcher and the exercises fetcher, keeping
the log-and-degrade behavior so a live account stays alive while the gap becomes
visible in the event stream.
Truth up the client docstrings against the verified AccessLink contract: each
vitals list is a bare array or a named wrapper over a server-fixed 28-day window
with no pagination, and exercises is a bare array over the last 30 days of
uploads. Every hourly tick re-reads the full window, so a late-synced device or
a short outage self-heals with no cursor or watermark; history beyond the window
is an upstream cap. No request parameters added.
Restructure the Polar vitals sync from a single Promise.all over the five
collections into a Promise.allSettled over a per-collection spec, each closure
fetching and mapping so a throwing mapper is contained exactly like a failing
fetch. The healthy collections import; a failing one is recorded once as a
partial failure that names it, without stamping success and without rethrowing,
so a single unavailable data type — SpO2 on a watch without the sensor, a
higher-tier cardio-load read an account lacks — stops blanking recharge, sleep,
and activity on every tick.
A 401 is now a per-collection failure classified reauth_required, not a
whole-leg throw; the write-path escape still surfaces unmarked for the poll
boundary to record once. Mirrors the shipped Oura isolation, minus the
token-refresh leg Polar has no path for.
A failed sync no longer reads as success across WHOOP, Withings, Oura, Polar,
Nightscout, and Strava: a dead WHOOP token stays parked instead of flipping back
to connected, an interrupted history backfill is retried rather than marked
complete, Withings ECG syncs on its own hourly schedule, activity and sleep
write failures surface on the status card, poll-cohort sync errors that
previously vanished are recorded, and Strava history import starts at connect
time.
Add an hourly ECG catch-net cron so a watch-only account with no scale pulls its
recordings without a webhook: the queue was registered and bound but never
scheduled, and the handler now grows a cohort arm that walks every connection on
a payload-less tick, mirroring the activity and sleep handlers.
Make the activity and sleep write paths honest. A swallowed activity batch-write
fell through to the success stamp — a green pill over lost data, green forever on
a persistent write bug; it now records a transient failure and refuses the
stamp. A reconcile throw out of either sleep transaction propagated past the
stamp and was swallowed by both callers, leaving neither success nor failure
recorded; the source now records a transient failure and rethrows so the cron
and webhook paths stay honest without changing their warn-and-continue
behaviour.
Port the hard-fail ledger the Fitbit transport already carries. A revoked WHOOP
grant makes every resource short-circuit and return zero rows without throwing,
so the cycle read as clean: it stamped success, un-parked the reconnect state,
reset the failure streak, and the hourly cohort hammered the dead refresh token
on every tick. Record the three dead-token null-return paths on the ledger and
fold that verdict into both drivers — the full orchestrator and the per-resource
cron path that runs most often — so a dead-token cycle refuses the success
stamp and the park holds.
Return an imported-plus-failed verdict from the orchestrator and gate the
history backfill on it: an interrupted full-history walk now throws for a
pg-boss retry instead of being stamped complete, and a connection parked at
reauth returns without burning the retry budget. Thread the same verdict into
the one-shot sleep-timeline backfill.
Also catch a write-path throw on both drivers so a persistent measurement-write
failure surfaces on the status ledger instead of leaving a stale-green pill, and
widen the cycle overlap to the seven-day re-score window the other collections
use so a late-revised day's strain and energy are re-fetched rather than
stepped past.
Introduce a recorded-marker primitive on the integration ledger so a provider
that records-then-rethrows a sync failure is not recorded a second time at the
poll-cohort boundary, while an unrecorded escape (an Oura or Polar write-path
throw, or any future path) lands on the ledger exactly once, correctly
classified and redacted by the source that owns it. Without the marker a
boundary-blind recorder would double-count the failure streak and could persist
a Nightscout token-bearing URL into the stored error.
Give the poll-cohort factory a required per-provider failure recorder and wire
one for Oura, Polar, Nightscout, and Strava.
Nightscout now counts only freshly inserted readings instead of insert plus
re-confirm (the inflated count fired the reminder trigger every tick), records a
transient failure and skips the success stamp when some rows fail to write, and
emits the measurement-arrival spine for its inserts. Correct the docstring that
promised a far-history backfill that does not exist: the hourly window imports
about 48 hours and nothing deeper.
Strava now enqueues its history backfill at connect time, mirroring the WHOOP
and Fitbit callbacks, so deep history imports within the hour instead of waiting
for the next worker restart.
The GET spread the optimistic-concurrency `updatedAt` into the response even
when the user had never saved preferences, which broke the strict-equality
contract that a never-saved read is exactly `DEFAULT_COACH_PREFS`. Mirror the
insights-layout never-saved GET: a null `coachPrefsJson` returns the pure
default with no token (the first save is the tokenless unconditional write), and
only a saved row and the PUT response carry `updatedAt`. Update the integration
round-trip to expect the token on the saved GET and PUT while keeping the
`coachPrefsJson` column assertion pure.
Add interleaved-writer, tokenless-compat, and malformed-token coverage for the
notification-prefs, modules, coach-prefs, and mood-tag-layout write guards,
including the notification-prefs case where a stale web write must not revert
the app-managed medication flag. Pin the inventory PATCH and DELETE routing
through the advisory lock, and cover the insight-generation privacy re-check on
both the main commit and the reroll arm.
Wire the notification-prefs cards, the module toggles, the coach-preferences
save, and the two mood-tag manage cards to echo the optimistic-concurrency
token they last read and to reload on a 409 instead of resaving over a newer
change. Document the token and the 409 on the four write endpoints in the
OpenAPI registry via the shared base-token field and conflict-response
fragments.
Apply the shared optimistic-concurrency guard (takeBaseToken + guardedUserUpdate)
to the notification-prefs, mood-tag-layout, modules, and coach-prefs write
endpoints so an interleaved edit can no longer silently overwrite a category a
fresher writer just set; a tokenless request keeps the prior unconditional
write. Route the inventory stock-correction PATCH and DELETE through the same
per-medication advisory lock intake consumption takes, so an absolute stock
write can no longer clobber a concurrent dose decrement. Guard every insight
cache commit on the privacy mode still being what the generation read, so a
privacy-mode change is respected by a generation that was already running.
Add per-endpoint unit tests for the insights layout, medications layout, and
coach about-me write guards: an interleaved stale-base writer gets a 409 and
changes nothing, a tokenless request still writes (older-client compatibility),
the fresh token is echoed, and a malformed token 422s. Cover the client token
echo, the two 409 dispositions, and the insights read that must keep the token
through the resolver. Refresh the CHANGELOG and the generated OpenAPI spec.
Extract the dashboard-layout optimistic-concurrency mechanism (issue #581)
into a shared server helper (takeBaseToken plus guardedUserUpdate /
guardedRowUpdate) and a client helper (readUpdatedAtToken, withBaseToken,
isConflict). Retrofit the dashboard-widgets route onto the helper and apply the
same guard to the insights layout, medications layout, and coach about-me write
endpoints.
A request that omits the base token keeps the byte-identical unconditional
write, so older clients are unaffected. Document the contract in the OpenAPI
registry, including the dashboard-widgets path that was previously absent.
Compliance card: a dose-change imperative and a fabricated risk figure are
withheld to the deterministic fallback (no model text persisted, updatedAt
stays null), while a clean grounded summary still renders and persists.
Hero lines: a well-formed, truncated, or fenced JSON envelope resolves to the
deterministic lead/template for both the arrival reaction and the coach nudge;
a plain-text line still ships verbatim.
Route the medication-compliance status card through finalizeStatusSummary so
it enforces the same dose / risk / causal outbound contracts its seven sibling
status cards do, not only the unparseable-envelope withhold it already had. A
dose-change imperative or a fabricated figure is now withheld to the controlled
fallback on this surface too.
Add the status cards' structured-envelope detection to the two background hero
lines (arrival reaction, coach nudge): a brace-led or fenced JSON reply, or a
truncated envelope under the char cap, now resolves to the deterministic lead
rather than rendering raw.
Fold in the residuals from the same sweep: strip a truncated SUGGEST-ACTION
marker from the Coach prose tail, drop an unreachable zero-rate branch in the
adherence fold, and document why the internal conversation summary stays off
the render-boundary screen.
A dose logged from another screen marked the Today hero and the dashboard
stale without re-reading them, so returning to the home page kept showing the
old state until the 120 s poll or a hard reload. The daily reads run with
refetchOnMount off, so a bundle invalidation with the default active
refetch-type never touched them while they were unmounted.
Route every intake-affecting medication write through one blessed
invalidateMedicationReads (bundle invalidation plus a forced inactive refetch
of the digest and snapshot): Take all due, the intake edit dialog, the history
bulk/row delete, the dose-history ledger and its add dialog, the lifecycle and
danger-zone actions, and the intake import. Give the Vorsorge reminder
mutations the same daily-rail refresh, bust the biomarker assessment and the
labs-changes read after a lab write, and refetch the daily reads after a
briefing regenerate so the Today lead updates without a reload.
Restore the dashboard-layout concurrency belt: the settings layout query now
always loads its own tokened copy, so a home-page visit can no longer strand a
save without its base token and silently drop the v1.32.16 conflict guard.
Add a structural guard that fails CI when a new site invalidates a daily-reads
bundle without the paired inactive refetch, so this class cannot recur a
fourth time, plus behavioural coverage for the batch take and the Vorsorge
refresh.
Refs #581
Hold the Fitbit sync verdict when the token is dead or a write fails, and
verdict-gate the history backfill so a partial first-time import retries
instead of marking itself complete. No schema or contract change; the OpenAPI
version string tracks the bump.
New sync-dead-token suite mirrors the Google Health parity test: the three
getValidToken null-return paths each register on the hard-fail ledger, a
successful refresh leaves it empty, and a dead-token cycle returns
{ imported: 0, failed: true } without stamping markSynced or recordSyncSuccess.
Extends the upsert suite with the two write-catch labels (measurements:create,
measurements:update) and the sync orchestrator suite with a write-label
watermark hold. Rewrites the backfill suite to mirror google-health-backfill:
a clean run stamps, a failed verdict throws without stamping, a parked
connection returns without running the sync.
A revoked or expired Fitbit refresh token used to fail the cycle's status
ledger and then stamp the same cycle a success: the connection flipped back
to connected, the watermark advanced, and after a reconnect the 24h overlap
could no longer reach the missed days. getValidToken's three null-return
paths (credentials missing, refresh failure, vanished connection) now register
on the per-cycle hard-fail ledger, so the cycle's verdict fails, the park at
error_reauth holds, and lastSyncedAt does not move. The two measurement write
catches do the same, so fetched-but-unwritten rows are re-fetched next tick
instead of aging out of the overlap.
syncUserFitbit now returns { imported, failed }; the manual-sync route, the
poll cohort adapter, and the backfill consume the new shape. The backfill
stamps backfillCompletedAt only after a clean full-history walk: a parked
connection returns without stamping, and a failed verdict throws so pg-boss
retries on the already-configured retry budget.
Berlin boundary pairs (previously dropped), the gone New York lag-1
offset, all three metric partners moving, UTC-user invariance, and a
metric-by-metric guard proving the weight x BP and BP-medication joins
stay byte-identical across timezones. Update the mocked aggregates for
the additive weightRawRows field and pin the widened raw pull in the
aggregator test.
The mood series has been local-day-keyed since v1.32.12, but the metric
partners it joins against (blood pressure, weight, resting pulse) were
still keyed on the UTC calendar day. The join is an exact day-string
match, so for anyone outside UTC an evening reading landed on the next
UTC day and paired with the wrong mood entry — a one-day misalignment
sold as a same-day correlation.
Derive the three mood pairing partners as exact local-day means, keyed in
the user's own timezone via userDayKey (Intl-based, DST-safe). Blood
pressure and resting pulse reuse raw rows already on the request path;
weight rides one additive column on the existing raw-rows query. The
UTC-consistent weight x BP and BP-medication joins are left untouched —
both their sides share one UTC key space and they migrate with the
rollup tier later.
Server: a write with the current base token succeeds and returns the advanced
token; a stale base returns 409 and writes nothing (no unconditional fallback);
a malformed base 422s; an omitted base keeps the unconditional write. Client:
the ring payload never carries widgets and threads the base token, the shared
busy gate covers either write, and the Save/ring wiring is source-pinned.
Refs #581
A dashboard layout change could be silently reverted when a hero score-ring
toggle was made around the same moment. Both writes PUT the whole layout to
/api/dashboard/widgets, and whichever reached the server last won, so a ring
write built from a pre-save snapshot overwrote a fresher Save.
Guard the write with optimistic concurrency. The client sends the updatedAt it
based its edit on; the route writes conditionally on the stored row still
carrying that token (updateMany where updatedAt = base) and returns 409 with a
typed body on a stale base, writing nothing. The advanced token rides back on
every GET/PUT response so the client can rebase. No migration: the User row
already has an @updatedAt column.
On the client, the ring PUT carries only its own fields plus the base token,
the Save and ring writes share one mutation scope so they serialise, the Save
button and ring controls disable while either write is in flight, and a 409
refetches the winning layout and asks the user to redo the change.
Refs #581
Parity suite proves a Polar exercise and an equivalent Strava activity map to a
downstream-indistinguishable Workout row (sport bucket, start instant, duration,
distance, calories, HR, null-vs-zero). Regression guard asserts the exercises
request hits GET /v3/exercises with no userId path segment. Dedup ladder tests
mirror the Strava suite (overwrite-in-place, Apple+Polar twin collapses, Polar
above Strava). Composite test proves a failure in one Polar leg leaves the
other leg's result intact on the shared ledger. Defensive-shape tests cover the
four flagged assumptions: numeric-vs-string id, UTC-offset composition (positive
and negative), bare-array-vs-paged body, and a 403 scope error as a soft error.
Refs #568
Pull the athlete's exercises from the stateless GET /v3/exercises list (last
30 days of uploads, no transaction, no userId in the path) and upsert each into
the Workout table as source POLAR, keyed on (userId, source, externalId) so a
re-fetch or Polar-side re-analysis overwrites in place. A new table-driven
sport map normalises the raw Polar sport labels into the canonical
WorkoutSportType bucket at ingest, mirroring the Strava/WHOOP mappers; the raw
labels and training load ride metadata for provenance.
The exercise leg rides the existing hourly polar-sync cohort tick alongside the
vitals leg, with independent attribution on the shared polar ledger: each leg
is attempted and records its own failure, and neither can mask the other's
result. Polar takes its place in the workout source ladder between Withings and
Strava. No migration and no OpenAPI change: MeasurementSource.POLAR and the
full workouts source enum already exist.
Refs #568
Replace the internal "[unverified]" token the grounding guard left in a Coach
reply with the editorial elision mark […], and add a quiet muted caption
under the bubble explaining that some figures could not be checked against the
user's data. The mark holds the slot grammar-safely mid-sentence, before a
surviving unit, and inside a range; the notice carries the meaning. The withheld
count rides the existing provenance envelope (unverifiedFigures) through the
stream, persistence, and reload, and the fenced document-chat path carries a
minimal envelope so its notice survives too. New i18n key across all six locales.
Also fix an adjacent reload gap: provenanceFromJson restored only a subset of the
serialised envelope, so a persisted suggestion card, confirm action card, and the
tool trace were dropped on conversation reload despite the render layer expecting
them to survive. Restore them defensively alongside the new field.
A truncated or malformed { "summary": "..." } envelope fell through the
status-card parser and rendered raw — braces, the summary property name,
quotes, and literal \n — in the Assessment card (resting pulse and the
other per-metric cards). parseSummaryFromContent returned the raw content
on any JSON parse failure, and a truncated envelope with no closing brace
also fell into that path, so partial structured output reached the reader.
Split the read into extractAssessmentSummary, which classifies a completion
as a parsed summary, genuine prose, or an unparseable structured envelope.
A payload that opened like a JSON object but failed to parse (or parsed
without a usable summary) resolves to the controlled fallback rather than
raw text; plain prose that merely contains a brace is still preserved.
finalizeStatusSummary withholds an unparseable envelope so every status
card serves its deterministic stub, and the two direct callers
(medication compliance, derived-score assessment) get the same guard.
Refs #608
The persistent mood-rollup writer bucketed on the UTC truncation of
`mood_logged_at` and ignored the canonical per-row `MoodEntry.date`
label. Every coverage-miss fallback keys on `entry.date`, so the rollup
tier disagreed with the fallback and with the day the user logged the
mood: a late-evening or after-midnight local mood landed on the wrong
calendar day, and the label shifted the moment the boot backfill flipped
a user from the live fallback onto the rollup tier.
Key every bucket off the stored `date` label instead — DAY, WEEK, MONTH
and YEAR — with `bucket_start` set to that label's UTC-midnight instant.
The rollup is now byte-identical to the live `entry.date` fallback and
timezone-independent by construction: DST correctness stays where it
already lives, in `moodDateKey` at write time. No consumer changes, no
schema change; every rollup reader already formats `bucket_start` as a
UTC day label.
Migration 0269 empties `mood_entry_rollups` so the boot-time
full-backfill worker re-mints every user's buckets under the corrected
keying; the worker now deletes-then-refolds so it is self-cleaning
against any row left by an older instance. While the table is empty,
reads fall back to the live `date`-keyed walk, which was already
correct, so there is no window where a wrong day is shown.
Self-hosters can sign in to the iOS app through their own domain, so the
password manager files credentials under the right site and passkeys run in
the correct origin. The app opens the instance's own web login inside an
ASWebAuthenticationSession; after an interactive login the server mints a
single-use, PKCE-locked handoff code on a compiled-in custom scheme, which the
app exchanges for the standard native token bundle. The token pair never rides
a URL — only the opaque code does.
Three endpoints mirror the OIDC native handoff and share its mint/consume core,
now extracted to native-handoff.ts (the OIDC leg keeps working unchanged, as
the default flow):
- GET /api/auth/native/login validates the PKCE challenge, sets a
short-lived encrypted state cookie, and
redirects to /auth/login?flow=native.
- GET /api/auth/native/complete validates the web session, enforces the
freshness binding, mints the code, and
redirects to healthlog://login-callback.
- POST /api/auth/native/token exchanges the code under the cookie-less
native transport gate for the native bundle.
The freshness binding refuses any session older than the flow start, so a
pre-existing browser session can never silently complete a handoff. The start
time is stamped from the database clock (SELECT now()) to stay comparable to
Session.createdAt with no app/DB skew, and createdAt is read through a
dedicated projection so the comparison cannot be silently dropped. A flow
discriminator on the handoff row (migration 0268) makes an OIDC code
non-redeemable at the web-login route and vice versa.
The callback scheme is a compile-time constant with no redirect_uri parameter,
requireAdmin and requireFreshMfa stay cookie-only, and the exchange mints no
wider scope than password login. The state cookie is AES-256-GCM with a fresh
IV. Adds the flow=native login-page banner in all six locales and the three
routes to the OpenAPI contract.
The Coach output guard now tracks the numbers it was shown across the whole
conversation, so a figure carried forward or grounded in a goal, a schedule,
or a reference band stops being marked unverified, while an invented one is
still caught and a fabricated risk number now blocks in six languages.
Promote the per-turn magnitude bag to a per-conversation typed Grounding
Ledger built from every source the model was actually shown: this turn's
tool payloads, prior-turn tool figures, coach-memory goals, the medication
schedule, the reference-grounding block, and guided blocks. Assistant prose
is never a registration source, so a figure that slipped a rung cannot
launder into an authoritative value on the next turn.
Complete the action ladder: a reconciled number passes, a reference or
education sentence is exempt across all six locales, a positively-typed
benign number (date, time, year, ordinal, list marker) passes while an
untyped unreconciled one stays on the strip path, and an unreconciled
health figure is soft-stripped.
Gate the dose continuation exemption on the user's actual schedule: a
"keep taking your N mg" is trusted only when N is a dose the user is on;
an off-schedule maintenance dose is caught. Extend the risk and percent
classifiers to fr/es/it/pl to match the EN/DE depth.
No schema migration: the ledger is rebuilt per turn from persisted tool
traces and re-rendered prompt blocks.
Bumps the version, regenerates the OpenAPI contract and the service-worker
version fallback, and records the release notes for the intake-source and
sync-trigger provenance surface plus the shared hhmm util and migration-number
guard.
Two short-lived branches can each grab the next free migration number and
both merge, leaving the tree with two directories sharing a four-digit
prefix. This structural test fails the build the moment a prefix repeats, so
the collision surfaces at PR CI instead of after merge. One historical
collision (0025) predates the guard and is grandfathered — a migration
directory name is the primary key in the applied-migrations ledger, so it
cannot be renamed without orphaning the record on every deployed database.
Seven near-identical copies of the HH:mm to minutes-since-midnight parser
had accumulated across the scheduling surfaces in two flavours: a strict one
that trusts an already-validated literal and a null-returning one for
unvalidated input. Both now live in src/lib/medications/scheduling/hhmm.ts as
hhmmToMinutes and hhmmToMinutesOrNull, and every call site imports from there.
Behaviour is identical: the per-medication route keeps its malformed-to-zero
fallback as a thin adapter over the shared core, and dose-window re-exports
the strict variant so its consumers and unit suite keep their import site.
Route-level tests that run the real apiHandler + requireAuth so the
cookie-vs-Bearer derivation is exercised end to end, not papered over by a
unit mock:
- a cookie POST records WEB, a Bearer POST records API, and a body that
forges `source` is ignored;
- the dose-history ledger selects and returns `source` per row, null on a
legacy row;
- a batch carrying `syncTrigger:"background"` rides the ingest annotation,
a batch without it annotates null, an invalid value is 422, and the field
leaves the per-row insert/update outcomes byte-identical.
Root-cause the intake source attribution and give the HealthKit batch a
diagnostic sync-trigger, one attribution surface for both.
AuthContext now carries a read-only authMethod ("cookie" | "bearer"),
derived from the same branch that sets the wide event's auth_method. The
medication-intake route maps it to the stored IntakeSource: a Bearer
(native) call records API, a cookie (browser) call records WEB. The other
producer values are untouched — a converge onto a pending reminder row
updates through applyCanonicalSlotWrite, which never rewrites source, so
REMINDER / IMPORT / APPLE_HEALTH provenance is preserved. The value is
never client-asserted, the same posture as userId being narrowed from auth.
The dose-history ledger now selects and returns source per intake row so a
client can label how each dose was recorded. The endpoint gains its first
OpenAPI entry (path plus response schema) so the field lands in the
iOS-consumed contract.
The measurements batch accepts an optional per-request syncTrigger
(foreground | background | push). It is diagnostic-only: recorded on the
measurement.batch.ingest wide event and nothing else — never persisted,
never part of the dedup key, never an input to attribution or how a sample
is stored. Sending it or omitting it produces byte-identical row outcomes.
Regenerated docs/api/openapi.yaml.
Replace the untyped magnitude bag in the Coach numeric verifier with a typed
tokenizer and a normal-form reconciler, and narrow the outbound risk and dose
banks so correct figures and correct refusals stop being blocked while every
fabrication shape still blocks.
Grounding verifier (coach-prose-grounding.ts):
- Classify each prose number before grading — ISO/written dates, clock times,
bare years, ordinals, list markers, hyphenated time spans, ranges, percents,
thousands-separated integers, unit-suffixed measurements. Benign types are
never graded or stripped; ranges decompose into per-endpoint magnitudes.
- Grade against typed authoritative entries (value + kind + aggregation).
Sign-insensitive, canonical rounding, ratio to percent in percent space, and
minutes to hours widen the match only for an aggregate, kinded entry; kind
scoping stops a weight value grounding a blood-pressure claim.
- Thousands/decimal separators follow the reply language.
- Boundary-safe strip so a flagged token never clips a larger grounded number.
Outbound screen (outbound-screen.ts):
- Risk bank requires an asserted figure or a categorical engine result; a bare
engine or horizon mention (education/refusal) passes. Spelled-out percents
count as figures. No refusal-context exemption. EN + DE.
- Dose bank keeps a maintenance-continuation restatement, voided by any change
stem. Taper suffix fixed.
Route + fenced-chat thread the reply locale into the verifier.
Merge the eight reviewed audit fixes that were cut a week ago and never
landed. Main has moved on since, so each fix was re-checked against the
current tree and each of the five conflicts resolved by understanding
both sides.
Kept, still open on main:
- Proxy early exits now carry the transport security header subset via a
shared helper. Reconciled with main's /api/health worker-only carve-out.
- Batch measurement ingest bounds the entry instant so a future-dated row
can no longer win "latest reading" or poison the rollup buckets.
- Web Push dials through the pinned safeFetch dispatcher with
requirePublicHost, closing the last user-controlled-host egress.
- Mood bulk upsert and its tag/factor links run in one transaction, so a
rejected rating rolls the mood row back instead of leaving a tagless row.
- Full backup carries NutrientIntakeDay with its unit and source. Folded
into main's portable-export / disaster-recovery split.
- Live daily-series fallback restores the min/max spread band the tiered
read already emitted.
- Coach stream aborts its in-flight reader on unmount and resets send state
on a conversation switch.
- Manual water writes share the day-bound helper, nutrient add clamps after
the increment, and the thiamin cap drops to a real supplement ceiling.
- Compliance point window widens symmetrically instead of wrapping overnight.
- Oura, Polar and Fitbit token helpers classify an empty or non-JSON 2xx
body as a handled transient error.
- WebAuthn relying-party fallback to localhost is gated to development.
- Chart-token strip list is derived from the allowlist and pinned by a test.
- Regression tests pin the snapshot-cache tenant key, the GlitchTip
path-secret redaction and the Withings credentials delete.
Superseded by main, tests re-pointed at the current shape:
- The intake CSV import was rewritten on main to run through a background
job with a single-active-job admission gate. The future-date bound is
re-applied at request validation and the per-user limiter is kept for
parity with the bulk twin; the old synchronous-write test is replaced.
Integration coverage for `validateSessionFromCookieValue` against real
Postgres: a live secret-backed session resolves to its user, an expired one
and an unknown/forged/empty value return null, and a legacy cuid cookie
resolves only while its row still carries no hashed secret. This is the
hashing + row lookup + expiry the callback's duplicate detection turns on,
which the unit suite stubs out.
Refs #600
A browser SSO sign-in landed on /auth/login?error=oidc_failed even though it
had succeeded. Two layers conspired:
- The service worker enables navigation preload, but its fetch handler returned
early for /api/ paths without calling event.respondWith(). For the OIDC
login/callback navigations that left the preload unconsumed, so the browser
also ran its own default fetch and the callback was requested twice. The
first request redeemed the single-use authorization code and set the session;
the second tried to redeem the now-consumed code, failed at the IdP token
endpoint, and that 307 to the error page is what the user saw.
- Even with the double request gone, a legitimate retry (proxy, double
navigation) hit the same wall on the server.
Fix both layers:
- The worker settles /api/auth/* navigations itself, consuming the preload
response (or fetching once when there is none), so exactly one request
reaches the server. These responses are never cached.
- The callback is idempotent. When the single-use state cookie is already
consumed but the request carries a database-validated, unexpired session, the
callback treats it as a duplicate of a completed login and redirects into the
app instead of surfacing an error. The authorization code is never exchanged
on this branch, and a consumed/forged/expired state from a caller with no
valid session still fails closed with the unchanged oidc_state error.
Refs #600
The previous commit spliced the DATA INVENTORY manifest into the tool-mode
authoritative payload set unconditionally. That made the payload set
non-empty on a turn where the model answered WITHOUT calling any tool
(empty tool results), which flipped the verifier from dormant to active —
grading the reply against an inventory that carries per-domain sample counts
only, never the snapshot's pre-computed averages. A figure the model
correctly narrated from the snapshot (a 30-day systolic average) was then
stripped to "[unverified]" — the exact false positive this work exists to
prevent, and a regression from main's "no tool figures this turn → skip,
prompt-level grounding is the backstop" contract.
The inventory must only WIDEN an already-active authoritative set, never
ACTIVATE it on its own. Gate activation on a real present figure block
(a pinned workout-evidence block or a present tool result) exactly as main
did; when active, the inventory counts still join the set so a plain count
restatement stays grounded.
Adds a route-level regression test (fast suite) proving the verifier stays
dormant when no tool returned figures, plus companion unit tests documenting
that a real tool section grounds the average while a counts-only inventory
does not.
Related to #585. Investigated the intraday pulse chart's read path
end to end: the ZIP-import raw-sample fold, the live-sync uploaded
10-min bucket fold, their merge, and the chart's render logic. All of
it treats the two ingestion shapes consistently — the chart has no
shape awareness at all, it paints whatever bucket series it receives.
No read-side or display-side defect explains a systematic density
difference between the two ingestion paths.
What was missing is visibility into the write and read sides that
would let a future report actually be diagnosed instead of
re-investigated from scratch. The batch endpoint's wide-event trace
never broke a skip down by reason, and never distinguished an
HR-bucket write from any other entry in the same request. The
intraday route's own trace surfaced only the final bucket count, with
no visibility into which shape (raw sample rows vs uploaded bucket
rows vs folded hourly rows) a day's data actually came from. Both
traces now carry that breakdown, so the next report of "the chart
looks sparser after a given date" is answerable by grep instead of by
re-deriving the whole read path again.
Three confirmed false-positive shapes in findUnverifiedCoachNumbers/
stripUnverifiedNumbers were replacing legitimate prose with the literal
"[unverified]" placeholder even though the underlying data was grounded:
- A correctly-computed delta, midpoint average, or percent change between
two of a payload's own headline aggregate figures (e.g. "down 3.5 kg from
your 7-day average") was never itself a raw leaf in any tool result, so it
was flagged as invented. The authoritative set now also carries the
pairwise delta/average/percent-change between a SINGLE payload's own
named aggregate fields (latest, avg7, avg30, avgSys30, allTimeAvg, ...),
scoped per payload so a weight delta cannot ground itself against an
unrelated metric's figure.
- The always-sent DATA INVENTORY manifest (tool-mode base context) carries a
sample count per domain, but that count was never added to the
authoritative set the way the pinned workout-evidence block already is —
so a plain restatement of a domain's count ("you've logged 42 BP
readings") was flagged whenever the model didn't also call that domain's
retrieval tool in the same turn. The inventory entries now splice into
toolResultPayloads exactly like workoutEvidence.
- A calendar-date ordinal ("July 21st") or a numbered-list marker ("1. Cut
back on sodium") tokenised as a bare number and, when ungrounded, got
soft-stripped mid-word into visibly broken text ("July [unverified]st").
Prose is now masked for these two narrow, high-precision shapes before
numeric extraction; the original text used for stripping is untouched.
A genuinely fabricated figure (unrelated to any grounded point, inventory
count, date, or list marker) is still caught and stripped — covered by new
adversarial cases alongside each fix.
Investigated and ruled out: multi-round tool-loop result accumulation (all
present results across every round already reach the verifier); unit
conversion (the app resolves and serves one consistent unit, so there is no
independent conversion step for the model to get wrong). The Daily
Briefing's separate withhold-a-whole-section grounding gate is unrelated to
the reported symptom (a different failure mode, whole-line omission vs.
inline replacement) and was not touched.
Closes#584. `/insights/pulse` computed hasRestingHr from
RESTING_HEART_RATE presence and used it to swap the ENTIRE primary
chart and the Coach read strip to that series whenever any resting
row existed, while the title, stat strip, capture action, and
"show all values" link kept saying PULSE. A user could see a
resting-only chart under a page that claimed to be pulse throughout.
The chart now always includes PULSE as its first series; when resting
data exists, RESTING_HEART_RATE renders as a second, clearly-labelled
series (the same multi-series pattern the blood-pressure page uses for
systolic/diastolic) instead of replacing it. The Coach read strip
stays pinned to PULSE. The page also drops the resting-calibrated
target band it used to paint behind the chart — that band is
calibrated to steady-state resting readings and would misreport an
expected workout spike in raw PULSE data as "out of target"; the
personal target belongs on the dedicated resting-pulse page against
the clean resting series.
PUT /api/settings/ntfy reconstructed the entire stored config from the
request body. GET never returns the write-only authToken (only
hasAuthToken), so clients correctly omit it on any edit that doesn't
touch the credential — but the route treated an omitted or empty token
as "clear it", silently destroying a working notification credential on
routine edits like toggling enabled.
An omitted or empty authToken now preserves the currently stored value;
only a non-empty value replaces it. Mirrors the identical headerValue
preserve-on-empty contract already shipped on the webhook channel route.
PATCH /api/measurement-reminders/{id} persisted an explicit null cadence
clear correctly, but the immediate nextDueAt recomputation merged fields
with `updateValue ?? existingValue` — a JSON null and an omitted field
are both nullish, so the merge silently fell back to the OLD cadence for
one cycle. This hit interval-to-RRULE and RRULE-to-interval transitions
and an explicit anchorDate clear alike.
The merge now keys off Object.hasOwn(updateData, field): updateData
already encodes exactly which fields are being persisted, including the
mutual-exclusivity auto-clear (setting one cadence nulls the other), so
recomputation can never diverge from what the row ends up holding.
Closes#588. The ZIP extractor decompressed export.xml synchronously
into one in-memory buffer, then wrote it with a blocking writeFileSync
— on the same event loop the web server runs in the default
single-container deployment. A real multi-year export could pin the
Node main thread for minutes, freezing every other request, while
briefly holding roughly 2x the decompressed size in heap. A process
that died mid-extraction never reached the failure path, so the
ImportJob row stayed stuck in "unpacking" with "0 rows imported"
forever and no error ever surfaced.
Extraction now streams through zlib's async inflate into a file write
stream, moving the decompression work off the event loop and bounding
memory to the chunk size. The zip-bomb ceiling moves from
inflateRawSync's maxOutputLength to an equivalent byte-counting
transform on the streamed output.
Orphan-ImportJob reconciliation used to run once, at worker boot.
docker-compose's restart:unless-stopped does bring a crashed worker
back, but a fast restart can land before the stuck row's heartbeat
goes stale or pg-boss stops reporting the job as live — and because
the boot-time pass never re-runs on a healthy worker, that row was
never revisited again. A 15-minute cron now re-runs the same reconcile
so a genuinely abandoned import converges to a visible failure instead
of staying silently stuck.
The Settings page's instant score-ring toggle PUT the full dashboard
layout, built from the client's cached `remote` snapshot. That snapshot
can go stale mid-flight: if a normal tile/chart Save committed a newer
layout first, the ring request's explicit stale `widgets` (and
`comparisonBaseline` / `chartOverlayPrefs`) still won on write and
silently reverted the just-saved layout whenever it resolved last.
The ring mutation now PUTs only the two ring fields
(`buildRingMutationPayload`). `widgets` joins the existing
preserve-when-absent merge (`LAYOUT_FIELD_MERGE_DISPOSITION`) so an
omitted field is carried forward from whatever is currently stored
instead of a client-held copy, closing the race regardless of request
order.
Renumber the elevation migration from 0260 to 0267 — main claimed 0260
for an unrelated table while this branch sat unmerged, and 0266 is now
the highest committed number.
Two loose ends the merge itself did not touch: the schema comment on
StepUpElevation.method still called it "audit/telemetry only, never an
authorisation input" — the exact claim the fresh-factor fix (a prior
commit on this branch) made false, since the method is now precisely
what decides whether an elevation reaches the destructive MFA routes.
Reworded to match. And pruneExpiredStepUpElevations in step-up.ts was
dead code: the actual sweep runs in cleanup-handlers.ts against the
worker Prisma client for reasons that function could never satisfy
against the request-scoped one. Removed rather than left to drift.
The named-risk-engine pattern in the outbound safety screen used
score2? (the 2 optional), so it matched bare "score" too — any mention
of one of this app's own computed, documented scores (Sleep Score,
Readiness Score, Health Score, Recovery Score, Stress Score, Strain
Score) tripped the bank meant only to catch a fabricated named
clinical-risk calculator (SCORE2, Framingham, ASCVD, QRISK).
Require the literal "SCORE2" instead. The other risk patterns —
percentage-shaped risk claims and the other three named engines — are
untouched, so a genuinely fabricated risk figure or a named
risk-calculator claim still blocks.
Closes#587.
The empty-path guard in the score fast-path checked raw record presence
(any row at all) rather than each pillar's own eligibility threshold —
weight needs 2 readings, mood needs 5 entries, BP needs a paired
reading, compliance needs an active scheduled medication. An account
with some data but no eligible pillar (a single mood entry, a single
weight reading) fell through to computeHealthScore, which summed no
components and returned a raw 0/red score indistinguishable from a
genuinely poor result.
Check the computed result's own components instead of re-deriving the
thresholds, so the guard can never drift from computeHealthScore's
actual eligibility rules. A pillar legitimately computed as 0 keeps its
0 value and is never swept into the null branch.
Closes#582.
The harness landed in v1.30.35, after the slices that built this
release were already cut — so the reaction line's prompt module was
never registered, and its own docblock's warning against the
two-locale collapse was untested. Registering it surfaced a real gap:
the meaning-first instruction lived only in the system prompt's own
words, never in the user prompt in the family's shared phrasing, which
is where the rest of the surfaces carry it and where the tone contract
checks for it. The German body carried no such instruction at all.
Threads locale through the user-prompt builder so the fixed
instruction line matches the reader's own reply language, and wires
the per-user per-day opener-archetype rotation the milestone spec
called for and the first implementation had not connected.
Nothing warms this queue and no read path triggers it, so an unregistered name
fails open in the worst way: every send resolves, every job vanishes, and the
symptom is indistinguishable from the surface's honest empty state.
The paragraph is model output. Rendering it as markup would be an XSS surface,
and this project ships no markdown library so the question stays closed — the
test is what keeps that from being treated as a formatting decision.
The disable-route suite only ever supplied a cookie-shaped context, which is why
the token-id-as-session-id defect was invisible to it. It now covers both arms
and asserts the credential shape handed to the session revoker, plus that the
elevation is spent before the teardown and not spent when the factor code is
wrong.
The one-paragraph-per-workout claim is a unique index, the cascade is an FK,
and the daily-cap window is a SQL fact — a mocked upsert reports success for
all three regardless.
The push endpoint host is user-controlled — the browser supplies it at
subscribe time. `isPublicUrl` guards it at subscribe time and again before each
dial, but it is a literal input-time check and cannot see a DNS rebind between
the accept and the connect; with a short-TTL record the dial reaches the pod's
private network.
`web-push`'s `sendNotification` dials with its own internal `https.request`,
which bypassed `safeFetch` and therefore the connect-time pin entirely. Both
senders now sign and encrypt through `generateRequestDetails` — identical VAPID
handling and payload encryption — and dial through `safeFetch` with
`requirePublicHost`. Non-2xx is re-shaped as the `{ statusCode }` rejection the
existing classification already understands, so the 410/404 expiry reap and the
transient/permanent split are unchanged.
This is the last user-controlled-host egress that was not behind the pinned
dispatcher (issue #217).
The contract now names which factors satisfy the fresh-factor routes and which
do not, carries `satisfiesFreshFactor` on the mint response so a client can pick
the ceremony before spending a proof, and describes the options endpoint's two
ceremony kinds. The second-factor status read is documented as plain
authentication — it carries no credential material, so it needs no elevation.
The projection's central claim was carried by a comment. It is now walked
before it is returned: any non-numeric leaf at any depth throws, and the only
two string leaves are allowlisted by shape — the sport type against the schema
enum itself, and the day key against its format.
This matters because sport_type is a TEXT column whose API write path is
enum-constrained but whose backup-restore path accepts any non-empty string, so
a restored row can carry arbitrary text. It is re-asserted at the projection
boundary rather than trusted from the column. The heart-rate provenance string
is dropped rather than allowlisted, and route geometry stays consumed-not-carried
— it yields one number, and location history may not ride a prompt.
Own-history rows now collapse cross-source twins through the canonical picker,
as the detail page's comparison already did; a session recorded by two paired
watches was inflating the sample size and biasing every median.
The tiered rollup read emits minValue/maxValue; readLiveDaily omitted them, so
on a rollup-cold account or a coverage miss the chart silently dropped the
min/max spread band for the same underlying data — contradicting the file's own
byte-for-byte parity claim. The live aggregate now selects MIN/MAX alongside the
average.
useSendCoachMessage never aborted its in-flight reader on unmount. The drawer
works around that through handleOpenChange, but the full-page /coach surface
passes no registerReset, so navigating away mid-stream left the SSE loop alive:
the provider kept generating and billing, and trailing setState fired on an
unmounted component.
Switching conversations reset every piece of local state except the send state,
so the previous thread's last assistant or error bubble rendered appended to the
newly selected thread until the next send.
The two lifecycle invariants are pinned structurally, not behaviourally — the
repo ships no renderHook. That is weaker, and the header says so; both defects
were a missing call, which is what a structural guard does catch.
Four suites, each mutation-checked against a reverted protection:
- route-workout-scope: the workout row is read exactly once across a ten-turn
conversation that keeps sending the id, the where clause always names
userId, another user's id resolves to nothing and the chat proceeds, and
the section sits inside the health-data fence.
- workout-evidence: the numbers-only closure. An instruction-shaped
sport_type — reachable through the restore path, which types the column as
free text — folds to "other" and never reaches the block; a free-text leaf
at any depth throws.
- page-launch-params: an unknown ?scope= is ignored silently, and ?workout=
yields to both ?c= and the fenced ?doc= path.
- composite-score-anatomy-coach: every score sheet carries an opener, a
scope narrowed to the composite's inputs, and auto-send.
resolveLaunchState's shape assertion gains the workoutId field.
`createTagLinks` validates `ratedFactors` after the mood row is written. On the
bulk path the two ran as separate awaits on the base client, so an out-of-scale
rating threw once the row had already committed: the per-entry catch reported
"skipped" — the one status that promises nothing was saved — while a mood entry
with no tags or factors sat on disk. The single-entry POST has always wrapped
the pair in one transaction; the bulk twin now matches.
The test client for the transaction is a distinct object from the base client
on purpose. Sharing one identity made the atomicity assertion vacuous: a write
that escaped the transaction looked exactly like one that stayed inside it.
Three properties, each with a mutation-verified guard:
The chip and the line run on DIFFERENT clocks and must keep doing so. The chip
expires after three hours; the line stands for the rest of the local day.
Collapsing them onto one clock reads like a simplification and silently deletes
the day's read at lunchtime.
Degradation is the headline. For every reason a line can fail to exist — no
provider, no consent, an exhausted budget, a dead provider, an unusable output
— the row stays line-less, the reservation is reconciled, and the chip and the
provisional-to-final flip still work. The degraded digest is built through the
real composer rather than a hand-shaped fixture, so the assertions cannot agree
with themselves.
The server pass carries no wall-clock time. The hero paints on the SSR pass
because it is the LCP element, so a locale-formatted time inside it would differ
from the browser's — React #418. The tests render through the server snapshot,
which is exactly the pass that must stay clock-free.
The seeded end-to-end spec lands a sleep batch through the real ingest route,
writes the arrival marker, and asserts the chip and the phase flip in place on
a refetch trigger the digest hook already declares — no reload, no new cadence.
One test per gate, each asserting the refusal reason AND that the provider
entry was never reached — a gate that refuses but still resolves a provider
would pass a reason-only assertion while costing what the gate exists to
prevent. The cap and the hash are asserted independently of each other, since
the singleton queue key that normally collapses a burst is best-effort.
The backfill fixture walks the real chain: 750 historical sessions plus 40
re-synced ones plus one fresh, through the live classifier and the live spine
worker, yielding exactly one generation.
The peak detector changed under test: on a flat ride the ninetieth percentile
sits a beat above the mean, so a bpm of sampling noise read as two efforts the
rider did not do. Peaks now need an absolute prominence over the session mean
as well.
The spine dispatches a generator-free enqueue; the worker resolves the
provider. Five gates stand in front of it in ascending cost order — both
modules on, ten-minute duration floor, a hard count of four a day in the
user's own timezone, an input hash over the deterministic evidence, and the
token ledger inside the shared status chokepoint. Each refusal returns a
status rather than throwing, because retrying against a ceiling that does not
move until midnight is an unbounded loop.
The detail route only reads: a workout with no row serves null and renders no
card, which is every workout that predates this and every re-synced one. An
undecryptable paragraph degrades to no card rather than failing the page.
Today gains two additive-nullable digest fields. `justIn` names the day's
newest arrival while it is still news — under three hours old, after which the
record is simply current rather than new. `reactionLine` carries a single
generated sentence about that arrival and replaces the hero's standing lead for
the rest of the local day, never appending a second paragraph, so the hero stays
one lead line tall and nothing below it shifts.
Nothing here polls. The open dashboard picks the change up on the cadence it
already runs — a state change, not a notification.
The generated sentence is bounded before it is written: the arrival spine's
unique row per kind per local day IS the throttle, the worker re-checks the
same claim before spending, and the reservation is reconciled on every exit
including each failure path. Without a provider, without consent, or against an
exhausted budget the row stays line-less and the surface falls back to the
deterministic lead — the chip and the provisional-to-final flip are unaffected.
The chip's time string is formatted client-side after mount; `justIn.at`
crosses the wire as an ISO instant so the server never renders a local
wall-clock time the browser would disagree with.
Status cards and derived-score sheets read one way: they state a verdict and
end there. The metric pages already carried the hand-off; the score sheets
passed neither an opener nor a scope, so they painted no action at all.
- Score sheets (sleep / readiness / recovery / stress / strain) gain the same
auto-sent opener the metric pages use, each scoped to the inputs that drive
the composite rather than to a block it has no equivalent of.
- Workout detail's coach launch carries the workout itself. The chat request
gains an optional workoutId; on the FIRST turn the route narrows by
{ id, userId } and pins ONE bounded evidence section onto the snapshot.
Later turns reuse the pinned snapshot, so per-turn work stays constant in
conversation length.
- /coach?workout=<id> covers the full-page and maximize paths.
The workout block deliberately does not take the fenced transport the stored-
document path takes: a workout row is the user's own sensor record, and the
projection admits no free text. That is enforced, not assumed — sport_type is
a TEXT column, so it is re-asserted against the closed sport union, metadata
is excluded apart from its numeric zone durations read through a Zod slice,
and a recursive guard throws on any non-numeric leaf.
Own-history sport averages move to src/lib/workouts/sport-context.ts so the
narrative and the detail page cannot quote different figures.
The evidence block is a numbers-only server projection: zone seconds, the
workout-window heart-rate shape, route climb and a same-sport 90-day own-history
median. Free-text metadata never enters it, and the sport string is narrowed
against the closed enum first, so the payload carries no text the user did not
generate through a measured field.
The prompt composes the shared assessment base and adds the session contract:
device-attributed, own-history-comparative, effort acknowledged where the
numbers show it, and no training prescriptions of any kind. Registered in the
tone harness beside the assessment cards.
A step-up elevation minted from the account password satisfied the fresh-factor
routes. That is a second-factor bypass: a stolen token plus a known password
could rotate the recovery codes — which take no factor in the body — and then
spend one of them to disable the second factor outright. The web has never
allowed that chain, because a password login does not stamp mfaVerifiedAt.
The method is now an authorisation input. Only totp, webauthn, and passkey
satisfy a fresh-factor gate, which is exactly the set of ceremonies the web
stamps a session for; password reaches what a plain cookie session reaches and
stops. The mint gains TOTP and security-key arms so a TOTP-only account can
still manage its factor natively, and the response says which reach it bought.
The gate also returns a discriminated context. session.id meant a session row on
one arm and a token row on the other, and the disable route passed the token id
to a session-scoped query — excluding nothing, so it deleted every browser
session and revoked the caller's own device login. The transport is now in the
type and the compiler refuses the confusion.
Redemption splits into validate-then-commit so a 429, a 422, or a wrong code no
longer burns a proof against a five-per-fifteen-minute mint ceiling. The claim
is UPDATE ... RETURNING, so the statement that decides is the one that reports.
The mint is transactional behind a partial unique index. Sign-out-everywhere and
both password-reset paths clear elevations. The status read drops to plain
requireAuth — it carries no credential material — and every mutation stays gated.
Each of these was already correct in the tree and had nothing asserting it, so
a refactor would have regressed it silently.
The decrypted-snapshot LRU keys on userId, but only same-user memoisation and
scope-change recomputation were covered — dropping the userId segment would have
served one account's health snapshot to another with a green suite. The project
has shipped one cross-user cache-leak fix already; this is a repeat class.
The GlitchTip forwarder redacts path-segment secrets in the url it sends. The
query-string strip was tested; the path-segment pass was not, and that is the
one that reaches a durable external incident UI.
The Withings credentials DELETE narrows its connection-row delete to P2025 and
rethrows everything else, so a real failure can no longer null the client
credentials and report a successful disconnect while the encrypted OAuth tokens
survive. The route had no test file at all.
Its own table, cascading from the workout row, because workout deletes are
hard deletes. The paragraph is AES-256-GCM at rest and registered for key
rotation; the input hash and prompt version make a re-post of the same
session a free no-op.
The deterministic fallbacks nine metric surfaces render when no provider is
reachable carried German and English bodies only. `getLocalizedText` returned
`locale === "de" ? de : en`, so French, Spanish, Italian and Polish readers got
English — on the no-key path, an outage, a spent budget or a timeout, which is
precisely when this text is the whole assessment.
Every string now has a body per locale: the verdict leads and value sentences
of the signal-grounded composer, the per-metric labels and pointers, and the
eight no-signal floors. Selection reads the reader's locale; English stays as
the structural floor for a locale that is ever genuinely missing a body.
The bodies are written per language, not translated word-for-word. fr/es/it
replace the comparative adjective ("higher than your usual average") with a
preposition — au-dessus de / por encima de / sopra — because a comparative
would have to agree in gender with the metric noun, and the labels do not share
one. Polish restructures further: the sentence takes `wynosi`, which is
invariant for the subject's gender, and the comparison uses powyżej / poniżej
with the genitive; the labels span all three genders, so nothing that inflects
can sit in a shared template.
The tone harness grades the four locales by the same rules rather than passing
them by default: false cheer, clinical-verdict and imperative-opener vocabulary
extended to all six, the no-assessment sentinel keyed per locale instead of a
de/en binary, and a byte-identity guard that fails if any locale silently falls
back to the English text again. The value-led-opener rule was rewritten as
well — it assumed the copula sits against the figure, which under-matched its
own second locale (German "liegt aktuell bei 24") and would have missed all
four new ones.
The batch route now also resolves the user's timezone and module map through
the arrival seam, so a raw call count on user.findUnique no longer isolates
the read this guard is about. Filtering by the SELECT shape keeps the
once-per-batch contract pinned while letting the unrelated cached lookups
through.
The water quick-add validated calendar realism but never the upper day bound the
batch route applies, so a far-future day was accepted and — every read filters
`day >= since` with no ceiling — became the permanent latest day on the settings
card and the dashboard tile. Hoist the bound into one shared helper both routes
use.
`mode: "add"` bounded a single request to the catalog's plausible daily max but
nothing across requests, so repeated quick-adds drove the stored total
arbitrarily high while the batch route enforced the same cap strictly. Clamp
after the increment so the write stays atomic.
Thiamin's cap sat at exactly 1000 mg while its PRI is exactly 1.0 mg, so a
microgram-read-as-milligram sample landed on precisely the cap and the `>`
comparison let it through. 500 still clears any real supplement dose.
NutrientIntakeDay was in no export path, which contradicted the schema's own
reason for denormalising the unit column. Carry it in the full backup with its
unit and source — source is part of the composite key, so without it a restore
cannot tell a manual entry from a synced day total.
The proxy's seven early exits — worker-only 503, two legacy 301s, the demo-mode
403, and the login / onboarding / MFA redirects — returned before the security
header block, so they carried no HSTS, no framing refusal and no nosniff. Factor
the transport subset into a helper and apply it on every exit. The per-route CSP
stays where it is; it is nonce-bound and only meaningful for a response with a
body.
The WebAuthn relying-party candidate list always carried http://localhost:3000,
including in production. The signed rpIdHash still pinned the real domain, so
this was never a reachable bypass, but a configured production origin has no
reason to carry it. Gate the fallback on development, keeping it when nothing is
configured so getRpId() still resolves.
ORPHAN_ENUMS was a hand-maintained literal that drifted every time
ALLOWED_CHART_TOKENS grew — eighteen allowlisted metrics had no strip entry, so
a model writing 'Your PHQ9_SCORE is elevated' leaked the raw enum into rendered
prose. Derive it from the allowlist and pin the coupling with a test.
`classifyIntakeTiming` treated `windowStart === windowEnd` as an overnight
wrap, inflating a degenerate point window into a ~30h on-time band — a dose
taken many hours late still classified `on_time` and fed a perfect-compliance
streak. Widen the point window symmetrically instead, matching the treatment
`resolveLegacyWindowBounds` already applies.
`/api/measurements/batch` never applied the entry-instant plausibility bound
its single-entry twin has carried since v1.17 W1b, so a future-dated row landed
and permanently won 'latest reading' while poisoning the rollup buckets and the
canonical picker. Skip per entry, like the range guard. The past floor stays at
1900 — a historical backfill is a legitimate shape here.
The intake CSV import had neither an upper date bound nor a rate limiter, while
running up to 1000 sequential find + create + consume iterations per call. Add
both, on the bulk twin's bucket shape and budget.
The no-signal floors were the coldest text the product renders. They run when
there is nothing to ground against, and they opened on a bare order — "Measure
blood pressure at rest and under comparable conditions", "Evaluate mood trends
over several weeks" — so the one moment the surface has nothing to say was also
the one moment it told the reader what to do.
They now open on what the surface can honestly say: no assessment is available
right now. That is the meaning-first opening for this case, and it is the only
one available, because a floor with no signal may not imply a read. What
follows describes how the measure behaves rather than ordering the reader
around. The signal-grounded lines above them are unchanged.
The prompt version bump re-fingerprints the assessments cached under the old
biomarker prompt; the per-card input hash folds the constant in, so the
regeneration is one-time and paced by the nightly warm.
Backfill immunity is proved by replay, not by a hand-picked case: a ten-year
Apple Health export (12772 writes) and a 30-day provider re-sync (58 writes)
must both emit zero events, while a fresh sleep night, workout and first
weight of the day emit exactly one each.
The provider-isolation guard walks the worker's static import graph and fails
if any provider client or resolver becomes reachable. It carries two positive
controls, so a broken entry path cannot make it pass against an empty graph.
The integration test covers what unit mocks cannot: the unique index, the
ON CONFLICT DO NOTHING claim, the losing side of ten concurrent claims, the
forward-only marker, ciphertext at rest, the delete cascade, and the
retention window.
The German prompt-injection test asserted only inside `if (decision.refuse)`,
so it stayed green whether or not the filter fired. Un-guard it, drive it from a
case table, and widen the two German patterns to cover the determiners that
walked straight past them ("saemtliche", "die", "deine").
The Oura / Polar / Fitbit token helpers cast a possibly-null `json` to the token
type on a status-only success gate. A 2xx with an empty or non-JSON body handed
callers a null they dereferenced, surfacing an unclassified TypeError instead of
a handled integration error. Narrow explicitly and classify as transient.
Every ingest path in the product can now emit a typed, salience-classified
event into pg-boss, consumed by one worker that claims a durable per-day
reaction marker and fans out to the reaction surfaces.
The recency classifier runs at the seam, before anything is enqueued, so a
mass backfill — a multi-year Apple Health export, a provider re-sync, a
30-day catch-up — emits nothing at all. Non-salient writes are annotated and
dropped.
The worker makes no provider call by construction: it imports no provider
module, writes only markers, and enqueues nothing that is not budget-gated
downstream. Business refusals return a status rather than throwing, so a
refusal is never retried against a ceiling that will not move until the local
day rolls over.
Seams: sleep (one hook, inherited by all seven transports), workouts (the
batch route, the four provider syncs, the export importer), weight and blood
pressure (both measurement routes), labs (manual, OCR commit, document fact
commit).
The provider workout syncs could not previously tell a created row from an
updated one, so a re-sync of a recent session would have re-fired on every
poll; they now recover the distinction from the row the upsert already
returns.
Reaction lines are AES-256-GCM at rest, following the InsightNarrative
precedent, and the column is registered for key rotation. Markers are pruned
after 14 days by a daily maintenance job.
A deterministic grader over the assembled assessment prompts and the
deterministic fallback text. No provider, no network, no database — it runs in
the normal suite in under half a second, which is the point: the live-judge
eval needs a key and can only advise, so the regressions that actually shipped
need a check that can block.
Three classes are blocking. A surface stops leading with meaning (it drops the
shared opening contract, or its user prompt loses the meaning-first
instruction). A locale collapses (fr/es/it/pl fall back to the German body or
lose the reply-language directive). A prompt reintroduces a value-first
instruction — matched with negation handling, since the shared prompts quote
that failure mode in order to ban it.
The deterministic fallbacks are graded on the same axis, including the
asymmetry that matters: a signal with no baseline supports no confident read,
so that one line honestly opens on the value. Pinning it stops a future
"always lead with meaning" edit from pushing the surface into claiming
something the data does not support.
A coverage guard fails when an assessment prompt module has no harness entry,
so the next surface cannot stay uncovered the way the biomarker card did.
The three unit suites that stubbed `requireCookieAuth` / `requireFreshMfa`
directly now stub `requireMfaManagementAuth`, and the password-change suite
stubs the elevation revocation the route gained. Adds the unit checks that need
no database: the elevation TTL is pinned to the cookie path's step-up window so
the two cannot drift apart, the minted value is a prefixed CSPRNG secret whose
plaintext never reaches the row, and a value carrying another credential's
prefix is refused without costing a query.
The per-biomarker assessment was the last surface in the family building its
prompt inline: a five-line scaffold that skipped the base system prompt, took
no opener hint, and instructed the model to "state the current value" — so the
one card where a bare number reads most like a verdict was value-first by
instruction while every sibling had moved to meaning-first.
It now composes exactly as the seven bespoke cards do. The new prompt module
carries a METRIC section that states the one thing this card needs and the
others do not: its snapshot has no signal block, so the read comes from the
pre-computed range placement plus the direction across draws.
Also point both archetype-driven cards at a registered budget. The generic
metric card and the biomarker card each carried temperature/maxTokens inline
at 0.45/1000, so the registry that documents every surface's ceiling was
silently wrong about the two widest assessment surfaces.
The single-marker no-key fallback replaces the multi-metric overview tip the
card used to render — text about watching several metrics at once, on a card
showing one lab value.
Adds the structural guard that freezes the exact route set an elevation can
unlock, plus the behavioural suite that drives each threat case against a real
Postgres: a token alone mints nothing, an elevation minted for one token is
refused for another (same account included), a second redemption fails, a
concurrent redemption lets exactly one winner through, an expired one is
refused, an elevation never reaches requireAdmin, a wrong password is throttled
and audited without saying what was wrong, and the cookie flow is untouched.
The OpenAPI registry carries both new routes and names the accepting set in
prose, so the client implements against the contract rather than against a
description of it.
Second-factor management has been cookie-only since v1.23. The gate is right:
a Bearer token is a credential whose provenance the server cannot see, and
tearing down a second factor must not be reachable from the weaker of an
interactive login and a value pasted into a script. What was missing was never
Bearer access to those routes — it was a way to say, over a transport with no
session row, that the human holding this device just re-proved a factor.
An elevation is that proof. It is minted only against a re-proved account
password or primary passkey, bound to the exact token that re-proved, single-use
via a conditional UPDATE, five-minute-lived to match the cookie path's step-up
window, and stored hashed. A stolen token mints nothing; a second token cannot
redeem another's elevation; a password change drops every one the account holds.
requireMfaManagementAuth is the one gate the management routes share. On a
cookie session it delegates to the original helpers unchanged, so the web flow
runs the code it always did. requireAdmin stays cookie-only and out of reach.
2026-07-19 12:42:00 +02:00
3162 changed files with 446759 additions and 63102 deletions
# Opt-in for a plain-HTTP IP_GEO_LOOKUP_URL provider (e.g. free ip-api.com); off by default — looked-up IPs then travel unencrypted over your server's egress.
# IP_GEO_ALLOW_INSECURE="true"
#
# 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.
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
echo "::notice::Pull-request image validation does not publish release tags."
exit 0
fi
if [ "$GITHUB_REF_TYPE" != "tag" ] || [[ ! "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "::error::Release ref '$GITHUB_REF_NAME' is not a strict SemVer tag (expected vMAJOR.MINOR.PATCH with no leading zeroes or prerelease suffix)." >&2
echo "::warning::COOLIFY_AUTO_DEPLOY is not 'on' or 'off' — skipping auto-deploy. Set an explicit mode in repository variables."
exit 0
fi
if [ -z "$COOLIFY_WEBHOOK" ] || [ -z "$COOLIFY_TOKEN" ]; then
if [ "$mode" = "on" ]; then
echo "::error::COOLIFY_AUTO_DEPLOY=on but COOLIFY_WEBHOOK or COOLIFY_TOKEN secret is missing. Set both under repo Settings → Secrets and variables → Actions, or set COOLIFY_AUTO_DEPLOY=off to silence."
exit 1
fi
echo "::warning::COOLIFY_WEBHOOK or COOLIFY_TOKEN secret missing — skipping auto-deploy. Configure in repo Settings → Secrets to enable, or set the COOLIFY_AUTO_DEPLOY variable to 'off' to silence this warning."
exit 0
echo "::error::COOLIFY_AUTO_DEPLOY=on but COOLIFY_WEBHOOK or COOLIFY_TOKEN secret is missing. Set both credentials or disable promotion explicitly." >&2
exit 1
fi
# v1.4.31 — wait for GHCR cache propagation before firing
# the Coolify webhook. Five releases in a row (v1.4.27
# through v1.4.30.1) the webhook reported "finished" but
# Coolify still pulled the prior `:latest` digest. The
if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Maintainer: deploy manually on the deployment host (SSH force-pull recipe, see docs/ops/deploy.md)."
echo "::error::Coolify deploy webhook returned HTTP $status for verified image ${IMAGE_TAG}@${IMAGE_DIGEST}." >&2
exit 1
fi
echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, re-trigger the deploy from the Coolify UI or deploy manually on the deployment host (docs/ops/deploy.md)."
echo "Coolify deploy queued — awaiting outgoing notification webhook to /api/internal/deploy-webhook for final status."
echo "Coolify deploy queued for verified image ${IMAGE_TAG}@${IMAGE_DIGEST}."
- **Timezone**: `Europe/Berlin` for display, UTC in database.
- **Encryption**: Sensitive data (Withings tokens, API keys, VAPID private keys) encrypted with AES-256-GCM via `src/lib/crypto.ts`.
- **Dracula colors**: Use CSS variables `var(--dracula-purple)`, `var(--dracula-cyan)`, `var(--dracula-green)`, `var(--dracula-orange)`, `var(--dracula-pink)`, `var(--dracula-red)`, `var(--dracula-yellow)`, `var(--dracula-fg)`, `var(--dracula-comment)` for chart/graph elements.
- **Proxy route protection**: `src/proxy.ts` checks `healthlog_session` cookie on all non-public paths. Unauthenticated page requests → redirect `/auth/login`. API routes keep their own `getSession()` auth. Public paths defined in `PUBLIC_PATHS` array.
- **PDF generation**: Client-side via `src/lib/doctor-report-pdf.ts` using jsPDF. Data fetched from `POST /api/doctor-report`. European medical format with German terminology.
- **Achievements**: Persistent in `UserAchievement` table. API at `/api/gamification/achievements` computes current state, compares with DB, persists new unlocks. `unlockedAt` timestamp is stable once set.
## Critical Gotchas
These are hard-won lessons. Ignoring them will cause errors:
### Prisma 7
- **No `url` in schema.prisma** — The database URL is configured in `prisma.config.ts`, not in the `datasource` block.
- **Import path**: `import { ... } from "@/generated/prisma/client"` (with `/client` suffix, not `@/generated/prisma`).
- **Adapter required**: PrismaClient needs `PrismaPg` adapter from `@prisma/adapter-pg`. See `src/lib/db.ts`.
- **No `earlyAccess`** in defineConfig — causes TypeScript errors.
### Next.js 16
- **Proxy, not middleware**: Next.js 16 renamed middleware to proxy. The file is `src/proxy.ts` (not `middleware.ts`). Having both causes a build error: "Both middleware file and proxy file detected".
### Libraries
- **SimpleWebAuthn v13**: No `@simplewebauthn/server/script/deps` — define Transport type inline.
- **zxcvbn-typescript**: Default export only. `zxcvbnAsync` and `zxcvbnOptions` do not exist as named exports.
- **pg-boss v12**: `{ PgBoss }` named import (not default), use `localConcurrency` (not `teamSize`), handler receives `Job<T>[]` array.
- **Zod v4**: Import from `"zod/v4"`, not `"zod"`.
- **jsPDF**: Client-side only. Import dynamically in browser context. Used with `jspdf-autotable` plugin.
### Settings & Admin (per-route layout, v1.4)
- **Settings**: 8 routes under `/settings/[section]` — `account`, `integrations`, `notifications`, `dashboard`, `ai`, `api`, `advanced`, `about`. The legacy `/settings` 308-redirects to `/settings/account`. Sidebar deep-links and the `<a href="/settings#anchor">` patterns from 1.3 still resolve via the redirect.
- **Admin**: `src/app/admin/page.tsx` is now a 77-LOC shell that mounts the per-section components in `src/components/admin/`. Status-card grid lives in `status-card-grid.tsx`. The aggregator endpoint `/api/admin/status-overview` returns the six-card summaries in one batched query.
- ESLint `react-hooks/set-state-in-effect` is **strict** now (was non-blocking when the settings monolith carried inline-effect state-setters). Use lazy `useState(() => …)` for localStorage reads, TanStack Query for data fetches.
### Insights Page
- Large file (~1750 lines), 7 content sections with AI summaries.
- Sticky horizontal chip navigation (`InsightsSectionNav` component) with IntersectionObserver for active state tracking.
- Returned in `/api/auth/me` response as `gravatarUrl` field. Used by `AvatarImage` component with `AvatarFallback` for initials.
### Notification System
- **Channels**: `telegram`, `ntfy`, `web_push` — stored as plain `String` on `NotificationChannel.type` (no Prisma enum; the canonical list lives in `src/lib/notifications/types.ts`).
- **Event types**: `medication_reminder`, `measurement_anomaly`, `compliance_low`, `withings_sync_failed`, `system_alert` — also `String` on `NotificationPreference.eventType`. Source of truth is the discriminated union in `src/lib/notifications/types.ts`.
- **Opt-out model**: Preferences default to ON (enabled) when no `NotificationPreference` row exists.
Working notes for anyone (human or otherwise) editing this repository. Skim before the first PR; refer back when a convention question comes up. Pair with `README.md` (the public face) and `CHANGELOG.md` (the release history); this file is the maintainer's operating manual.
## What HealthLog is
Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. PolyForm Noncommercial 1.0.0 (AGPL-3.0 through v1.15.18). Current line: v1.20.x.
## Voice and privacy
Hard rules for every committed artefact — commit messages, CHANGELOG entries, PR bodies, code comments, GitHub release notes, docs site copy. Treat these as the project's public face.
- **Voice.** English. Maintainer prose: present-tense, imperative for commits, factual for prose. No marketing tone, no apology tone, no "wow what a cool feature" framing. The voice is one human writing notes for the next.
- **No assistant / tooling references.** Do not surface AI, agent, assistant, copilot, marathon, phase, wave, round, or session vocabulary into anything that lands on GitHub — including commit messages and trailers. Commits carry no `Co-Authored-By` tooling trailer; they are authored by the maintainer's git identity alone.
- **No personal data.** No real names (the maintainer's, a reporter's, or anyone else's). No live health figures, no live tenant identifiers, no real-account anchors. Generalise to "a self-hoster", "a user", "an operator", "an authenticated session", etc. The internal `.planning/*` files are gitignored and exempt; everywhere else in the repo, no.
If a draft commit message or release note would violate one of the three, rewrite it before committing. The `git log` is more durable than today's task.
## Branch and release model
`main` is the single trunk and is always releasable. There is no long-lived `develop` branch — the former one was retired and archived as the tag `archive/develop-pre-v1.5` (it carried no work absent from `main`; a long-lived integration branch has no role in a single-maintainer shop and only accrued back-merge debt). This is trunk-based development with release tags: short-lived branches off `main`, work always converges back onto `main`, and the immutable `vX.Y.Z` image built from a tag on `main` is the one source of truth — including for the OpenAPI contract the iOS client consumes.
- **Trunk.** `main` always builds, passes CI, and is deployable. Every commit on it is a release commit (`chore(release): vX.Y.Z`), a release-branch merge, or a maintenance commit (docs, gitignore, audit findings) applied directly.
- **A release** is built on a short-lived `release/vX.Y.Z` branch cut off `main` — built wave-by-wave, then opened as a PR `release/vX.Y.Z` → `main` with green CI, merged (merge-commit), and tagged `vX.Y.Z`. The tag push triggers `docker-publish.yml` (GHCR multi-arch + `:latest`). Deploy is manual (see `docs/ops/deploy.md`); after a deploy, verify `/api/version` (it returns `version` + `buildSha` + `builtAt`) on every target, since the `:latest` pull is the source of truth, not the queued deploy status. Delete the release branch after merge.
- **Small standalone changes** (a single fix, a docs touch) can go through a short-lived `fix/X` / `feat/X` branch + PR, or directly onto `main` when the maintainer chooses — `main` is the target either way.
- **Hotfixes** branch from `main` and merge straight back to `main`. There is no second long-lived line to forward-port to, so the old back-merge debt cannot recur by construction.
- Never force-push `main` or any release branch. Never skip hooks (`--no-verify`). Never `--no-gpg-sign` unless explicitly asked.
## Semver
Prefer patch bumps. A release dominated by bug fixes plus additive features without breaking changes ships as `vX.Y.Z+1`, not `vX.Y+1.0`. Bumping minor is reserved for breaking changes, removed flags, deprecated routes, or multi-feature milestones framed as such (e.g. v1.5 = native iOS client + Apple Health integration). Pre-existing example: a `v1.5.0` cut was rerouted to `v1.4.14` mid-flight when the diff turned out to be bugfix-dominated.
| Forms / validation | `react-hook-form` 7 + `zod` 4 + `@hookform/resolvers` + `zod-openapi` 5 | The Zod registry is the source of truth for `docs/api/openapi.yaml`. |
| Container | Multi-stage Alpine Dockerfile, GHCR multi-arch (`amd64` + `arm64`) | `pull_policy: always` in `docker-compose.yml` is load-bearing — without it Docker re-uses the stale `:latest` digest. |
More detail in `.planning/codebase/tech.md`, generated as part of the v1.5.2 codebase audit.
## How a request flows
```
client
│ HTTP
▼
src/proxy.ts renamed from middleware.ts. Public-path allowlist,
The architecture map in `.planning/codebase/arch.md` walks each layer with file:line citations.
## Code conventions
- **UI work follows the design standards.** `.planning/audits/2026-07-05-fable/UI-STANDARDS.md` is the binding design rulebook (card anatomy via TileHeader, spacing scale, text-colour rules — foreground for content, muted for meta only —, token discipline, primitive mapping). Read it before touching any surface; deviations need a written reason in the PR. Where the standards conflict with older in-file comments, the standards win.
- **RSC by default.** Server components are the default; add `"use client"` only when a hook, state, or browser API actually needs it.
- **Every API route wraps in `apiHandler`, with three documented exceptions.** The MCP OAuth routes (`mcp/oauth/{authorize,token,register}`) use `withBackgroundEvent` instead, because OAuth requires RFC-format error bodies that the standard envelope cannot express. They carry the same protections by hand — rate limit, body cap, kill switch, mandatory PKCE-S256, redirect-URI allowlist. Every other route wraps. Every body-accepting route runs Zod `safeParse` and returns 422 via `returnAllZodIssues` (multi-issue envelope sanitised against echoed input through `sanitiseZodIssues`).
- **`userId` is always narrowed from session or Bearer.** No route accepts `userId` as a body field — it comes from `requireAuth()` and feeds the Prisma `where`. The batch endpoints (`/api/measurements/batch`, `/api/mood-entries/bulk`, `/api/medications/intake/bulk`, `/api/workouts/batch`) don't even declare a `userId` field in their Zod schemas.
- **No mass assignment.** Every `prisma.X.{create,update}({ data: ... })` builds its `data` object field-by-field from `parsed.data`, never by spreading the parsed object whole. `tokens.create` builds `permissions` from a hardcoded literal, never from request input.
- **Bearer scope is fail-closed.** `requireAuth()` with no argument admits a cookie session or a wildcard (`["*"]`) token only — a narrow-scope token is refused with 403 and an `undeclared_scope` audit row. A route that means to accept a narrow token names the scope: `requireAuth(FHIR_READ_SCOPE)`. Adding a route therefore cannot silently widen an existing token's reach, because the default arm denies. Only the login / passkey / refresh paths mint `["*"]`; every user-facing mint is narrow, and narrow now means narrow. Two surfaces resolve a Bearer token outside `requireAuth` — `/api/ingest/medication` and `/mcp` — and that set is frozen by a structural test (`src/__tests__/bearer-scope-enforcement-guard.test.ts`).
- **`requireAdmin()` is cookie-only.** Bearer tokens — even with `["*"]` scope — cannot reach admin endpoints. This is a structural boundary in `requireAdmin()` (`src/lib/api-handler.ts`), not a runtime check that future code can soften.
- **Raw SQL is parameter-bound or whitelist-spliced.** Every `$queryRaw` uses tagged-template parameters. Every `$queryRawUnsafe` / `$executeRawUnsafe` either passes positional `$N` placeholders with an argument array OR splices a value already asserted against a closed enum + regex (see the whitelist-splice block in `src/lib/rollups/measurement-rollups.ts`, around the `$queryRawUnsafe` calls). Inline comments document the whitelist at the splice point.
- **TanStack Query keys live in the centralised factory.** `src/lib/query-keys/` (16 modules, `index.ts` barrel) is the only legal source of `queryKey` / `mutationKey` arrays. The in-repo ESLint rule `healthlog/queryKey-factory` (set to `error`) flags any bare array. Same-key + different `queryFn` shape silently poisons the cache; the factory prevents it. Every read unwraps `(await res.json()).data` from the envelope.
- **`annotate()` on every interesting code path.** Wide-event observability. Action names follow `<surface>.<noun>.<verb>` (`coach.budget.exceeded`, `measurement.batch.ingest`). Don't free-text in `meta` — pin the shape so dashboards stay stable.
- **i18n keys exist for every `t()` call.** `i18n-call-site-coverage.test.ts` walks every `.ts(x)` under `src/`, extracts every `t("ns.key")` literal, and asserts each key resolves in `messages/en.json`. `i18n-locale-integrity.test.ts` propagates the EN guarantee across `de / en / es / fr / it / pl`. If a guard fails, fix the bundle, don't suppress the test.
- **OpenAPI registry stays in sync.** Zod schemas carry `.meta()` annotations under `src/lib/openapi/`; `pnpm openapi:generate` emits `docs/api/openapi.yaml`. CI fails on drift. Re-run the generator after touching a request / response schema and commit the YAML alongside the Zod change.
-`pnpm typecheck` — `tsc --noEmit`. Required before every commit.
-`pnpm lint` — ESLint flat config with `eslint-config-next` + the project's `healthlog/queryKey-factory` rule. One harmless warning in `withings/resume/__tests__/route.test.ts` is allowed; everything else should be clean.
-`pnpm test` — Vitest 4 unit + component suite. Required before pushing a fix; full suite is fast (~30 s).
-`pnpm test:integration` — Vitest against a `testcontainers/postgresql` Postgres 16. Needs Docker / OrbStack running. Slower but pins the contracts the unit-mocks can't.
-`pnpm e2e` — Playwright against `pnpm exec next build` + Postgres. Heavy; CI runs it, locally only when touching UI flows.
-`pnpm openapi:generate` — regenerates `docs/api/openapi.yaml` from the Zod registry. Run after any request / response schema change. CI's `openapi:check` fails the build on drift.
-`pnpm check-env` — validates that `.env.production.example` covers every required variable. Wired into CI as `env-check.yml`.
-`pnpm bundle-report` / `ANALYZE=1 pnpm build` — bundle inspection. Use when a feature adds material weight.
- One-shot scripts live in `scripts/`. Run them via `pnpm dlx tsx scripts/<file>.ts` — the production standalone image strips `tsx` and the bare `pnpm tsx ...` invocation fails inside the container. Recurring tasks belong on pg-boss, not on a CLI script.
| Session | `src/lib/auth/session.ts` + `src/lib/auth/secure-cookie.ts` | Postgres-backed sessions, sliding 30-day expiry, `shouldEmitSecureCookie()` the one source of truth for the `Secure` flag (every cookie setter routes through it). |
| Refresh tokens | `src/lib/auth/refresh-token.ts` | Per-device one-time-use rotation with reuse-detection that revokes the device's token family. |
| Bearer tokens | `src/lib/auth/hmac.ts` + `src/lib/auth/issue-token.ts` | HMAC-SHA256 hashing under `API_TOKEN_HMAC_KEY`; no plaintext path; `lastUsedAt` updated fire-and-forget. |
| Rollup tier | `src/lib/rollups/` | DAY / WEEK / MONTH / YEAR pre-aggregations. Read-swap pattern: try the rollup, fall back to live SQL only on coverage miss. Boot-time `rollup-full-backfill` queue handles new accounts. |
| Compliance | `src/lib/analytics/compliance.ts` | Cadence-aware medication compliance — daysOfWeek + intervalWeeks honoured across the eight call sites that surface a rate (Coach prompt, BP-status gate, dashboard pillar, …). |
| AI providers | `src/lib/ai/provider.ts` (`resolveProvider`) + `src/lib/ai/provider-chain.ts` (`PROVIDER_CHAIN_TYPES`) + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is excluded structurally by the `PROVIDER_CHAIN_TYPES` allowlist — production cannot reach it. |
| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes/` (23 modules, `index.ts` barrel) | Source of truth for `docs/api/openapi.yaml`. |
| Schema | `prisma/schema.prisma` (80 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. |
`.planning/codebase/arch.md` carries the full annotated walk for every section.
## Security-relevant patterns
The v1.5.2 audit produced `.planning/security-audit-v1.5.2.md` and a stack of per-domain findings under `.planning/security-audit-findings/`. The patterns worth preserving on future churn:
- **Encryption: fail closed everywhere.** Missing keys, malformed JSON, unknown key ids — every path throws rather than silently writing plaintext. Operators rotating keys must keep the legacy entry in `ENCRYPTION_KEYS` until the rotation script reports zero remaining legacy rows. The script covers every `*Encrypted` column + `CoachMessage.encryptedContent` (Bytes) + `IntegrationStatus.lastError`.
- **SSRF: input-time `isPublicUrl` is the floor.** The helper `isPublicUrl()` in `src/lib/validations/notifications.ts` catches every IPv4 / IPv6 alt-notation class (octal / hex / decimal IPv4, IPv4-mapped IPv6, IPv6 ULA, CGNAT, link-local, loopback, metadata). Use it for every outbound URL that comes from a user / admin input. DNS rebinding and the redirect-follow gap remain as architectural follow-ups (issues #217 + #218). Until those land, every NEW outbound `fetch()` that touches a user-controlled host must pin `redirect: "manual"` and an `AbortSignal.timeout`.
- **Outbound egress goes through `safeFetch`.** `src/lib/safe-fetch.ts` is the one documented egress entry (`redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults; opt into the connect-time DNS-rebinding pin with `requirePublicHost: true` for any operator- or user-supplied host). The in-repo ESLint rule `healthlog/safe-fetch-required` (set to `error`) bans raw `fetch(` under `src/lib/` + `src/app/` outside the wrapper internals and test files; same-origin relative-path (`/api/…`) client fetches are exempt by construction.
- **Rate limits live in Postgres.** `src/lib/rate-limit.ts` uses a single atomic SQL upsert returning `{count, reset_at}`. Multi-instance correctness is structural. Bucket-key convention: `<surface>:${identifier}` where identifier is `userId` for authenticated routes and the trusted-proxy IP for anonymous. Anonymous auth surfaces use `checkAuthSurfaceRateLimit(...)` so a trust-violation collapses every anonymous caller into a single tight bucket rather than a free-for-all under the unknown-IP bucket.
- **Webhooks: shared-secret + `timingSafeEqual` + per-source rate limit BEFORE secret verification.** Withings, Telegram, moodLog, the Coolify deploy webhook, and the CSP report endpoint all follow this exact pattern. The Withings path-segment secret is scrubbed from `http.path` / `http.route` by `redactSecrets()` via the `PATH_SECRET_PATHS` registry in `src/lib/logging/redact.ts:40`.
- **Idempotency keys are user-scoped, length-bounded, and refuse to cache secret-shaped responses.** `src/lib/idempotency.ts`. A client cannot replay another user's idempotency key (composite unique index by `userId_key_method_path`). The cache helper refuses to persist a response body containing `hlk_` / `hlr_` / `sk-` patterns even if a future caller wraps an auth endpoint by mistake.
- **CSP: strict, per-request nonce, surgical `connect-src`.** Production CSP gates third-party hosts to the routes that need them — AI hosts only on `/settings/ai/*`, `wbsapi.withings.net` only on `/settings/integrations/withings/*` + `/api/withings/*`. Exactly one `dangerouslySetInnerHTML` in the tree (the theme bootstrap in `src/app/layout.tsx`, nonce-bound, file-literal content). No markdown library installed anywhere; Coach / briefing / insights render as plain React text children. Don't add one without a security review of the renderer's HTML-allow defaults.
- **No CORS headers anywhere.** Same-origin-only by construction. Adding `Access-Control-Allow-Origin: *` anywhere would silently open every authenticated endpoint to cross-origin POST; webhook routes accept cross-origin POSTs but they authenticate by header secret, not cookie.
- **Redaction at the egress boundary, not at the call site.** `redactSecrets()` + `redactSensitiveFields()` + `redactOptional()` are applied centrally in `WideEventBuilder` and `reportToGlitchtip`. Don't add new redactor calls in handler bodies — extend the central denylists in `src/lib/observability/redact-payload.ts` and `src/lib/logging/redact.ts`.
## Self-hosting gotchas
These keep tripping operators; they're worth knowing before you push something that breaks them.
- **`SESSION_COOKIE_SECURE`** controls the `Secure` flag on every cookie the app issues. Unset → flag tracks `NODE_ENV === "production"`. Set to `false` for LAN / Tailscale / VPN-only HTTP self-hosts where the operator deliberately serves plain HTTP. Set to `true` to force the flag even in dev. The compose `environment:` block must list it explicitly — vars not on the whitelist don't reach the container even when `.env` has them. v1.5.2 closed the missing-whitelist regression.
- **`pull_policy: always`** in `docker-compose.yml` is load-bearing. Without it Docker keeps a stale `:latest` digest cached and silently skips the registry round-trip on `compose up`.
- **`ENCRYPTION_KEYS` map vs legacy `ENCRYPTION_KEY`.** The map (`{"v1":"<hex>","v2":"<hex>"}` + `ENCRYPTION_ACTIVE_KEY_ID=v2`) is the modern path. The legacy single-key env var stays accepted as `v1`. Rotation playbook lives at `docs/ops/encryption-key-rotation.md`; do NOT drop a key from the map before the rotation script reports zero remaining rows on that key.
- **GeoLite2 databases.** Operators run `scripts/fetch-geolite2.sh` with a MaxMind licence key before `docker build`; the image bakes the MMDBs into `/opt/geolite2/`. Without them the resolver falls back to online `ipwho.is` and the admin login overview surfaces "unknown" for offline lookups.
- **APNs `.p8` key.** v1.4.47.x established `APNS_KEY_B64` as the env-var contract — pasting the PEM body verbatim through Coolify's `env_file` mangled the literal newlines. The key must be scoped "Sandbox & Production" in the Apple Developer Portal; a Sandbox-only key fails with `BadEnvironmentKeyInToken` on production builds.
## iOS handoff
The native SwiftUI client lives in a separate repository and rides public beta via TestFlight. Backend contract the iOS app speaks against is locked in `docs/api/openapi.yaml` and has been continuously validated since v1.4.23. Coordination notes worth knowing on the backend side:
-`POST /api/measurements/batch` recognises `externalId` values starting with `stats:` (e.g. `stats:HKQuantityTypeIdentifierStepCount:YYYY-MM-DD`) as **overwrites** — a re-post replaces the row's value / unit / measuredAt / externalSourceVersion / deviceType / sleepStage. Every other externalId prefix (`uuid-*`, opaque HK identifiers) keeps the first-write-wins immutable contract because each sample is a canonical reading. Per-entry status field carries `"updated"` for an overwrite vs `"inserted"` for a fresh row.
- Refresh-token rotation is per-device; reuse-detection revokes the family scoped to that device.
-`requireAdmin()` is cookie-only by construction — a Bearer token, even with `["*"]` scope, cannot reach `/api/admin/*`. The iOS app is not an admin client.
- The `clientManaged` opt-in on `PATCH /api/auth/me/notification-prefs` suppresses server-side `MEDICATION_REMINDER` APNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag is `true` and emits a `medication_reminder.suppressed_client_managed` wide-event annotation per skip.
-`push_attempts` table holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at `/api/admin/notifications/diagnostic` surfaces masked device tokens + channel state + last 20 attempts for the calling user.
- Any request / response schema change must regen `docs/api/openapi.yaml` and commit the result. CI fails on drift.
- The iOS app signs in via the native OIDC SSO handoff **or** password/passkey. The native SSO leg: the app opens `GET /api/auth/oidc/login?client=native&code_challenge=<S256>` in an `ASWebAuthenticationSession`, the callback returns to the fixed compiled-in scheme `healthlog://oidc-callback` with a one-time `code=hlh_…` (or an `mfa_ticket` when the account has a second factor), and the app exchanges the code at `POST /api/auth/oidc/native/token` for the standard native bundle (`src/lib/auth/oidc-native-handoff.ts`). The `healthlog://` scheme is a compile-time constant — no `redirect_uri` param, no env allowlist (the open-redirect class is removed by construction). The token pair NEVER rides a URL; only the opaque handoff code / MFA ticket do. The SSO login can never satisfy `requireFreshMfa` step-up — the native transport is Bearer and `requireFreshMfa` is cookie-only, and even the web path sets `mfaVerifiedAt` only from the app's own factor. `OIDC_ONLY=true` is now supported for iOS but requires the app build that ships this flow; an older password/passkey-only build is locked out. The MFA-verify and `/api/auth/refresh` routes that complete/rotate an OIDC login are deliberately not OIDC-only-blocked.
## DO-NOTs
- **No `--no-verify` on commits.** If a pre-commit hook fails, fix the cause; never bypass.
- **No `--no-gpg-sign` unless explicitly asked.**
- **No force-push to `main`** (or to release branches, by extension).
- **No destructive git operations as shortcuts.** `git reset --hard`, `git clean -fd`, `git checkout .` etc. only when the maintainer asked or the state is unambiguously local-only.
- **No assistant / planning vocabulary in any committed artefact** — see Voice and privacy above.
- **No personal names** — the maintainer's, a reporter's, or anyone else's — in any committed artefact, including code comments referring to "the user who reported issue #X".
- **No secret-shaped strings in `.env.example` or git history.** Pre-existing scan shows clean.
- **No markdown library in dependencies.** Coach + briefing + insights render as React text children for XSS reasons; adding one is a security decision, not a UX one.
- **No `Access-Control-Allow-Origin` headers** anywhere except the documented webhook surfaces — and those authenticate via header secret, not cookie.
- **No `pnpm tsx scripts/foo.ts` in production.** The standalone image strips `tsx`. One-shot scripts run via `pnpm dlx tsx`; recurring tasks belong on pg-boss.
- **No backwards-compatibility shims for hypothetical callers.** When a change is clean, make it cleanly; do not leave dead branches "in case" something we don't ship needs them.
| Maintainer working notes | `.planning/PROJECT.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` (gitignored release scratch lives alongside; the patterns are in `.gitignore`) |
| Personal preferences + collaboration memory | `~/.claude/projects/-Users-marc-Projects-HealthLog/memory/MEMORY.md` (local to the maintainer's working copy; not in the repo) |
- **NEVER read `src/generated/**`** — Prisma-generated client (~9 MB, 83 files incl. a 224 KB+ inline schema). Reading/grepping it explodes the request context → 10-min "cogitations" + `ECONNRESET`on large turns. Regenerate via`pnpm prisma generate`if needed; never open it. (Enforced via`.claude/settings.local.json` Read-deny.)
- Avoid bulk-reading other large artifacts: `pnpm-lock.yaml`, `CHANGELOG.md`, `messages/*.json`, `docs/api/openapi.yaml` — grep narrowly instead of full reads.
- For very large multi-agent actions: cap concurrent sub-agents (many simultaneous streaming requests + big context is the worst combo for connection resets). When on a VPN (Tailscale MTU 1280 / iCloud Private Relay), large requests can hit MTU black-holes — disable the tunnel for heavy sessions.
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project
HealthLog — a personal health-tracking PWA (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first design, offline caching, and doctor report PDF export.
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.
- **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
- **Doctor report PDF**: rendered from`src/lib/doctor-report-pdf-core.ts` (jsPDF), the same module in the browser and on the server; the server side is the clinician share link's `/c/[token]/report.pdf`. 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`
@@ -64,7 +64,7 @@ For more details, see the [security documentation](https://docs.healthlog.dev/se
HealthLog Docker images are built and published from this repository's CI:
- **Source**: [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) — runs on every push to `main` and every `v*` tag
- **Source**: [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) — publishes on every `v*` tag; pull requests build the image without publishing it
- **Architectures**: `linux/amd64` and `linux/arm64`
- **Provenance attestation**: each image carries a [SLSA build provenance](https://slsa.dev/spec/v1.0/provenance) statement linking it back to the GitHub Actions run, the commit SHA, and the workflow definition
"$comment":"Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Measured at v1.27.15 + the mobile/PWA fix wave: / 417, /insights 402, /measurements 405, /insights/mood 419, total 2683, baseline 129 — budgets carry ~10-15% headroom so the gate catches step changes (a statically imported ~100 KB catalog, a ~90 KB duplicated library chunk), not noise. Raise a number consciously, in the PR that pays the cost, with the reason in the PR body.",
"$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. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged.",
| `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. |
| `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. |
`search` + `fetch` are the de-facto two-tool retrieval convention and the
**only** tools ChatGPT calls in its default (non-Developer) mode. Each
@@ -98,6 +99,35 @@ Every read tool is annotated read-only / non-destructive / idempotent /
closed-world — the read-only guarantee is structural (only read tools are
registered for a read session), and the annotation merely advertises it.
### Naming a metric or a nutrient
The tools that take a free-text name — `compare_metric`,
`get_metric_baseline`, `detect_changepoints`, `get_nutrients` — accept the
metric's or nutrient's own display name in **any language HealthLog ships**
(de / en / es / fr / it / pl), alongside the internal code and the English
aliases. `poids`, `Blutzucker`, `Fer` and `Żelazo` reach the same series as
`weight`, `blood_glucose` and `iron`. Case, accents and punctuation are
folded, so `magnésium` and `MAGNESIUM` are one word.
The accepted names are read out of the app's own message bundles rather
than listed in the resolver, so a language works as soon as its bundle
ships. Two consequences worth relying on:
- **The vocabulary is still closed.** A word in no bundle is a miss, not a
guess: `{ present: false, reason: "unknown_metric" }` or
`"unknown_nutrient"`. That reason is distinct from `"no_data"`, which
means the name WAS understood and the record honestly holds nothing.
Widening the accepted spellings moves words from the first answer to the
second; it never turns absence into data.
- **An ambiguous word is refused, not resolved.** Where a language uses one
phrase for two different measurements — French and Italian call both body
fat and fat mass `masse grasse` / `massa grassa`, and French, Italian and
Polish use one phrase for fat-free mass and lean body mass — the word
resolves to neither. Name those in English, or by their metric key.
Responses are unchanged: labels and units come back in protocol-level
English whatever language the request used.
### Clinical signals
The v1.25 clinical-signals set — **grip strength**, **pain (0–10 NRS)**,
<titleid="title">HealthLog data flow — device to insight</title>
<descid="desc">Five-stage pipeline: sources (Withings, Apple Health export, iPhone HealthKit, manual entry, moodLog.app) feed ingest endpoints, which write to Postgres Measurement and MeasurementRollup tables, then surface on Dashboard tiles, Insights cards, the AI Coach, and the Doctor PDF.</desc>
<descid="desc">Five-stage pipeline: sources (Withings, Apple Health export, iPhone HealthKit, manual entry, Nightscout) feed ingest endpoints, which write to Postgres Measurement and MeasurementRollup tables, then surface on Dashboard tiles, Insights cards, the AI Coach, and the Doctor PDF.</desc>
| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. |
| `OPENAI` | <https://platform.openai.com/api-keys> | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic |
| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
| `OPENAI`| <https://platform.openai.com/api-keys> | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
| `ANTHROPIC`| <https://console.anthropic.com/settings/keys> | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic |
| `LOCAL`| None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
| `OPENAI_COMPATIBLE` | Your gateway's key, or none | none (you name it) | OpenAI-compatible URL you configure | Reaches whatever your gateway routes to |
| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
The four spellings above are the canonical `User.aiProvider` enum
The five spellings above are the canonical `User.aiProvider` enum
values. Anything else falls through to the admin-shared OpenAI key
(if configured) or the `NoProvider` stub that surfaces "No AI
provider configured" to the UI.
@@ -54,6 +55,11 @@ order. The default is encoded in
4.`local` — self-hosted Ollama / LM Studio / vLLM endpoint.
| `<Workout>` | `Workout` rows | Activity type + duration + energy + distance. |
| `<Correlation>` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. |
| `<ClinicalRecord>` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. |
| ECG CSV | `EcgRecording` rows | The source-provided waveform is encrypted before persistence; descriptors and the device's classification are stored without diagnostic inference. |
The HK quantity types that map to HealthLog metric types live in
`src/lib/measurements/apple-health-mapping.ts`. Anything outside the
mapped set (workout routes, ECG voltage traces) is counted in
`progress.records_skipped` but not stored.
mapped set (for example workout routes) is counted as unsupported and
not stored.
### ECG archive safety, privacy, and idempotency
ECG support is source-agnostic: if HealthKit includes a valid
`HKElectrocardiogram` CSV, HealthLog can import it regardless of which
compatible device originally wrote it. Archive member names are never
used as filesystem paths and ECGs are never extracted to plaintext
temporary files.
The auxiliary reader enforces limits before and during decompression:
- at most 20,000 total archive members and 2,000 ECG CSV members;
- at most 16 MiB per decompressed ECG CSV and 512 MiB across all ECG CSVs;
- at most a 200:1 declared compression ratio; and
- at most 2,000,000 waveform samples per recording.
Encrypted ZIP members, unsupported compression, unsafe or duplicate
paths, forged size metadata, malformed timestamps or samples, and
limit overruns are rejected. An ECG failure is fail-soft: valid
`export.xml` measurements and workouts still commit, while the
terminal result reports only
`discovered/imported/updated/skipped/failed` ECG counts. It never
returns filenames, source labels, sample values, or raw parser errors.
Waveforms are converted to integer microvolts and encrypted with the
existing AES-256-GCM ECG codec before the transaction writes an
`EcgRecording` with `source=APPLE_HEALTH`. Stable identity is derived
from normalized recording content rather than the CSV filename, so a
renamed re-export updates the same user-scoped row while distinct
recordings and different users remain independent. Known Apple
device classifications are preserved as source-reported facts;
unknown or localized classifications are stored as `null`. HealthLog
does not inspect the waveform to infer AFib or make a diagnosis.
### Cumulative HK type → daily aggregate
Cumulative quantity types — steps, active energy, walking/running
distance, flights climbed — get **collapsed into one row per
user-local day** to match the iOS daily-aggregation convention.
distance, flights climbed, time in daylight, and fall count — get
**collapsed into one estimated row per user-local day**. `export.xml`
contains source-specific records, not HealthKit's canonical daily
statistics, so the importer does not sum different devices or apps
into a source-blind total.
- Records are grouped by type, local day, and a SHA-256 hash of the
available source name, source version, and device attributes.
Values sum within each identified source-day; the largest
source-day subtotal is selected deterministically. If every source
attribute is absent, records remain separate rather than being
trusted as one source. Raw source and device labels are not
persisted.
- The row records `EXPORT_XML_SOURCE_MAX` provenance, its distinct
contributor count, and the selected non-identifying source hash.
- External ID format: `stats:<HKType>:<YYYY-MM-DD>` (e.g.
| 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. |
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
caller is refused before anything else happens and told to use a browser.
Accepting it has no such restriction. Manage never carries a section
scope, because "they can do anything, but only to part of you" promises a
boundary an edit cannot keep.
Everything a manager does is written to the trail under their own name,
with a verb naming the act rather than a generic "made a change".
**Nothing about the account around the record.** This is the line the
whole feature is built on, and it is drawn on the server:
| Password, passkeys, second factor, recovery codes | Silent takeover, surviving any revocation |
| Sessions, trusted devices, sign-out-everywhere | Ending or enumerating the owner's logins |
| 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 |
| 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 |
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
with no login" below for exactly which part of it a guardian may reach.
The last two rows are worth a sentence each. Sharing is not
transferable: somebody given access cannot invite a third person, cannot
raise their own level, and cannot hand it on. And display preferences
stay with the person at the keyboard, so reading somebody else's record
does not change your own theme, language or dashboard.
The AI row is the one that looks arbitrary and is not. Server-managed AI
features send the record's data to a provider under a consent the record's
OWNER gave, for their own use. Somebody else triggering that would create
a consent-shaped act the owner never made. So a manage grant can READ
insights that were already generated, and nothing a delegate does at any
level causes an outbound provider call or queues a generation job. The
owner's own path is unchanged and is the control that proves the
difference.
These are not checks that a page forgot to render. Every route in the
product refuses by default while a browser is acting on another account;
a route becomes reachable only by declaring it, and the set of declared
routes is frozen by a test.
## Revocation, and what it cannot undo
Ending access takes effect on the other person's **next request**, not at
their next login. The grant is checked against the database on every
single request, so there is no cached verdict to wait out. If their
browser is sitting inside the record when the owner ends it, the same
database transaction puts that browser back into its own account — they
land at home rather than on a wall of refusals.
What revocation cannot do is take back what was already seen. A person
who had read access could have read, remembered, screenshotted or written
down anything in the record. The product can close the door; it cannot
reach through it. Nothing in the interface implies otherwise, and neither
does this page.
Ending access never deletes the record of it. The row stays with the
dates and with who ended it, because "who had access, from when to when,
and who ended it" is a question a deleted row cannot answer. Re-inviting
the same person creates a new row beside the old one.
## Records with no login
A **managed profile** is a health record for somebody who does not sign
in: a child, or an adult who is cared for. It is a real account row with
no password, no passkey and no e-mail address, and it is never presented
as something to log in as.
- It is created from **Settings → Shared access**, under a fresh second
factor. An account with no second factor enrolled is refused rather than
waved through, because the act creates a permanent management
relationship over a person who cannot object to it.
- Creation takes a display name, optionally a real date of birth, and the
language and timezone the record's own days and reminders are measured
in. A year on its own is never turned into the first of January. The
language belongs to the record, not to the guardian reading it, so the
guardian's own screens stay in their own language.
- The creator becomes the first **guardian** in the same transaction, over
an ordinary accepted MANAGE grant with the profile as grantor. If either
half fails, neither happens: there is no window in which the profile
exists with nobody looking after it.
- A second guardian is invited and accepts exactly like any other
invitation. An accepted guardian grant does not expire; the invitation
token still can.
- **A managed profile can never be left guardian-less.** Revoking,
handing back, deleting a guardian's own account and deleting the
profile all take the same lock on the profile and re-check the count
inside it, so two people cannot both leave at once. The last guardian is
refused, with a message naming the two ways out: add another guardian,
or delete the profile under a fresh second factor. Deleting the profile
deletes everything recorded for it.
- A guardian may keep that record's settings: modules, units, thresholds,
its language and timezone, and its notification preferences. Connected
services are visible as a status only. Connect, callback, sync,
credential, disconnect, resume and test are not guardian surfaces in
this release, and their deep links land on the shared-record unavailable
page rather than on a raw refusal.
- **Its reminders go to its guardians.** A record with no login has no
channels of its own, so medication reminders, measurement reminders,
safety-floor alerts and low-stock alerts fan out to every active
guardian, in that guardian's own language and over the channels that
guardian already configured. Nothing else fans out; every other event
stays on the path it was already on. The payloads carry no take or skip
action, on any channel, so a notification about somebody else's record
cannot be acted on from a lock screen. One guardian suppressing
server-side reminders on their own device does not suppress anybody
else's.
- Delivery attempts record both the record the message is about and the
person it was sent to, so the admin notification diagnostic can answer
"who was this for" and "whose record was it about" separately.
There is no emancipation flow: no route, no control and no copy suggests
a managed profile can be turned into an ordinary account. The data model
leaves room for it (the credentials are nullable and the managed marker
can be cleared) and clearing that marker stops the notification fan-out,
but nothing in v1.37.0 exercises any of that.
## Seeing what happened
**Settings → Shared access** answers three questions for the owner, at
any time: who can open this record, since when, and when they last did.
Below that, a list of the days somebody else opened it and how many times
— one line per person per day, rather than a line per request.
That list is bounded by the instance's audit retention, and the page says
so with the actual number rather than a hardcoded one. The default is 365
days (`AUDIT_LOG_RETENTION_DAYS`); an operator who shortens it shortens
this view too, and the sentence under the heading changes to match. The
query is bounded to that window, so the sentence is the real limit of the
list rather than a caption over an unbounded one.
Two things the list does not claim, both worth reading before treating it
as an answer:
- **An empty list is not proof that nobody opened the record.** It means
nobody did so within the window, and that any earlier row has been
purged.
- **The list is capped.** It returns at most the hundred most recent rows
inside the window; when it hits that ceiling it says so under the
heading. A busy household can reach the cap in far less time than the
retention window, and the oldest line on screen is then the oldest line
shown, not the oldest that exists.
## What this does not change about the instance
Stated plainly, because a sharing feature is exactly the moment somebody
re-reads the trust model:
- **The operator could always read the database.** Anyone with server or
database access, and anyone with an admin account on this instance, can
reach health data directly. That was true before sharing existed and is
unchanged by it. On a household instance the operator is usually one of
the people using it; on an instance hosting other people, it is worth
saying out loud.
- **Encryption at rest is instance-keyed, not per-user.** The encrypted
columns are protected by the key in `ENCRYPTION_KEYS`, which belongs to
the instance. Sharing does not weaken that, and it never did protect one
account's data from the operator of the same instance.
- **AI processing stays under the record owner's consent**, because the AI
surfaces are not part of what sharing covers.
## For operators
Nothing to configure. Sharing is available on every instance, needs no
environment variable, and does nothing until one account invites another.
The same is true of managed profiles.
Four operational notes:
- **Invitations disclose whether a username exists.** An invitation to a
name nobody uses is refused with "no account with that name". That is
deliberate: the alternative is a silent pending row that will never be
accepted because the owner mistyped. The caller is already
authenticated and the endpoint allows ten invitations an hour per
account, which bounds how fast the surface could be walked.
- **Deleting an account** removes the grants it is party to. The audit
rows naming that account as the one who opened somebody's record stay,
and render as "a deleted account" — nulling them would turn "your
daughter opened your record" into "you opened your record", which is
worse than an unresolvable id. Deleting the last guardian's account is
refused while the managed profile would survive it, so an account
deletion cannot strand a record.
- **A managed profile is a user row and appears as one.** It is listed in
the admin user list like any other account, with no e-mail address
beside it and an internal username nobody is ever asked to type. No
login path will accept it: there is no password hash to verify against
and no address to look it up by.
- **The second-factor requirement is not the same on both features, and
the difference is deliberate.** Creating a managed profile, deleting
one and inviting a second guardian require a second factor
unconditionally: an account with none enrolled is refused and told to
set one up, because those acts create or end a permanent relationship
over somebody who cannot object. Offering manage access on an ordinary
invitation asks for a fresh factor only from an account that has one, so
an instance where nobody has enrolled a factor can still use every level
of ordinary sharing. All of them are browser-only either way.
- **`utils/`** — fixtures such as `mock-dashboard-snapshot.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.
## Convention
## Conventions
Import `test` and `expect` from `./setup/test`, never from `@playwright/test` — types (`Page`, `Route`, …) still come from the package. The entry point extends Playwright's `test` with an automatic fixture that drops route handlers and page listeners after every test, so a handler left in flight cannot fail the test it belonged to once the context closes. `src/__tests__/e2e-route-cleanup-guard.test.ts` enforces the import boundary.
Assert against stable `data-*` attributes (`data-slot`, `data-display-step`, root display attrs), not viewport-dependent visible text. Responsive `sm:hidden` UI changes break `getByText` on desktop while the underlying element is still present — anchoring on data attributes keeps the specs stable across breakpoints. See [`../CLAUDE.md`](../CLAUDE.md).
`an intentionally 1500px-wide block did not widen main's scroll area — main overflow-x:${state.overflowX} overflow-y:${state.overflowY}, canary width ${state.canaryWidth}, inside main: ${state.canaryInMain}`,
Loaded 100 of 3162 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.