The structured lab-create path (POST /api/labs) now accepts an optional
`source` marker ("MANUAL" | "OCR"). The on-device-OCR ingestion path posts
"OCR" so a reading captured from a phone-camera lab photo records its
provenance; the raw image never touches the server on this path. Omitting
the field reads as "MANUAL", so the existing hand-entry contract is
unchanged. The value is narrowed by a closed Zod enum and assigned
field-by-field, never spread from the body. Mirrors the "OCR" source the
server-side /api/labs/ocr/commit path already stamps.
Regenerate the OpenAPI spec for the widened request body.
The daily AI-token gate was a flat 25,000/day for every provider. That cap
exists to bound the OPERATOR's API bill, but a ChatGPT-OAuth (Codex) or BYOK
chain egresses on the USER's own plan/key and costs the operator nothing, so
gating it on the operator-cost ceiling is wrong. A single gpt-5.x reasoning
turn legitimately reports 20k-40k gross total_tokens (re-sent system prompt +
inventory + tool defs per round + hidden reasoning, summed across rounds), so
one or two turns exhausted the flat cap and locked the user out of a plan they
pay for.
Classify the chain's cost owner by its primary provider via resolveDailyCap:
an admin-openai primary (operator pays) keeps the operator-cost cap; every
user-egress primary (codex/openai/anthropic/local) gets a generous
abuse-only ceiling. Threaded into the Coach chat gate and both OCR-extract
modes.
Raise the operator-cost cap 25,000 -> 200,000 so the operator-key path also
survives a normal day of reasoning turns, keeping gross-token accounting
rather than reworking the reconcile math.
Subtract cached input tokens at reconcile: the Responses-API gross total still
includes prompt-cached input the user did not re-pay for. The codex client
already parsed cached_tokens; sum it through the tool loop and bill
total_tokens minus cached.
Drop the unreachable text-only-model reason from the OCR capability
enum and its DTO type. Replace the literal NUL delimiter in the Fitbit
dedup map keys with a named constant so text tooling stops treating the
file as binary (behaviour unchanged). Correct the Coach tool-executor
docblock, which overstated cross-tool snapshot-cache sharing.
Text-mode OCR structures browser-extracted text rather than running a
vision call, so it reserved and reconciled the full vision budget even
though the spend is far lower. Add an ocrExtractText ceiling and reserve
that in text mode, and refund the reservation in full on a clean extract
failure instead of charging the reserved estimate.
Add an opt-in in-browser OCR path so a user whose provider cannot read
images (ChatGPT-OAuth/Codex, a text-only model) can still scan a lab
report. The capability probe gains a `mode` field: `vision` when the
provider reads the image directly, `text` when local OCR is enabled and
any provider is configured, or null. The extract route grows a text
branch that accepts the in-browser-OCR'd text as JSON and structures it
through the existing schema + review/commit flow; the raw image never
reaches the server in text mode. New `labsLocalOcrEnabled` user column
(migration 0184, default off) gates it, with a dedicated PATCH endpoint.
The per-row commit loop ran isDuplicate() then create() sequentially, so
two identical rows in one confirmed document only collapsed because row 1
autocommitted before row 2's live query. Track written keys in a
per-request seen-set keyed on analyte+day+value so in-batch dedup no
longer depends on DB visibility. Note the mint-then-create orphan on a
mid-row failure is benign and self-healing, matching the manual path.
Resolve a vision-capable provider, gate with consent + a 6/hour rate bucket +
a reserved per-day token budget, then transcribe an uploaded photo or PDF into
structured proposed rows. The raw upload stays in memory only and is never
persisted. The commit route writes only the rows a human confirmed, resolving
or minting a biomarker per row with source OCR and skipping commit-time
duplicates. The capability probe drives whether the UI shows the scan entry.
Extracted text is treated as untrusted; the review screen is the safety
boundary.
Add first-class qualitative lab findings (positive / negative / borderline
serology) alongside the existing numeric path. A LabResult is now EITHER a
numeric reading (value + unit, optional reference bounds) OR a qualitative one
(valueText, e.g. "negativ"), never both, never neither.
- schema: make `value` nullable, add `value_text`; migration 0181 is additive
(DROP NOT NULL + ADD COLUMN), every existing numeric row is untouched.
- validation: numeric-XOR-qualitative refine on create + update; unit/bounds
apply to the numeric path only; valueText capped at 120 chars. The single
resource PUT rejects a cross-type edit.
- serialise: carry valueText through both DTOs; a qualitative row reports the
neutral `unknown` range verdict (no number to classify).
- FHIR: a qualitative result emits valueCodeableConcept — SNOMED qualifier
value for confidently-known terms (Negative 260385009, Positive 10828004,
Not detected 260415000, Detected 260373001), text-only for borderline /
grenzwertig and any unrecognised term. The raw text always rides .text.
- UI: a numeric/qualitative toggle in the lab form; list, history, detail,
chart, sparkline, and the doctor-report PDF all render qualitative readings
as their text and never plot or divide on a null value.
- i18n: new labs.form keys across all six locales.
- openapi: response carries a nullable value + valueText; regenerated.
The illness module shipped opt-in/default-off via a bornGated flag, the
inverse of the standard disabled-allowlist. Make it a normal optional
module that defaults on (opt-out), matching labs and its siblings.
No module used bornGated other than illness, so drop the flag, its
isBornGatedModule helper, and the gate resolver's born-gated branch
entirely. The module stays a normal toggle under What you track and the
operator-level availability override is unchanged; an account that wants
it off sets an explicit false. Refresh the gate, doctor-report, labs and
biomarkers comments plus the OpenAPI route descriptions accordingly.
A PUT that moves a single reference bound past the row's stored other
bound slipped through the schema refine (which only fires when both bounds
arrive together). Both the lab-result and biomarker routes now merge the
effective bounds and 422 on low > high. Editing catalog-resolved fields
(analyte/unit/panel/range) on a linked reading is also rejected rather than
silently no-oped. Shared isInvertedRange/effectiveBound helpers with tests.
A free-text lab write resolves or mints a user-scoped biomarker by
(userId, lower(analyte)) so no LabResult row ever persists unlinked. An
existing marker is reused as-is (its unit/range stay authoritative); a
miss mints one from the reading's own spelling. Every reading is now
editable and detail-navigable, and the boot-time backfill becomes a pure
pre-upgrade migration. Closes the unlinked-row dead-end and the contract
hole in one path.
Add a user-scoped Biomarker catalog so a marker is defined once — name,
unit, lower/upper bound, optional encrypted context, panel — and every
later reading just links it. New /api/biomarkers CRUD plus a structured
/api/labs create path: a biomarkerId resolves the unit + reference range
server-side, so the list and detail DTOs carry the resolved values and
the client never recomputes the range. Lab reads include the linked
marker and fall back to the legacy free-text fields for unlinked rows.
Add a lab-result restore endpoint for the delete-Undo affordance, a
shared serialiser, a static common-panel seed list, and tests for
catalog validation, structured-entry resolution, and the context codec.
Add a reminder-satisfy pg-boss queue (mirrors pr-detection) that every
measurement and lab ingest path enqueues — manual create, batch, and the
Withings / Polar / Oura / Nightscout / Fitbit / WHOOP syncs. A reminder
now auto-resolves the moment a matching reading lands instead of waiting
up to 15 minutes. The 15-minute cron stays as the idempotent safety net;
the forward-only guard lets the eventful hook and the cron converge
without double-stamping.
The worker honours the module toggle (a reminder for a disabled module
produces no engine activity) and runs the same satisfaction primitives
the cron uses.
Record discrete biomarker readings (HbA1c, LDL, ferritin, TSH, …) with
their value, unit, reference range and sample date. The store pairs with
the annual blood-panel reminder, which until now had nowhere to record
the result it reminds the user to obtain.
The list/create/update/delete endpoints narrow userId from the session,
build the write data field-by-field, soft-delete via deletedAt, and
encrypt the optional note at rest. A neutral three-state reference-range
classifier (in-range / below / above / unknown) is computed
server-side so every surface renders the same calm verdict.
findLatestLabResultForAnalytes exposes the read point the blood-panel
reminder consults to mark itself satisfied, without coupling the two.