Commit Graph
208 Commits
Author SHA1 Message Date
Marc-André Bombeck c66bcbc295 feat(admin): the AI card shows the day's spend split by who paid
The provider-health route answered a spendToday payload that nothing rendered,
which is the pattern the two-ended rule exists to stop. It is also the screen
the operator opens when a surface refuses on budget, and the one mixed number
could not answer the question that raises: a day at 1.24 million tokens is
alarming until the card says that 151 200 of them were the instance's own key
and the rest ran on the users' own plans. The operator ceiling is enforced
against the second figure. Copy lands in all seven locales.
2026-09-11 11:11:17 +02:00
Marc-André Bombeck d37993822e fix(admin): keep the off-host freshness badges readable in both themes
The destructive tone as 12px badge text on its own wash measures 4.08:1 in
the light theme and 3.99:1 in the dark one; the muted foreground on the
muted wash 3.6:1 in the dark one. The badge takes the shape the status rows
on the same console already use: the token paints a small indicator and the
wording stays in the foreground colour. The word states the condition, so
nothing hangs on the colour alone.
2026-09-10 16:00:38 +02:00
Marc-André Bombeck a7faae8ded refactor(admin): read the off-host card from evidence, sort the worst row first
Four smaller things around the same card.

The configured flag came from the environment of whichever process serves the
request. A hand-rolled split that gives BACKUP_S3_* to the worker and not to
the web process would have shown an operator "nothing leaves this host" over a
ledger full of fresh rows. The ledger is queried either way now, and a row in
it outranks an environment read: a row is proof an upload happened.

Rows sorted by username put the one account that needs attention wherever the
alphabet leaves it, which on a hundred-account cohort is the crowd problem the
card exists to end, moved from a count into a list. Worst verdict first,
alphabetical within a verdict, decided server-side so the tests and the
accessibility fixture see the order the operator does.

The card's docblock claimed the worker holds PutObject and GetObject. The
runbook is right and the multipart path needs AbortMultipartUpload too, so the
prose no longer documents a grant that would break the uploader.

And the byte formatter moved to lib. The off-host card was importing it out of
a thousand-line client component, which costs nothing while both cards render
on one route and becomes a cost the moment either moves.
2026-09-10 10:32:33 +02:00
Marc-André Bombeck 9d18b8fd25 fix(admin): let the error-reporting badge follow reports, not a parseable URL
The badge read the enabled flag plus a DSN that parses. Neither says anything
about the target accepting a report: a wrong public key, a wrong project id and
a host that stopped answering all parse, and every one of them showed green
with a line naming the host. That is the false reassurance an operator finds
out about when they go looking for a crash that was never reported.

Nothing in the tree recorded an outcome, so there was nothing to read. The
sender now stamps one next to the DSN it used — instant on a success, a short
classification on a failure. Never the response body: that is text from a
remote host and has no business in this database or on this page.

The badge is green only while a report has left inside the last fortnight,
amber while a target is set and nothing has come back, and it names the reason
underneath. The clock lives with the ledger, so the verdict is resolved in the
route and the card paints what it is handed. A failure that a later success
superseded is not shown at all. Pressing Test records like any other send, so
the button proves the target on demand.
2026-09-10 10:28:57 +02:00
Marc-André Bombeck 25f0310596 feat(backups): tell an empty ledger apart from an empty bucket
On the release that adds the ledger the table is empty, so on a host that has
been uploading nightly for months every account read Never, under a sentence
saying no copy had ever reached the bucket. That sentence is untrue on exactly
the morning an operator opens the new card for the first time, and it is a
claim the host cannot make: what is empty is the record, not the bucket.

The run now records every account it walks, not only the ones an object came
of. A walk that produced nothing moves the attempt instant and leaves the
success columns alone, so the two cases separate: no row at all is no record
yet, a row without a success is never. Never now means what the word says —
a run looked at this account and has never put anything there.

The card names the last check on the never row, so the state is a fact with a
timestamp rather than an assertion. Runbook and all seven bundles carry the
same two sentences. The integration file proves both arms against real
Postgres, including that a failed run leaves the previous good copy on the
row.
2026-09-10 10:15:31 +02:00
Marc-André Bombeck 34d647a884 test(backups): prove the off-host ledger end to end and the card's empty state
The unit tests either side of this mock Prisma, so neither proves the
migration applies or that the writer and the reader agree on a shape. The
integration file runs the real nightly job against real Postgres with an
in-memory bucket, then reads the row back and asks the console's own
classifier what it says: fresh after a run, one row across two runs, stale
once it ages past two nightly periods, gone when the account is.

The component test pins the case an operator most needs stated: a host with
no bucket configured says so, rather than painting an empty list that reads
as "no problems". The accessibility scan's admin-backups response gains one
row in each state, because a card that paints only its header proves nothing
about the rows anybody reads.

Two small corrections ride along, both from writing the tests. The freshness
badge composes its classes through `cn` like every other surface, and the
error-reporting line waits for the settings to arrive instead of asserting
"nothing is sent" while the answer is still loading.
2026-09-10 09:34:33 +02:00
Marc-André Bombeck 532483e782 fix(admin): say where error reports go, and stop calling a silent target configured
There is no default error-reporting DSN in this tree and there should not be:
a self-hoster's crash reports must not leave their host because nobody told
them not to. That part was already right, so nothing here adds a default.

What was wrong is what the console said about it. The GlitchTip card showed a
green "Configured" badge as soon as a DSN existed, whether or not the switch
that actually sends anything was on, and it never said where reports go. An
operator reading that badge has been told their errors are being collected
somewhere, which may be true of neither half.

The badge now tracks reports actually leaving, and one line under the switch
states the case: the host they go to, or that they stay here, or that tracking
is on with no target. The host only — the public key in the DSN answers a
different question. The deploy runbook says the same thing for a
maintainer-operated instance.
2026-09-10 09:28:12 +02:00
Marc-André Bombeck 1c10bbbde8 feat(admin): say per account how fresh the off-host copy is
The nightly off-host job reports counts — uploaded, failed, total — and a
count cannot name the account it is failing for. A cohort where one object
has been refused every night for a month reads as "99 uploaded, 1 failed"
each time, which looks like weather rather than like an account with no
disaster-recovery copy at all.

The worker now writes a ledger row when an object lands (account, instant,
size), and Admin → Backups gains a second card that rates every account
against the nightly schedule: fresh inside one run, due when one run
produced nothing, stale when two did not, never when no object has ever
landed. On a host without BACKUP_S3_* the card says off-host backup is not
configured and stops there.

The verdicts are computed from the ledger, never from a bucket listing: the
worker's grant is deliberately PutObject + GetObject, and an admin page must
not put the operator's credentials on its render path to state a number the
database already holds.
2026-09-10 09:27:58 +02:00
Marc-André Bombeck 17671d9dc1 Merge the AA-contrast status rows on the admin page 2026-09-10 07:41:22 +02:00
Marc-André Bombeck 9e1f64e733 fix(admin): keep the worker-status row readable with no worker present
The admin snapshot paints its worker row in `--destructive` whenever the
process serving the page is not the worker: a split deployment running
`HEALTHLOG_PROCESS_TYPE=web`, or a worker that stopped answering. The token
is AA on a card (4.85:1 in the light theme) but the stat tile lays a
`bg-muted/50` wash over that card, and axe measured the tinted wording at
3.97:1 there, under the 4.5:1 AA floor. `e2e/a11y.spec.ts` went red in
exactly that state and green in every other one, which is why the console
could ship it.

The status grids now name a tone instead of spelling a class at every call
site. Success and warning keep their tint, both clear AA on the tile.
Destructive takes the shape the other status rows already use, the
medication detail row and the invite chips: the token paints a small
indicator and the wording stays in `text-foreground`. The word itself
("Stopped", "Error") states the condition, so nothing hangs on the colour
alone. The database, Umami and GlitchTip rows sit on the same surface with
the same tint and move with it.

The success-affordance guard's two pinned entries for the status grids went
stale in the process: the tint they carried now lives once in the tone
table, so the pins follow it there.

Checked against a standalone build on a throwaway database: the whole a11y
spec green in both themes and both viewports with `HEALTHLOG_PROCESS_TYPE`
at `web` (the failing state) and at `all`. jsdom cannot measure a contrast
ratio, so the new unit test pins the token choice for both arms instead.
2026-09-10 06:56:50 +02:00
Marc-André Bombeck 343d6397a0 test(e2e): say what the backup journey really touches, and pin it to promises
The file claimed it stayed inside its own account. "Backup now" enqueues the
pass for every account on the instance, so the header says that now, and says
what is confined instead: the restore's transaction, and the instance-wide
settings the dialog's opt-in leaves alone — which the journey asserts is clear
before it confirms, because a default that flipped would hand this file the
power to rewrite the document size cap for every spec sharing the server.

The run's own answer is asserted where it happens. Without it a missing worker
cost two minutes of polling and then blamed the backup pass for a 503 about
something else; the failure now names the endpoint in two seconds.

The rest are the assertions that pinned incidentals rather than promises: the
Argon2 cost comes from the constant the writer exports, so a legitimate cost
bump is not a red spec; the document is found by id rather than by position;
the poll compares the row against the row it saw before the click rather than
against the runner's clock; the non-admin bounce is pinned by pathname; the
empty-state CTA has its own test id, so nothing has to pick between two
buttons that share one; the deliberately corrupted copy is deleted when the
control is done with it instead of sitting in the console's list; and the
fixture orders by id after the timestamp so a tie cannot present as a change.
2026-09-10 05:48:18 +02:00
Marc-André Bombeck 6b366b4f27 fix(backup): make the console restore leave instance settings alone
A disaster-recovery snapshot carries the singleton `app_settings` row —
registration, the MFA requirement, the default locale and timezone, module
availability, the notification and AI configuration, the document size cap and
quota — and the restore wrote it back as a side effect of restoring one
account. An operator putting a single record back on Wednesday reconfigured the
whole host from Monday's file, silently, with nothing on screen saying so.

Rebuilding a host from a snapshot is a real case, so the write stays: it is now
`restoreInstanceSettings: true` on the request and a checkbox beside the typed
gate, off unless the operator ticks it. Restoring an account and reconfiguring
the installation are two decisions, so they are two answers.

The 422 confirmation sentence and the dialog copy drop the instance-wide claim;
the opt-in carries it instead, in all seven catalogues. Integration coverage
runs both arms: without the flag the settings row keeps its own values, with it
the file's values land.
2026-09-10 05:16:59 +02:00
Marc-André Bombeck 5cb5c7df44 test(e2e): drive backup and restore end to end through the settings surfaces
One journey across the whole path: an account writes readings in three
domains and files a PDF, downloads its own record as the passphrase-sealed
archive, an admin snapshots it from the backups console, and the snapshot is
restored over an account that has since lost a reading and a dose and gained
one the snapshot never saw.

The assertions address what the documentation promises rather than what the
implementation happens to do. The archive is checked byte by byte against the
HLX1 header in src/lib/export/passphrase-archive.ts — magic, version, KDF id
and the Argon2 cost the file carries — because "a file arrived" is equally
true of plaintext JSON under a new extension. The restored reading and dose
are checked by their ORIGINAL ids, because a restore that re-created them
under fresh ones would have written a different record that reads the same.
The reading created after the snapshot is asserted GONE: the runbook says the
restore replaces the account's data tables, and replacing is not merging.

Two refusing controls. A stored copy with one flipped ciphertext byte is
refused with the route's decrypt failure and leaves the record untouched — the
envelope is AES-256-GCM and its tag covers every byte, so the flip is caught
before the transaction opens. A delegate holding whole-record READ inside the
account gets the not-covered panel instead of the export card, never reaches
the console, and is refused 403 by the list, the manual run and the restore,
the last of them naming the real snapshot so the refusal cannot be mistaken
for a missing row.

Waiting is on the job's own evidence: the console's endpoint is polled until
this account's stored copy is newer than the click, so nothing in the file
guesses at a queue. The journey has its own account, its own delegate and its
own cookie jars; only the reset between repetitions and the deliberate
corruption reach Postgres directly, because a browser cannot express either.

The backups console gains data-testid hooks and a data-backup-id on each row
so the spec addresses controls rather than translated labels.
2026-09-10 04:53:03 +02:00
Marc-André Bombeck 5556478691 Make uploads and every other write work over plain HTTP
crypto.randomUUID() is [SecureContext] in the Web Cryptography IDL, so a
page served over plain HTTP never gets it. Measured in Chromium against
http://<lan-ip>: isSecureContext false, randomUUID and subtle undefined,
getRandomValues still a function.

The document vault minted its queue id and Idempotency-Key that way, inside
the file-input change handler. On a LAN self-host the call threw before a
queue row existed, so picking a file produced no upload, no error and no
trace — the picker simply looked inert, and the only way through was to
reach the app over HTTPS. The client transport has minted a default
Idempotency-Key the same way since v1.37.18, which put every apiPost and
apiPatch on the same footing: on such a host nothing wrote at all.

Add randomId(), which prefers the platform call and otherwise assembles a
v4 UUID from getRandomValues — the same CSPRNG, the same 122 bits, no
secure context required — and route every browser-side call site through
it. Nothing in an upload needs one: the bytes travel over multipart XHR and
every cryptographic operation happens on the server. Passkeys, web push and
home-screen install still need HTTPS; browsers withhold those outright.

A structural guard fails a direct crypto.randomUUID() anywhere outside the
two server-side files that may keep it, so the class cannot return one call
site at a time.

Also point the CSV and JSON import cards straight at the import guide. They
resolved through a redirect under the integrations tree, which is the wrong
place for a guide that is not an integration runbook. The suite that
covered those links pinned the old spelling rather than the guarantee; it
now asserts an absolute docs-site URL, no internal path, and new-tab
hygiene.
2026-09-05 16:22:39 +02:00
Marc-André Bombeck 7dbec3bc4d feat(admin): name the status a failing provider came back with
The provider-health ledger has recorded the HTTP status of every failure
since v1.11.0 and nothing has ever read it. The operator card could say
three of five chains were failing without saying whose problem that was,
which is the one thing the operator needs: a 401 is a credential only they
can replace, a 429 or a 503 is the provider's afternoon and waiting is the
right move.

The fold carries the status of the failure whose instant the row already
shows, not an aggregate over the column. A MAX of two HTTP statuses is
arithmetic on a label, and a status describing a different failure than
the one on screen would send the operator to the wrong place. The column
joins the grouping key instead, and the fold picks the group belonging to
the newest failure.

Blank where there was nothing to record: a connection that never completed
has no status, and a chain that has since recovered had its status cleared
along with the rest of its failure state.
2026-09-03 06:05:58 +02:00
Marc-André Bombeck 154c93b5e1 feat: surface a stalled or failed backup schedule
The weekly pass can stop and leave no trace where anyone looks. The
handler's own error never runs when the job is killed rather than thrown;
`recordError()` increments an unnamed counter; and the admin status page's
failing-queue panel looks back 72 hours, which for a weekly queue means a
permanent failure is on screen three days in seven. The backups page
itself showed an ordinary row with an ordinary timestamp, because a copy
made six weeks ago reads exactly like Sunday's until somebody subtracts
the dates.

The listing now carries two facts that fail at different times: the age of
the newest scheduled copy, which never leaves any retention window, and
the terminal state of the last scheduled run, which is the only place the
reason is written down. `readLastQueueRun` takes the newest run rather
than a window, so a weekly queue's outcome stays readable until the next
one replaces it, and it unwraps the value pg-boss stores a thrown error
under, so the page says "job timed out" instead of raw JSON. The
failing-queue reader gets the same unwrap.

Manual uploads are excluded from the cadence judgement by the shape of the
input, so an ad-hoc upload cannot make a dead cron look alive, and a fresh
instance is not called overdue for having no copy yet.

The listing also stops reading every blob into the web process to measure
it; `octet_length` answers that in the database, which on an instance with
large records was its own way of running out of memory.
2026-09-03 02:22:37 +02:00
Marc-André Bombeck b34e911c8e docs(admin): say which providers the server-key base URL accepts
The operator card offered a free-text base URL with an OpenAI placeholder
and no word about what else it takes, so an Anthropic endpoint looked
unsupported even though the field accepts that host. One line beside the
existing hints, in all seven locales, plus the changelog entry.
2026-09-02 16:17:17 +02:00
Marc-André Bombeck 6c4682acfe Give the operator a provider-health readout
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.
2026-08-27 11:26:40 +02:00
Marc-André Bombeck 283699db63 Refuse a backup that claims a section it does not carry (#237)
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.
2026-08-23 16:55:59 +02:00
Marc-André Bombeck a74dc289a8 Carry the archived schedule eras, chain and all
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.
2026-08-22 11:51:52 +02:00
Marc-André Bombeck 768320f3d3 Restore what each medication was supposed to move
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.
2026-08-22 11:49:52 +02:00
Marc-André Bombeck 14638a6660 Bring the ECG strips back from a restore
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.
2026-08-22 11:44:01 +02:00
Marc-André Bombeck f95fb95295 Carry the bests, the badges and the environmental history through a restore
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.
2026-08-22 11:14:15 +02:00
Marc-André Bombeck d5dde3eb1c Carry the vault's filing and its staged facts through backup and restore
`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.
2026-08-22 10:23:58 +02:00
Marc-André Bombeck 9d64eb6aa2 Show what a backup contains before the operator types RESTORE
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.
2026-08-14 09:51:40 +02:00
Marc-André Bombeck 73b82f444e test: use render thunks in the read-error family suites for the jsx-key rule 2026-08-09 04:37:03 +02:00
Marc-André Bombeck ba42354733 fix(admin): replace hand-rolled read-error boxes with the shared primitive
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.
2026-08-09 04:32:54 +02:00
Marc-André Bombeck 6926caa11d feat(vaccinations): carry the immunization log through export and restore
- 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
2026-08-08 13:54:24 +02:00
Marc-André Bombeck 339e815527 Keep an appointment reminder inside the visit that owns it
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.
2026-08-08 11:24:08 +02:00
Marc-André Bombeck 24425e9aff Add the visit record and the carriers that keep it
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.
2026-08-08 07:14:02 +02:00
Marc-André Bombeck c6246092ff Merge branch 'main' into refactor/settings-admin-one-piece 2026-08-08 05:20:04 +02:00
Marc-André Bombeck cbf949d43a Merge branch 'main' into fix/wipe-geo-truths 2026-08-08 03:29:51 +02:00
Marc-André Bombeck 6dd70f532a Merge branch 'main' into refactor/settings-admin-one-piece 2026-08-08 03:05:27 +02:00
Marc-André Bombeck ef35fe681d fix(admin): wipe every account's data, not nine tables
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.
2026-08-08 01:05:31 +02:00
Marc-André Bombeck 272c426bea fix(ui): close the holes the audit found in this work's own guards
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.
2026-08-07 23:20:39 +02:00
Marc-André Bombeck 18edd34c33 fix(backup): restore the mood note, zone, counter and binary tags
`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.
2026-08-07 21:37:06 +02:00
Marc-André Bombeck ba0cd63cf5 refactor(i18n): a description is one sentence, in all six languages
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.
2026-08-07 20:53:05 +02:00
Marc-André Bombeck 330f60115a refactor(admin): the console speaks the Settings vocabulary without an accent
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.
2026-08-07 19:55:47 +02:00
Marc-André Bombeck b2e1aa0cde refactor(settings): one header, one action row, one integration card
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.
2026-08-07 19:39:11 +02:00
Marc-André Bombeck 5445bfc1f1 refactor(ui): give Settings and Admin one card rhythm and one page header
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.
2026-08-07 19:05:15 +02:00
Marc-André Bombeck 75644bc3d2 Carry recorded side effects through the backup and back
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.
2026-08-03 21:15:45 +02:00
Marc-André Bombeck 47e50ece39 Wait for content the page owns before scanning it
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.
2026-08-02 18:50:31 +02:00
Marc-André Bombeck 63075c47d4 test(backup): prove the skip report reaches the screen, not just the wire
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.
2026-08-02 09:10:58 +02:00
Marc-André Bombeck 9c4235b66d fix(backup): a retired catalogue key must not cost the whole file
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.
2026-08-02 09:04:36 +02:00
Marc-André Bombeck 80c8674ee1 Remove the Health-Score explainer switch, which switched nothing
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.
2026-07-31 08:06:07 +02:00
Marc-André Bombeck 23c738efac fix(01-17): align and pin desktop sidebars
- place both sidebars on the heading row with one main scroll owner
- bound short-viewport sidebar content without horizontal layout drift
2026-07-29 14:36:18 +02:00
Marc-André Bombeck 7396119ec8 feat(01-17): group gated admin navigation
- cluster allowed destinations under semantic settings-style headings
- preserve fail-closed role gating and mobile target order
2026-07-29 14:29:51 +02:00
Marc-André Bombeck 65ca1aae42 test(01-10): add red responsive navigation contracts
- pin grouped and heading-aligned Settings and Admin sidebars
- add mobile Insights geometry and deterministic release evidence captures
2026-07-29 14:22:54 +02:00
Marc-André Bombeck 3aff2c16db fix(insights): scope generation guard to prompt fields, clear cache on restore 2026-07-28 21:11:43 +02:00
Marc-André Bombeck 8ece6f39cc fix(status): expose background failures 2026-07-28 14:00:44 +02:00