Commit Graph
558 Commits
Author SHA1 Message Date
Marc-André Bombeck 3ad8332e66 test(vaccinations): prove the managed-profile and scoped-grant record paths
Two record-scope properties through the real routes and a real Postgres. A
Guardian standing inside a managed profile writes a dose against the resolved
record — the profile's rows, never the Guardian's own — and reads it straight
back. A delegate holding a profile READ grant on an ordinary record lists and
reads the doses but is refused the PATCH with a 403, the escalation stopped at
the manage check before the row is looked up, and the row is left untouched.
2026-08-08 23:00:39 +02:00
Marc-André Bombeck de83954a7f fix(sharing): let an expired grant be re-granted instead of colliding
The live-pair uniqueness is a partial index (WHERE revoked_at IS NULL),
which cannot read the clock: an expired but never-revoked grant kept
occupying the slot, so re-inviting the same person after expiry always
failed as a duplicate though they had no access. Close the lapsed row at
the moment of re-invite — conditionally, the expiry state in the WHERE —
so revocation still never deletes and the fresh invitation mints its row.
The revoker is stamped GRANTOR because the paired revoked-by check
requires both columns set and the enum has no expiry member. An audit row
records the close. Proven against the real index: the guard was watched
fail with a duplicate before the fix landed.
2026-08-08 21:01:50 +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 4f2dd51390 Merge remote-tracking branch 'origin/main' into feat/vaccinations-phase-1 2026-08-08 19:35:18 +02:00
Marc-André Bombeck 9456b18497 Carry the owner's full name through the account-access round trip test
The account payload gained a full-name field; this integration test
built its expected entries without it, so the exact-match assertions
disagreed with the response. The fixtures name the field now, null for
an owner who set no full name.
2026-08-08 18:02:21 +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 fa402f2f72 test(vaccinations): drive the four new admissions through the manage matrix
A frozen tag is a promise about code; the matrix is the one that proves
behaviour. Each of the four vaccination mutations now has its own
driver: a manager with a live grant changes the owner's record and the
audit row names both people, the same person under a lesser grant is
refused with nothing moved, and a caller who was never granted anything
gets the same refusal.
2026-08-08 16:47:50 +02:00
Marc-André Bombeck f28c6df861 Merge branch 'main' into feat/encounter-phase-2 2026-08-08 16:11:50 +02:00
Marc-André Bombeck 5d6bc73cdc test(encounters): guard the batched link redaction on the visit list
The per-family withholding of a link's label and date from a scoped delegate
was proven on the detail read and not on the list read. The two resolve links
through different functions — the list through the batched loadEncounterLinksForMany,
added when links moved onto the list — and a batched read that dropped the
redaction would leak a lab analyte and its date to a delegate the single read
withholds them from, with no assertion on the detail path to notice.

Three legs, mirroring the detail coverage against the list route: an
entire-record delegate sees every name, a profile-only delegate sees the shape
of all three links and the name of none, and a profile-plus-labs delegate sees
the lab name and neither of the other two. The entire-record leg is the control
that stops the redaction assertion passing against a read that redacts
unconditionally.
2026-08-08 15:54:19 +02:00
Marc-André Bombeck 75c43c74bc docs(mood): state the validation's real leak boundary, drop the unused weekday floor, correct two comments
Four honesty fixes with no behaviour change to the math.

The validation header claimed more than it proves. The ridge FIT is
out-of-sample and no training fold sees ahead — that holds. But feature
SELECTION and per-user standardisation both run over the whole window
before the split, a small documented optimism (measured at about one
part in sixty on the error). The header now says exactly what is and is
not leak-free, and says to nest the screening if the feature set ever
grows or the optimism is ever measured larger.

The per-weekday observation floor had no consumer: no weekday board is
built on this fit, the forecast only publishes seasonalUnlocked and
leaves the weekday view to the insights page, which has its own floors.
Removed rather than left as a number nothing reads, with a note to bring
it back alongside such a board.

Two comments were wrong and are now true: the route's parameter is
declared for the exported type, not to steer a method check the wrapper
resolves from the Next.js request with a GET fallback; and the label
surface imports the key parser from feature-keys.ts, not features.ts.

And the write-surface guard now states its own structural blind spot,
the way the session-surface guard does: the wipe path deletes this table
through a dynamically-resolved delegate the literal matcher cannot see,
which is why that deletion is correctly not counted as a writer.

The Coach staleness fixture moves above the forecast floor, because the
read path now refuses below it: a one-entry account correctly gets an
absence rather than the present forecast that case exercises.
2026-08-08 15:30:38 +02:00
Marc-André Bombeck 9a47b4db49 fix(mood): refuse a forecast below the data floor on the read path, not only in the job
The job clears a below-floor account's rows nightly, but the read path
trusted whatever row was on disk between passes. So for up to a day
after an account deleted entries back under the floor, GET
/api/mood/prognosis returned present:true with a value the account no
longer had the data to support — and worse, under a stage the row
implied ("insufficient" / "learning") that is not in the published enum,
which fails a strict client decode of the whole payload.

The read path now re-reads the ladder and returns the absence shape
below the floor, exactly as the job would, so the two agree and no
out-of-enum stage can reach the wire. The present type's stage is
narrowed to the three rungs that carry a value, so the compiler enforces
it too.

The integration case that missed this ran the job before reading, which
is precisely why it was silent. The new case deletes entries back into
the learning band and reads WITHOUT running the job, with the row still
proven on disk, and asserts present:false with no stage. Watched red:
before the fix it returned present:true with a predicted value.
2026-08-08 15:30:00 +02:00
Marc-André Bombeck 2d69842994 fix(encounters): the visit list carries its links
Two defects behind one omission. The card counts what a visit produced, so a
list that dropped the links made every card read zero for a visit with three
documents. And the edit sheet seeds its pickers from the row it was handed: an
edit opened from a linkless list row would save a replace-set of three empty
arrays and silently unfile everything the visit had collected.

The links now ride the list, resolved in three grouped queries for the whole
page through the batched arm of the same service the detail uses, with the same
per-family redaction from the same predicate. The single-visit read hook had no
caller and is gone with it — the list is where the surfaces read from.

Both defects get a test that would have caught them: the card counts assert
against a populated links object, the draft asserts the arrays survive a round
trip through draftToBody, and the integration file asserts the list response
itself carries every family.
2026-08-08 15:06:10 +02:00
Marc-André Bombeck bdfea7dd85 test(encounters): the Phase 2 surfaces against real Postgres, and end to end
Three properties here cannot be answered by a mocked client, and each has a
shape that would pass a weaker assertion while being wrong:

- filing a visit against a due checkup twice satisfies it ONCE. Phrased as
  "the second call returned 200" it proves nothing, so the assertion is that
  lastSatisfiedAt did not move — and the instant is captured once, because two
  calls milliseconds apart are two different filings and the forward-only guard
  correctly advances on the later one.
- a lab commit writes one link PER RESULT ROW. A test that asserted "there is
  a link" would pass on one link for a three-marker panel.
- the visit list's condition filter runs in SQL. With one visit in the account
  a filter and no filter return the same list, so there are always two.

The bulk arm gets per-entry tolerance over a hundred documents, one unknown id
reported as one failed row with the rest filed, and a foreign visit refusing
the whole batch before anything is written.

The e2e spec proves the incidental-linking promise once, addressing stable
data-slot attributes rather than viewport text: a visit saves with a date and
nothing else, and a document dated inside the window offers that visit,
pre-selected because there is exactly one, and files itself against it.
2026-08-08 14:51:56 +02:00
Marc-André Bombeck 82982038cb feat(vaccinations): let a logged dose clear the boosters it answers
- the dose is expanded to its component antigens first, so one Tdap
  entry settles a tetanus booster and a pertussis booster at once;
  matching the preparation's own slug would leave both standing
- it runs inside the create transaction and nowhere else: no cron, no
  ingest hook, and an edit never re-runs it, so correcting a typo in a
  date cannot move a reminder
- forward-only, which is what makes transcribing an old record safe: a
  page from decades ago never clears a booster that is due now
- a free-text-only dose matches nothing; a name string is not evidence
  about what was in the syringe
- an ordinary preventive-care reminder carries no antigen key and is
  structurally out of reach
2026-08-08 14:36:08 +02:00
Marc-André Bombeck 7ac81f3eed feat(vaccinations): publish the routes, classify them, gate the module
- four route files: list and create, read and edit and delete, restore,
  and the link pair; every delegable one is classified as health
  background, in each leg of the sharing guard, with its reason written
- link and unlink stay bare, consistent with the document and visit link
  routes; the three are revisited as one decision
- vaccinations becomes the nineteenth module key, default-on and
  surface-gated like medications: the data routes stay exempt so a
  restore keeps working and re-enabling finds every dose intact
- a delegate's dose entries get their own sentence in the owner's
  activity view rather than reading as a generic change
2026-08-08 14:27:36 +02:00
Marc-André Bombeck e22c7ddfcc feat(vaccinations): teach the link facade the dose-to-document pair
- one new source and one new pair; the module's invariants carry over
  unchanged, so ownership, idempotency, the batch cap and the
  soft-delete refusal need no new code
- each invariant gets its own case against the new table, because every
  one of them is a where clause on a different table and a pair wired to
  the wrong column would pass all the existing ones
- the ceiling is written down and asserted: the vaccination source owns
  exactly one pair, and asking for a second is refused loudly
2026-08-08 14:05:58 +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 8d48e3e262 Merge remote-tracking branch 'origin/main' into feat/mood-prognosis 2026-08-08 13:00:02 +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 5c25bde822 Pin the orphan sweep in both directions
The sweep that retires an appointment reminder no visit claims had no
test of its own. Two now, because the rule has two halves and only one
of them is obvious.

It reaps a live row nothing points at, and it must leave a row whose
only claimant is a soft-deleted visit. That second half is the subtle
one: the sweep matches on the relation and does not read the visit's
tombstone, so a deleted visit still owns its nudge and restoring the
visit finds it intact. A future change that "tidied" the predicate by
also excluding tombstoned visits would destroy that on the next
unrelated write, and nothing else would notice.

The visit is tombstoned directly rather than through the delete route,
which retires the reminder deliberately and would have proven nothing.
2026-08-08 12:20:19 +02:00
Marc-André Bombeck c33f7f9f6a feat(mood): let the Coach read the deviation, and publish the resolved forecast
The snapshot gains the day's two readings with the age of the second
one, through the same reader the page uses, so the page and the
assistant cannot disagree about what today's comparison is. The
freshness rule is the shared one: a comparison about the day before
yesterday carries the date it is about and an instruction not to state
it as today. Absence is a present field rather than a missing key —
a model told "there is none yet" says so, and a model told nothing
estimates one. The wording rule travels with the values, because a model
handed a number and no instruction writes the sentence its training
suggests, and about a forecast that sentence is a causal one.

The contract publishes the resolved value: the expected figure, the
band, the count, the deviation against the band, the age, and the
contributions that carried weight. Nothing is left for a client to
re-derive — two clients deriving the same figure about somebody's health
record is how they start disagreeing about it. The description carries
the observational wording and the display rule: the count goes wherever
the value goes.

The end-to-end proof runs against real Postgres and reads the table
rather than a return value: nothing written a day short of the
threshold, a banded and counted row on the day it crosses, the
provisional label dropped at sixty days, the rows cleared when the
module goes off, the rows cleared when entries are deleted back below
the threshold, and an older pass's row replaced rather than left beside
a fresh one. A delegate holding only the mind section is refused, and
the assertion is on the response bytes.
2026-08-08 12:15:18 +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 61c220b7b8 Merge branch 'main' into feat/mood-level-b 2026-08-08 10:57:59 +02:00
Marc-André Bombeck eac40e79d6 fix(api): a bad dimension skips its own bulk entry, as the contract said
The doc comment beside `a1`..`a5` promised that an out-of-range value marks
THAT entry skipped and never the batch, while the fields sat in the batch
schema and answered 422 for all five hundred. The words were right and the
code was not, and a phone draining a year of local history lost the year to
one row.

The five now parse per entry, the same way the day context does; a bad row
reports `invalid_dimensions` and the rest of the batch lands.

The split buys the tolerance and costs a second copy of "0 to 10", so a guard
offers the same value to both schemas and makes them agree at both ends of the
scale and one step outside each.
2026-08-08 09:58:50 +02:00
Marc-André Bombeck 88f83104e0 fix(mood): the linked day read crosses sections, so it declares record
The route answered sleep minutes, steps, active energy, resting heart rate,
heart-rate variability and an illness day-log with its symptom keys, and it
declared `mind`. A delegate given only the mind section could walk arbitrary
dates through the one URL and read two sections the owner had declined to
share; no mood entry had to exist for any of those dates.

`record` is the convention for a read that crosses sections, and a scoped
grant never reaches one, so the scoped delegate loses the linked block rather
than receiving a filtered day. A block that silently dropped the sections a
grant does not cover would show a day that looks empty where the truth is that
it is none of the reader's business.

The reason is written at the declaration and in the frozen surface entry,
whose previous justification held only for whole-record grants.
2026-08-08 09:58:22 +02:00
Marc-André Bombeck 4f3a790ce7 test(workouts): pin the losing side of a concurrent enrichment
Two requests can probe the same workout, both find no heart-rate series,
and both mean to attach one. The unique index on `workout_id` picks a
winner; the loser has to report the duplicate it turned out to be rather
than claim an attach the database never made. Nothing could catch that
arm going wrong: the unit fake echoes every row back as landed, and the
race suite carries no series at all, so replacing the status with a
constant `enriched` left the whole suite green. Two concurrent posts of
the same enrichment now assert one `enriched` against one `duplicate`
and a single stored series.

The published contract also says now what the counters mean, instead of
leaving it in a comment: an entry reported as `enriched` counts towards
`duplicates`, because the top-level counters count workout rows and an
enrichment writes none. Field descriptions on the response and on the
per-entry status and reason, OpenAPI regenerated.
2026-08-08 09:53:09 +02:00
Marc-André Bombeck 508d44633e fix(workouts): one series, one enrichment, when a batch names a workout twice
Two entries carrying the same external id and both a `samples` array
reached the enrichment arm one after the other, and both read the same
landed workout id back, so the response claimed two attaches where the
database had written one. The second entry now takes the duplicate arm:
a workout is claimed by the first entry of the batch that reaches it.
2026-08-08 09:26:24 +02:00
Marc-André Bombeck d24dfd89a9 Drive each new visit mutation through the sharing matrix
The v1.37 matrix demands an executable driver per admitted mutating
handler, not just a line in the inventory: it runs the real route as a
delegate, asserts the effect landed once, and asserts the audit row
names the acting delegate rather than the owner. Eight admissions came
with the visit API, so eight drivers land with them.

The visit fixture is deliberately in the past. A planned future visit
mints an appointment reminder, and a driver that asserted one effect
while producing two would be checking the wrong thing.
2026-08-08 09:19:06 +02:00
Marc-André Bombeck 3d55289ba3 feat(workouts): attach a heart-rate series to a workout already stored
A batch entry matching a stored workout stays first-write-wins for the
workout row. What it can do now is hand over a heart-rate series the
stored workout does not have yet: the series is attached, the row itself
is not written at all, and the entry reports back as `enriched`. A
workout that already carries a series is a no-op, so a client can walk
its whole history as often as it likes.

- The pre-flight probe now answers both questions at once, whether the
  row exists and whether it already has a series, and carries the id so
  the attach addresses the stored workout without a second round trip.
- The attach rides the same child write as a fresh workout's series.
  RETURNING names the rows that landed, so a workout that gained a
  series between probe and write reports as the duplicate it is instead
  of claiming an attach that never happened.
- A `samples` array that fails validation is refused per entry with
  `reason: "invalid_samples"` rather than failing the batch. One
  unusable session out of a hundred must not stall a history sweep the
  client cannot repair by retrying; every other validation failure keeps
  failing the batch.
- The wide event and the audit row tally enrichments. Counts only, the
  series never reaches either.
- `enriched` is the one contract movement: additive on the per-entry
  status, request shape unchanged. OpenAPI regenerated.

Integration coverage against a real Postgres pins the attach, the
repeat as a no-op, the refusal of a bad series alongside entries that
land, a mixed batch reporting each status, and the stored workout row
compared whole before and after, `updated_at` included.
2026-08-08 09:15:44 +02:00
Marc-André Bombeck 7b1a44e799 chore(mood): account for MoodContext in the four whole-schema guards
- the wipe plan names it before its parent, so the confirmation screen's count
  is the number of rows that existed rather than zero
- the backup plan classifies it as carried and two-ended, and the round-trip
  registry seeds one, which is what makes that claim provable
- the sharing surface admits the linked-day read with the reason it may answer
- CHANGELOG for the release
2026-08-08 08:10:15 +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 8f263e4767 feat(api): publish the mood dimensions and the day context on the wire
- /api/sync/changes carries a1 to a5 and the day context under the same keys
  the write path accepts, so a client reads back exactly what it can post
- BulkMoodEntry accepts both, all optional; a1 stays server-derived from the
  five-point label, so a build that sends only a label still writes a complete
  row and needs no change to stay correct
- the batch parses each entry's context on its own: one row carrying a value
  the vocabulary does not name is skipped and the rest of the batch lands
- docs/api/openapi.yaml regenerated alongside
2026-08-08 07:46:14 +02:00
Marc-André Bombeck ba21c3a9bc Put both link families behind one service
Three narrow link tables landed with the visit record, which is a step
away from the one-shared-service direction the polymorphic-linking work
argues for. This bounds that step to a single module: every caller now
speaks one vocabulary — a source, a target kind, a list of target ids —
so replacing the storage underneath later is a change in one file rather
than a sweep across every route that files something.

The document-condition links move behind the same signature, so there
are two families and one call surface. Behaviour is unchanged and the
existing vault tests are the proof; where a test double had to follow
the new query shape, it says why in the diff.

The rules that used to live at each call site now live in the module and
are proven against real Postgres rather than a fake that ignores a
where clause: both ends re-read scoped to the owner before a row is
written, a repeated link is a success that changes nothing, a call is
capped at a hundred targets, and a tombstoned record is not linkable on
either end. Unlinking one deliberately still works after a delete, or a
filing nobody can clear would be left behind.

Reads return a resolved label and date rather than a bare id, so a
client rendering a list never re-derives what the server already knows.
The restore is the one documented exemption and writes the tables
directly: it rebuilds an account rather than filing something in one.
2026-08-08 07:26:14 +02:00
Marc-André Bombeck 1f609d42e0 feat(mood): resolve sleep, activity, vitals and symptoms at read time
- one resolver reads the day's figures from the modules that own them; nothing
  is copied onto the mood row, so correcting a sleep session corrects it here
  in the same moment
- absence answers { present: false } rather than zero, because a night nobody
  recorded and a night of no sleep are different facts
- a module switched off blanks its whole block instead of filtering down to an
  empty one
- keyed by the local day so the capture sheet can ask before an entry exists
2026-08-08 07:22:33 +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 8045540b7e Merge pull request #735 from MBombeck/feat/mood-level-a
Five dimensions behind the mood check-in
2026-08-08 04:29:27 +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 cbf949d43a Merge branch 'main' into fix/wipe-geo-truths 2026-08-08 03:29:51 +02:00
Marc-André Bombeck 3dd79ec734 fix(mood): one spelling on the wire, one day-key helper, one written decision
Three small things the audit named together.

The mood reads spread the whole row, so adding the wire keys shipped
every level-A value twice — once as the column name and once as the key
the write path accepts. A client would have picked one and been stuck
with it. The column spelling is stripped now, and the create response's
key set is asserted so it stays stripped. The published contract is
unaffected either way; these routes are not in it.

The dimension series carried its own copy of the day-key subtraction
that the freshness module already exports. One copy now.

And the reason a stale dimension ships with a notice instead of being
dropped the way the status surfaces drop one is written at the builder:
a one-line card has nothing left to say about a value it cannot narrate,
while an open conversation can say when the value is from — and silence
there would leave no way to tell an unanswered dimension from one nobody
has looked at in a week.
2026-08-08 03:03:38 +02:00
Marc-André Bombeck 4db0f3a62a fix(mood): say which age governs a claim about a dimension
The snapshot stamps every metric block with an asOf built from the
newest reading in it, which for mood is the newest ENTRY on the 1-5
axis. Somebody who taps a face each morning and opens the sliders once
a week therefore carries a block stamped as current and a stress
reading six days old, and a reader taking the block stamp as covering
the dimensions states last week's answer as today's.

The block now names the precedence in one line beside the dimensions,
and each dimension keeps its own age. Present only when there are
dimensions to explain.
2026-08-08 02:57:40 +02:00
Marc-André Bombeck 0a39d50b05 fix(mood): stop offering to clear a value that cannot be cleared
An entry always carries a five-point label, so it always implies a
pleasantness value: the create path derived one from a null and the
capture sheet still showed a clear control for it, which meant a button
the server undid on the way to the database.

One rule on both routes now — a null asks for a value that cannot exist
and gets the derivation, on create and on edit alike — and the control
is gone for that field. The other four are genuinely optional and keep
theirs.
2026-08-08 02:55:08 +02:00
Marc-André Bombeck aa56246ba2 fix(backup): the restore states a level-A value only when the file has one
Matches what syncVersion, syncedAt and the row stamps already do a few
lines above: a key the file does not carry is a key the file predates,
not a key the file says is empty. Reachable when one file addresses the
same row twice, where the second entry now adds without erasing what
the first one set; an explicit null still clears.

Worth stating plainly, because the audit that asked for this expected it
to protect live rows from an older file: it does not, and it cannot. The
restore deletes the owner's whole mood partition before it rebuilds, so
by the time the update arm runs there is nothing live left to protect. A
test now pins that contract too, so nobody reads the omission as a
guard it is not.
2026-08-08 02:52:41 +02:00
Marc-André Bombeck d3577d2557 test(mood): cover the level-A blocks at the composition that ships
The staleness test rebuilds the block shape from the pure calculator, so
it proves the calculator and the freshness rule agree and nothing else:
hardcoding the freshness flag in the feature builder, deleting the
notice that goes with a stale value, or dropping the whole block left it
green. This calls extractFeatures against real rows instead.

Both failures were watched. With the flag hardcoded true the unit test
still reported four passing while this one named the six-day-old stress;
with the notice removed it named the missing instruction.
2026-08-08 02:48:53 +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 30f35cfcaf Merge branch 'main' into fix/mood-backup-completeness 2026-08-08 01:48:51 +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 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