Commit Graph
335 Commits
Author SHA1 Message Date
Marc-André Bombeck a283211586 Describe the remaining auth, settings and notification-transport surfaces
Twenty-eight more verbs that exist, are called, and said nothing in the
contract: registration, password rotation, the profile patch, the SSO entry
point, passkey enrolment, the Codex device-auth flow, the device list and its
revocation cascade, four per-user preference scalars, the email channel and the
four channel self-tests, the record wipe, the privacy facts, the update check,
and the Web Push transport.

Four of them needed more care than the rest.

`GET /api/auth/oidc/login` is documented as the two-armed route it is. The
browser arm redirects to the provider; the native arm takes `client=native`
plus a mandatory S256 challenge and returns to a compile-time scheme with a
one-time code — or an MFA ticket when the account has a second factor. The two
PKCE exchanges in play are named apart, because confusing them is the way to
get this wrong.

`POST /api/auth/register` is the only genuinely anonymous surface in the set,
and its refusals are stated exactly: the SSO-only block, the 5-per-15-minutes
bucket that collapses rather than falls open, the invite that is consumed
whenever it is sent, the zero-user bootstrap that mints the first admin under a
lock, and the one 409 that covers a taken email and a taken username so it
cannot be used to enumerate accounts. `POST /api/auth/password` is a change
behind authentication, not a reset, so it is described as one.

`DELETE /api/settings/data` names what it takes and what it leaves: ninety-nine
models hard-deleted, the account's own personal columns reset, credentials and
interface preferences kept, one transaction, and a confirmation string that is
deliberately not the one account deletion wants.

Two collisions surfaced while wiring this up and are resolved rather than
worked around. `PUT /api/auth/profile` and `PATCH /api/user/profile` are two
doors onto one handler, so they now share one published request component
instead of two descriptions of one validator. Their RESPONSES differ, though —
one carries id and role, the other the display preferences, and neither is a
superset — so they are named apart and the older description no longer claims
to cover both.

The four Web Push and APNs surfaces land in their own route module rather than
beside the notification preferences: that module answers what gets sent and
whether it arrived, this one the plumbing a client sets up and then verifies.
Both self-tests report failure inside a 200, which the contract says plainly,
because a client reading only the status will tell someone their notifications
work when they do not.

The generated YAML is left out; it is regenerated once the remaining groups
land.
2026-08-22 13:03:11 +02:00
Marc-André Bombeck b6efc6cb5a Register the sixteen auth, settings and discovery routes the spec never had
`pnpm openapi:check` compares the registry against the committed YAML and
never the routes against the registry, so a route that was simply never
registered stayed invisible to the gate. Sixteen of them are called by the
native client and describe nothing: sign-out, the three per-user preference
scalars, the two pre-session discovery reads, passkey login options, the
passkey rename and delete, registration status, the consent latest-receipt
reader and its revoke, account deletion, the instance service switches, the
API-token list and revoke, the threshold overrides, the version read, and the
assistant feature matrix.

Each contract is derived from the handler rather than from its docblock, so
the published statuses are the ones the code can actually produce — the 413 on
a 1 KB body cap, the 400 the consent family answers where the rest of the API
answers 422, the 403 an instance-wide API switch produces on a token READ, the
last-admin and last-Guardian refusals on account deletion, and the fresh-factor
arm that a Bearer transport cannot clear at any scope.

Four request bodies moved to the validation modules the handlers now parse
with, so the published wire cannot drift from the runtime one: the three
`/api/auth/me` preference scalars into a new `user-prefs` module, and the
passkey rename beside the login schemas. Behaviour is unchanged.

The generated YAML is left out; it is regenerated once the remaining groups
land.
2026-08-22 12:24:52 +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 f4fe08c73e Carry the screener history and the consent record, disaster recovery only
Two models come off the coverage register together, and neither is an
ordering or an id-remap problem. Both hang off the account and nothing
else. What kept them off was a question the code never wrote down, so
this change writes it down.

The screeners hold completed WHO-5, PHQ and GAD administrations, and for
PHQ-9 the encrypted blob contains item 9, the question about thoughts of
being better off dead. The application already draws a line around that
blob: it keeps a coarse item9Flagged boolean so the history view can
re-surface crisis resources without decrypting anything. The intent here
was to follow that line and carry the administration while leaving the
answers encrypted. The schema settles it more firmly. responsesEncrypted
is Bytes and NOT NULL, so there is no half-row to write and the choice
is the whole thing or none of it. A plaintext export in a downloads
folder is the wrong home for that answer, so the row rides in a
disaster-recovery file only.

The consent receipts are not sensitive in that way. Their problem is
that a consent is given to an operator. A receipt restored onto a
different instance asserts an agreement that operator never obtained,
with a signature timestamp to make it look settled. A disaster-recovery
file goes back to the same instance, so it travels there and not in a
portable one.

In both cases the portable file SAYS so in its own manifest rather than
reporting an account that has neither. The manifest is merged rather
than added beside the records one, because two keys of the same name
would shadow each other depending on spread order.

item9Flagged is restored from the file rather than recomputed.
Recomputing would mean decrypting every administration during a restore,
which is the handling this data is kept away from, and the stored flag
is what the history view has been reading all along.

Verified by breaking it: dropping revokedAt on the way back fails the
round trip with a withdrawn consent coming back as an active one.
2026-08-22 09:48:10 +02:00
Marc-André Bombeck 16b10c7855 Carry per-drug reminder phase timings, and stop claiming the notification preferences could travel
Two entries sat side by side on the coverage register, described as "the
same shape of loss". They are not the same shape at all, and the
difference is worth writing down.

ReminderPhaseConfig hangs off a medication, one row per drug, nested
inside something that already travels. It needed nothing new and it
travels now. The round trip tunes all four thresholds away from their
defaults and puts one of them in PERCENT rather than MINUTES, because a
restore that quietly fell back to the schema defaults would hand back
four plausible numbers and change when the drug turns orange.

NotificationPreference cannot travel, and the register was wrong to
imply it was merely unbuilt. A preference addresses ONE notification
channel by a hard foreign key. The channel model is deliberately
excluded from every backup because it carries channel secrets, and the
restore deletes every channel it finds. A carried preference would point
at a row that cannot exist: the same constraint violation the custom
mood categories were causing, waiting for somebody to implement it in
good faith.

So it moves to the deliberate exclusions with a reason that names the
dependency instead of the symptom. Re-applying somebody's tuning to a
channel they add after a restore is a feature about channel types, not a
backup contract about rows, and it should be argued for on its own
terms rather than smuggled in as debt repayment.
2026-08-22 09:16:38 +02:00
Marc-André Bombeck d596f8c66d Restore an account that grouped its own mood factors
An account that ever created its own mood category could not be restored
at all. Not partially, not with a gap: the restore violated
mood_tags_category_id_fkey and rolled the whole file back, so the
operator got a 500 naming a constraint and an empty account.

The coverage register described this as custom tags landing in a
grouping that no longer exists, which reads like a cosmetic loss.
Measured, it is the same shape the retired-catalogue-key work already
removed once: one row nobody thought about taking down everything else
with it.

MoodTag.categoryId is a real foreign key. Seeded categories belong to
the instance and are deliberately still not carried, but a category the
person made is theirs, cascades away with them, and has to travel. It
now does, and it is written before the tags that point at it.

The hidden set travels by key rather than id, because what people hide
is almost always a seeded tag whose id differs on every instance. A key
this instance no longer knows costs one hidden row and is reported: a
silent drop would un-hide a factor somebody had deliberately taken out
of their mood editor, and they would have no way to know why it came
back.

Found by experiment rather than by reading: seeding a user-owned
category into the round trip turned it red with the constraint name, and
the same test is green now.
2026-08-22 01:15:59 +02:00
Marc-André Bombeck 86231ed23e Carry medication stock and its ledger through backup and restore
A restored account reported no supply at all. Every pack was gone, and
with it every low-stock reminder, on a feature that became first-class
when medication moved to one stock per drug.

The two travel together because the register itself paired them: without
the events the count "cannot be rebuilt even in principle", and without
the packs the events are a list of deltas against nothing.

The count comes back exactly as recorded, not recalculated from the
ledger. It is the value the server had resolved and the person was
shown, and every reminder already sent was computed from it. The fixture
makes the difference visible on purpose: thirty in, eight consumed, and
a remaining count of twenty-one, because somebody dropped one without
recording it. A restore that "corrected" that to twenty-two would
disagree with the account's own history and look like a bug fix while
doing it.

Both decimal columns cross the wire as strings. Half tablets are a real
prescription and a JSON number would round them.

The note follows the contract the side effects and dose changes beside
it already use, and the packs nest inside their drug, so a portable
restore minting fresh ids needs no remap.

Verified by breaking it: recomputing the remaining count from the ledger
fails the round trip with twenty-one against twenty-two.
2026-08-22 00:59:15 +02:00
Marc-André Bombeck 1fbd3b628b Carry the Coach's memory through backup and restore
The transcript came back in the previous change; what the Coach keeps
BETWEEN threads did not. A restored account had every word of every
conversation and a Coach that has to be told its own history again — the
register said as much for all three: "a restore makes the account
re-tell its history", "keeps the talk and loses the commitment", "a
restored account silently stops sending them".

Tombstones ride only in a disaster-recovery file. All three soft-delete,
and a deleted fact is hidden from injection while kept for audit — a
distinction only the owning instance can act on. A portable file
carrying them would hand a person rows their own app refuses to show
them, and a restore that resurrected one would put a fact back into the
Coach's mouth that the person had deliberately taken out of it.

Neither reference here is a foreign key. sourceConversationId, on all
three, and relatedPlanId on the reminder are bare id columns, so nothing
in the database refuses a value pointing at nothing. That makes them
more dangerous than a real key rather than less: a dangling reference
costs no error, it just quietly stops meaning anything while the row
goes on looking complete. The restore resolves both against what it
actually wrote, nulls what it cannot resolve, and reports the count.

Nulling rather than dropping is the point. A live reminder must not be
lost because the plan it mentions was tombstoned out of a portable file.
The reminder is still the person's and it still fires; what it loses is
a pointer that was already going nowhere.

Order follows from that: threads, then plans, then reminders. Running
the memory before the transcript would cut every fact loose from the
conversation it came out of and still report success.

Verified by breaking it: making the conversation lookup always return
null fails the round trip on the fact's own source field.
2026-08-22 00:41:26 +02:00
Marc-André Bombeck bcb7716ffa Carry the Coach transcript through backup and restore
A restored account got a Coach that had never spoken to it. The
conversations, every turn in them, and the record of which vault
documents a thread was grounded in were all classified as backed up and
travelled with nothing. The register's own entry for the messages called
them the largest volume of free text an account owns after its
documents.

The three travel together because two of them mean nothing alone: a turn
without its thread has nowhere to hang, and an attachment names a
pairing rather than a thing.

documentScoped is the field that makes this more than lost history. It
is a permanent fence — set when a conversation is created through the
fenced endpoint or gets its first document, and lowered by no code path
anywhere, not by detaching every document and not by deleting one. A
restore that let it default to false would hand the tool loop back to
precisely the conversations the fence exists to keep away from it, and
nothing in the interface would look wrong afterwards. So it is carried
verbatim, restored verbatim, and asserted per thread rather than in
aggregate: a restore that wrote false everywhere would still return both
threads and every turn and satisfy every count.

Prose follows the contract the rest of the payload already uses. A
disaster-recovery file carries the stored ciphertext; a portable file
carries the text, because a portable export exists to be readable by the
person who owns it, exactly as it already decrypts medication notes,
mood notes and document summaries. A single row under a key the instance
no longer holds is named rather than thrown, so one unreadable turn
cannot cost someone the rest of the transcript.

Ids ride in both modes, as the reminders do, so an attachment resolves
and the facts, plans and reminders still owed have something real to
point at. The restore runs after the documents for the same reason, and
an attachment naming a document the file does not carry is dropped and
reported instead of failing the whole file over one link.

Verified by breaking it: forcing documentScoped to false on the way back
fails the round trip on that field by name.
2026-08-22 00:25:07 +02:00
Marc-André Bombeck 5e0994aab9 Carry medication dose changes through backup and restore
The titration history was classified as backed up and travelled with
nothing. An account restored from a backup showed the current dose and
no record of how it got there: the ramp, the reductions, the note the
prescriber gave for each step. For a long-running drug that history is
often the more clinically interesting half.

Export writes the rows nested inside their drug, ordered by
effectiveFrom, so a portable restore that mints fresh ids needs no
remap. The note follows the contract the side effects already use: a
portable export decrypts it for a human reader, a disaster-recovery
export carries the ciphertext verbatim, and a restore never writes it
into the legacy plaintext column.

Order is the content here, not decoration. Two rows on the same drug in
the same unit, 5 mg then 10 mg, describe a ramp; the same two rows
returned in the other order describe a taper. Counting them back cannot
tell those apart, so the round-trip test asserts the sequence and the
values, not the count.

MedicationDoseChange moves from the coverage-pending register into the
two-ended list, which makes the round-trip registry incomplete until a
row is seeded and counted. That is a compile error, not a review note.
2026-08-21 23:23:23 +02:00
Marc-André Bombeck 48f4cba3aa Carry medication pause eras through backup and restore
`MedicationPauseEra` was classified as backed up and travelled nowhere. The
spans where a drug was deliberately paused are the difference between "stopped
taking it" and "was told to stop taking it", and nothing else in the payload can
reconstruct them: an era is a decision, not an observation, and no intake row
records its own absence.

Without them the compliance recomputation reads a deliberate pause as missed
doses, so a restored account looks non-adherent for a stretch during which it
did exactly what it was told.

Both ends land together. The eras ride nested inside their medication, like the
schedules and side effects beside them, so a portable file that mints fresh
medication ids needs no remap — Prisma binds each child to whatever id the
parent actually got.

## The open era is the point

`resumedAt` is nullable end to end and the writer always emits it. An open era
means the drug is paused right now, and readers run it to the present. The
restore passes the null through rather than closing it at restore time: a pause
that was still running when the backup was taken is still running afterwards,
and closing it would silently turn an ongoing decision into a historical one.

Counting alone cannot catch that — a restore that closed the era would still
hand back two rows. So the round-trip test seeds one closed era and one open
one and asserts both field by field.

## What is proven and what is not

Moving the model out of `COVERAGE_PENDING` and onto `TWO_ENDED_MODELS` makes
the round-trip test's registry incomplete, which is a compile error. Verified
by watching `tsc` fail with TS2741 before the count-back was written: the
coupling the plan's comment describes does hold.

The round-trip test itself needs Postgres via testcontainers and has not been
run here. Typecheck, lint, format and the 21458-test unit suite are green; the
integration proof comes from CI.
2026-08-21 22:01:51 +02:00
Marc-André Bombeck 4941cd075b Carry the custom-metric entry tombstone through backup and restore
The entries gained a soft-delete this release; both backup ends follow in
the same one. A portable export omits tombstoned readings so a restore
cannot resurrect them; a disaster-recovery payload carries the tombstone
and the restore writes it back, so the account returns exactly as it
stood — undo affordance included.
2026-08-14 09:58:11 +02:00
Marc-André Bombeck b1be67bfa7 Carry the reminder schedule and its ledger through backup and restore
The reminder cadences were the oldest entry on the coverage-pending
register: carried in name, restored never — an account came back silent
until every reminder was rebuilt by hand. Both models are two-ended now,
in the house shape (both ends in one file, reminders-backup.ts): every
column travels, nextDueAt verbatim rather than recomputed, the completion
ledger beside it, tombstones in disaster recovery only, and a portable
export omits a tombstoned reminder together with its ledger rows.

Encounter.reminderId and VaccinationRecord.reminderId stop being dropped
on restore: both remap against the restored reminders and fall to NULL
with a skip report only when the file genuinely lacks the row. The wipe
plan names the ledger child-before-parent; the classification guard,
completeness guards and the integration round trip all demand the new
coverage (proven failing with the section removed).

Refs #223
2026-08-14 09:36:03 +02:00
Marc-André Bombeck 5619783648 Let a checkup be skipped or pushed back, and remember every outcome
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.
2026-08-14 09:23:58 +02:00
Marc-André Bombeck 97cb63a77f Report every restored section, disclose export decrypt losses, re-fold on zone change
- The restore summary counts practitioners, encounters, the three link
  tables, and vaccinations — restored in full since 08-01 but absent
  from the report, so a file carrying visits or an Impfpass read as if
  those sections were empty.
- A portable export that cannot open a field's ciphertext still nulls it
  fail-soft, but now names the loss in the file's own decryptFailures
  manifest — an export with a lost emergency note is no longer
  byte-identical to one where the note was never written.
- A timezone change enqueues the medication-compliance re-fold: the
  rollup keys its rows by the local day minted under the OLD zone, and
  the coverage probe compares counts, not keys, so nothing else could
  notice the stale bucketing.
2026-08-14 06:14:13 +02:00
Marc-André Bombeck 893db887e9 Record why a document's index attempt failed and say so
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
2026-08-13 12:40:50 +02:00
Marc-André Bombeck e83b58c9da Let the AI-consent card grant again after a withdrawal
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.
2026-08-13 02:27:05 +02:00
Marc-André Bombeck fd104dd0ac feat(documents): review and extract lab values on the document itself
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.
2026-08-13 00:33:14 +02:00
Marc-André Bombeck 08a1dbccac feat(backup): carry per-schedule units-per-dose through backup and restore
The schedule exporter enumerates columns, so the new per-schedule
units-per-dose needs adding by hand: export it as a Decimal string in
the disaster-recovery payload, accept it in the wire schema, and write
it back on restore (NULL stays NULL to inherit). Extend the round-trip
test to prove a half-tablet slot survives export and re-import.
2026-08-10 07:58:48 +02:00
Marc-André Bombeck 88123c53da feat(medications): accept and return per-schedule units-per-dose
Add schedules[].unitsPerDose to the create/update contract with the
same whole-number-or-curated-fraction validation as the medication
level, built into the Prisma data field-by-field on both the create
and the schedule-replace path. Unwrap the nullable Decimal to a JSON
number at every medication read/write response through one shared
serializer so the wire shape cannot drift, keeping NULL as NULL to
mean inherit-the-medication-level.
2026-08-10 07:58:48 +02:00
Marc-André Bombeck 6770bf3b7f feat(api): add emergency profile read and write route
GET/PATCH /api/anamnesis/emergency, wrapped in apiHandler. The PATCH
validates a partial body with per-field length caps, builds the Prisma
data field-by-field from the parsed input (never a spread), narrows the
user from requireAuth rather than the body, encrypts the free text on
write, and audits the change. The GET decrypts fail-soft for the form
prefill. Registers the OpenAPI operations and schema. An integration
test reads the row back to prove the write lands.
2026-08-10 05:32:59 +02:00
Marc-André Bombeck 772da42348 feat(backup): carry emergency profile through rotation and backup
Register the three encrypted emergency columns in the rotation registry
and the key-rotation script so a legacy-key drop cannot strand them.
Carry all six new columns through the profile backup builder and restore
(the enums by value, the free text as ciphertext in a DR payload or
decrypted plaintext in a portable export) and extend the wire schema.
The full-backup round-trip integration test seeds the six columns and
asserts each survives an export and restore.
2026-08-10 05:32:59 +02:00
Marc-André Bombeck 9b58394724 feat(vaccinations): link documents to doses, with the upload suggestion
- from the record: a document link picker on the capture form, gated on the
  documents module (blanked when off, never post-filtered)
- from the upload: a VACCINATION scan offers to file against a dose recorded
  within the shared ±7-day window; a single candidate pre-selects, two offer a
  picker with nothing pre-selected (WR-8 seen red on the pre-select rule,
  reverted by content hash), zero shows nothing, and it never blocks the upload
- the suggestion rule reuses the visit moment's window constant rather than
  forking it; it registers with the shared filing module when that lands
- suggestion and link copy in six locales
2026-08-08 21:11:21 +02:00
Marc-André Bombeck b5ce44628a feat(vaccinations): mint the booster reminder a logged dose suggests
- rung 2: a prefilled, user-confirmed, editable, declinable prompt writes an
  ordinary VORSORGE reminder keyed on the dose's primary antigen; the
  published origin enum and reminder DTO are unchanged
- idempotent by antigen — a second confirmation re-anchors the existing
  reminder rather than minting a second (WR-9 seen red on the reminder count,
  reverted by content hash)
- rolling day cadence so logging the next dose re-anchors the due date through
  the Phase 1 satisfy matcher; the whole loop proven through real routes
- booster prompt copy in six locales, plural-aware
2026-08-08 21:01:25 +02:00
Marc-André Bombeck e1a4b7631c Merge branch 'main' into feat/vaccinations-phase-1
The visit stage's second half landed while this was in flight and both
touched the same three places. Resolved as one set: the link facade
carries the reverse document-to-visit direction and the new dose pair
side by side, the sharing literals hold both sets of admissions with the
count re-added on top, and the language bundles take the shorter module
description the trunk settled on.
2026-08-08 17:07:58 +02:00
Marc-André Bombeck 4dc3b1aa27 feat(vaccinations): add the wire schemas and the resolved DTO
- a dose needs a day and one way of naming what it was, and nothing
  else; a required catalogue pick would make an old Pass line unloggable
- the write path refuses a future date and a slug the catalogue does not
  offer, while storage stays tolerant so an older backup restores whole
- the DTO publishes the practitioner, the visit, the linked pages, the
  catalogue entry and the derived series already resolved, in numbers
  rather than localised text
- a linked page's name and date are withheld from a delegate whose grant
  does not reach the vault, and the withholding says so rather than
  looking like a page with no name
2026-08-08 14:09:52 +02:00
Marc-André Bombeck c47122ccfe feat(encounters): the lab moment, the condition side, and closing a checkup
A lab panel files itself against the visit it came from, and never has to. The
OCR review and the manual form both offer the suggestion through the one shared
field, anchored on the draw date rather than on now, so backdating a reading
moves the offer with it. The commit writes one link PER RESULT ROW, which is
why the join is m:n: a marker re-run on a different day belongs to its own
visit. Best-effort by design — the readings are the write that matters and must
not be lost to a link that could not be made.

The condition side gets a Visits about this card with an action that pre-links
the episode. From the episode side only, and that is a decision: an episode
spans weeks and a visit is a point, so matching the other way would be wrong
often enough to teach a person to ignore every suggestion in the product.

A due checkup gains a second action beside erledigt. Filing the visit as done
satisfies the checkup through the same forward-only, idempotent primitive the
checkbox uses, so doing both is one satisfaction and no error. The two actions
answer different questions — one says it happened, the other says where.

An appointment now appears where a person looks, and NEITHER surface relaxes
the reminder-table exclusion that keeps it out of the preventive-care lists.
Both read the visit table: a quiet line under the dashboard checkup block for
anything inside two weeks, and one rail item for today or tomorrow. A diff in
either that touches origin is the wrong fix.

A visit write now also refreshes the reminder reads. Closing a checkup moves
its next-due date on three surfaces that would otherwise keep showing it due
until their own poll.

OpenAPI regenerated in the same commit.
2026-08-08 14:08:09 +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 be9c79974f feat(documents): a document can say which visit it belongs to
Every arm here is the same shape as the one that already exists for
conditions, because the vault's filing model is settled and a second shape
would be the thing to explain rather than the thing to use: encounterIds on
upload and on PATCH with replace-set semantics, linkEncounter / unlinkEncounter
beside the episode pair in the bulk action, and an encounterId list filter.

Both directions ride ONE table. The visit's sheet writes encounter_document_links
from its end and the document reads it from the other, declared as a second
direction in the link service rather than a fourth table. A visit's kind
crosses the wire as the enum constant rather than a rendered name: unlike a
condition label, which is the person's own words, a kind is one of eight closed
values whose human name the reader's own bundle owns.

The suggestion is offered where the document is being looked at, resolved by
the shared rule rather than by this surface: one candidate pre-selects with an
undo, two offer a picker with nothing pre-selected, none offers nothing at all.
It resolves with no AI provider configured, and it renders nothing once the
document already names a visit.

The bulk verb has a caller — the vault's selection bar files a whole selection
against a visit, mirroring the condition verb and hiding itself on an account
with no visits. Nothing here is one-ended.

OpenAPI regenerated in the same commit.
2026-08-08 13:41:42 +02:00
Marc-André Bombeck 467e83d639 feat(encounters): one suggestion rule for every filing moment
A document arriving in the vault and a lab panel being committed both ask the
same question — does this belong to a visit? — and the answer has to be the
same question asked once. The rule lives in one module with one exported
window constant: the caller's live DONE or PLANNED visits within seven days of
an anchor date, resolved into a verdict rather than a list.

One candidate pre-selects, visibly, with one undo. Two or more offer a picker
with nothing pre-selected: pre-selecting the first of two is what teaches a
person to stop reading suggestions, and the return type has no field on that
arm a caller could mistake for a choice. Zero offers nothing.

Cancelled visits and no-shows are not candidates. Neither happened, so neither
produced the letter or the panel being filed, and admitting them would widen
the set with rows that are wrong most of the time — which pushes the honest
single match into the picker branch.

The verdict is served over HTTP because all three moments live in the browser.
Handing the client a windowed list and letting it decide would put the rule in
two places, and the copy in the browser is the one nobody re-reads.

- suggest-window.ts with the window as an exported constant, not a literal
- GET /api/encounters/suggest, profile domain, reviewed into the frozen literal
- OpenAPI regenerated
- fixtures are relative to the run instant; a fixed date slides out of a
  relative window and rots the test into a false green
2026-08-08 12:43:51 +02:00
Marc-André Bombeck a83be074ca Merge branch 'main' into feat/encounter-phase-1 2026-08-08 12:22:04 +02:00
Marc-André Bombeck f158494def Add the visit and address-book API, and the appointment arm
Nine visit routes and five practitioner routes, all classified under the
existing profile domain and each reviewed into the frozen sharing
literal with the sentence that argues for it. An address book of a
person's own doctors is record content, not account configuration: it
names no credential, no integration and no notification channel, which
is the fence that classification turns on. The eight sharing domains are
unchanged. Link and unlink stay bare, matching document link and unlink,
and the pair is revisited as one decision later.

Nothing is a required field. A visit saves with a date and nothing else.
Every id a body may carry is re-narrowed to the caller before anything
is written, and an unknown link id never blocks a save.

A booked visit mints exactly one reminder on the existing engine with
the new origin, and the appointment half is the part worth reading
twice. Both cadence columns stay null, which is the schema's one-shot,
so the first due instant is stamped directly and the never-nag property
is structural rather than guarded: after it fires, the cadence helper
has no occurrence to return. Rescheduling re-anchors that row and never
mints a second, which the tests assert by counting rows rather than by
checking the date moved.

Filing a visit as done closes the checkup it was filed against, on the
transition rather than on every later edit. Writing the test first found
that: satisfying is forward-only, so re-saving a completed visit was
pushing the checkup's next due date out again as if the person had been
a second time.

An appointment must appear on no preventive-care surface, and that is
four separate reads, one of them in the assistant tooling. Each carries
the exclusion, a guard proves all four by source text with a non-zero
match count, and the reminder mapper refuses such a row outright — so a
read site that loses its filter fails loudly instead of showing an
appointment as a checkup. The published origin enum is untouched.
2026-08-08 07:51:29 +02:00
Marc-André Bombeck 8eb36da228 feat(backup): carry the mood day context through export and restore
- the completeness guard now binds a third model, MoodContext, to a named
  select; the four omitted columns each carry the reason the backup is right
  to drop them
- the disaster-recovery arm carries the ciphertext, the portable arm the plain
  text, and every other column rides in both
- the restore rebuilds the context after its entry, inside the same
  transaction, and a file written before contexts existed restores without one
- the round trip asserts the restored row by value, zero included
2026-08-08 07:14:04 +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 a25ec211cd feat(mood): capture and persist a mood entry's day context
- moodContextSchema derives every enum from the vocabulary, so a value it does
  not name answers 422 through returnAllZodIssues rather than being dropped
- the context is written in the same transaction as the entry and its links; a
  failure rolls the entry back rather than leaving a client retrying a 5xx
  into a duplicate
- an entry with nothing filled in stores no row, and a context that says
  nothing removes the one that was there
- the create, list, detail and edit reads answer the context with its lists
  decoded and its note decrypted; the ciphertext never leaves the server
2026-08-08 07:07:03 +02:00
Marc-André Bombeck bd787daf8b Merge branch 'main' into feat/mood-level-a 2026-08-08 03:29:52 +02:00
Marc-André Bombeck b9a6eeb1d7 fix(mood): a re-post no longer blanks the values it says nothing about
The single create's upsert restated all five columns on its update arm,
so a client re-posting an entry with only a label and a timestamp — the
phone, whose build has no sliders — cleared the stress, energy,
connectedness and stability somebody had answered on the web. The batch
and import paths already preserved them, so the two disagreed.

One answer now, and it is preserve: absence in a request means nothing
to say, an explicit null takes an answer back, and pleasantness still
follows the label because the request always carries one. The create
schema accepts the null that makes clearing expressible.

A re-post that omits a dimension is asserted to leave it standing, and
that assertion has been watched to fail against the old arm.
2026-08-08 02:45:31 +02:00
Marc-André Bombeck 94816d31a8 Merge branch 'main' into feat/lab-source-reference-ranges 2026-08-08 02:27:10 +02:00
Marc-André Bombeck 9e44c8754e feat(mood): carry the five level-A values through backup, restore and CSV
- the backup select and the payload carry all five, on both the portable
  and the disaster-recovery arm
- the restore writes what the file holds and nothing else: a file from
  before these columns restores them empty rather than deriving them
- the CSV appends them after the existing columns so nothing shifts under
  a spreadsheet, and an unanswered value is an empty cell rather than a
  zero, which is a real answer on this scale
- the round trip asserts each of the five by value, including a zero
2026-08-08 01:08:27 +02:00
Marc-André Bombeck a1c78eceef feat(mood): derive pleasantness on every write path
- one helper resolves the level-A values for a write; the five writers
  call it instead of carrying their own copy of the derivation
- the create schema takes a1 to a5, and a value the user set wins over
  the derived one; the batch request shape is untouched
- both arms of both upserts restate the values, so a re-post cannot
  leave a stale reading beside a mood that has since changed
- the JSON import derives from the row's own label rather than the
  file's score, which is the one place the two can disagree
- correcting an entry's label re-derives pleasantness the way it
  re-derives the score; an explicit null takes an answer back
- a round trip through all five writers against real Postgres asserts
  the rows agree and that nothing invents the other four values
2026-08-08 00:49:15 +02:00
Marc-André Bombeck 9a390aacea feat(mood): add the level-A dimension table and the A1 derivation map
- MOOD_DIMENSIONS carries the five self-state dimensions in capture order,
  each with its column, its label and anchor keys, its orientation and its
  0-10 bounds
- getA1ForMood maps the five-point label onto the pleasantness scale once,
  so the quick check-in stays a full-value capture path
- only stress is inverse-oriented, and a test pins that count
- the secret scan reads a dotted message key on a *Key field as a token;
  allowlist the dimension keys the way the existing message keys are
2026-08-08 00:36:09 +02:00
Marc-André Bombeck fd2bd67ff3 feat(labs): keep the reference range a report prints beside a value
A lab runs its own method on its own device, so the window a physician
evaluates a value against is the one printed next to it on that report,
not a catalog band averaged over every lab. Reading a report pulled the
value and dropped that range: the extraction asked for it, the commit
handed it to the biomarker resolver, and the resolver only uses it when
it mints a NEW marker. For every analyte the user already had — the
normal case after a first panel — the printed range was read and then
thrown away.

Add `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText`
to `LabResult` and carry the range from the report to the reading. The
text is stored whether or not numbers can be read out of it, because
"bis 5,0" and "negativ" state a window too; a range that could not be
parsed is kept verbatim, never replaced with invented bounds.

`resolveEffectiveReferenceRange` is the one place the precedence lives:
the source window wins for that single reading, and the catalog stays
the net for readings without one. A printed string with no derivable
bound does not displace the catalog. Every surface that judges a
reading routes through it — the API verdict, the doctor report, the
insights blocks and per-marker assessment, the coach snapshot, the MCP
read and the FHIR `referenceRange` — so a value cannot read in range on
one surface and out on another.

The API keeps `referenceLow` / `referenceHigh` as the window in force,
so a client that reads only those two renders the right range and the
right verdict with no change of its own; the catalog band, the source
window, its origin and a divergence flag ride alongside for clients
that want to show both.

Read side: the chart draws the source window as a second, visually
distinct band with the reading it came from named beneath it, the
reading rows name the printed range and the catalog band where the two
disagree, and the doctor-report PDF prints the report's own string with
a footnote naming the saved range. Entry side: one optional field on
the lab form, folded away until asked for, and the printed range is
editable on the scan review screen.

The range parser covers the notations German and English reports
actually print — two-sided, one-sided, comma or dot decimals, thousands
separators, trailing units — reads units without ever converting them,
and refuses to guess on anything ambiguous.
2026-08-07 21:40:41 +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 ec9d93aa24 docs: publish the managed-profile lifecycle routes
The contract already pointed at them: the Guardian roster names
`DELETE /api/managed-profiles/{id}/guardians/{grantId}` as the route its
`grantId` is for, and that route was not in the spec. Creation, deletion,
invitation and removal now are, with the two bodies moved to
`src/lib/validations` so the wire shape has one source.

Three things are stated rather than left to be discovered:

- the two refusals on the invitation that carry no `meta.errorCode`, and the
  order a client must resolve in because of them
- the last-Guardian 409 on the removal route, which nothing can elicit through
  that route today, published with the reason
- that the sentences on the ordinary invitation describe an invited grant only,
  so a client cannot carry its MANAGE rules across to a Guardian grant
2026-08-07 04:42:51 +02:00
Marc-André Bombeck 5de488c35c docs: state the sharing boundary that is actually enforced
Two sentences promised that a manage grant reaches no settings at all. Both
were true when they were written and stopped being true when the anamnesis
destination opened, and both are in the worst places for a stale promise: one
is the access description generated into the public contract and read by the
native client, the other is the paragraph a delegate meets on a page sharing
does not cover.

Both now name the boundary the code draws — record content versus account
configuration — and say which part of the record a manage grant reaches. What
stays with the account owner is spelled out rather than implied: login, the
second factor, provider connections, tokens, notification routing, module and
threshold configuration, and who else has access.

A guard checks the copy against the classification rather than against itself,
so opening a second destination or closing this one moves the sentence with it.
The contract is regenerated.
2026-08-07 00:52:25 +02:00
Marc-André Bombeck 45fec0fc1b feat(02-12.1): refuse a record request whose context has moved
- both record resolvers check the fence after authentication and before
  the carrier, off the one session row that also decides the scope
- POST /api/account/switch takes an optional expectedEpoch and writes
  conditionally, so concurrent switchers resolve to one outcome
- GET /api/auth/me publishes the context a browser bootstraps from
- every response that resolved a record scope echoes it back
- regenerate the API contract
2026-08-06 18:21:40 +02:00
Marc-André Bombeck 247ac8494c fix: complete managed record settings
- Expose canonical defaults and reject invalid settings families\n- Localize Guardian controls and refresh dependent caches
2026-08-06 03:40:13 +02:00
Marc-André Bombeck e4e773d543 feat: define scoped sharing levels 2026-08-05 23:58:18 +02:00
Marc-André Bombeck ab14fdb6f3 Let an invitation name its sections and offer management
An owner can now narrow a grant to the parts of their record they mean to
share, and can offer the third level. Both decisions are made once, on the
invitation, and neither widens afterwards.

The invite schema takes `scope`: absent or null is the entire record, which
is what every stored grant means and what an owner who does not narrow still
gets. An empty array, a key outside the vocabulary and the whole-record
sentinel are each refused with a 422 naming the field; `inviteGrant` refuses
the same three underneath, and its `invalid_scope` now has an HTTP mapping
instead of falling into the generic refusal.

Offering MANAGE asks for a fresh second factor when the account has one. That
gate resolves through the session cookie and has no Bearer fall-through, so a
token caller is refused first with `sharing.invite.manage_browser_only` rather
than being told it is not authenticated by a route that had just
authenticated it. The consequence is deliberate: management of a health
record is offered from a browser.

The account payload gains `sections` and `level` beside `access` and
`canWrite`, resolved server-side and ordered as the consent screen reads.
`access` was `grant.access === "WRITE" ? "write" : "read"`, which would have
published a grant that can delete entries as read-only; it is a total map
now, so the next level cannot ship silent. The capabilities hook carries both
facts and turns neither into an affordance yet — the edit and delete controls
come back in the release where the routes behind them answer.

The consent surface asks two questions in order rather than laying out a
nineteen-cell grid. The level is three radios, narrowest preselected. What
they can open is two radios, the entire record preselected, and only the
narrow branch expands the eight sections with a sentence each. Both honest
limits are stated above the checkboxes, where the decision is being made: a
section shares everything written in it, and the cross-record surfaces do not
appear for a narrowed grant. Documents carries its own warning, cycle appears
only for an owner who tracks it, and Manage collapses the question to the one
line that answers it.

The Manage sentence says the other person can add, change and remove entries,
including ones the owner entered themselves, and says in the same breath what
it does not reach. The acceptance side gets its own responsibility line rather
than the write one wearing a stronger adjective.

Six locales.
2026-08-05 23:37:00 +02:00