The card read like API documentation sitting in a settings page: it named
the Authorization header, spelled out both endpoint paths, and listed the
scope's limits as four clauses. None of that is what someone deciding
whether to mint a token needs, and the endpoint paths were already in the
API endpoints card immediately above it.
Rewrite to the shape the MCP connector card next door uses — a short
title, one line on what the token is for, one line saying it is shown
once, and one sentence on what it can and cannot do. Drop the endpoints
note entirely rather than rephrase it, since the card above owns that.
Say the useful consequence instead of the mechanism: readings arrive as
if entered by hand, so they can be corrected. That is the thing a person
will actually notice.
Translations were revised across all seven locales — the previous German
in particular read as translated English rather than as the rest of the
app, which uses short informal sentences.
Since v1.30.17 there has been no self-service path to a token that can
write a measurement. That release made the Bearer scope default
fail-closed, which closed a real privilege-escalation hole, and removed
the generic mint at POST /api/tokens along with it. The generic mint had
been the only way to get a token that could reach /api/measurements, so
a local sensor pushed through Home Assistant or Node-RED lost its door
while the OAuth integrations kept theirs.
Add a narrow scope accepted by exactly two routes, both writes, both on
the holder's own record.
The scope constant is a leaf module beside the surface that consumes it,
matching FHIR_READ_SCOPE and the MCP scopes. requireRecordAuth grows an
options object rather than a sibling function, because the sharing
surface guard keys every leg on that one identifier and a second name
would mean two copies of the delegable declaration. AuthContext carries
the admitting scope as a single nullable string rather than the token's
permission array, so nothing downstream can re-derive an authorisation
decision from it.
A scoped credential is refused a record selector before any grant is
read. Placing the refusal above the sharing check rather than beside it
means no future widening of what a grant permits can reach one of these
tokens.
Rows are attributed MANUAL. An entry that explicitly names APPLE_HEALTH
is refused 422 rather than relabelled: that source is half the
(userId, type, source, externalId) dedup key the iOS client writes into,
it participates in the cross-source merge, and it gates the HealthKit
sync fields. For the same reason a scoped write no longer advances
User.lastSyncedAt, which the phone reads to size its undelivered window.
Also closes a narrower gap found while building this. The idempotency
wrapper resolved a replayed delegated response against a live grant
without considering which credential was presenting it, so a cached
response about a shared record could be returned to a token the handler
itself would refuse. No write was reachable that way; the response body
was.
Guards: T3 gains the mint site, T4's vocabulary gains the scope and
learns to read the requireRecordAuth form (without which this change
would open a way to widen a route that the closed vocabulary could not
see), and a new T5 freezes the set of delegable routes naming a scope.
The sharing surface guard's AST matcher reads only the first two
arguments, so it needed prose but no code.
A pillar can leave the set behind the number without anybody choosing it:
a window rolls past its floor, a module goes off, a source goes quiet. The
delta already refuses to narrate that as a health event, which stops a
false "you dropped six points" and puts nothing in its place. The level
moves, the panel shows a different number, and the cause is nowhere on the
surface. Under the adaptive minimum this matters more rather than less,
because compositions are smaller now and sit closer to their floors.
The report carries `compositionNotice` when today's set differs from the
last local day the account has a stored score for. Optional and nullable,
the additive shape the score's wire has used six times, so a payload
cached before it existed stays valid. It names what left and what joined
as pillar ids; the surfaces already own the labels, and the reason a
departed pillar stopped counting comes from that pillar's own row in the
same report rather than from a second vocabulary.
Two changes deliberately do not raise it. A method move and a recipe
change each already have their own note, and telling someone twice about
one event teaches them to ignore both.
The key is `health-score:v<version>:composition:<hash of the sorted set>`
and rides the existing dismissed-item ledger, so a dismissal holds until
the set moves again rather than until tomorrow. The read excludes today on
purpose: the row for today is written by the same request that computed
the report, so comparing against the newest row would find today's own
composition on the second load of the same page and the note would vanish
between two views of it.
This gives `HealthScoreRecord` its first production reader. Since it
shipped it has been written on every scored read and consumed by nothing
but backup and restore, which is the write-only shape the contributor
notes call a liability and which nothing in the gate can notice, because
every check proves one end. The round trip is proved against real
Postgres: raise the note, wipe the table, restore it through the admin
route, get the same note back, and hold the dismissal across it.
Rendering is one muted line on the score card and a card of its own on the
settings page, beside the recipe note rather than inside it. Deliberately
not a push.
Four fake Prisma clients gain the new read. They are doubles of a client
the reader really does call now; leaving them short and guarding the call
site instead would have been a shim for a caller that does not exist.
The key shipped with the English sentence in es, fr, it, pl and ko, which
the English-leak guard catches by design: a value byte-identical to the
English one for a multi-word string is a forgotten translation, and that
guard exists because this project has shipped exactly that before.
Nobody sending a two-line fix should have to produce seven languages.
Two areas of health score now, and one does. The arithmetic is the same
at every breadth, so nothing on the web surfaces distinguished a number
resting on blood pressure alone from one resting on five areas. The
panel now states its scope.
The basis line. Below the recommended breadth the card renders "Based on
{n} of 3 areas of health" under the band sentence, in the same weight,
and stops there. It stands where the learning gate used to stand for
those accounts, which is what the gate was for before the refusal moved.
The number keeps its size and its band colour at every tier, there is no
warning glyph, no second colour, no apology: the coverage meter and the
confidence band underneath already do the quantitative caveat work, and
the composition list names the areas one tap away. At full breadth the
line says nothing, because a fraction that reads "3 of 3" (or "4 of 3" —
the block counts domains, the recommendation caps at three) is not a
label. The daily hero carries the same sentence beside its ring, in the
hero's quiet tier, because the ring shows a number and nothing else.
The gate survives for the one case that is still a refusal: not a single
pillar readable. Its copy stopped claiming the score needs three areas
and stopped counting them — above zero there is a score now, and the
meter below renders the fraction anyway.
The coverage axis. "Covers the three areas the score needs" was false
the moment three stopped being needed, and the branch that chose it was
worse: it asked whether a score exists, so a two-area account would have
been told it covers the three areas the score recommends — the old lie
one floor up. The branch is the breadth tier now. Full breadth states
the recommendation; everything narrower shows the fraction, which since
this release is a real moving number rather than a floor every scored
account cleared by definition. The key was renamed off "minimumMet"
because there is no minimum left to name.
Settings names the recommendation rather than a requirement: three areas
is what the score recommends, a selection with one pillar saves, and a
narrower score is worked out the same way.
Copy lands in all seven catalogues.
Tests: the axis guard gains a third claim, rendered rather than read out
of the source — the real panel at each tier, including the two-area arm
that was unreachable for a scored account until now. The card gains a
partial-score block comparing the headline number's markup byte for byte
against a full-breadth render, so "renders the same" is asserted where
it can actually break, plus the counter-case that a refusal still gets
the gate and nothing else. The hero strip proves the sentence reaches
the band, not only the component. The geometry reserve was re-measured
with a browser rather than widened: a three-row panel carrying the basis
line lands at 420px, inside the 400-484px range the reserve brackets, so
the declared minimum is unchanged and the narrow shape is now measured.
Three distinct areas of health stops being the price of admission and
becomes what it always described: a recommendation. `evaluateScoreBreadth`
returns a tier instead of a pass/fail — `full` at three areas or more,
`partial` at two, `minimal` at one — and refuses only when not one pillar
came back usable, with the reason `no_usable_data`.
The arithmetic does not change with the tier. Same mean, same worst-pillar
band drag, same noise floor. A one-area score of 82 is an honest 82 that
rests on one area, and discounting it for the pillars somebody does not
track would have been a new invented target, which is the thing this score
spent two releases removing.
The physiological requirement becomes a labelling fact rather than a veto.
An account whose only readable pillar is activity or wellbeing gets its
number with `physiological: false` on the basis block; the old refusal told
that person nothing they could not already see.
`SCORE_MIN_ELIGIBLE_DOMAINS` is now `SCORE_RECOMMENDED_DOMAINS` and still
feeds `deriveCoverage`, so the coverage fraction and the confidence blend
keep telling the truth with no new machinery: a one-area score lands at low
confidence through the path that was already there. The fraction used to be
a floor every scored account had cleared; it is a real moving number now.
`SCORE_VERSION` 3 -> 4 and `SCORE_ALGORITHM_CHANGED_AT` moves to the
release date. Both are method-identity duties: deltas suppress as
`algorithm_changed` across the straddle window, the notice key rotates so
each account is told once, and stored days stamp 4. No v3 row is rewritten
and no v3 number changes meaning.
The settings write follows the same rule rather than a stricter one. It
refused two-area and non-physiological selections on the ground that they
could not produce a score; they can, so refusing the write would be the two
layers disagreeing about the one question they share. The empty selection
is still refused, under a single reason `no_pillars_selected`, and the seven
message catalogues trade the two old refusal sentences for one.
Also fixes the stale `./series.ts` reference in the config header — that
module was deleted as dead in v1.35; the columns the sentence describes are
still written by `./record.ts`.
Integrates @aucun6352's Korean locale (PR #839): the full catalog, the
NFC recompose in the shared fold, the Hangul lab-name normaliser, the
tilde range separator, and the officially published Korean PHQ-9, GAD-7
and WHO-5 wording. Resolves the CHANGELOG and OpenAPI conflicts against
the current trunk and regenerates the spec. Raises the aggregate bundle
budget to 3700 KB gz for the seventh message catalog, with the reasoning
recorded next to the value.
The contributor's three commits are preserved with their authorship.
The per-user retry ledger has recorded every AI delivery outcome for a
long time, but no surface ever read it for the operator: a central
provider could fail on every call for weeks and the admin console said
nothing. The AI settings section now carries a health card, backed by a
new admin-only endpoint that folds the ledger into one row per provider
type: users tracked, users currently failing, the worst uninterrupted
failure run, and the last success and failure instants. Operator-managed
types sort first because their failure takes the shared fallback down
for everyone whose chain relies on it. Counts and timestamps only; the
response never names a user.
The Korean questionnaire wording was translated freshly from English, so
a score built from it was comparable to nothing in the literature. Each
instrument now carries the text its own publisher put out, or no Korean
at all.
PHQ-9 and GAD-7 come verbatim from the instrument owner's distribution —
Pfizer's `PHQ9_Korean for Korea.pdf` (2010-06-15) and `GAD7_Korean for
Korea.pdf` (2010-05-18), the latter being the file Ahn/Kim/Choi 2019
names as what it validated. The sheets grant reproduction, translation,
display and distribution without permission. Verbatim means verbatim:
the spacing in "7 일 이상 방해 받았다" and the sheet's "안절부절 못하거나"
against the GAD-7 sheet's "안절부절못해서" are reproduced as printed.
WHO-5 has no official Korean arm. Neither the WHO's 2024 distribution
(26 languages) nor the Psychiatric Research Unit folder it inherited
from (31 languages) has ever carried one, and ePROVIDE lists the same
31. Korean therefore carries the published forward/back-translation
from Kim HJ et al., J Korean Geriatr Psychiatry 2010;14(2):90-96, which
is CC BY-NC and so redistributable; the attribution line names it. Its
validation sample was community-dwelling elderly rather than general
adults, which the definition says out loud.
The Sleep Condition Indicator reverts to English. It is validated in
English only, its cut-off was derived from those words, and no openly
redistributable Korean exists — so `ko` now matches de/es/fr/it/pl:
items, section stems and response anchors byte-identical to en.json,
under the localized "validated in English" note.
`ALL_APP_LOCALES` grows to seven, which is what stops the check-in
wizard claiming English wording over Korean items on every Korean
check-in.
One gap is deliberate and not yet resolved. The official Korean PHQ-9
and GAD-7 sheets disagree on anchor 2 — "7 일 이상 방해 받았다" against
"2 주 중 절반 이상 방해 받았다" — but both instruments read one shared
`mentalHealth.options` set, which holds for the other six locales
because their two sheets agree word for word. Korean takes the PHQ-9
wording for both here. GAD-7's anchors are therefore validated in
substance but not verbatim; splitting them into a `gad7Options`
namespace is the fix, and is left for the maintainer to weigh against
the 24 duplicated keys it would add across the other locales.
The functional-impairment item and its four anchors come from the same
PHQ-9 sheet. They are not scored and not covered by the
`validatedItemLocales` contract.
The range selector said points — 7 pts, Last 7 data points — while the
fetch has always treated the value as a calendar-day window. Someone
who weighs in twice a week picked 7 pts and saw two, and read that as
a broken chart; the labels were the broken part. The tabs now say 7d /
30d / 90d / All across all six locales.
Behaviour is unified on the same reading everywhere. The measurement
chart's visible slice becomes a real day-window filter — the old
slice(-N) was a no-op on the self-fetched path but silently meant
last N readings on the dashboard's preloaded batch slice, so the same
tab meant two different things depending on the mount path. The mood
chart sliced the last N logged days out of the whole history and now
windows by days like its siblings. The header chip reads the same
grain decision the chart and the data table use instead of re-deriving
it in a second memo.
Component fixtures move to now-anchored dates: the windows are anchored
on now, so absolute-dated fixtures age out of the window and would have
exercised only the empty states. The span-dependent cases (weekly chip,
bucketed-caption table) mount the All range, which is now the only
range that can span past 90 days.
Registering `ko` in the `Locale` union left seven locale-keyed tables
without an entry, so the build could not type-check and the safety
screens, the citation-coverage grader and the reference-range parser
read nothing at all for a Korean reader.
Three failures were not missing keys but wrong assumptions:
- The shared fold decomposes with NFD and strips U+0300-U+036F. Latin
accents become plain letters, but Hangul decomposes into jamo, which
are not in that range and survived — so folded Korean text was a jamo
sequence no precomposed table entry could ever match. It recomposes to
NFC now; every Latin, Polish and German fold is byte-identical.
- `normaliseLabKey` kept `[a-z0-9]`, which reduced every Korean analyte
name to the empty string, so the derived index held none of them and a
Korean cholesterol result reached a FHIR export uncoded.
- The reference-range parser had the hyphen and the dashes but not the
tilde a Korean lab actually prints (`3.5~5.0`).
The Korean safety banks carry no `\b` next to a Hangul token — `\b` is
an ASCII `\w` boundary and Hangul is not `\w`, so `/\b증량/` matches
nothing — and the clause order is the mirror of the Latin banks, since
Korean closes with the verb. The dose patterns require a target-marking
particle for the same reason the Latin banks require "to"/"by": without
it the permitted restatement "7.5mg을 복용 중이고, 체중은 내려가고
있어요" tripped the lowering pattern.
The Coach and Insights prompts route `ko` down the reviewed English body
plus a Korean reply directive; there is no `safety-contracts.ko.yaml`, so
the native builder has no Korean ground rules to compose from.
Register `ko` in the locale list, language names, and labels, and ship
messages/ko.json so the whole UI — dashboard, settings, share views,
clinician PDF, notifications, and AI prompts — renders in Korean.
Extend the locale-parity, English-leak, plural-completeness, and copy
guard tests to cover the new locale, and update README/CONTRIBUTING to
list Korean among the supported languages.
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.
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.
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.
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.
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.
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.
`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.
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.
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 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.
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.
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.
- 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 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.
- 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.
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.
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 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
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 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
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.
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.