Commit Graph
812 Commits
Author SHA1 Message Date
Marc-André Bombeck cbf949d43a Merge branch 'main' into fix/wipe-geo-truths 2026-08-08 03:29:51 +02:00
Marc-André Bombeck 0eac84aa95 fix(geo): read the offline databases first
The resolver queried the online provider first and read the mounted
GeoLite2 databases only when that missed. Every document describing the
feature said the opposite, including the self-hosting runbook and the
admin notification that tells an operator their login IPs are leaving
the server. So an operator who mounted the databases in order to stop
that egress kept all of it, and nothing said so: the location resolved,
the admin overview filled in, and the only difference was where the
address had been.

Now the local databases answer and the provider only ever sees an
address they could not place. A host with the City database mounted and
a hit makes no request at all — including when the ASN database is
absent, because reaching out for a carrier string would send the address
off the host anyway, which is the thing the mount was for.

IP_GEO_LOOKUP_DISABLED=1 remains the full off-switch: it disables the
online step only, so an offline hit still resolves and an offline miss
returns nothing rather than going online.

The wide-event warning on the fallback path named ipwho.is even when the
operator had pointed IP_GEO_LOOKUP_URL somewhere else; it now names the
host the runtime actually queries. The admin notification copy needed no
change — it fires only when the databases are absent, and then every
lookup does go online.

The two cases in geo-asn.test.ts that pinned the old order are the
watched red for this change.
2026-08-08 01:05:44 +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 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 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 c3c058074b chore(release): v1.37.0 — share part of a record, or keep one for somebody with no login 2026-08-07 08:25:17 +02:00
Marc-André Bombeck 2980cca200 docs: bring the sharing copy up to what the release ships
The operator page still said every grant is read-only, one release after
the write level shipped, and the table of what a grant cannot reach
predates both the manage level and managed profiles.

Rewritten against the frozen behaviour: the eight sections a grant can be
narrowed to and what each opens, the three levels and what each adds, the
step-up that is unconditional on a managed profile and conditional on an
ordinary invitation, and what a guardian may keep for a record that has no
login of its own. The AI row is corrected rather than dropped: a manage
grant reads insights that already exist, and no level causes an outbound
provider call.

Every claim on that page was read out of the code before it was written
down. The one about backups was cut instead, because it was not.
2026-08-07 08:20:08 +02:00
Marc-André Bombeck 6bb0360695 docs(api): publish the sharing request bodies as named schemas
Zod 4's .meta() is immutable: it returns a new schema carrying the
metadata and registers that one. Four request bodies in the sharing
family called it as a statement and dropped the result, so the route
table went on referencing the bare schema, the bodies were emitted
inline and anonymous, and the object-level descriptions never reached
docs/api/openapi.yaml. One of them carries the clause that scopes every
rule on the invitation endpoint to an invited grant, which is the
sentence that stops a reader carrying the step-up and the refusals
across to a guardian grant.

Bind each to a const, reference the const, and correct the level
boundary in the invitation's description while it is being published:
it still carried the blanket denial that stopped being true when the
anamnesis surface opened to a manage delegate.

The wire format does not move. Four named components appear and the four
bodies point at them; the fields, the types and the requiredness are
what they were. A new guard asserts every id this route table declares
reaches the built document, because the failing form looks exactly like
the working one.
2026-08-07 08:10:48 +02:00
Marc-André Bombeck 4d86dceb99 docs: publish the last-Guardian refusal where it is actually raised
The floor was described on the one route that cannot reach it and left unsaid
on the two that answer it every time: withdrawing the last Guardian grant and
renouncing it both raise `managed_profile.guardian.required`, and both
published only 'that access had already ended'. Both 409s now name their two
codes, and the annotation on the removal route enumerates all four emitters
rather than three.

A guard closes the class rather than the instance: every route module that
raises the refusal must publish it on its own operation, with the emitters
discovered from the sources.

The freeze guard gains its containment leg — a module becomes delegable by
review, not by discovery, so every file contributing a fenced verb has to be a
member of one of the surface guard's frozen allowlists.
2026-08-07 05:43:06 +02:00
Marc-André Bombeck 38766cd4ca docs: define sharing refusal contracts
Every route that resolves a record through the sharing fence can answer 403
`sharing.access.denied`, and until now that fact was published on the selector
header and on none of the 120 operations that produce it. All 120 carry it now,
byte for byte, from one constant: the refusal is deliberately indistinguishable
across a missing account, a missing grant, a scope that does not reach the
surface and a read grant on a write, so 120 paraphrases would invite a client to
tell apart what the server refuses to.

Where a delegable route already answered 403 for its own reason — a module
gate, the cycle gate, AI consent — the two share one description rather than one
of them going unpublished, since OpenAPI allows a single response per status.

The guard recomputes the delegable set from the route sources on every run and
classifies per verb, which matters: seventeen modules have a delegable read
beside a write that refuses a switch outright.
2026-08-07 04:56:56 +02:00
Marc-André Bombeck ec9d93aa24 docs: publish the managed-profile lifecycle routes
The contract already pointed at them: the Guardian roster names
`DELETE /api/managed-profiles/{id}/guardians/{grantId}` as the route its
`grantId` is for, and that route was not in the spec. Creation, deletion,
invitation and removal now are, with the two bodies moved to
`src/lib/validations` so the wire shape has one source.

Three things are stated rather than left to be discovered:

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

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

A guard checks the copy against the classification rather than against itself,
so opening a second destination or closing this one moves the sentence with it.
The contract is regenerated.
2026-08-07 00:52:25 +02:00
Marc-André Bombeck 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 c92a80ee4c feat: describe managed sharing activity
Every action a manage-level route files now renders as its own sentence in
the owner's activity view, in all six locales. A write delegate can only add,
so the generic "made a change" was at least the right shape of fact; a manager
changes and removes entries the owner made themselves, and the generic line
withholds the only part that matters — whether a reading was corrected or
deleted, whether a dose history was purged.

The inventory reads the routes and calls the map rather than matching `case`
in its source, so an action that falls through to the fallback fails, and it
follows each resolved key into all six bundles. Every sentence interpolates
the actor's name and nothing else: the audit table is not encrypted and a
value in a verb line would be a health figure in a second store.

The revoke dialog's retention disclosure no longer depends on which of two
requests answers first. `retentionDays` rides the grant list, so a client
holding a row to revoke holds the window it must state, and the branch that
omitted the sentence is gone.
2026-08-06 23:30:07 +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 4923db6464 fix: preserve account access compatibility 2026-08-05 23:58:26 +02:00
Marc-André Bombeck ab14fdb6f3 Let an invitation name its sections and offer management
An owner can now narrow a grant to the parts of their record they mean to
share, and can offer the third level. Both decisions are made once, on the
invitation, and neither widens afterwards.

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

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

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

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

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

Six locales.
2026-08-05 23:37:00 +02:00
Marc-André Bombeck d77a3fe8d7 chore(release): v1.36.1 — a second person can contribute, and the front door works 2026-08-04 11:44:13 +02:00
Marc-André Bombeck 533400ce60 Let an invitation carry the level it offers
A grant has always had an access column and `inviteGrant` has always written
READ into it. That was the right way to ship the first version: "this release
never grants write" was a property of the file rather than a promise in a
comment. Remove it deliberately now, so somebody sharing their record can offer
reading or reading plus adding.

The level is a required argument to `inviteGrant` and is defaulted in exactly
one place, the invite schema, where an omitted field becomes READ. An older
client's payload therefore stays valid and stays narrow, and the domain module
never guesses a level nobody named.

There is no upgrade path and that is the decision, not a gap. A grant is READ
or WRITE from the moment it is offered and stays that way until somebody ends
it. Widening a live one would change what the delegate agreed to without asking
them again, and their consent is half of what makes a write grant legitimate.
The way up is a new invitation, accepted again.

The audit row and the wide event carry the level: "who was given what, and by
whom" is the question that row exists to answer.
2026-08-03 10:12:08 +02:00
Marc-André Bombeck 314bf26638 chore(release): v1.36.0 — one record, two people, and a line that says which 2026-08-02 20:28:53 +02:00
Marc-André Bombeck 210380f46d Merge pull request #690 from MBombeck/work/sharing-client
Account sharing, the part people touch
2026-08-02 17:13:30 +02:00
Marc-André Bombeck 526041770d Merge pull request #691 from MBombeck/fix/gate-blind-spots
Let the structural guards see the directories they were skipping
2026-08-02 17:05:49 +02:00
Marc-André Bombeck 89532ee48e docs(about): state the GeoLite2 licence in full
The credits page named CC BY-SA 4.0 alone. MaxMind stopped distributing
GeoLite2 under that licence by itself: the databases ship under the
GeoLite End User License Agreement, which incorporates CC BY-SA 4.0 by
reference and controls wherever the two disagree. Name both, in that
order, and link the EULA. The attribution string was already there and
is unchanged; the e2e page check now asserts both licences rather than
the one that no longer stands alone.

Reconcile the self-hosting guide with the same wording, point it at the
current EULA URL, and say why the `/about` page must stay reachable — it
is where an operator's own attribution lives.

Correct the project licence line while on the page: it still claimed
AGPL-3.0, which the page's own footer contradicts. The trunk has been
PolyForm Noncommercial 1.0.0 since v1.15.19.
2026-08-02 16:45:51 +02:00
Marc-André Bombeck c82ff767a3 Say in the runbook what a backup does not carry
Every credential-shaped row is left out on purpose — API tokens, trusted
devices, step-up elevations, known devices, clinician share links, and the
grants behind shared record access. The per-model reasoning already lives in
backup-plan.ts with a structural test behind it, but an operator planning a
recovery does not read that file.

The consequence differs by target and the difference is the part worth knowing.
Restoring onto the same instance changes nothing: the restore replaces data
tables and does not touch grants, so shared access carries on as it was.
Restoring onto a fresh instance brings none of it, so both people invite and
accept again before sharing works. That is the fail-safe direction, and it is
better planned for than discovered.
2026-08-02 16:39:35 +02:00
Marc-André Bombeck f574e63368 docs: what sharing a health record does, and what it cannot undo
An operator page for the feature, written in the register the
reproductive-health page established: say the narrow true thing rather
than the reassuring one.

What the person given access can reach, and the table of what they cannot
— credentials, sessions, tokens, connected services, notification
channels, exports, share links, the sharing itself, and the AI surfaces.
The AI row gets its own paragraph because it is the one that looks
arbitrary: server-managed processing runs under the consent the record's
owner gave for their own use.

Revocation is described honestly. It lands on the next request rather
than the next login, it puts a browser that was inside the record back
into its own account, and it cannot take back what was already seen. The
product can close the door; it cannot reach through it, and the page says
so rather than implying otherwise.

The activity view's window is described as the operator's setting, not as
365 — an instance that shortens the audit retention shortens this view,
and an empty list means nothing was recorded within the window rather
than that nothing ever happened.

The three trust facts stay where they were: the operator could always
read the database, encryption at rest is instance-keyed rather than
per-user, and AI processing stays under the owner's own consent.

The data-protection note is deliberately absent — its stance is a
maintainer decision, and the page reads complete without it.
2026-08-02 15:17:31 +02:00
Marc-André Bombeck 883fd2d047 docs(api): publish the account-access block and the selector header
Three additions to the contract, all of them things a native client cannot
work out on its own.

`GET /api/auth/me` enters the spec for the first time, and enters it loose:
`accountAccess` is specified in full, the remaining preference fields are
declared open rather than half-enumerated. The endpoint has never been in this
document and its payload carries thirty-odd keys no contract has ever named;
specifying a subset and closing the object would publish something the server
does not keep.

`X-HealthLog-Account` is published as a reusable parameter with no route
referencing it, which is the honest state of the contract — the routes that
read another account's record arrive with the surface migration and will
reference it as they land. The header is live in the resolver today, and a
client needs the description before it can be built against a tagged spec: the
value comes from `accountAccess.accounts`, it is a selector and never a
permission, and it must never ride the sync engine, device registration, or an
auth call.

`GET /api/account/activity` carries `retentionDays` in the response and says
why: the window is operator-configurable, so a client that assumed 365 would
tell somebody their record was never opened when the honest answer is "further
back than this reaches".

The parameter is defined in the sharing route module rather than the shared
components block, so the boundary guard keeps holding the header's name to two
files — the resolver that reads it, and the one place that publishes it.
2026-08-02 14:50:05 +02:00
Marc-André Bombeck 56447a9457 feat(sharing): invite, accept, switch, revoke, renounce
The lifecycle surface under /api/account. An owner offers read access to
somebody already on the instance, the delegate accepts, either side ends
it, and a browser session points itself at a record it has been granted.

Ending a grant clears the acting-account carrier on every session of the
delegate pointing at that record, in the same transaction that stamps the
row. Split apart, a revocation would leave a browser inside a record it
can no longer read: every request refused, the banner still naming the
owner, nothing on screen able to explain it.

Revocation has no step-up and no confirmation step. Reducing access must
never be harder than granting it was.

Grant management is non-delegable by construction rather than by a check:
every route here resolves through bare requireAuth(), which refuses while
the caller is acting on another account, so no code path through them
runs as somebody else. The switch endpoint is the one actor surface, and
it is the way back out. A delegate already inside a record renounces
after switching out, one request, rather than the fail-closed default
growing an exception that later gets widened.

The state machine is not re-implemented anywhere: routes call the grant
module's predicate and its transitions, and the session carrier has one
writer.
2026-08-02 13:04:25 +02:00
Marc-André Bombeck 3cf2cb417f chore(release): v1.35.6 — one line where there were two 2026-08-02 07:29:42 +02:00
Marc-André Bombeck c9ee84e5e4 chore(release): v1.35.5 — a read stops deciding what gets written 2026-08-02 01:30:12 +02:00
Marc-André Bombeck b40e9bc40f Merge work/side-effecting-gets 2026-08-02 01:29:46 +02:00
Marc-André Bombeck 6ac3a61829 Take the writes off three read routes
A GET must not have side effects — api-handler.ts says so next to
READ_HTTP_METHODS, where an MCP-audience token is admitted on the safe
methods precisely because they are assumed to write nothing. Three routes
broke that: the sync handshake bumped User.lastSyncedAt, the achievements
read inserted unlock rows, and the cycle calendar fired a discarded promise
at the forecast cache so a failed write left no trace anywhere.

Each write moves to the place that causes it.

The sync checkpoint is now stamped by the batch ingest endpoints for
measurements, mood and intakes. The handshake reports it. That also fixes
the watermark: the checkpoint used to advance past a window the client had
not drained, and a client trusting it would skip that window. It can only
trail a real sync now.

The unlock rows move to a nightly achievement-unlock-sweep. The badge grid
never needed them — unlocked comes from the live metrics and the completion
date is derived — so the payload is unchanged; the rows pin that date and
carry it into the backup, and the sweep reaches accounts that never open
the page.

The cycle forecast moves to a nightly cycle-prediction-refresh. The
CyclePrediction row is not a cache of a read: it is the index the reminder
cron scans, so which reminders an account received depended on whether
somebody had opened the calendar. persistPredictionCache no longer
swallows its errors either — the job reports them.
2026-08-02 01:21:03 +02:00
Marc-André Bombeck 2618a7ae71 chore(release): v1.35.4 — the checks tell the truth about the calendar and about themselves 2026-08-02 00:38:01 +02:00
Marc-André Bombeck ffe5545032 chore(release): v1.35.3 — an ECG arrives on its own, and only once
Apple Watch ECG recordings reach the record straight from the app instead of
only through an export archive. One recording per request, gated by the same
switches that already govern reading them.

A recording is now identified by what it is rather than by what a source calls
it, so the same strip arriving through both doors converges on one row. The
migration that adds the constraint resolves the duplicates already stored: it
keeps the row holding the waveform over one holding only a verdict, and moves a
removed row's event link onto the row that is kept. The losing rows are deleted.
2026-08-01 19:54:08 +02:00
Marc-André Bombeck 3b37d73f31 Merge branch 'work/ecg-ingest' into release/v1.35.3 2026-08-01 19:50:52 +02:00
Marc-André Bombeck 0e301b580c Let an Apple Watch ECG arrive without a 1.5 GB export archive
POST /api/insights/ecg takes one recording per request: the stable
recording id the client already has, the on-device timestamp, the
sampling rate, the waveform as integer micro-volts, the lead, the
average heart rate, and the device's own verdict. Same gating as the
reads (auth, the insights module, the insightStatus surface), userId
from the session, APPLE_HEALTH the only source a client may claim.
sampleCount and durationSeconds are derived here, and nothing about the
waveform is ever interpreted.

The write body now lives in one place, src/lib/ecg/persist-recording.ts,
shared by the Withings sync, the export-archive importer and the new
route. Three writers with three persistence bodies is how the two ends
of one rule drift apart.

Migration 0291 adds the second unique index on
(user_id, source, recorded_at, sampling_frequency). The archive importer
identifies a strip by a hash of its content and a live client by the
device's own sample uuid, so the same physical recording arriving
through both doors carried two different ids and would have landed
twice. One device does not record two different strips at the same
instant at the same sampling rate, so the constraint is sound and the
convergence stops being a convention.

The table already holds rows on deployed instances and two writers have
been filling it since v1.19.0, so the index cannot just be created: a
pre-existing collision would abort the migration. The migration resolves
duplicates first, keeping the row with the most samples (a stored strip
beats a verdict-only row), then the one linked to its event
measurement, then the most recently written, then the lowest id so the
choice is total. The losers' event link is salvaged onto the keeper
first, so resolving a duplicate never costs the recording its place in
the event timeline.

If that resolution is wrong on some instance, the failure is visible at
deploy time and not later: db:migrate:deploy runs on boot, so the
container crash-loops with the migration named in the logs and the
release never serves traffic. A deploy that finishes in under thirty
seconds did not run it.

The GET list and detail routes and the export-archive path are
unchanged; this is a second door, not a replacement.
2026-08-01 19:19:01 +02:00
Marc-André Bombeck 99ceb236de chore(release): v1.35.2 — one answer, published
The cycle ring stops working out its own verdict and reads the server's. The
constant that made that possible is deleted rather than deprecated, because a
constant left in client code is a constant that gets copied again, which is how
the other client's ring froze in the first place.

The score settings page takes the shape the other settings cards have, loses a
pillar that could never score, and turns green where the app's other two scores
already did. A saved selection now comes back as it was sent.

Five gaps in the published specification are closed, including a route that
shipped this morning and was never described at all.
2026-08-01 12:16:27 +02:00
Marc-André Bombeck a830e8a9d3 Merge work/contract-touchups score-config registration 2026-08-01 11:58:17 +02:00
Marc-André Bombeck 7ddcee0445 Publish the health-score configuration route
/api/auth/me/health-score-config shipped in v1.35.0 with a settings page
in front of it and no entry in the contract at all — no path, no schemas,
nothing. A client had the surface and no way to generate against it. This
is publication only; the route does what it already did.

Both verbs are registered off what the route actually returns rather than
off a neighbour's shape. The asymmetry matters and is now written down: the
request speaks the positive selection, the pillars that should count, while
the response carries both sides resolved plus the recipe version. The column
stores the complement, which is storage and no client's business.

The response's updatedAt is documented as optional because it genuinely is
absent until a selection exists — an account that never chose has nothing to
guard a write against, and a decoder that required the field would break on
that read.

The PATCH 422 is written out rather than reusing the shared base-token
response, because this route has four of them and a client branching on
meta.errorCode needs all four: the breadth refusal with the sentence to show
the person, the validation failure, the unparseable base token, and the
non-JSON body. Naming only the token would have been a contract that omits
the refusal people actually hit.

The route test now drives the real route to produce each of those responses
and asserts the published contract names every code that comes back, so a
response the route can emit but the spec does not describe fails the suite.
That closes the base-token ledger: nine routes emit invalid_base_updated_at
and all nine now document it.
2026-08-01 11:57:31 +02:00
Marc-André Bombeck f6edff9b82 Merge work/contract-touchups 2026-08-01 11:50:33 +02:00
Marc-André Bombeck 6a69bad05e Publish the passkey list and the base-token 422
GET /api/auth/passkeys was absent from the contract entirely — no path
entry, no schema — while the client teams had been pointed at a published
schema for it since v1.32.8. It is now registered with the shape the route
actually serves: id, name, credentialDeviceType, credentialBackedUp,
createdAt, and the nullable lastUsedAt that started this. The route itself
is unchanged; only the record of it was missing. PasskeyInfo is deliberately
separate from WebauthnMfaCredentialInfo — one lists sign-in credentials, the
other second-factor security keys, and merging them would blur two surfaces
that behave differently. The route test now compares the projection the
route asks Prisma for against the published property set, so neither end can
move without the other.

`invalid_base_updated_at` existed only in route tests. It is now on all
eight published endpoints that accept a `baseUpdatedAt` body token, and the
field description says the thing implementers keep getting wrong: an
unparseable token, `null` included, is rejected with 422 rather than falling
back to the unconditional write. Omitting the key is the only way to get
that write.

/api/auth/me/health-score-config also returns this 422 but is not in the
spec at all; publishing that route is a separate piece of work.
2026-08-01 11:49:40 +02:00
Marc-André Bombeck f6bd8be6d5 Merge work/fhir-retire 2026-08-01 11:38:55 +02:00
Marc-André Bombeck 5767e0330b Retire allowFhirApi and resourceTypes off the share-link response
iOS green-lit removal on 2026-07-30 (their decoder treats both as
optional, confirmed at ShareLinkDTO.swift:105-106) and confirmed no
shipped build breaks. Drops RETIRED_FHIR_SCOPE_FIELDS and its spread
from the create and list responses, removes the two properties and
their required-list entries from ShareLinkCreated/ShareLinkList in
the OpenAPI source, and regenerates the spec. The request side was
already closed off by .strict() in an earlier pass; this finishes it
on the response side.

Route tests now assert the fields are absent from both responses
instead of asserting the constants they used to carry.
2026-08-01 11:38:16 +02:00
Marc-André Bombeck 4f79b9d49a Merge work/cycle-verdict 2026-08-01 11:32:07 +02:00
Marc-André Bombeck 4d200ebd43 Merge work/score-settings-polish 2026-08-01 11:32:07 +02:00
Marc-André Bombeck c60689affd Take the fitness pillar out of the health score
It could never score. The schema records VO2max but cannot tell a
measured test apart from a device estimate, so every fitness row reached
the scorer unproven and was refused - the pillar has never produced a
value on any account. Listing it on the score settings page as a thing
you can choose, with a line admitting it does nothing, was chrome over a
switch that changed no number.

So it leaves the catalogue: no pillar id, no domain, no scorer, no row,
no wire field. A stored selection that still names it reads back fine -
the resolver has always filtered stored ids against the catalogue and
drops what it no longer knows, so nobody's saved recipe throws or is
discarded over it.

That also fixes the page claiming unsaved changes forever. The save
stripped the fitness pillar out of the selection on its way to the
server, so the answer that came back could never match the form that
sent it, and 'All pillars' left the save button lit no matter how often
you pressed it.
2026-08-01 10:51:01 +02:00
Marc-André Bombeck dbecbbcbdb Publish the resolved cycle verdict instead of letting each client work it out
The cycle ring's day count, its phase arcs, and the judgement that a period
is late all used to be worked out in web client code, including the
fourteen-day grace window that decides when the count stops being an
observed fact. Anything else that wanted the same answer had to reproduce
that rule, and a second copy of it is how a ring ends up showing someone a
cycle day their record does not hold.

GET /api/cycle/calendar now carries a verdict: the state, the cycle day,
the ring's arcs, the cycle start, how many days until the next period, and
when the period is late, how late. It is resolved against the user's own
timezone day, which a browser in another zone cannot do. The web wheel,
the wellness-strip tile, the insights teaser and the temperature chart all
render it, the grace constant is gone from the components directory, and
the spec says the field is required.

The overdue caption now names the number of days instead of only saying
later than usual.
2026-08-01 10:37:18 +02:00
Marc-André Bombeck 7231c9f3f7 chore(release): v1.35.1 — stop asking for a weekly dose that was already taken
A medication on a rolling interval had its expected dose anchored on the moment
the person actually injected, and was then checked off against the moment it was
planned for, with six hours of tolerance. Those two are allowed to sit days
apart, because a dose taken late still counts, so the dose someone's own
injection created could never be marked done. It stayed open, went overdue, and
the app asked for it again.

No editing was needed to reach it. Taking a weekly dose a few days late was
enough, on any account. Push reminders read the plan rather than the entries and
were never affected, so this was on screen, not on a phone.
2026-08-01 10:28:40 +02:00
Marc-André Bombeck 778725834f chore(release): v1.35.0 — the score you decide
The Health Score stops being a by-product of switches meant for something else.
It gets its own page, its own recorded history with the recipe that made each
day, and a comparison that refuses to draw a line across two different
definitions.

An account that upgrades keeps the composition it already had, so the number
does not move on its own. What changes is that the composition is now visible
and yours.
2026-07-31 20:32:25 +02:00
Marc-André Bombeck c29055be1c Carry the configured flag onto the snapshot and digest wires
The dashboard snapshot copies it off the composite, the daily digest
copies it off the snapshot, and the derived-metric payload already
carries it inside the composite value. Optional on both DTOs like the
score identity fields beside it, so older cached bodies stay valid.

The digest's score object is additionalProperties:false and had never
declared deltaReason, scoreVersion or composition, all of which it has
been sending. A generated strict decoder would have rejected the real
payload, so they are declared here too rather than left standing next
to a field that documents itself correctly.
2026-07-31 18:03:53 +02:00