6016 Commits
Author SHA1 Message Date
Marc-André Bombeck 214b268b41 Merge pull request #898 from MBombeck/release/v1.38.4
chore(release): v1.38.4 — the shared key finds its provider
v1.38.4
2026-09-02 17:24:10 +02:00
Marc-André Bombeck 253bd3175a chore(release): v1.38.4 — the shared key finds its provider
The admin AI key now goes to the API its base URL names, seven code
scanning findings are closed at their source, and the guard that was
meant to hold the image's two dependency pins in step can see scoped
packages for the first time. Two accessibility markers and one audit
read no longer race their own page.
2026-09-02 17:01:06 +02:00
Marc-André Bombeck a0f2e0d260 Merge pull request #894 from MBombeck/chore/scanning-sweep
chore(security): close seven scanning alerts and the guard that hid one
2026-09-02 17:00:26 +02:00
Marc-André Bombeck 0635db898a Merge origin/main into chore/scanning-sweep 2026-09-02 16:42:17 +02:00
Marc-André Bombeck 15ded13b79 Merge pull request #896 from MBombeck/test/a11y-visible-locators
test(a11y): match only the visible copy of a painted marker
2026-09-02 16:41:57 +02:00
Marc-André Bombeck 0e605b0698 Merge pull request #897 from MBombeck/fix/admin-provider-host
fix(ai): send the admin key to the provider its base URL names
2026-09-02 16:34:56 +02:00
Marc-André Bombeck eb53e9aaf4 Merge remote-tracking branch 'origin/main' into fix/admin-provider-host 2026-09-02 16:17:55 +02:00
Marc-André Bombeck b34e911c8e docs(admin): say which providers the server-key base URL accepts
The operator card offered a free-text base URL with an OpenAI placeholder
and no word about what else it takes, so an Anthropic endpoint looked
unsupported even though the field accepts that host. One line beside the
existing hints, in all seven locales, plus the changelog entry.
2026-09-02 16:17:17 +02:00
Marc-André Bombeck f4c90dc134 fix(ai): pick the admin client from the configured base URL
The operator's global provider always built an OpenAI client, so an admin
who saved an Anthropic key and https://api.anthropic.com/v1 (a host the
settings allowlist accepts) had every AI call fail: an OpenAI-shaped body
posted at Anthropic's Messages API, with nothing in the error pointing at
the mismatch.

Route by hostname instead. api.anthropic.com and any subdomain of
anthropic.com get the same AnthropicClient the per-user ANTHROPIC arm
builds, with the same Claude model default; every other host keeps the
OpenAI client, which is what an OpenAI-compatible endpoint needs. The
match parses the URL rather than searching the string, so a lookalike
host cannot claim the Anthropic wire, and an unparseable value keeps the
OpenAI path it had before.

The chain tag stays admin-openai: it names the slot, not the wire, and it
is persisted in user chains and pinned by the OpenAPI enum. The wire is
already visible in the instance type the wide events read.
2026-09-02 16:17:10 +02:00
Marc-André Bombeck a9055c96a4 Merge pull request #895 from MBombeck/docs/readme-thanks
docs: say thank you where people can see it
2026-09-02 16:14:57 +02:00
Marc-André Bombeck f2c7ed38a4 test(a11y): match only the visible copy of a painted marker
The login case failed with a strict-mode violation: the marker it waits
for resolved to two elements, one of them hidden. Two copies of a page
coexist for a moment during a route transition, so a bare attribute
locator can see both, and Playwright refuses an ambiguous match rather
than picking one. The run had passed on the same page code minutes
earlier, which is what a race looks like.

Four locators in this file already carry the :visible filter for that
reason. Seven did not, and had no .first() either, so every one of them
was exposed to the same failure on a slow enough runner. They carry it
now, which leaves the assertion aimed at the copy a reader would see.
2026-09-02 16:07:08 +02:00
Marc-André Bombeck 915d5dea3c Say thank you where people can see it
The contributor graph has carried every outside author correctly, because
fork pull requests are merged as merge commits and the authorship
survives onto main. But nobody reads a graph tab. The front page now
names the practice and renders the graph inline, so a first-time
contributor sees their own avatar on the page they landed on.

The avatar strip comes from a third-party renderer, the same class of
service as the four shields badges already at the top, and GitHub proxies
it so no visitor address reaches that host. It is generated rather than
hand-written on purpose: a hand-kept list is the kind of thing that goes
stale quietly.
2026-09-02 15:56:15 +02:00
Marc-André Bombeck 6cac31403b docs(changelog): record the dependency floors and the token scope 2026-09-02 15:47:14 +02:00
Marc-André Bombeck c5fc707e59 ci: scope the dependabot auto-merge write token to its job
The workflow granted contents: write and pull-requests: write at the top
level, so every step in the run held them, including the checkout and
the dependency install that execute code from the pull request being
merged. Scorecard flags the shape, and the shape is the problem: a
compromised transitive in a Dependabot branch would run with a token
that can write to the repository.

The workflow default is contents: read now, and the two write scopes sit
on the auto-merge job, which is the only job there is and the only place
they are spent. gh pr merge --auto needs contents: write to enable
merge-on-green and pull-requests: write to act on the pull request; the
major-bump comment needs pull-requests: write. The metadata fetch and
the test run need neither. Nothing about which updates merge
automatically changes.
2026-09-02 15:47:14 +02:00
Marc-André Bombeck c9f8aee476 build(docker): mirror the hono and valibot pins into /opt/prisma-cli
The image carries two installs. pnpm builds the app tree from
pnpm-workspace.yaml; npm builds /opt/prisma-cli and reads only its own
overrides field. The scan reported @hono/node-server@1.19.11
(GHSA-frvp-7c67-39w9, CVE-2026-39406) and valibot@1.2.0
(CVE-2026-59952) under that path while both packages were pinned and
clean in the app tree. Same shape as the deepmerge-ts finding, same
cause.

Rebuilt this exact install both ways to confirm rather than assume.
Without the two fields npm resolves @hono/node-server 1.19.11 and
valibot 1.2.0, which are the two versions the scan named. With them it
resolves 1.19.17 and 1.4.2, and prisma --version still reports 7.8.0.
Both arrive through prisma -> @prisma/dev.

The lockstep guard could not have caught this. Its workspace matcher
started a package name at [^"@], so every scoped key was invisible to
it: @hono/node-server was in the workspace file and absent from the
Dockerfile, the shared set never contained it, and the guard stayed
green. Scoped keys parse now, a test pins the reduction itself so the
blind spot cannot return quietly, and a second test asserts that the
three packages the image scan has actually flagged under /opt/prisma-cli
keep their npm-side mirror. Both fail when the pin is removed or moved
out of step.
2026-09-02 15:47:13 +02:00
Marc-André Bombeck f62cd59789 chore(deps): raise the stale advisory floors and pin valibot
Three overrides had drifted behind the advisories they were written for,
and the scan found the packages at versions the pins were supposed to
exclude.

dompurify moves from <=3.4.10 to <3.4.13. GHSA-55q2-fjhq-7xh7 is fixed
in 3.4.13 and the tree carried 3.4.12. The copy is jspdf's optional HTML
renderer, an optionalDependency at ^3.3.1, so the raise stays inside the
range jspdf declares.

postcss moves from <8.5.10 to <8.5.23 for CVE-2026-69153. next declares
an exact postcss@8.4.31, so nothing but this bound moves it; the build
has run on the 8.5 line since the first bump and stays there.

valibot gets a floor it never had, <1.4.2, for CVE-2026-59952. Two
copies arrive and neither is ours to call: an optional peer of
@hookform/resolvers, which this app does not use because it validates
with zod, and prisma's local dev server. 1.4.2 is the same major, so no
consumer is moved onto a new one.

@hono/node-server moves from <1.19.13 to <1.19.15 for
GHSA-frvp-7c67-39w9. The app tree already resolved 1.19.17, so this
changes nothing here; it exists so the bound states the real floor and
so the Dockerfile mirror has something correct to match.

The floor test in ci-determinism asserted the exact key and replacement
string, which meant every legitimate raise failed it while a floor gone
stale against a newer advisory passed it unchanged. It now reduces each
key to a package name and asserts the pin is at or above the fixed
version, with a case proving the comparison can return false.
2026-09-02 15:46:55 +02:00
Marc-André Bombeck c2ea07b0bc Merge pull request #891 from MBombeck/test/sharing-fence-flake
test(sharing): wait for the refusal audit row instead of guessing
2026-09-02 15:25:15 +02:00
Marc-André Bombeck 1de8634625 Merge pull request #890 from MBombeck/release/v1.38.3
chore(release): v1.38.3 — delete from the phone, name every source
v1.38.3
2026-09-02 15:24:46 +02:00
Marc-André Bombeck 9d77120136 test(sharing): wait for the refusal audit row instead of guessing
The scope-fence test asserts that a refused section leaves a
sharing.access.denied audit row naming the reason. That row is written
without being awaited, on purpose, so the audit never delays the 403 the
caller is waiting on, and it lands some time after the response returns.
The test bridged that gap with a fixed 150 ms sleep, which a slow runner
outran: the read found an empty table and the assertion reported no
refusal, which reads exactly like the section having reached the wire,
the one thing this file exists to catch.

The positive case now polls for the row and fails with a message naming
the caller when none arrives within five seconds. The absence case keeps
a fixed wait, longer than before, because an absence claim is only worth
something if it waited. Proven by dropping the audit write behind an env
flag: eleven cases fail with the new message, and pass again with the
write restored.
2026-09-02 13:01:23 +02:00
Marc-André Bombeck 397baac818 chore(release): v1.38.3 — delete from the phone, name every source
The account-deletion fix landed after v1.38.2 was tagged, so its entry
moves out of that section and into this one where it belongs. Also in
this release: every measurement source carries a display name in all
seven languages, with a test that reads the enums out of the schema.
2026-09-02 12:29:09 +02:00
Marc-André Bombeck 71a6dd0e2a Merge pull request #889 from MBombeck/fix/source-priority-labels
fix(i18n): name every measurement source instead of showing its enum
2026-09-02 12:28:06 +02:00
Marc-André Bombeck 4918e9fd63 Merge remote-tracking branch 'origin/main' into fix/source-priority-labels 2026-09-02 12:09:58 +02:00
Marc-André Bombeck 7ee8bc8134 Fail the suite when a source enum member has no label
The four raw values in the sources ladder shipped because nothing checks
the catalogues against the enum they render. The new guard reads
MeasurementSource and IntakeSource out of prisma/schema.prisma, not out
of a copy, and fails naming every member no catalogue labels; it also
asserts the key each entry names resolves in all seven bundles, since a
key that resolves nowhere renders no better than the enum value. Run red
against the tree before the labels landed, it reported seven unlabelled
measurement sources per catalogue and one unlabelled intake source.

The schema reader now carries each enum's declared members so the guard
can read them.
2026-09-02 12:08:38 +02:00
Marc-André Bombeck 0548c0265e Label every ingest source instead of leaking its enum value
Settings, Sources listed GOOGLE_HEALTH, OURA, POLAR and STRAVA by their
internal name: the catalogue was written for seven sources and every
provider added since fell through to the raw value. The measurement list
carried the same fall-through in its source filter and its table column,
and the medication intake history read "via APPLE_HEALTH" for a dose
synced from Apple Health.

The three catalogues move into one module keyed by the enum, so an
omission is a type error rather than a raw value on screen, and the sleep
discrepancy tooltip drops the private brand-name map it had grown to work
around the gap.
2026-09-02 12:08:31 +02:00
Marc-André Bombeck 53689ee99e Merge pull request #888 from MBombeck/fix/account-delete-bearer-mfa
fix(auth): let the app delete an account that has a second factor
2026-09-02 12:08:20 +02:00
Marc-André Bombeck 101c11f85e Merge remote-tracking branch 'origin/main' into fix/account-delete-bearer-mfa 2026-09-02 11:52:34 +02:00
Marc-André Bombeck df5dd1c160 docs: publish the Bearer arm on the two erasure routes
The published contract still told a client that a Bearer token can never
clear the step-up on account deletion or the record wipe, which is what
an app generates its client from. Describe the elevation arm on both:
which factors qualify, that a password proof does not, that the proof
rides the X-Step-Up header, and that it is spent only when the erasure
runs. The 403 a narrow-scope token gets was never published either;
it is named now, ahead of the second-factor question it precedes.

The mint endpoint lists the surfaces an elevation unlocks, so both
routes join that list.

Also note on the record wipe that API tokens are on its own delete list:
a client that wipes over Bearer signs itself out, which is worth knowing
before the retry rather than after it.
2026-09-02 11:51:19 +02:00
Marc-André Bombeck 5856b2a547 auth: let a token prove a second factor for erasure
Account deletion and the record wipe both demand a fresh second-factor
proof once a factor is enrolled, and that proof lived on the session row.
A native client has no session row, so an account with TOTP or a security
key could not delete itself from the app on any request it could build:
requireFreshMfaIfEnrolled fell through to requireFreshMfa, which resolves
via getSession and answers 401 to a perfectly valid token. App Store
guideline 5.1.1(v) asks for exactly that deletion.

Add requireFreshMfaOrElevationIfEnrolled beside the cookie-only sibling
rather than softening it. Its cookie arm is the sibling's, unchanged;
its Bearer arm takes the single-use elevation the second-factor
management routes already accept, with the fresh-factor rule pinned on,
so a password-proved elevation cannot erase an account any more than a
password login satisfies the gate on the web. No factor rides the request
body: one mint surface, one redemption path. Scope stays fail-closed, so
a narrow token is refused before the header is read.

The elevation is spent immediately before the erasure, after the typed
confirmation and the last-admin check, so a rejected request does not
burn a proof against a 5-per-15-minute mint ceiling. The Bearer branch
of requireMfaManagementAuth moves into a shared helper so both gates read
and claim in the same place.

The elevation guard grows a section naming the two routes, because the
set of surfaces an elevation can unlock only stays small if adding to it
is a visible edit.
2026-09-02 11:51:09 +02:00
Marc-André Bombeck 72117104d5 Merge pull request #887 from MBombeck/release/v1.38.2
chore(release): v1.38.2 — Anthropic on current models, briefing in your language
v1.38.2
2026-09-02 11:33:55 +02:00
Marc-André Bombeck 56ded00395 chore(release): v1.38.2 — Anthropic on current models, briefing in your language
No assistant prefill on Anthropic JSON calls, Claude 5 and Fable count as
vision-capable, the briefing cache carries its language and is never
served to a reader of another one, and job paths resolve a missing
account language to the instance default.
2026-09-02 11:10:35 +02:00
Marc-André Bombeck ad0a055fdc Merge pull request #886 from MBombeck/fix/briefing-locale
fix: never serve the briefing in another language than the reader's
2026-09-02 11:09:23 +02:00
Marc-André Bombeck a55ecc5a48 Merge origin/main into fix/briefing-locale 2026-09-02 10:51:56 +02:00
Marc-André Bombeck 718cc3568f Merge pull request #885 from MBombeck/fix/anthropic-no-prefill
fix(ai): no assistant prefill on Anthropic, Claude 5 counts as vision-capable
2026-09-02 10:51:25 +02:00
Marc-André Bombeck 7414f6e79b Note the briefing language fix in the changelog 2026-09-02 10:44:04 +02:00
Marc-André Bombeck f003b6f413 Resolve job-side locale through the operator default
Every background path that resolved a user row's locale collapsed a
NULL column to English: the nightly briefing warm, the status crons,
the period-narrative warm, reaction lines, the morning refresh, the
briefing push, coach nudges, the reminder workers, and the document and
workout summaries each carried their own copy of
`locales.includes(x) ? x : defaultLocale` or called
`normalizeLocale(user.locale)`. None consulted the instance's configured
default language, so an account that never touched the language picker
got English prose and English pushes on a German-language instance.

Add resolveJobLocale (src/lib/i18n/job-locale.ts): stored user locale
when valid, else the operator's app_settings.default_locale when valid,
else English. The settings read lives beside the other app-settings
accessors. Every job and daily path resolves through it once, where it
has the row; the template helpers keep coerceLocale for a value that is
already resolved, and queue payloads keep normalizeLocale.

The briefing push hands its resolved locale down to the digest, and the
digest to the snapshot read, so the push copy and the digest lines are
in the same language instead of two different fallbacks.

A structural test walks src/lib/jobs and src/lib/daily and fails on a
hand-rolled coercion, a validator applied to a row column, or a row
reader that does not import the helper. It was red against the previous
tree on all three counts.
2026-09-02 10:44:04 +02:00
Marc-André Bombeck ba4e34f09e Tag the briefing cache with its language and refuse a foreign one
The comprehensive-briefing cache (User.insightsCachedText) is one slot
per account with no record of the language it was written in. Every
reader served whatever was there, so a German-language account whose
nightly warm had written English kept seeing English on the dashboard
lead and the Today briefing. The request path only replaced it once the
row was a day old and the provider answered, which with a provider at
its usage limit was never.

Add User.insightsCachedLocale (migration 0335). Both writers store the
locale they generated in; the timestamp-only refresh on an unchanged
snapshot tags the row too, since the fingerprint covers the generation
locale. Every reset that nulls the text nulls the tag.

The snapshot briefing lift and GET /api/insights/generate treat a tag
that differs from the reader's locale as an empty cache: no prose, no
timestamp, the preparing / no-provider state, and the out-of-band warm
enqueued in the reader's locale through the existing path. The
generator's own short-circuit and the POST short-circuit refuse to call
a foreign-language row fresh. A NULL tag (rows written before this
change) is served as before until the next write tags it. The Today
digest derives from the snapshot and follows.

Covered by unit tests on the lift, the route and the generator, and an
integration test that writes the row through Prisma and reads the
snapshot for a reader in another language.
2026-09-02 10:44:03 +02:00
Marc-André Bombeck 7d93af9d09 fix(ai): recognise Claude 5 and Fable models as vision-capable
The Anthropic vision check listed the Claude 3 and 4 families by prefix,
so a key pinned to claude-sonnet-5, claude-opus-5 or a Fable model was
told the provider cannot read images, and lab and document OCR refused
the upload even though the model handles image and PDF blocks.

Replace the per-release prefix list with a rule: any claude-<tier>-<major>
slug from the 4 family upward, plus every claude-fable-* model, counts as
vision-capable. Claude 3 handling is unchanged. The Settings preset list
gains claude-sonnet-5 and claude-opus-5 alongside the existing entries.
2026-09-02 10:32:50 +02:00
Marc-André Bombeck a463206774 fix(ai): stop prefilling the assistant turn on Anthropic JSON calls
The Anthropic client steered JSON replies by appending an assistant turn
containing an opening brace and gluing the brace back onto the reply.
Current Claude models (the 4.6 / 4.7 / 4.8 family, Opus 5, Sonnet 5,
Fable 5 and 5.1) reject an assistant prefill with HTTP 400, so every
JSON surface on the provider (briefing, status cards, document and lab
extraction) failed for a self-hoster on a current model while plain chat
kept working.

Drop the prefill on every model. The request now carries a plain
instruction on the last user turn to reply with a single JSON object and
nothing else, and the reply is narrowed to the outermost object body via
a shared extractJsonObject helper, so a fenced or prefixed answer still
parses. The insights stripJsonFences helper delegates to the same
function rather than keeping a second copy. The tool-use path is
unchanged.

Reported independently by @sweidinger.
2026-09-02 10:28:05 +02:00
Marc-André Bombeck d5cf82fb6e Merge pull request #884 from MBombeck/release/v1.38.1
chore(release): v1.38.1 — push readings in, see the range at a glance
v1.38.1
2026-09-01 21:26:13 +02:00
Marc-André Bombeck a2e50c761f Note the callback-notice fix and the mysql2 pin in the changelog 2026-09-01 21:00:50 +02:00
Marc-André Bombeck 05d3736d75 Resolve OAuth callback URLs on the server for integration cards
The v1.38.0 notice that warns when the configured OAuth callback origin
differs from the origin the browser is on never rendered in the published
image, and the copyable callback address in the redirect guide showed a
bare path. `integrationCallbackUrl()` read `process.env.NEXT_PUBLIC_APP_URL`
inside a "use client" module. Next.js inlines NEXT_PUBLIC_* into client
bundles at build time, and the GHCR image is built without that variable
(only the version, build-sha and built-at args reach the build), so the
base compiled to an empty string, `new URL("/api/<provider>/callback")`
threw, and the notice returned null on every card for every self-hoster.

The value now comes from the server at request time. A new map
(`src/lib/integrations/callback-urls.ts`) calls each provider's own
redirect-URI function (the four private `getRedirectUri()` helpers are
exported under provider-specific names; Polar, Oura and Strava already
exported theirs), so the page shows exactly what the handshake registers,
explicit `*_REDIRECT_URI` override included. The settings route resolves
the map and passes it as a prop through `IntegrationsSection` and
`ConnectionsPanel` to every card. Fitbit and Google Health validate their
URI and throw on a bad configuration; that surfaces as `null`, the notice
skips the comparison and the guide falls back to the fixed path.

Tests: a map test proving the explicit override wins and the derived form
is used otherwise, a component test rendering the notice from a passed
value (mismatch renders, match does not), and a structural guard that
fails when any "use client" file under src/components/settings/integrations
reads NEXT_PUBLIC_APP_URL.
2026-09-01 21:00:12 +02:00
Marc-André Bombeck 97b1603cb9 Pin mysql2 past GHSA-3f6p-5ww8-9rcr in both installs
The dependency audit went red on the release branch: mysql2 below 3.22.0
downgrades its auth plugin to mysql_clear_password and leaks the
credential. It reaches the tree only through prisma's MySQL driver, which
this app never opens, but the gate does not distinguish reachable from
present. Pinned to ^3.22.0 in the workspace overrides and mirrored with
npm's own override for the /opt/prisma-cli install, the pair the
lockstep guard holds together. The audit reads three moderates, below
the gate.
2026-09-01 20:53:47 +02:00
Marc-André Bombeck c462d9a282 chore(release): v1.38.1 — push readings in, see the range at a glance
A measurement ingest token for readings from your own hardware, a
reference-range bar beside every lab reading, lab OCR working with a
GPT-5 model, an impossible reference window no longer written to the
record, and the image build kept intact across the Prisma adapter bump.
Dependency updates ride along.
2026-09-01 20:33:30 +02:00
Marc-André Bombeck cba389515c ci: split the integration suite four ways and widen the guard
Three of the last four pushes to main failed the integration workflow
without a single failing test. Shard 1 of 3 was still logging green
results when the 25-minute job timeout cancelled it; the same tests in
the same shard had run in 13 minutes the day before. A slow runner day
doubles every test's wall time, and a guard under twice the ordinary
run cannot absorb that.

Four shards keep each slice around fifty files, and the guard moves to
40 minutes, three times an ordinary run. A wedged test still fails
through vitest's own 60-second budget, so the job timeout only has to
catch a container that never boots. The fan-in job is unchanged; it
still refuses a matrix with any cancelled shard.
2026-09-01 20:28:32 +02:00
Marc-André Bombeck 8016d375c6 Merge pull request #880 from TimonBed/lab-visual-improvement
Improve lab range and trend visual alignment
2026-09-01 19:06:09 +02:00
Marc-André Bombeck 60939475b4 Give an impossible reference window no verdict at all
A floor at or above its ceiling fell through to the one-sided branch and
was read as "minimum only", which paints a high reading green. That is
the wrong direction for a transcription error to fail in: the colour
answers a question nobody could answer from that data.

Each one-sided branch now requires the OTHER bound to be absent, so a
present-but-impossible pair lands on the neutral dot instead. It matches
lab-reference-range-bar.tsx, which renders nothing for the same input —
the two components sitting next to each other should not disagree about
what a nonsense range means.

The file had no test. It has one now, covering all five window shapes
plus the zero-width band; with the two conditions relaxed again, exactly
the two new cases fail and the four existing ones stay green.
2026-09-01 18:51:07 +02:00
Marc-André Bombeck 1cc5779282 Merge pull request #883 from MBombeck/docs/knip-triage-current
Say which knip entry is kept on purpose, and re-measure the count
2026-08-31 21:43:05 +02:00
Marc-André Bombeck 6d98a67751 Say which knip entry is kept on purpose, and re-measure the count
The production pass reports 22 files, and the comment said 21 — a small
drift, but the paragraph reads as a triage nobody has redone, so the
number is dated now and carries an instruction to re-measure.

Two things the block did not say:

The standing exception. `signals/adapters/metric-status.ts` is reported
unreachable because its only importer is its own registry-invariant test.
That is what the module is for: it projects the signal registry onto the
MetricStatusMeta shape so the test can assert the two agree field for
field. Deleting it to clear the line removes a check rather than ballast.
Someone triaging the list without that written down has to rediscover it,
and the obvious move is the wrong one.

The healed example. The false-positive slot cited a module reported
because scripts/ sits outside the tsconfig that defines the @/ alias.
It no longer appears — module and importing script both still exist, so
resolution improved under us. Replaced with the case that does hold
today, the service-worker policy that public/sw.js mirrors by hand, and
the old one kept as a note: an example that heals silently makes the
reasoning around it look wrong to whoever checks it next.
2026-08-31 21:04:22 +02:00
Marc-André Bombeck e6751f0f0c Merge pull request #882 from MBombeck/fix/transposed-reference-window-v2
Drop a reference window whose floor sits above its ceiling
2026-08-31 20:35:14 +02:00
Marc-André Bombeck 963f61cf0f Drop a reference window whose floor sits above its ceiling
A lab report's reference range reaches the record by four paths. Three of
them validate that the floor does not exceed the ceiling; the document
path did not, and the catalog store writes what it is handed. An
integration run against a real Postgres confirmed the consequence: a
100/30 pair landed on the row and on the freshly minted biomarker, so
every later reading of that analyte inherited a window that cannot be
true and a high value came back reassuring.

Dropping beats swapping. A transposed pair says the transcription is
wrong, not which of the two numbers is, and inventing an order would
state something the report never printed. The verbatim string survives
either way, so nothing the document actually said is lost.

Three points, answering according to what each one is:
- the extraction schema drops the pair, because every field there is
  catch-guarded so one bad value cannot sink a 120-fact document
- the review-screen edit refuses with the wording the lab schemas
  already use, because a person can see both numbers and fix them
- the commit path drops it as the last gate, which is what covers facts
  staged before this rule existed and confirmed afterwards

parseReferenceRange has answered a transposed PRINTED window this way
since it was written, and the commit path already counts a window it
could not read at all. This extends both to the case that was readable
and cannot be true.
2026-08-31 19:38:08 +02:00