Commit Graph
6346 Commits
Author SHA1 Message Date
Marc-André Bombeck 67bb45a2b3 chore(api): keep one onboarding entry in the error-code catalogue
Two branches each added an onboarding surface to the catalogue; the object literal carried the key twice and the second entry hid the first from the guard.
2026-09-10 12:30:10 +02:00
Marc-André Bombeck 12d6aa3078 Merge the scoped-grant fences, the delegate controls that ask their own section and the published switch refusals 2026-09-10 12:29:09 +02:00
Marc-André Bombeck 26f95aa12e chore(api): list the onboarding routes' error codes in the catalogue 2026-09-10 12:28:53 +02:00
Marc-André Bombeck 83b7cd223a docs(sharing): the booster needs two sections, and the guide promised it at one level
The write-level row listed "a vaccination dose and the booster reminder it
suggests" three paragraphs under a section table that puts the vaccination
record in Health background. Read together, that tells a self-hoster a
health-background WRITE grant can plan a booster — which the route beside it
now refuses, because the reminder it mints lives under Readings. The row lists
the dose alone, and the exceptions paragraph, which is where a reader looks for
exactly this, carries the two-section case and says the dose logs either way.

With it, the small things the same pass turned up:

The cycle-insights fixture asserted a property it could not show. Its docblock
claims the cycle-native answer survives the fence, and it seeded no symptom at
all, so `symptomPatterns` was empty in every leg and a regression that emptied
it along with the foreign columns would have passed green. One catalogue
symptom on the luteal days, asserted on the owner leg and on the cycle-only
one.

The immunization rows matched on a bare `role="button"`, which passes vacuously
the day the card becomes a real button and fails for the wrong reason if
anything else in the subtree grows one. They carry the same stable marker the
visit card took.

`/api/cycle/calendar` reads the same wrist-temperature channel the crosstab
does and is deliberately not fenced, and it was the only one of the pair with
nothing saying why: no value from it reaches the response.

The encrypted export's new 403 documented the switch refusal and not the
narrow-scope one it answers first, unlike the two settings operations beside
it. Both sheets say at the top that their Save and Delete are gated at the
caller, so a third mount reads the constraint before it violates it. And one
docblock line ran past the wrap.
2026-09-10 12:23:59 +02:00
Marc-André Bombeck 3017e24bb1 Merge the needs-based onboarding's server state and the derivation that never hides data 2026-09-10 12:23:34 +02:00
Marc-André Bombeck f5df6111a1 test(onboarding): put the setup flow's cases through the finished questionnaire
Two cases in the integration file answered one or two questions and posted
the confirm, which the gate now refuses; they answer or pass all six. The
audit assertion reads the details column as the JSON string it is.
2026-09-10 12:20:50 +02:00
Marc-André Bombeck 44b6ea01c6 fix(onboarding): tidy the small edges around the setup routes
Four of them. The confirm re-stamped its completion instant on every call, so
a replay quietly moved the moment the setup finished at while the derivation
beside it was carefully latched. The two list answers were stored exactly as
sent, duplicates included, so the column could hold something the reader would
never return — the reader de-duplicates on the way out and now so does the
write. The wide-event name for saving an answer used a different noun from its
two siblings, which makes a dashboard filter awkward for one feature. And the
contract said the restart body was required while the route accepts none.
2026-09-10 12:14:32 +02:00
Marc-André Bombeck 681e69a723 fix(modules): drop the last three fail-open gates and discover the class instead of naming it
Six surfaces were named and fixed; nine carried the expression. `/labs`,
`/medications` and `/mood` still read `inSharedRecord || user?.modules?.X`, and
every precondition holds for them too: all three are presentable to their own
scoped grants, all three module keys map to a real section so the masked map
answers correctly for a delegate, and none of the three routes behind them
carries a module gate of its own — so a typed URL rendered a working page for a
record whose owner had switched the module off.

The guard is a discovered sweep now rather than a six-entry list. It walks
every component under `src/app` and `src/components` through the shared walker,
keeps the ones that read the module map, and asserts the SHAPE: no boolean `||`
joins the delegate flag to a module read. The search window cannot cross a `;`,
a `{` or a `}`, so it sees one expression at a time and a delegate check
elsewhere in the same file is none of its business. A tenth surface fails on
arrival.

Writing it turned up why the first version could not have found the three: the
comment stripper every structural guard in this directory re-spells removes
block comments first, and cannot tell a real opener from a `/*` inside a line
comment. `mood/page-client.tsx` explains its gate with "Every
`/api/mood-entries/*` route also enforces the gate", and that `/*` opened a
fake block that swallowed the next 2.2 kB of real code — the gate three lines
below it included. A guard reading that file was searching a source with a hole
in it and reporting a clean sweep. The shared walker now carries a stripper that
walks each line left to right and lets whichever opener comes first win; the
published-coverage sweep beside it moved onto the same one.
2026-09-10 12:14:14 +02:00
Marc-André Bombeck b17f18295e fix(onboarding): let the getting-started list go when its rows are done
Three things, all in the same pair. The reader that orders the list from the
answers shipped without the proof that it reads correctly: every existing case
took the early return, so `checklistOrderFromNeeds` and the two new arms were
never executed by a test. They are covered now — medication first for somebody
who takes one daily, the named connection ahead of the untouched rows, profile
always first, and no answer combination that drops or duplicates a row.

A first-result task that was offered and then passed left the setup reading as
permanently unfinished, because the check asked only whether the offered task
had produced anything. A deliberate pass is an answer. And the published state
is additive — every record carries the field — so nine pending steps and empty
answers must read as "never entered the flow" rather than as an unfinished
one, or every account that predates the flow would look half set up.

The list itself now follows the design: for a record that ran the flow it goes
when its rows are done or the person hides it, and the five-reading rule stays
only for records that never entered.
2026-09-10 12:11:17 +02:00
Marc-André Bombeck f90307fd22 fix(auth): answer the setup state for the record the payload describes
The account payload resolved the setup state for the actor while its module
map, its cycle flag and every count the checklist reads beside it answer for
the active record. A guardian looking at a child's dashboard therefore got the
child's rows ordered by their own answers, and the child's reading count
tested against their own flow state. The two halves of that component have to
agree about whose record they describe, so the field is record-scoped now,
like the module map next to it.

Under a grant narrowed to one section the answers come back empty, for the
reason the module map is masked: they name the domains the record tracks, and
a grant that opens readings does not open that. The shape of the flow still
comes through.

The three write routes stay actor-only and say why: a delegable declaration is
a reviewed entry in the sharing surface guard, and a read that is wider than
its write is the honest shape while that entry is still to come.
2026-09-10 12:06:02 +02:00
Marc-André Bombeck b10a3ef22e fix(onboarding): leave a trail for the three settings columns the flow writes
The setup flow writes a record's module map, its glucose unit and its unit
preference. All three have a dedicated settings route that records an audit
row, and the flow recorded none, so "why did my modules change" had no answer
in the activity panel. Restarting the flow did write a row, which made the gap
look like an oversight rather than a decision, because it was one.

Same event names as the dedicated routes, since it is the same column and the
panel should group them. The module row names the keys that actually moved and
the domains kept for their content.
2026-09-10 11:57:11 +02:00
Marc-André Bombeck 07f1b6ce92 refactor(modules): one record-keyed write for a record's module state
The guardian's modules route and the setup flow's confirm both decide a
record's modules, and each had its own copy of what that means: write the
preference blob, write the delegated cycle column, invalidate the health
score. Two copies of a three-step write drift, and the second one was added by
this branch. They share one function now, keyed on the record, which is the
carrier the managed-profile work needs.

Authentication stays at the routes and stays different on purpose — the
guardian route acts on a managed record, the setup routes refuse outright
under a switch until a guardian's route into a record's setup lands with the
rest of that record's configuration. So does the merge: what the answers imply
and what somebody just ticked are different questions about what may be
overwritten. The actor's own modules route keeps its own write, because that
one is guarded on a concurrency token the record-keyed callers do not carry.
2026-09-10 11:54:19 +02:00
Marc-André Bombeck efd4efa862 docs(sharing): stop the cross-section predicate's docblock from listing its callers
It named three of them and the booster mint made four the same day. A comment
that enumerates its own callers goes stale on the next one, so it states the
shape instead and keeps the note about the two verbatim copies it replaced.
2026-09-10 11:52:54 +02:00
Marc-André Bombeck 2d2350252f fix(onboarding): never switch a module off on a record that already uses it
The derivation wrote an explicit off for every module the answers did not
name, which is right for a record being set up and wrong for one with years of
rows behind it. An established self-hoster has, for most modules, no stored
preference at all — they were default-on and nobody ever touched the toggle —
so the first confirm took away the navigation entry, the dashboard widget and
the settings entry for domains full of content. The rows survived; the
surfaces did not.

A derived off now asks first, and only for the modules it would actually
retract. Ownership is not re-answered: the measurement domains come from the
same map the MCP reads and the coach snapshot gates off, and each remaining
module names the one table its content lives in, exhaustively, so a module
added later fails to compile until somebody says where its content is. A
module whose domain holds rows keeps whatever the record already had, because
absence is what "on" looks like in the stored map.
2026-09-10 11:48:27 +02:00
Marc-André Bombeck cc6e6ca221 fix(onboarding): derive the module map only from a questionnaire that was finished
The only gate on the derivation was Q1 being set, so one answer was enough to
turn the whole questionnaire into a module map. Two ways that landed wrong and
they compound. Somebody answers who the record is for, closes the tab, and
later finishes the legacy four-step wizard, which posts to this same endpoint
and knows nothing about these questions: the five unanswered ones each read as
their conservative default, eleven surfaces are written off, and the
once-only marker makes it unrepeatable. Or any client patches one answer and
posts the confirm straight back.

The gate now asks whether every question has an answer or a deliberate pass.
A half-answered flow derives nothing, is not stamped complete, and keeps its
confirm step pending, so finishing the questions later still does the work.
The legacy arm is untouched: a caller with no setup row still answers the
fixed acknowledgement it always did.
2026-09-10 11:42:38 +02:00
Marc-André Bombeck 7a1f3fe32d fix(onboarding): stop the setup flow asking for units the account already holds
The spec's sixth question is skipped when the account already carries the
preferences, and the flow had no way to know: `User.unitPreference` is
nullable with null meaning "default metric", and the account payload coerces
that away, so nothing downstream could tell a chosen metric from an unset
column. The two columns are read raw on the server now, into the same
vocabulary the wire publishes, and a record whose account holds both reads its
`units` step as done — the question answers itself and is never put again.

Read-side only: the stored ledger keeps saying pending, because the person
really has not answered it, and an answer given later still lands.

The two writes the units answer makes — the display columns and the record of
the answer — go in one transaction, so a failure between them can no longer
leave the columns changed with no record of what changed them.
2026-09-10 11:39:32 +02:00
Marc-André Bombeck 70e40e097d docs(sharing): correct the four statements the self-hosting guide gets wrong
Measured against the route declarations on this tree:

The health-background section names allergies, family history and the profile
facts, and omits visits, the practices behind them and the vaccination record —
all three of which `scope.ts` puts in that section, so a grant is narrower on
the page than it is in the product.

"Aggregate surfaces appear only on a whole-record grant" is true and
incomplete: a whole-record grant does not open them equally either. The daily
digest and the dashboard's snapshot answer at read, the health score, the
dashboard's summary and the insights need manage.

The write level lists seven creates against twelve WRITE declarations. A visit,
a practice, a vaccination dose and the booster reminder it suggests were
missing, and the guide said nothing about the creates that need manage inside a
section a write grant can otherwise add to, or about the vault taking no
delegated write at any level.

"Exports, the health-record archive" sat under what a delegate cannot reach,
while `POST /api/export/health-record` answers at manage on a whole-record
grant and its own comment says why. The row is about the full exports and the
encrypted archive; the doctor report is named as the exception it is.

The sharing-denial guard's docblock carried the same class of stale claim — 116
fenced modules, 26 never published — and that gap is closed on this tree. The
caveat becomes an assertion: a new leg recomputes the fenced modules from the
route sources and requires every one to be published, and it names no number,
because a hard-coded figure is what turned the paragraph into a false statement
about the contract. It fails when a published path is withheld from it.
2026-09-10 11:25:09 +02:00
Marc-André Bombeck 0e4619a0c0 docs(nav): say why Insights stays closed under a switch, since the old reason lapsed
The entry carried "Non-delegable in v1, server-side and here". That was true at
v1.36.0 and is not true now: twenty-seven `/api/insights/*` reads plus
`/api/dashboard/summary` and `/api/export/health-record` declare
`("manage", "record")`, and the AI-egress objection they were closed for is
answered by construction rather than by the flag — the record resolver stamps
`setDelegatedGenerationSuppressed`, so a delegate reads what the owner's own
account generated and causes no egress.

What keeps the door shut is the overview's own reads rather than the tiles':
`/api/insights/layout` and `/api/insights/generate` both resolve `requireAuth()`
and refuse any switch at any level. Opening the entry without first deciding
what those two mean for a delegate — whose saved layout, and whether the
briefing reads or regenerates — would hand a whole-record MANAGE delegate a page
whose chrome 403s while its tiles answer, which is the failure the presentation
inventory exists to prevent. That is a decision about two more routes, not a
flag on this one, so the comment now says what is actually load-bearing and the
Coach's own reason is stated separately.
2026-09-10 11:22:06 +02:00
Marc-André Bombeck 3f015effec test: prove the setup flow against a real database and a real grant
Four claims the unit suite cannot reach, because each is about what is in the
database afterwards: the answers persist and a replay writes nothing new, the
module derivation happens once and leaves a later Settings change alone, a
restart resets the steps and not the modules, and a delegate with a live
accepted grant and a switched session is refused on all three writes — with
neither record acquiring a row, which is the quiet failure a fall-back to the
caller's own record would produce.

The demo guard gains the three paths and, beside them, the case that matters
more: admitting one verb on a path must not open a sibling.
2026-09-10 11:17:00 +02:00
Marc-André Bombeck 4410a496cc docs(sharing): say that the writable-section list is existential, not per verb
`writableDomains` answers "is there at least one delegated write route in this
section this grant satisfies". Its docblock promised a per-section answer the
table cannot give: within a section the split runs by verb, and three of the
four sections marked writable carry MANAGE-only creates beside their WRITE ones
— an allergy and a family-history entry under `profile`, a water entry and a
checkup reminder under `measurements`, a snooze and a re-recorded dose under
`medications`.

No shipped control is wrong because of it: the allergy and family-history forms
already ask the manage question, and so does the water entry. But the name and
the sentence beside it invite a control to read "my section is writable,
therefore my create is", and that reading is wrong in three sections out of
four.

Documentation only, in the three places the claim is made — the published
field, the function that derives it, and the guard that holds it against the
routes, whose green run now says what it does not prove. A per-verb table would
be a second place the route declarations live, and the declarations are what
decides; the control keeps asking the hook for its own verb class.
2026-09-10 11:16:55 +02:00
Marc-André Bombeck c809971a23 docs(sharing): name the screener's score row where the section is consented
`POST /api/mental-health/assessments` is fenced on `mind` and writes one row
into `measurements`: the instrument's total, minted `COMPUTED` and keyed on the
administration it came from, so the trend reads through the chart and rollup
tier without the encrypted item answers going anywhere. A grant scoped to mood
and mind therefore adds a point to the owner's measurement series, and nothing
said so.

Left in place rather than fenced. Withholding the projection the way the
booster mint is now fenced would file a screener whose own total is missing
from the record — an inconsistency inside the section the grant does open, to
avoid a derived point in one it does not. So it is documented instead, in the
three places it is decided or agreed to: the declaration, the section's own
definition, and the consent copy in all seven locales, which is where a
widening belongs. The published operation says it too.
2026-09-10 11:15:50 +02:00
Marc-André Bombeck 1b74bf8b54 fix(vaccinations): keep the booster mint inside the sections the grant opens
`POST /api/vaccinations/{id}/booster` is declared `("write", "profile")`, and
that is right for the act — filing the Impfpass and arming the booster it
suggests are one thing to the person doing them. The row it writes is not: a
`MeasurementReminder` belongs to the measurements section, every direct route
over the model declares it there, and creating one needs MANAGE. So a WRITE
grant scoped to the health background alone produced a reminder on the owner's
checkup list that the delegate could neither read nor change through the
section that owns it.

The seam is fenced the way the visits service fences a checkup closure, with
one difference stated at the call site: this refuses rather than skips. There
the closure is one effect of a larger save and reporting it as not done keeps
the save honest; here the reminder is the whole request, and a 201 over a row
that was never written would tell the person their booster is planned while
nothing rings. The prompt that raises the call asks the same question client-
side, so a delegate outside the section is not offered it in the first place;
the dose itself still logs either way.

`vaccination-booster-scoped-delegate.test.ts` drives the owner, a delegate
holding both sections, a profile-only delegate and a whole-record one. The
scoped-refusal leg fails against the previous handler, which answered 201 and
left the row.
2026-09-10 11:11:38 +02:00
Marc-André Bombeck 0ee26625be fix(modules): let the record's own module map close its pages for a delegate too
Six page gates read `inSharedRecord || user?.modules?.X`, with a comment saying
the delegate's own module map does not govern the owner's record. That was true
when it was written and stopped being true in v1.38.14: `GET /api/auth/me` now
publishes the RECORD's map, masked to what the grant opens, so the prefix that
protected against reading the wrong map now widens the gate instead. The
navigation drops the entry correctly while a typed URL still renders the page
for a record whose owner switched the module off, and `/api/vaccinations`
carries no module gate of its own, so it renders and works.

Deleting the prefix answers both cases from the one map, which is what it is
for. A structural guard holds the six expressions: it locates each gate, proves
it is a module gate, and then asserts it does not widen on the sharing
question. It finds nothing to assert on if the expression moves, and says so,
rather than passing on an empty match. All six legs fail against the previous
gates.
2026-09-10 11:04:49 +02:00
Marc-André Bombeck 3f44b882ac feat(backup): carry the setup answers out of the account and back in
Nothing recomputes an answer. The module map they produced survives on the
account row, but the map says what is switched on, not that the person told the
flow they take medication daily and have a visit next month — so a restore
without the row opens a blank questionnaire for somebody who already answered
it, and leaves the checklist ordering itself from nothing.

Both ends live in one file, the arrangement the visits and the score already
use, because a reader who greps only the restore route gets a false negative
when the route delegates.

`modulesDerivedAt` is the field that makes this more than history and it is
asserted rather than defaulted: it is the once-only latch on the module
derivation, so a restore that let it default to null would hand the next confirm
permission to re-apply the questionnaire over every module decision taken since,
silently. The round trip seeds it non-null and reads it back.

The file's schema validates the three answer objects loosely and the restore
narrows them through the same fail-soft readers the account payload uses. Being
stricter there would refuse a whole account's restore over one retired chip.

The wipe takes the row for the reason the reset already takes the wizard's own
columns: the record the answers were about is gone, so the answers describe
nothing.
2026-09-10 11:03:56 +02:00
Marc-André Bombeck dbfad0f28e feat(api): publish the setup state on the account payload, and read it
Additive: `onboarding` carries the nine steps with their status, the answers as
given, the flow's own completion stamp and the one task it offered. Always
present — a record that never entered the flow reads as empty answers with nine
pending steps, so a client branches on the statuses rather than on whether the
field exists, and an unknown step id is skipped rather than refused.

It is resolved for the CALLER's own record rather than for the active one,
unlike the two module fields beside it. The three write routes are actor-only,
and publishing a setup state the browser cannot write would be a read it could
act on and never complete.

Both ends land here, because a field with no reader is the failure this
repository keeps rediscovering. The getting-started checklist orders its rows
from the answers — medication first for somebody who takes one daily, the
data-source row first for somebody who named a wearable — and it stays on screen
while the flow is unfinished. That is the second half: five readings were never
evidence that the setup finished, and now a pending step, an unconfirmed set of
answers or an offered task that produced nothing keeps the list where it is.

The ordering only applies once the answers are confirmed. Re-ordering the
dashboard under somebody who is still mid-question would be movement they did
not ask for.
2026-09-10 11:03:47 +02:00
Marc-André Bombeck 021ea8f1ff feat(api): let a record answer the setup questions, and act on them
Three writes, all on the caller's own record.

`PATCH /api/onboarding/answers` saves one step the moment it is answered, so
leaving the flow and coming back resumes where it stopped. It is idempotent
because the whole computation is a pure function of the row and the body,
including the first-result instant, which is kept rather than re-taken. There is
no ordering contract at all: a back button has to work.

`POST /api/onboarding/complete` keeps everything it did and gains the confirm
screen's job. For a record that answered the questions it derives the module map
once and applies it, and the once-only latch is the point: a second confirm
would re-apply a questionnaire over decisions taken in Settings since. Cycle is
switched on through its own column and never off, because the gate already
derives an unanswered cycle from recorded sex and an untick is not a request to
retract that.

`POST /api/onboarding/restart` puts the steps back and clears the latch. It
writes no module state whatever: a re-run never turns off something switched on
by hand, and the safest way to hold that is for this route to have no opinion
about modules at all. It also leaves the first-run redirect's own stamp alone,
so asking the questions again does not push anybody back through the old wizard.

All three refuse outright while the session is acting on somebody else's record,
at every grant level, so a delegate can never write another record's setup. The
route into a managed profile's flow belongs with the rest of that record's
configuration and is not smuggled in here.

The demo allowlist gains the three: the flow has to be completable there with
preset answers, and none of them creates health data.
2026-09-10 11:03:36 +02:00
Marc-André Bombeck cc98c56172 feat(onboarding): store what a record needs, and map it to modules
The setup questionnaire the design spec asks for needs somewhere to put its
answers. `OnboardingRecord` is that place: one row per RECORD rather than per
person, so a guardian running a managed profile's setup writes the child's row
and not their own, and the three JSON columns hold the answers, the nine
ordered steps with their status, and the one task the flow ends on.

Nothing here is encrypted, and the schema comment says why: the columns hold
closed enum vocabularies, not free text, which is the same class of value as
the module map and the goal slugs sitting in plaintext beside them.

The needs to modules map lives next to the module keys rather than in the flow,
because a module renamed or retired has to break the map at compile time rather
than leave a chip that quietly changes nothing. The derivation is total — every
directly-owned key gets an explicit value — and the merge beside it is
one-directional: what the answers name is switched on, what they do not name is
switched off only where the record does not already carry a deliberate on. The
two delegated keys are absent by construction: `cycle` is reported separately
because its real column is on the cycle profile, and the Coach is not offered by
this flow at all.

Readers are fail-soft. They sit on the payload that rides every app boot, and a
value written by a release that named an area this one retired must not take the
shell down with it.

Nothing is retired: the four-step wizard, its goal slugs and the one-time tile
seed keep working and keep their meaning until the new surface replaces them.
The two states are disjoint — the slugs decide tile order, these answers decide
which modules are on, and neither reads the other.
2026-09-10 11:03:24 +02:00
Marc-André Bombeck e7a1950f3a refactor(sharing): retire the coarse "may this person add" answer
`canWrite` is the grant's LEVEL and carries no scope term. Its docblock said
"May this caller ADD to that record", and the client hook republished it as
`canAdd`, so fourteen controls across measurements, medications, labs, illness
and the dashboard gated a create on a boolean that answers `true` for a WRITE
grant scoped to the document vault — a grant whose writable-section list is
empty, because the vault takes no delegated write at any level.

Nothing shipped was visibly wrong before the capture picker, because the nav
drops a section a grant does not open. That is a property of one surface, not
of the answer, and the answer is the thing many controls read.

So the coarse answer is gone from the hook, and every consumer asks
`canWriteDomain` for its own section instead: `measurements` on the two
measurement surfaces and the dashboard's empty state, `medications` on the
medication list, table, ledger, card menu and side effects, `labs` on the lab
list and biomarker detail, `illness` on the episode list. Each of those is the
section its own route declares. `canWrite` keeps its name and its wire shape —
native clients read it — and its docblock now says what it is: whether the
grant is a reading one, with the per-section lists beside it for the question a
control actually has.

The hook table gained the vault-scoped WRITE grant as its own leg: level true,
every section false. The take-all-due fixture described a grant the server
never mints — `canWrite: true` over an empty writable list — and now derives
its list from the same table the server derives it from.
2026-09-10 11:02:32 +02:00
Marc-André Bombeck b6d3f9903f docs(api): publish the switch refusal on the wipe, the deletion and the encrypted export
The three routes that act on the whole of an account — `DELETE
/api/settings/data`, `DELETE /api/settings/account`, `POST
/api/export/encrypted` — name no record. They resolve the caller and act on
whatever the caller owns, so the question a switched session puts to them is
what happens when the browser believes it is somewhere else.

They already refuse: all three resolve through `requireFreshMfaIfEnrolled` or
its erasure sibling, and both open with `requireAuth()`, which reads the acting
carrier and throws `sharing.not_permitted` for anything but `none`. What was
missing is that the contract never said so. `/api/settings/{data,account}`
published a 403 about narrow Bearer scope alone, and the encrypted export
published no 403 at all, so a client that switches records had no way to learn
which of its calls stop working short of trying one.

One sentence, spliced onto the three, byte-identical for the same reason the
delegable refusal is one string. `switched-session-destructive-refusal.test.ts`
drives each route with a live whole-record MANAGE switch and asserts both
halves: the status, the code and the wording, and that neither record moved —
a refusal raised after the transaction would satisfy the first alone. The
export leg additionally asserts no archive rode along with the 403.
2026-09-10 10:49:12 +02:00
Marc-André Bombeck 927496236a fix(capture): ask each capture surface's own section before offering it
The capture FAB rides the bottom bar on every authenticated page, and its
fallback arm was section-blind:

    return caps.canAdd && DELEGABLE_CAPTURE_KINDS.has(kind);

`canAdd` is bound straight to the grant's LEVEL — `grantAllows(grant, "write")`,
no scope term at all. So a WRITE grant scoped to `["labs"]` published
`canAdd: true` with `writableDomains: ["labs"]`, and a delegate standing on
`/labs` was offered a weight form and a dose that the server refuses at
`grantCoversDomain`. The worst case is a WRITE grant scoped to `["documents"]`:
the vault takes no delegated write at any level, the writable list is empty, and
both kinds were still offered.

`writableDomains` is already the level × scope × route-table intersection, so
one question per kind, asked of that kind's own section, answers the scoped
case, the vault case and the mood entry's MANAGE-only create together. The
closed kind list and the separate manage arm are both dead once that lands and
are gone. The dashboard quick-entry sheets and the header menu carried the same
shape — unreachable today, because the dashboard opens only for a whole-record
grant — and move with it rather than waiting to become reachable.

The picker's legs in the affordances suite now build their capabilities from
the same table the server publishes, and four scoped cases join them: labs-only
offers nothing, measurements-only offers the reading, documents-only offers
nothing at all, and a mind-scoped manage offers the mood entry alone.
2026-09-10 10:45:01 +02:00
Marc-André Bombeck 3e176db5b7 fix(encounters): stop offering the visit edit sheet to a delegate who may not save it
`VisitsSection` asked the grant about its add button and nothing else. Every row
underneath stayed tappable into the edit sheet, whose Save is `PATCH
/api/encounters/{id}` and whose Delete is `DELETE` on the same path — both
`("manage", "profile")`. So a READ delegate opened a form that could not be
saved, and a WRITE delegate, who may file a new visit, opened one that could not
be changed. Reachable wherever the checkups page is, which is every
`profile`-scoped grant.

The row now takes its open callback only when `canManageDomain("profile")`
answers, and the card renders its content as content rather than as a button
when it does not. The add button keeps asking the write question, because that
is a different question with a different answer.

Four legs in the affordances suite over the same scoped fixtures the
immunization log uses: the owner opens the sheet, a READ and a WRITE delegate
see the row and cannot open it, a WRITE delegate still gets the add button, and
a MANAGE delegate opens it only while holding the section. Two of them fail
against the previous section.
2026-09-10 10:39:46 +02:00
Marc-André Bombeck e8a2cbe82e Merge the per-account off-host backup freshness and the honest error-report badge 2026-09-10 10:37:00 +02:00
Marc-André Bombeck 7c7e621fd9 fix(vaccinations): ask the grant before offering to add, edit or delete a dose
The immunization log carried no capability check at all: not one reference to
the record hook anywhere under `src/components/vaccinations`. The page is
presentable to every `profile`-scoped grant and to every whole-record one, so a
READ delegate was shown the add button, a clickable row that opens the edit
sheet, and the delete inside it — three controls the server refuses with a 403
each. A WRITE delegate was shown two of them.

`POST /api/vaccinations` answers at WRITE; the edit, the delete and the restore
behind it are `("manage", "profile")`. The two add buttons now ask
`canWriteDomain("profile")` and the row's edit affordance asks
`canManageDomain("profile")`, which is the split the address book next door
already gets right. The list already renders a non-interactive card when it is
handed no edit callback, and the sheet is only reachable through one of the two
gated entries, so nothing needs a second check that could drift from the first.

`delegated-write-affordances.test.tsx` gained a scoped-grant fixture — every
fixture in it was `sections: null`, which is why nothing noticed — and six legs
over the log: the owner, a READ, a WRITE and a MANAGE delegate holding
`profile`, a MANAGE delegate holding another section, and the empty state's own
add. Four of them fail against the previous view.
2026-09-10 10:36:52 +02:00
Marc-André Bombeck e645921852 fix(cycle): fence the phase crosstab's outcome columns to the sections they belong to
`GET /api/cycle/insights` is declared `("read", "cycle")`, which is right for
the phase labels and wrong for what it compares them against. The crosstab rows
carry `lutealAvg`, `follicularAvg` and `delta` in display units for resting
heart rate, HRV, sleep duration, steps, weight, body/wrist/skin temperature and
blood glucose, plus the mood score read straight off `MoodEntry` — measurement
and mind data, published in full to a grant scoped to the cycle alone, with the
lagged discovery's narrated interpretation on top.

The visits service already had the right shape for this: a predicate over the
grant's sections, asked before the foreign rows are read. Both sections are now
fenced at the query rather than in the response, so a caller outside them never
loads the values in the first place and the rows read exactly as they do for a
record with nothing logged. The wrist-temperature read that sharpens the
prediction stays: it never reaches the response.

The predicate had two verbatim copies, one in the visits service and one in the
vaccination service, with a comment on the second saying a second spelling would
be a second thing to keep in step. It is one module now, `sharing/acting-domains`,
and the third consumer imports it rather than a feature service.

`cycle-insights-scoped-delegate.test.ts` drives four grants against a fixture
whose separation is large enough that the FDR gate cannot be the reason a row is
missing: the owner and an entire-record delegate get both figures, a cycle-only
delegate gets neither and no lagged row, and a cycle+mind delegate gets the mood
column and not the weight. Both scoped legs fail against the previous handler.
2026-09-10 10:36:35 +02:00
Marc-André Bombeck a7faae8ded refactor(admin): read the off-host card from evidence, sort the worst row first
Four smaller things around the same card.

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

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

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

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

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

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

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

The card names the last check on the never row, so the state is a fact with a
timestamp rather than an assertion. Runbook and all seven bundles carry the
same two sentences. The integration file proves both arms against real
Postgres, including that a failed run leaves the previous good copy on the
row.
2026-09-10 10:15:31 +02:00
Marc-André Bombeck cfc19a211c fix(backups): give the off-host verdicts slack for a Berlin night that is 25 hours long
The threshold was the schedule period with no budget on top of it. Two things
spend that budget on a host where nothing is wrong. The cron runs 02:30
Europe/Berlin, so on the DST fall-back Sunday the gap between two runs is 25
hours and every account on every host would cross one period on the same
morning. And the ledger instant is when that account's object landed, by
design, so on a cohort walked one account at a time an account reached an hour
later tonight than last night drifts past 24 with two clean runs behind it.

The schedule period and the verdict threshold are now separate numbers. The
period is still what the card quotes; the threshold is the period plus six
hours, applied to both steps, so due still means one run produced nothing and
stale still means two. The fall-back night is pinned in the unit file by its
real dates rather than by an arithmetic 25, and a genuinely missed night at 31
hours still reads due. The runbook says why the slack is there.
2026-09-10 10:09:59 +02:00
Marc-André Bombeck e64435f6a5 fix(backups): size the off-host ledger column for the object the uploader allows
The column was INTEGER. A single object may occupy 8 MiB across 10 000
multipart parts, which is the 80 GB the uploader explicitly permits, and
Postgres INTEGER stops at 2.1 GB. An account between the two uploads fine and
then cannot be recorded, so the biggest account on the host is the one the
console would have to report on and could not.

BIGINT in the table, BigInt in the model, Number() where the route builds the
wire row so the payload type stays a plain number. The migration is edited in
place rather than followed by a second one: the table has not shipped, and a
column widened before its first release is one column, not a history.

The comparison with the in-database backup does not carry over, for the
record: that size is octet_length over a bytea Postgres caps at 1 GB, while
this object never touches the database at all.
2026-09-10 10:07:28 +02:00
Marc-André Bombeck 86823db34e fix(backups): a landed object stays a success when the ledger write does not
The per-account ledger row shared the upload's try. A pool timeout or a
connection reset on that upsert landed in the same catch as a refused upload:
the account was counted as failed, its reason pushed into the run's failure
list, and the console would go on to read never or stale for an account whose
encrypted copy was sitting in the bucket the whole time. That is the same
inversion the ledger was added to remove, on the write side.

The upload's boundary now ends where the object is durable. uploadEncryptedBackup
resolves only after the multipart completes, so the count and the largest-object
figure are taken there, and the row is written afterwards under its own boundary.
A write that fails warns with the account it belongs to and adds a run-level
count, which is a different fact from a failed upload and reads as one.

Two arms that were correct by construction and unasserted now have tests: a
failed upload writes no success row, so the previous timestamp stands, and a
run whose ledger write rejects after the object landed still reports it
uploaded.
2026-09-10 10:04:06 +02:00
Marc-André Bombeck 34d647a884 test(backups): prove the off-host ledger end to end and the card's empty state
The unit tests either side of this mock Prisma, so neither proves the
migration applies or that the writer and the reader agree on a shape. The
integration file runs the real nightly job against real Postgres with an
in-memory bucket, then reads the row back and asks the console's own
classifier what it says: fresh after a run, one row across two runs, stale
once it ages past two nightly periods, gone when the account is.

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

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

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

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

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

The verdicts are computed from the ledger, never from a bucket listing: the
worker's grant is deliberately PutObject + GetObject, and an admin page must
not put the operator's credentials on its render path to state a number the
database already holds.
2026-09-10 09:27:58 +02:00
Marc-André Bombeck 036720bcfb docs(changelog): name the modules route's method and the profile refusal as they are
The 1.38.15 entry said PUT where the record-settings route answers PATCH, and promised a rejectedFields field on the managed-profile edit that only the two profile routes carry; the edit refuses with a 422 and the issue list.
2026-09-10 09:09:26 +02:00
Marc-André Bombeck 0c14cbd489 Merge the issue list on every shape refusal, the shared limiter on single-record writes and the error-code catalogue 2026-09-10 08:55:26 +02:00
Marc-André Bombeck 3ffdfe15a1 fix(api): give the shared write ceiling a code a client can branch on
The eleven single-record writes answered their shared 429 with prose and
nothing else, while the envelope's own description tells a client to
branch on `meta.errorCode`. On `POST /api/medications/[id]/intake` and
`POST /api/cycle/day-logs` a second, narrower bucket can answer 429 as
well, so the presence of the rate-limit headers was the only thing
telling the two refusals apart.

All eleven now carry `record_write.rate_limited`, the catalogue lists
it, the published 429 names it, and the contract test fails if a site
stops emitting it or the sentence stops publishing it.

The same description now states what a client could not predict from
"300 requests per 60 seconds": refused requests count too, because the
check runs before the body is read.
2026-09-10 08:39:09 +02:00
Marc-André Bombeck 68964b4bac docs(api): correct two comments that describe behaviour the code does not have
The single-record write ceiling justified itself with "every one of these
routes is already wrapped in `withIdempotency`". Ten of the eleven are.
`POST /api/medications/intake` is not, and it is the one of the eleven a
native client actually loops — it is replay-tolerant for its own reason,
being an update of a named `intakeId` rather than a create. The comment
now says which is which, and adds the clause it was missing: the check
runs before the body is read, so a refused request spends the allowance
too.

The custom-symptom wrapper said a replay "answered the 409 that
uniqueness raises". There is no such 409. The row's key is minted fresh
per request and the label lives in an AES-GCM column with a per-write
IV, which can carry no unique index, so an un-keyed replay wrote a
second row with the same label — a silent duplicate in the person's own
vocabulary, up to the fifty-row cap. The wrapper is worth more than the
old sentence claimed, not less.
2026-09-10 08:38:16 +02:00
Marc-André Bombeck b2c8fe8567 fix(api): answer the unknown-leaf refusal with a sentence and a code
`PUT /api/auth/me/report-selection` threw a bare 422 whose whole `error`
string was `report-selection.leaves.unknown` — a machine token in the
field a client shows a person, with nothing beside it to branch on and
no way to learn which leaves were refused. It now answers a sentence,
with the token in `meta.errorCode` and the refused ids in
`meta.unknownLeaves`, and the operation's description says so.

The site was invisible to the refusal guard because the refusal is
decided by `if (!minted.ok)` rather than by a parse result, so the guard
now reads the refused string as well as the deciding block: every 422
under `src/app/api` whose `error` is a dotted token is inventoried with
the reason it still is one. Eleven remain, each with its reason, and the
inventory may only shrink. Removing the fix above turns it red naming
that route.

The four sibling shape refusals keep their token in `error` on purpose,
and the comment now says why that is not the same decision as the seven
`invalid_json` sites: those moved their string because their status
moved to 400 anyway, while a shape refusal that keeps its 422 would be
changing the wire for nothing. Both halves publish `meta.errorCode`.

Two loose ends in the same guard: a block calling `returnAllZodIssues`
or `apiValidationError` without spelling `422` is now judged (their
default status is 422, so a future `refuse422(err)` helper cannot slip
past), and the site floor moves from 40 to 200 against the 248 the tree
carries — the old floor would have survived losing five matches in six.
2026-09-10 08:35:35 +02:00
Marc-André Bombeck f9561395c7 fix(api): read every module a route reaches when listing the error codes
The catalogue guard read the route tree plus a hand-kept list of library
modules. The dose-history import builds its file-level codes in a module
that list never named, so `empty_file`, `missing_required_columns`,
`unreadable_json`, `json_not_an_array` and `json_carries_no_intake_time`
shipped on `POST /api/medications/intake/dose-history-import` while the
envelope's own description promised every code was enumerated below, and
the guard was green because it had not read the file.

The sweep now walks the import graph from every route under `src/app`:
1423 modules, resolved through the same `@/…` alias the app uses, with
the generated client never opened. A code is read where it is written,
however many hops from the route that answers with it, and nothing is
hand-listed any more. Seeding from the whole app tree rather than
`src/app/api` also reached the clinician share-link document route,
whose three codes were missing for the same reason.

A tuple of codes is read as a family, like the object form already was,
which is what makes the import's union visible; the constant holding it
is renamed to the `*_ERROR_CODES` spelling the sweep reads, since a list
named for anything else is as likely to be nutrients as error codes. The
two sync-ledger writers are cut out of the text first — `errorCode` on
`IntegrationStatus` is a different field with the same name.

Proven by breaking it both ways: dropping `empty_file` from the
catalogue turns the guard red, and a code planted in
`src/lib/analytics/compliance.ts`, a module no list ever named, turns it
red naming that file.
2026-09-10 08:31:44 +02:00
Marc-André Bombeck 348d9e0739 Merge pull request #946 from MBombeck/release/v1.38.15
Release v1.38.15
v1.38.15
2026-09-10 08:21:29 +02:00