Commit Graph
567 Commits
Author SHA1 Message Date
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
Marc-André Bombeck 643d8a5a00 feat(mood): map existing entries onto the pleasantness scale
- the migration fills mood_a1 from the five-point label for every row
  that has none, and leaves the other four columns empty because there
  is nothing in a label to derive them from
- guarded on the column being empty, so re-applying the migration is a
  no-op rather than an overwrite of an answer somebody gave since
- the test reads the CASE arms back out of the .sql and compares them to
  the runtime map in both directions, so neither can drift alone
- rows are asserted by value after the statement runs, and again after a
  second run, including one hand-set value that must survive it
2026-08-08 01:03:05 +02:00
Marc-André Bombeck 0849d59ecf test(cycle): pin the fixture clock to the suite's clock
Two cycle suites compute their relative day keys with local Date arithmetic
under TZ=UTC while the routes resolve today in the user's zone, falling back
to Europe/Berlin when the fixture sets none. For two hours every evening the
two clocks disagree about the date and every day-of-cycle assertion is off
by one — the suite was red from 22:00 UTC to midnight and green the rest of
the day. The fixtures now carry an explicit UTC timezone so the test's clock
and the route's clock are the same clock at any hour. Reproduced red at
23:07 UTC, green with the pin at 23:19 UTC.
2026-08-08 01:02:08 +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 72c5181218 Merge branch 'main' into feat/lab-source-reference-ranges 2026-08-08 00:06:22 +02:00
Marc-André Bombeck 9388f1d032 test(backup): round-trip a mood entry through the real restore
Seeds an entry with a ticked tag, a rated factor, an encrypted note, a
non-Berlin timezone and a sync counter above zero, exports through the real
builder, restores through the real `POST /api/admin/backups/[id]/restore`,
and reads the row back out of Postgres. Values, not a payload shape: this
repository has already proved that asserting a `moodEntries` key stays green
with the restore call excised.

Two more cases beside it. A hand-written payload in the shape files were
written in before the note, zone, counter and tag keys rode along — the
compatibility floor, and the only way to know it holds is to send one. And a
ticked tag this instance cannot resolve, which has to come back named in the
skip report rather than silently absent.

Seen red three times on the way in: re-adding the tag-link filter loses the
ticked tags, dropping `tz` from the restore write loses the zone, dropping
the note ciphertext from the payload loses the text. Each reverted and the
file's hash checked back to its recorded value.
2026-08-07 21:44:01 +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 8a1585a391 Merge pull request #723 from MBombeck/fix/cycle-correctness
Cycle correctness: overdue verdicts, backfills, deletes that leave no torso
2026-08-07 19:58:39 +02:00
Marc-André Bombeck 3778e70ff5 Merge pull request #720 from MBombeck/fix/handover-p1-batch
Four data fixes: backfilled doses, impossible readings, stale today-claims, sleep units
2026-08-07 19:32:40 +02:00
Marc-André Bombeck 626ec203ac Merge branch 'main' into fix/cycle-correctness 2026-08-07 18:24:37 +02:00
Marc-André Bombeck 61501500b9 Merge branch 'main' into feat/workout-hr-profile 2026-08-07 18:24:35 +02:00
Marc-André Bombeck 384992bb15 Merge branch 'main' into fix/handover-p1-batch 2026-08-07 18:24:33 +02:00
Marc-André Bombeck 82490870e5 fix(coach): make the launcher's dot mean a message you can read
The Coach button showed its unread dot over an empty conversation, and
opening the Coach neither showed anything nor cleared the dot.

Two paths fed one indicator. The nightly sweep flipped a "remind me"
reminder from active to due and stopped there — no conversation, no
message, and `surfaced` had no writer at all. The launcher lit its dot off
a second query that asked only whether anything was due. So the dot
promised content that was never written, and because opening the Coach
clears the message-based signal and not the reminder one, the only way out
was to resolve the reminder in Settings, which nothing on the badge
suggested. A reminder minted for a plan review put a user who had never
typed in the Coach in exactly that state.

The sweep now writes the reminder into the user's Coach thread as a real
assistant message and marks it surfaced in the same transaction: no
message, no status change, and nothing to point at. The badge derives from
that message like every other proactive one, so opening the Coach shows the
note and clears the dot. The launcher's second query is gone.

Reminders an older build left stuck on due are picked up by the same pass,
so an indicator that has been sitting on an empty conversation resolves
into the message it always implied.

No provider call and no push: the body is a deterministic localized
template around the user's own note text, six languages.

Proven end to end against Postgres — the row exists, the badge reads that
row, opening clears it, a second tick writes nothing, and a failed write
leaves the reminder untouched with the badge dark. All five fail against
the previous sweep.

The second indicator in the report, under the bottom bar's overflow entry,
is the active-route marker for that group and not a badge.

Refs #715
2026-08-07 16:29:58 +02:00
Marc-André Bombeck 3596720905 fix(cycle): let a period logged as flow count as a period
MenstrualCycle rows were only ever written by the one-tap period
boundary. Every other way of recording bleeding wrote a day-log and
stopped there: the log sheet's flow chips, the bulk drain, an Apple
Health export full of menstrual-flow samples. The engine reads cycle
rows, so a person could log every period they had and the module still
held that none had happened. No cycle length, no forecast, an empty
chart, and an offer to log a first period. An Apple Health user who
never found the one-tap button had the whole module dead.

A first bleeding day now opens the cycle it belongs to, through one
shared seam in the day-log write helper, so the single capture, the
bulk drain and the Apple Health fold all infer a start the same way and
the one-tap boundary's own write lands on a date that already has a
cycle, which the rule declines.

The rule is conservative, because a false start corrupts every length
derived after it. Spotting does not open a cycle: it is as often the
tail or the herald of a period as its first day. Bleeding flagged as
between periods does not, since that flag exists to say so. Neither does
a bleeding day within the hard physiological minimum of an existing
start, which is what keeps day three of a period, or a forgotten day
two, from starting a cycle of its own without needing a contiguity
check. A genuinely short cycle is not inferred from flow alone; the
one-tap start still opens one on any date.

An edit is symmetric. Setting a flow opens the cycle; clearing it, or
flagging it as between periods, removes the cycle that day opened and
re-opens the one before it. A cycle that an edit can create and not undo
leaves the record holding a period nothing on the day says happened.

- the period route's start arm and the flow inference now share one
  boundary helper, so a start means the same thing however it is entered
- back-dating re-anchors both neighbours, as the one-tap start does
- integration: eleven cases through the real routes, of which five are
  about what must NOT open a cycle
2026-08-07 15:50:23 +02:00
Marc-André Bombeck bd6089734d fix(cycle): let the month grid read the month it is showing
The page reads one window anchored on today, ninety days back and half a
year forward, and the month grid navigates freely past it. A month
outside the window came back with no days: the cells still rendered
(they come from the month, not the read) and still opened the log sheet,
and the write behind it still landed, because the period route is built
for back-dating and re-anchors the neighbouring cycles for it. What did
not happen is anything visible. Saving an April flow in August closed
the sheet onto a month the read never carried, so the entry looked like
it had not been made, and entering it again did not help.

The grid now reports the month it moved to and gets its own bounded read
for it. The anchored window is untouched, because every other surface on
the page means today and the route caps a span at 400 days, so a union
with a month two years back would simply be refused.

A write also says so now. Saving, deleting and both period boundaries
raise a confirmation; the sheet's inline strip already named a failure.
Between the invisible month and the silent success there was no way to
tell a save that worked from one that never happened.

The sheet header carries the date it is writing to, and the one-tap
boundaries drop the word today when the day is not today. It always
wrote to the selected date; saying otherwise made a correction read as a
mistake.

The gear beside the log button is gone. It was a second door to the tab
directly below it and no other page carries one in that corner.

- calendar days carry isCycleStart, so the delete dialog can say that
  removing this day removes the period start with it
- window arithmetic is a pure module with its own tests, including the
  400-day span the anchor cannot be widened past
- integration: a day 111 back is written, is absent from the anchored
  read, and comes back complete on a window over its month
- new strings in all six languages
2026-08-07 15:20:29 +02:00
Marc-André Bombeck 6db2f6f9ae fix(cycle): make removing a period start restore the neighbour it moved
Opening a cycle closes the one before it: the start write stamps the
previous cycle's endDate and lengthDays, and a back-filled start takes
its own end from its successor. Nothing undid any of that. Deleting the
start left the previous cycle as a closed torso holding an end date from
a cycle that no longer existed, and a record whose last cycle is closed
has no open cycle to forecast from — the prediction went away and there
was no way to bring it back from the app.

Both removal paths now re-derive the boundary from the cycles that
remain: a cycle ends the day before the next one begins, and with no
next one it is open again. That is a re-derivation rather than an undo
log, so it is correct for a history that came from an import or a
restore, and running it twice changes nothing the second time.

Deleting the day-log that opened a cycle now removes that cycle too.
The one-tap start writes both rows and the log sheet's delete is the
only way the web offers to take it back; leaving the cycle standing was
how the broken state survived the undo.

- soft-delete and re-anchor run in one transaction
- the audit row names the neighbour's moved boundary, the only part of
  the write the tombstone cannot reconstruct
- round trip through the real routes: photograph the cycle rows, log the
  start, remove it either way, compare field by field — including that
  the forecast comes back
2026-08-07 15:03:10 +02:00
Marc-André Bombeck 2f14379066 Carry the workout heart-rate curve to the end of the session
`durationSec` is a denormalised duration, and not every source reports
the elapsed one. Strava sends `moving_time` while the row's `endedAt` is
`startedAt + elapsed_time`, so a ride with twenty minutes of stops
carries a duration twenty minutes shorter than its own span. The curve
folded against that duration, which dropped every reading after the
moving-time mark: the tail of the session vanished with no gap and no
marker, and the detail page showed a complete-looking profile of an
incomplete session.

Fold over the span instead, with the duration as the floor for rows
whose `endedAt` is not after their `startedAt`. The bucket width tracks
the span too, so a stop-heavy ride keeps the same on-screen density as a
continuous one of the same length.

The reconstruction path also gets an integration suite over the real
route and a real Postgres. Two boundaries decide whether it is honest
and neither was provable against a mocked client: the query pads by five
minutes so a boundary reading still lands in the first or last bucket,
and a reading inside that pad must not extend the curve past the finish;
the measurement read is owner-scoped, and another account's pulse at the
same minute must not enter this curve. Both fixtures place a reading on
the far side with a bpm value no in-window reading has, so a leak shows
up in the numbers rather than only in a count. Verified by breaking each
boundary in turn and watching the matching assertion fail.
2026-08-07 14:41:24 +02:00
Marc-André Bombeck d97e3c0976 fix(measurements): store a hand-entered night in the unit the field asks for
The manual sleep field is labelled in hours. The column stores minutes, because
that is what every sync source delivers and because a single stage segment is
only a few of them. The form sent the typed number straight through, so a night
entered as 7.5 was filed as seven and a half minutes and the list showed eight
minutes of sleep.

Nothing rejected it. Seven and a half minutes is inside the column's
plausibility band — a stage really can be that short — so the entry looked
accepted and the loss was silent.

Both ends were already right: the field says hours, the list formats minutes as
hours and minutes. The assembly between them was where the factor went missing,
which is why the round-trip test runs the whole of it — the form's conversion,
the shipped POST, the stored row, and the formatter the list paints with.

The storage unit stays minutes. The conversion sits at the entry boundary in
one place with its inverse beside it, so a future editing surface cannot land
on a different answer, and it rounds to whole minutes rather than storing
437.99999999999994 for 7.3 hours.

The value field also lost "7,5" outright. `type="number"` sanitises a decimal
comma away on the engines that do not localise the field, so the entry arrived
as an empty string with no explanation, and `parseFloat` would have read it as
7 on the engines where it survived. The field now takes the same shape the labs
and custom-metric fields use — a numeric keypad and a submit path that reads
the comma — so every decimal metric on the form gains it, not only sleep.

`scripts/diagnose-manual-sleep-hours.ts` lists the rows already stored this
way. It reports and does not repair: an eight-minute stage and a night typed as
eight hours are the same number in the same column, and only the person who
entered it knows which it was.
2026-08-07 14:30:10 +02:00
Marc-André Bombeck 4563bce5b3 fix(insights): keep impossible readings out of the personal band
The baseline-drift card reported a pulse in the hundreds of millions against a
usual range in the tens of thousands. Neither figure is a heart rate, and both
came from the same place.

The application declares a plausibility domain per metric, and every
interactive, import, MCP and Telegram write validates against it. The
derivation layer did not ask. So a single stored value outside that domain — a
provider unit-decode slip, or a row from a writer that never had the gate — was
folded into the day mean, carried by the median into the band, and then held
against itself as "currently". The two numbers in the sentence were wrong by
different amounts, which is why the line read as nonsense rather than as an
exaggeration.

Both readers now refuse a value the application has already declared
impossible: the per-day means behind the band, and the latest-day mean the
deviation reads. A day left with no plausible reading contributes nothing
rather than a fabricated point, and the vital drops out of the comparison — the
coverage floor already knows how to report an absent day. The rollup branch
applies the same rule to a bucket mean, since the rollup writer averages
whatever is stored. Everything downstream of `readDayMeanSeries` inherits it,
which is every consumer of the usual range: the band strip, the drift card, the
changepoint detector, readiness, trajectory, the coach read.

The card also printed raw doubles. Each figure now renders at the metric's own
decimal precision through the shared formatter, so a mean reads as a number
rather than as a float and follows the reader's number convention.

Left open, and recorded for a separate pass: the provider sync writers do not
enforce the domain on the way in, so the impossible rows keep landing.
2026-08-07 14:16:17 +02:00
Marc-André Bombeck 4e9e8649ec fix(medications): record the slot a backfilled dose names
Marking a missed dose taken from the Verlauf ledger showed the row flip to
taken and then fall straight back to missed, with the entry nowhere on the day
it was made for.

The ledger posts the displayed slot's own anchor as `scheduledFor` and means
"record THIS dose". The route resolved the slot by window band from `takenAt`
instead, which answers a different question: which slot the recording moment
falls into. Backfilling an older slot matches no band, so the write fell
through to the standalone insert and landed as an ad-hoc row anchored at the
recording moment. The named slot stayed unserved, the optimistic row was
overwritten by the authoritative refetch, and the dose sat under today with no
sign of where the user had put it.

A named instant that is a real slot of this medication now decides the binding,
validated against the same band anchors the "diesem Slot zuordnen" pin uses, so
an arbitrary client instant still records ad-hoc. A slot that lies ahead of the
take is still refused by the existing forward guard, so a late-morning dose
cannot consume the evening slot. When the naming and the clock disagree the
binding rests on the user's choice, so the row stores it as such and the read
ledger anchors it there rather than dropping back to ad-hoc; an ordinary
on-time take resolves to the same anchor either way and is untouched.

The tests drive the shipped POST and the shipped dose-history GET, and assert
the persisted row and the ledger read back from it.
2026-08-07 14:03:09 +02:00
Marc-André Bombeck e2eea55b6e chore: drop two exports nothing reaches
The dead-code gate has been red on this branch since the sharing refusal
contracts landed. Neither name is new and neither was noticed, because
knip is not in the local gate.

consentRequiredResponse lost its last caller when every delegable route
started sharing one 403 description through recordRefusal(); the AI
consent sentence it wrapped is still used and stays, with a note at the
constant saying why there is no response object beside it any more.
mutateThenRestore was written with the sharing security fixtures and has
never been called.

No ignore entries: an export with no consumer is the thing the gate is
for.
2026-08-07 10:25:52 +02:00
Marc-André Bombeck c043a77429 test: wire every release journey to the tests that carry it
The eight journeys were enumerated and one of them was wired. The other
seven existed as claims in a fixture file, which is the gap the enumeration
was written to close: a browser suite reports what it ran, never what it did
not, and a journey nobody wired reads exactly like a journey that passed.

All eight are wired now, and the list is checked in both directions — an
enumerated journey missing from the wiring fails, not just a wiring entry
naming a journey nobody declared. Before this, a subset was legal, so a
dropped journey and a not-yet-written one looked the same.

A journey names more than one file where more than one file carries it. J1
claims a grant runs through invite, accept, switch, expiry, revoke and a
direct URL; the browser proves the switch, what each level opens and the
direct-URL refusal, and the transitions either side are database facts
proved against real Postgres. Folding that into one file would mean a
browser spec driving expiry through the clock or an integration file
asserting what a page renders, and both are worse tests than the two that
exist.

Three journeys ride Playwright and five ride Vitest, so a green run of this
guard means all eight are claimed and here is which have executed in the
ordinary gate — never that all eight passed. The runner is derived from the
path rather than trusted, so it cannot become a label set to whichever value
made the split read better.

The extractor now reads `it(` as well as `test(`, and the parameterised
`it.each(table)("title")` form alongside the single-call one. It saw neither
before: five of the journeys are proved by Vitest files, every per-file leg
is over a matched set, and an empty match set satisfied them all. The
synthetic spec that proves the parse grew a Vitest half, and it is what
caught the parameterised form being invisible. What the matcher still cannot
see is written into its own doc comment.
2026-08-07 06:11:36 +02:00
Marc-André Bombeck 93a588cf6f test: observe the reminder dedup anchor where it is written
The cross-midnight test read `push_attempts` for a synthetic `DEDUP`
channel. Separating the scheduler's bookkeeping from delivery diagnostics
moved the anchor to `notification_events`, and nothing has written that
channel since. The query matched no rows, `findMany` answered the empty
list, and the assertion compared an empty set it had no reason to expect —
a check that could not fail whatever the tick did.

It now reads `notification_events` by record and event type, and asserts
the single row's `dedupKey`. Removing the claim from the tick fails the
dispatch-count leg above it; narrowing the key so it no longer names the
slot fails this leg on the key's own content, which is what makes it a
check rather than a comment.
2026-08-07 06:02:31 +02:00
Marc-André Bombeck b2e225470b test: read the account payload as the release publishes it
Four cases in the accountAccess integration test were written against a
contract this release rejected. They asserted an entry of seven fields and a
block of three, both of which gained one; and they asserted that a MANAGE
grant publishes `access: "manage"`.

It does not, by design. `access` is the v1.36.0 name and its union is
`read | write` — a client compiled against that release decodes it as a
closed set. MANAGE therefore publishes as `write` there and as `manage` on
`level`, and the entry schema raises a validation issue unless a manage
entry carries exactly legacy write access and whole scope. The expectations
move; the schema does not.

`recordKind` is required on an entry and on the block. Both are asserted
now, including the block-level value moving from `self` to `shared` across
a switch, so a value stuck on either reads as two cases disagreeing.
2026-08-07 06:00:29 +02:00
Marc-André Bombeck 84475ea2f2 test: read the Guardian grant id under its published name
The lifecycle suite took the invitation route's answer as an account-grant row
and reached for `id` and `expiresAt`. It answers with the roster's entry now,
so the id arrives as `grantId` and the expiry is read off the stored column,
which proves it was persisted rather than echoed.
2026-08-07 05:24:11 +02:00
Marc-André Bombeck 0da9040f2c test: enforce sharing client step-up
Freezes the authority split the third level ships with. A token exercises a
MANAGE grant, and a Guardian's authority over a managed profile, exactly as a
browser does — proved at the resolver, where neither had a Bearer case before.
A token cannot offer MANAGE and cannot create a managed profile at all, because
both gates resolve through the session cookie; the second had no test until now,
and it runs against a real database with a wildcard token to make the width of
the permission irrelevant.

The claim set is held together by a map naming, per claim, the file and the test
title that proves it, so removing any one of them fails rather than quietly
narrowing what a native client may do.

The fence's client-facing half is frozen beside it: the account payload publishes
the record context on a cookie session and null on a token, and the published
contract says so under the same header names the server matches on.
2026-08-07 05:11:02 +02:00
Marc-André Bombeck 26fceca219 fix: answer the managed-profile Guardian routes in one shape
- publish the roster's entry from the invitation and `{ grantId, revokedAt }`
  from the removal, instead of the account-grant row Prisma returned
- drop the invitation's self refusal: it named a code no request can elicit,
  because a Guardian grant's grantor is the profile and never the caller
- name the duplicate grant error rather than catching every grant refusal into
  a sentence that would be wrong for any other one
- keep the rate limit and the unknown-identifier 404 free of an error code,
  as their siblings elsewhere are, and say why at the call site
2026-08-07 04:39:50 +02:00
Marc-André Bombeck 8793bbf229 fix: close four small gaps around the managed-profile surface
- bound profile creation at ten an hour per account, the ceiling its
  guardian sibling has carried since it shipped
- distinguish a failed roster read from a slow one, with the way back
- cover the three caller states the roster read has to refuse: an
  unaccepted invitation, an expired grant, and MANAGE held elsewhere
- cover what deletion does to a guardian standing inside the record
2026-08-07 03:56:39 +02:00
Marc-André Bombeck 6bfcccd755 feat: manage the guardians of a managed profile
- list who looks after a profile, invite another guardian and end an
  existing access from the profile's own row
- state the last-guardian floor from the real roster before the act, and
  say plainly that an invitation counts only once it is accepted
- give each of the invitation refusals its own sentence, branching on
  status for the two the route sends without an error code
- cover every call the card can make against the real routes
2026-08-07 02:22:30 +02:00
Marc-André Bombeck 365ba8c2eb feat: list the guardians of a managed profile
- add GET /api/managed-profiles/{id}/guardians, cookie-only, refusing
  anybody who is not an active guardian of that profile with the same 404
  an unknown id gets
- disclose the grant id, the party block already shared between grant
  parties, the derived state and the two timestamps, and nothing else
- publish the route in the OpenAPI contract
2026-08-07 02:01:21 +02:00
Marc-André Bombeck c7e847a766 fix: bound the activity feed to the window it promises
The revoke dialog tells an owner that "who entered what" stays answerable for
N days, and the activity list prints the same number under its heading. The
query behind that list had no date filter: on an instance whose retention was
shortened it showed rows the sentence said were already gone, and the number
was a caption over an unbounded read rather than its limit. It is the filter
now, resolved once so the bound and the sentence cannot drift apart.

The list also stopped at a hundred rows and said nothing about it, which is the
more dangerous of the two silences — an owner who scrolls to the bottom reads
the oldest visible line as the beginning, and a busy household reaches the cap
in a fraction of the window. The response says which of the two limits it hit,
and the view states it only when it did: a caveat that is always on is one
people learn to skip.

The self-hosting page said an empty list means nobody opened the record. It
means nobody did so within the window. Corrected, with the row cap named
alongside it.
2026-08-07 00:48:58 +02:00
Marc-André Bombeck f91f701e13 fix: say what a refused request actually was
Every delegable route files a breadcrumb when a request does not parse, and it
files it through auditLog on purpose: that helper is what stamps the actor, and
without the stamp a delegate's malformed request would read as the owner's own.
The rows are meant to be attributable. None of them had a sentence, so all of
them rendered in the owner's activity view as "made a change in your record" —
including one a view-only delegate can trigger by sending a query the route
cannot parse. Nothing had changed and nothing could have.

Thirty-three actions now have honest lines. A refusal says it was an attempt
and says it was refused; it never borrows the language of something that
happened. Two of them are filed by library modules the routes import — a
background import's completion row and a medication lifecycle reconciliation —
and a walk over route files alone could not see either.

The inventory guard was asking a different question from the feed. It kept only
awaited audit calls while the feed shows fire-and-forget rows identically, and
it read route files while the feed shows whatever the request reached. Both
criteria now match the feed's, the three rows the feed structurally cannot show
are excluded by name with the reason written down, and the exclusion list is
pinned at three. A leg asserts the shape of the sentence for every refusal, so
a future arm cannot map an attempt onto an ordinary verb and pass on coverage.

The whole round trip is covered against real Postgres: a view-only delegate
sends a malformed query, the row lands attributed to them under the owner, and
the sentence the owner reads is an attempt.
2026-08-07 00:40:40 +02:00
Marc-André Bombeck ef242f33bb feat: open the anamnesis record surface to managers
POST /api/allergies and POST /api/family-history have resolved
requireRecordAuth("manage", "profile") since the level shipped, while the only
form in the product that posts to either sat behind a Settings destination
classified unavailable. The write was admitted, audited and owner-scoped, and
nobody could reach it — the one-ended change this repository keeps
rediscovering, with the permission shipped and the surface left as a follow-up.

The destination now carries a classification that means what the routes mean:
reachable by everybody they already admit, which is a guardian inside the
profile they administer and an adult delegate holding manage. The four places
that decided visibility on the guardian-only literal were each updated
deliberately, and the shell that refused every settings path in an adult share
now admits exactly the destinations classified as record content. Record
configuration — modules, thresholds, notification routing — stays with the
owner at every level.

The page inside a record is its own component holding the two managers whose
routes answer for a delegate. Mounting the personal section would have put
three more forms on screen that refuse on submit. The family-history card says
whose statement the row is, which the route comment could not.

Both routes carried a long paragraph arguing the write was deliberately
unreachable. It is gone rather than left to send the next reader back to close
what this opens.

The integration coverage moves from the undeclared-mode refusal to a manage
contract: a manage delegate and a guardian each write and the row lands under
the owner with the delegate named as actor, while read and write delegates get
the insufficient-access refusal. Nothing exercised either route at manage
before.
2026-08-07 00:01:27 +02:00
Marc-André Bombeck 0cdfb97a45 test: cover sharing accessibility states
The shared-record accessibility suite had four tests for five states: success
and empty shared one, and neither of them ever entered an empty state. A
browser run reports what it ran and never what it did not, so a state nobody
wrote a test for produced exactly the same output as a state that passed.

The five states are enumerated in a fixture, each test names its state, and a
fast guard reads the spec back against the list — a state that loses its test,
its anchor, or one of its proofs now fails in the ordinary gate rather than at
the point somebody notices the spinner is silent.

Every state runs four proofs: an axe scan, the document reading order, either
a keyboard reach with a visible focus indicator or a live-region announcement
for the one state with nothing to press, and, where the copy is something a
person has to act on, that it renders as content rather than as muted meta and
clears the contrast floor. The retry control on the read-failure card gained a
stable slot so the keyboard proof targets the affordance rather than its
wording.

The eight release journeys are enumerated in the same fixture.
2026-08-06 23:46:43 +02:00
Marc-André Bombeck c11c462e57 fix: answer a duplicate measurement with 409 on both write paths
The batch path had no unique-violation handling at all, so one duplicate
anywhere in the transaction answered 500 — indistinguishable from a
server fault, and what every client retries against. The single path
narrowed with instanceof, the form prisma-errors.ts exists to replace.
2026-08-06 20:56:16 +02:00
Marc-André Bombeck 8fcbdcc0df test(02-12.1): prove the record-session fence end to end
- give every acceptance case a stable id and assert the mapping from a
  runnable artifact, recording which cases execute in the ordinary gate
  and which await the browser stage
- add the browser spec for the cases no cooperative layer can reach:
  peers reconciling to an exact context, an aborted switch response, a
  closed initiator, a delayed response, and the offline disk layers
2026-08-06 19:33:15 +02:00
Marc-André Bombeck 3a99de312e fix(02-12.1): check record context before an idempotent replay
- evaluate the fence in the wrapper above findCached, so a stale request
  neither replays a body, learns a cell exists, nor inserts a claim
- return the refusal rather than throwing: one route composes the
  wrapper outside apiHandler, where a throw escapes the envelope
- read the claim and the session's own context on one session lookup
2026-08-06 18:54:20 +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 5c9befc0ae feat(02-12.1): stamp a record-context epoch on every session
- add sessions.record_epoch, owned by a BEFORE UPDATE trigger so the
  ON DELETE SET NULL path and raw SQL writes cannot skip it
- normalise pre-existing rows so epoch zero means never switched
- project the epoch off the row getSession already loads
2026-08-06 17:52:10 +02:00
Marc-André Bombeck a08e82d0be Merge commit '38214e472' into work/v137-record-session-fence 2026-08-06 17:10:56 +02:00
Marc-André Bombeck 2ae3a66816 fix(02-11): bound APNs provider delivery
- Time out stalled provider sends without unhandled late rejections\n- Record a transient timeout outcome for cascade fallback\n- Cover timeout fallback with unit and integration tests
2026-08-06 14:10:28 +02:00
Marc-André Bombeck c56d1ca50b fix(02-11): protect guardian egress data lifecycle
- Exclude and erase egress claims with recipient-side lookup support\n- Serialize data wipes with managed-profile Guardian authorization\n- Cover last-Guardian and queued-claim wipe races
2026-08-06 14:05:59 +02:00
Marc-André Bombeck e8ce0e277a test(02-11): verify final claim uses database time
- Keep a stale application clock from authorizing an expired Guardian\n
2026-08-06 13:47:42 +02:00
Marc-André Bombeck 4b79f83cf7 fix(02-11): release guardian lock before provider egress
- Commit a final authorization claim under the lifecycle lock\n- Keep provider calls and retry outcomes outside database transactions\n- Retain authorization claims with notification delivery diagnostics
2026-08-06 13:46:42 +02:00
Marc-André Bombeck 1818bd874b fix(02-11): authorize guardian egress under lock
- Hold the managed-profile lock through every provider send\n- Add the notification event retention index and race coverage
2026-08-06 09:42:47 +02:00
Marc-André Bombeck 6ecf0cc8ff style(02-11): format notification attribution test
- Apply the repository formatter to the existing integration test
2026-08-06 09:37:42 +02:00
Marc-André Bombeck f5a02c2d29 test(02-11): cover guardian egress races
- Exercise revocation and expiry against blocked channel egress\n- Require a direct retention index for notification events
2026-08-06 09:35:43 +02:00
Marc-André Bombeck 60b4845fea feat(02-11): secure guardian notification delivery
- Remove record-mutating affordances from Guardian channel payloads\n- Reject managed Telegram callbacks and verify channel attribution
2026-08-06 05:44:41 +02:00
Marc-André Bombeck be4ee9ac6d test: cover guardian notification preferences 2026-08-06 05:30:49 +02:00