Commit Graph
120 Commits
Author SHA1 Message Date
Marc-André Bombeck c86f23c2c0 fix(security): keep ALLOW_LOCAL_AI_PRIVATE_HOSTS=true for the operator's own AI, deprecated
Making `=true` grant nothing would stop a single-user self-host's local
model on a patch update. It is deprecated for one release cycle instead,
in a narrowed and safe form: it still grants a private host, but only to
the operator's own configurations (the instance-wide admin provider and
AI settings saved on an admin account), and only through the pinned
operator-approved dispatcher, so redirects stay forbidden and metadata,
link-local and unspecified addresses stay refused. A non-admin account's
own base URL needs an exact origin in AI_PRIVATE_ORIGINS.

Provider resolution passes the owning account's role to the client
(`operatorTrusted`); the save route and the connection test apply the
same rule to the account being configured. The boot summary now says the
setting is deprecated and names the replacement, and the admin AI
settings page shows the exact AI_PRIVATE_ORIGINS line for the private
origins saved on the instance (every account's Local and gateway URL and
the admin base URL, scheme, host and port only), so the operator can copy
it and drop the old setting.
2026-09-26 20:59:10 +02:00
Marc-André Bombeck 1a674cf4b0 fix(security): grant private AI endpoints by exact origin, dialled pinned
ALLOW_LOCAL_AI_PRIVATE_HOSTS=true let any user of the instance point the
Local provider or the OpenAI-compatible gateway at any private address
the server could reach, the metadata range included, and a granted host
was dialled with no connect-time pin at all.

AI_PRIVATE_ORIGINS grants a private AI endpoint with the exact-origin
grammar NOTIFICATION_PRIVATE_ORIGINS and NIGHTSCOUT_PRIVATE_ORIGINS use.
A granted call goes through the pinned operator-approved dispatcher:
redirects forbidden, and metadata, link-local and unspecified answers
dropped at dial time even for a granted name. Everything else stays on
the public pin. The Local client (buffered and streaming) and the
gateway and admin-key tags of the OpenAI client all take their policy
from one function.

The host-list form of ALLOW_LOCAL_AI_PRIVATE_HOSTS keeps working with the
same pinning; a never-grantable literal or a host:port entry is refused
with a one-time warning. Its "true" form grants nothing any more and is
reported in the boot readiness summary with the replacement.

A failed lab-report scan no longer puts the endpoint's response body into
the wide event; status and model stay. The response to the caller never
carried it.
2026-09-26 19:46:11 +02:00
Marc-André Bombeck ce72fe226e refactor(ai): re-check every chokepoint through the one egress helper
The status chokepoint, the comprehensive briefing, reaction lines and
AI-composed nudges now ask `aiEgressRefusal` about exactly the chain they
are about to send to, instead of each pairing the operator-key check
with a surface receipt lookup of its own. The consent kinds come from the
capability table, so the wire and the published capability answer from
the same rule.

The document summary, its catch-up pass and the index backfill stop on a
closed switch or module before anything is picked, and leave "no
provider" and "consent needed" to the document pick, which re-checks the
wire for the provider it chose. A closed switch still only heals PENDING
to NONE; a withheld pick records UNAVAILABLE. The per-pick document
consent helper is removed, since nothing calls it any more.

The auto-read toggle mints the shared `AI_EXTRACTION_CONSENT_KIND`, and
the web grant helper goes back to minting the master grant only.

Refs #975
2026-09-24 09:29:00 +02:00
Marc-André Bombeck 3917a2face feat(ai): resolve the AI capability before any background model work
Every AI job now asks its capability for the user before it builds a
snapshot, and skips with a `.skipped` annotation naming the reason: the
nightly pregenerate (briefing and statusText, each half on its own), the
status batch and the per-metric crons, the on-demand status worker, the
narrative cron, Coach nudges and their AI composer, the Coach memory
refresh, reaction lines, workout paragraphs, and the document summary,
catch-up and index jobs. Seven of these read no assistant switch at all
before, so the master switch now stops them too.

The cron candidate queries drop people who switched AI analysis off in
SQL, and no longer read `disableCoach` outside the Coach: hiding the
Coach hides the Coach. Coach nudges use the one presence probe, so a
central Codex account is no longer skipped; a missing consent still gets
the deterministic template. The arrival spine asks only the
provider-free half (switches and modules) before it enqueues, keeping
its module graph free of provider code.

Document reading needs an extraction receipt (ai_extraction or ai_full)
for any external provider; the auto-read toggle is the trigger, no longer
a consent shortcut, so a revoked receipt wins over a toggle left on. Lab
report scans get the same rule for a whole chain.

Two structural guards: every file that calls a provider is named with
the reason its call is admitted, and the chokepoints re-check a
capability; every AI job references its capability gate. An integration
matrix runs each worker under the refusing states with a provider spy.

Refs #975
2026-09-24 09:07:16 +02:00
Marc-André Bombeck 1be2392c81 feat(ai): one provider presence probe
probeProviderChain describes the chain that would serve a record
without decrypting, building a client or refreshing a token: which
entries hold a credential, in chain order, whether each reads images,
the in-browser OCR opt-in, and where the credential comes from. It
applies the provider-work authority, appends the central Codex only
behind the opt-in, and falls back to the legacy provider only for an
empty chain. hasAnyConfiguredProvider and resolveProviderAvailability
now answer through it.
2026-09-24 07:39:56 +02:00
Marc-André Bombeck 4460047910 Merge the coach cap counting only the tokens the operator paid for
Background insights can no longer use up the day before somebody asks a question; a fifth of the daily limit is held back for what a person does by hand.
2026-09-11 14:18:21 +02:00
Marc-André Bombeck bcef5aeeee fix(ai): hold a reserve so the chat is never locked out by jobs
The background ceiling applied only when the operator funded the chain, so on a
chain the operator does not pay for the automatic generators and the chat shared
one undivided ceiling and the first could still take all of it. That is the
configuration the recommended remedy for the original lockout produces — put the
personal Codex account first — so the guarantee was missing on exactly the setup
it was written for.

The ceiling is now expressed as what it is meant to protect. Background work may
run until the day's spend would leave less than a fixed reserve for whoever is
waiting on an answer, on both cost owners. A reserve and a quota look alike at
the boundary and mean opposite things in the middle: a quota stops useful
generation on a day nobody was ever going to interrupt, while a reserve costs it
nothing until the moment it would take the last of the day. An account
generating 1.45 million tokens of insights on its own plan keeps every one of
them and the chat still holds 400 000.

INTERACTIVE_RESERVE_SHARE replaces JOB_SURFACE_SHARE. The old name described a
quota over the jobs rather than a floor under the person waiting, and a name
that states the opposite of the intent is what put the wrong comparison in this
ledger twice already.

The fraction is a default an operator will want to change. The setting and its
documentation are separate work.
2026-09-11 12:55:52 +02:00
Marc-André Bombeck b565edc48d docs(ai): drop planning vocabulary from the ledger comments
CLAUDE.md forbids planning shorthand in committed artefacts, and this branch
introduced 74 lines of it across source, tests, a schema field and two
migrations. Someone reading the migration SQL in a year has no way to resolve a
wave letter or a round number, and a comment that points at something outside
the repository names no reason at all.

Three passes. A version followed by a parenthetical keeps the version and loses
the parenthetical, because the version says since when something holds. A
sentence that leaned on the vocabulary to carry its meaning is rewritten to
state the reason directly: the arrival reservation now says it was refused over
money the operator never spent, rather than naming the defect a wave existed to
remove. Three test titles carried the marker in the name itself and are renamed
to what they pin.

Comments only. No behaviour, no renames beyond those three titles.
2026-09-11 12:37:54 +02:00
Marc-André Bombeck 18ed57b867 fix(ai): every surface keeps a ceiling on the day's mixed total
The owner split left an operator-funded chain with no ceiling at all. Its arm
compared operator_tokens, and that counter returns to roughly zero on every
reconcile the user's own plan settled: on the 2026-09-11 evidence the shared
key answered 500 and codex served, so the counter never grew, every job
reservation was admitted forever, and a runaway client loop could write an
unbounded row. A pre-existing concurrency contract caught it from the other
side, two reservations that must not both pass one cap both passing.

reserveBudget now refuses when the owner's counter is over its cap OR the day's
mixed total is over the abuse ceiling, and a background surface gets its share
of that ceiling too, so background spend is bounded on the total and not only
under a counter the operator's key never fills. The surface is a required
argument, so the type checker enumerated every reservation site.

The hop guard learns the same lesson: an operator-funded fallback hop is now
rationed at the calling surface's ceiling rather than the full operator cap. A
job admitted under the job share could previously spend the whole 200 000 once
its chain fell back onto the shared key, which is the original defect reached
by the fallback path.

The job share applies only where the operator pays for the chain. Multiplying
whichever ceiling the chain resolved halved background generation for a
self-hoster on a local model, who costs the operator nothing.

Refusals now name the ceiling they hit and whose it was. A refusal used to emit
the day's mixed total and nothing else, which on an operator refusal is not the
counter that tripped.
2026-09-11 11:11:01 +02:00
Marc-André Bombeck 41596d1cb8 docs: comments name what the code does, not how it was built
Thirty-one files carried internal build vocabulary and anchors pointing at
notes that are not in the repository: a reader of `checklist.ts` in a year
would find its rationale keyed to a research id they cannot open, inside a
comment that also names an internal build stage. Two test titles carried the
same anchors.

The version tags and the whole of the reasoning stay; only the vocabulary and
the dangling references go, with the few sentences that were built around an
anchor rewritten to stand on their own.
2026-09-11 11:03:04 +02:00
Marc-André Bombeck 65a62350ea fix(onboarding): the AI panel claims an outage only when one was seen
The done screen folded the `unknown` provider verdict into `unhealthy` and
painted "This instance's provider is not answering right now." `unknown` is
an empty `provider_health` table, which is the state of every instance until
its first AI call — so the most common path through the screen made a claim
about something nobody had measured. It now falls to the neutral panel, which
says nothing either way; the offer is withheld in both cases as before.

The projection itself drew the same unearned conclusion from staleness alone:
a quiet instance whose last success was two days old read `unhealthy` without
any failure behind it. A verdict now needs an observed one — a row whose last
outcome was a failure — and a merely old success reads `unknown`.
2026-09-11 10:53:41 +02:00
Marc-André Bombeck 2c46712e13 fix(ai): the operator cap counts only tokens the operator paid for
The reservation compared the day's TOTAL against whichever ceiling the
chain resolved, and the chain walker's hop guard did the same. On an
account whose background jobs run on the user's own ChatGPT plan, both
saw a total the operator had not paid for: the chat refused at 06:42Z
with 513 539 tokens against the 200 000 operator ceiling, and the
fallback hop to the shared key was closed on the same arithmetic.

An operator-funded chain is now measured against `operator_tokens` and a
user-plan chain keeps its abuse ceiling on the total; the hop guard reads
the operator share too. A day of turns the user's own plan paid for can
no longer lock anyone out of the operator's ceiling.
2026-09-11 09:56:44 +02:00
Marc-André Bombeck badef28e96 feat(ai): the daily ledger records which tokens the operator paid for
`coach_usage` counted one number per (user, day). A turn served by the
user's own ChatGPT plan after the operator's shared key answered 500 was
therefore charged to the operator-cost cap, and the operator's chat was
locked out every morning by background work he pays for himself.

The ledger gains `operator_tokens`: the share of `total_tokens` served by
an operator-funded provider. `reserveBudget` books a reservation to the
owner of the chain's primary and `reconcileSpend` settles it against the
provider that actually served, both inside the same statements as the
total so concurrent requests can never see one counter without the other.
Every call site names its cost owner and its serving hop; the argument is
required, so a new surface cannot inherit the old blind accounting.
2026-09-11 09:42:14 +02:00
Marc-André Bombeck 82b7c11b05 feat(ai): a tri-state health of the instance's shared provider, fail closed
The done screen promised working insights on the strength of a presence
read. A fresh account's own provider-health ledger is empty by
construction (the table is unique on user + provider), so the evidence has
to be an instance-wide fold. It folds across accounts and returns three
words: no counts, no timestamps, no account can be inferred from it.

Fails closed against the grain of the ledger it reads. There the fail-open
protects a generation already running; here the output is a promise made to
a person, so an empty table, a timeout or a read error is 'unknown', and
'unknown' buys nothing.
2026-09-11 09:04:50 +02:00
Marc-André Bombeck 703ca2744e Surface what an OpenAI-compatible gateway actually refused
A gateway can answer a failed generation with HTTP 200 and put the reason
in the body: a top-level `error` object and no `choices`, or
`choices[0].error` beside `finish_reason: "error"` when a generation dies
mid-flight. Both fell through to the empty-content throw, so a dead credit
balance, a data-policy block, a rate limit and a model that is down all
recorded the same hop: status 0, reason "returned empty content", no
excerpt. Nothing on the provider health card told them apart.

Raise the shape a non-2xx reply raises instead, with the gateway's own
message leading and its `error.code` as the status when that code is an
HTTP error status. The chain classifier, the health ledger and the card
then see 402 / 403 / 429 / 503 and treat each of them the way they treat
the same status on the wire. Message and excerpt are bounded and
key-redacted by the same helper the non-2xx path uses, so remote text is
no more trusted here than it is there.

Gated on the gateway tag: api.openai.com signals every failure with a
status, so the pinned tags parse exactly as before, and a genuine empty
reply stays the empty-content throw it was. The local-endpoint provider
had the same hole on its buffered path, its buffered-reply fallback and
its stream, and gets the same treatment. The body classifier learned the
two account-side refusals a gateway words in its own vocabulary rather
than the vendor's.
2026-09-05 17:56:47 +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 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
Timon_bed 34e37fe850 prettier fix 2026-08-30 18:01:47 +02:00
Timon_bed 6ab3b02935 fixes ocr for newer open ai models 2026-08-30 17:37:52 +02:00
Marc-André Bombeck 6ff81b2717 Merge branch 'pr839tmp' into feat/korean-locale
# Conflicts:
#	CHANGELOG.md
2026-08-28 21:59:36 +02:00
Marc-André Bombeck fb82954077 Let the operator provider reach an allowlisted private host
The global admin provider's base URL is operator-typed in the admin
settings, but the client pinned requirePublicHost unconditionally for
its tag — so an operator pointing it at a private OpenAI-compatible
proxy (an OAuth sidecar on the instance's own Docker network) got a
safeFetch refusal on every call, indistinguishable from an outage. The
admin-key tag now consults the same ALLOW_LOCAL_AI_PRIVATE_HOSTS
policy as the user-configured gateway: a public URL never consults the
allowlist, a private host without an entry is refused exactly as
before, and both ends of the grant are the operator's own. The codex
tag stays hard-pinned; its base URL is a repository constant. Every
non-operator admin-key construction pins the public api.openai.com
constant, so the personal-key posture is unchanged.

The test asserting the old blanket pin is split into the three cases
that actually differ: allowlisted private host passes, unlisted
private host still refuses, codex refuses even a blanket allowlist.
2026-08-27 08:48:00 +02:00
aucun6352 f7cc47850a fix(i18n): make the Korean locale actually work end to end
Registering `ko` in the `Locale` union left seven locale-keyed tables
without an entry, so the build could not type-check and the safety
screens, the citation-coverage grader and the reference-range parser
read nothing at all for a Korean reader.

Three failures were not missing keys but wrong assumptions:

- The shared fold decomposes with NFD and strips U+0300-U+036F. Latin
  accents become plain letters, but Hangul decomposes into jamo, which
  are not in that range and survived — so folded Korean text was a jamo
  sequence no precomposed table entry could ever match. It recomposes to
  NFC now; every Latin, Polish and German fold is byte-identical.
- `normaliseLabKey` kept `[a-z0-9]`, which reduced every Korean analyte
  name to the empty string, so the derived index held none of them and a
  Korean cholesterol result reached a FHIR export uncoded.
- The reference-range parser had the hyphen and the dashes but not the
  tilde a Korean lab actually prints (`3.5~5.0`).

The Korean safety banks carry no `\b` next to a Hangul token — `\b` is
an ASCII `\w` boundary and Hangul is not `\w`, so `/\b증량/` matches
nothing — and the clause order is the mirror of the Latin banks, since
Korean closes with the verb. The dose patterns require a target-marking
particle for the same reason the Latin banks require "to"/"by": without
it the permitted restatement "7.5mg을 복용 중이고, 체중은 내려가고
있어요" tripped the lowering pattern.

The Coach and Insights prompts route `ko` down the reviewed English body
plus a Korean reply directive; there is no `safety-contracts.ko.yaml`, so
the native builder has no Korean ground rules to compose from.
2026-08-26 20:00:44 +09:00
Marc-André Bombeck 267497dd3a fix(insights): measure citation coverage in the language the answer was written in
The normative-claim bank held English and German words. Insight
recommendations are generated in the reader's locale, so for French,
Spanish, Italian and Polish every recommendation scored non-normative:
the annotation reported zero normative claims and zero uncited ones, and
the admin quality dashboard read that as full coverage rather than as no
measurement at all. A check that cannot fail is indistinguishable from a
check that passes.

The bank now has one arm per shipped locale and the caller passes the
generation locale; the reader's arm and the English one both run, because
a fallback provider answers a non-English reader in English. Matching
folds both sides through the shared label fold and anchors the leading
word boundary, so inflections still hit while the everyday Italian
"soprattutto" no longer counts as a comparative.

These words are the model's prose, not app labels, so they are not in the
message bundles and cannot be derived from them. The structural test
refuses an empty arm instead: a seventh language fails the suite rather
than quietly scoring nothing. An empty arm also used to compile to an
alternation that matched every string, which would have flipped the same
blindness to the opposite extreme.
2026-08-23 17:55:45 +02:00
Marc-André Bombeck 6ebd5e8711 Guard the operator's cap on fallback hops and reap dead summary promises
- The provider-chain walker now checks the day's recorded spend against
  OPERATOR_COST_CAP before invoking an operator-funded admin-* entry. A
  request whose primary provider runs on the user's own key reserves
  under the generous user-plan ceiling; a fallback hop (or health-ledger
  reorder) onto the operator's key could then spend past the operator's
  cap unchecked. An exhausted cap skips the candidate like a failed hop.
- summaryState PENDING gets a giver-upper: the detail serialiser
  degrades a PENDING older than an hour to UNAVAILABLE (which offers the
  manual generate action), and an hourly reaper persists the same heal
  following the orphan-ImportJob reconcile precedent — a job dead past
  its retry budget no longer leaves the sheet saying "generating"
  forever.
2026-08-14 05:53:17 +02:00
Marc-André Bombeck d404d8275a fix: preserve import job wipe ownership
Keep durable actor attribution from deleting record-owned jobs.

Align provider and idempotency coverage with fail-closed behavior.
2026-08-06 02:11:01 +02:00
Marc-André Bombeck 3ef6a7119b test(ai): pin the gateway provider's isolation and host policy
The properties this provider has to keep: it resolves against the base
URL that was saved for it, the pinned OpenAI arm cannot see that URL and
still posts to api.openai.com, and the OpenAI key never travels to the
gateway. Each is asserted at the wire, not on internals.

The SSRF floor is checked through the same alt-notation classes the
Local provider is checked against, and the pinned tags are shown to keep
requiring a public host even when the operator opened every private one.

The AI settings connect-src is pinned to what it already allowed: the
gateway is called server-side, so no CSP change is needed, and a future
change that needs one will turn this red.
2026-07-26 22:27:48 +02:00
Marc-André Bombeck b20f1b5c66 fix: harden imports and daily medication state (#577) 2026-07-21 13:08:28 +02:00
Marc-André Bombeck d40cb3eef3 fix(ai): align the corrective retry prompt with the validator that grades the reply
The retry suffix still described the strict insight wrapper that was removed
long ago: it demanded citations[] and warnings[] — fields insightResultSchema
does not have — required metricSource, rationale, id and severity on every
recommendation when all four are optional, and named none of the five fields
the live schema actually requires (classification, findings, correlations,
dataQuality, disclaimer).

So the one corrective pass steered the model away from the contract it was
being corrected towards. A reply that obeyed the correction could not
validate, fell through parseComprehensiveResult's raw-object escape hatch,
and the downstream surfaces read an unvalidated payload.

Describe insightResultSchema and nothing else, and carry the contract as data
in RETRY_CONTRACT_EXAMPLE so a test can assert prompt and validator agree.
2026-07-19 11:01:57 +02:00
Marc-André Bombeck f96c18574b Correct two test headers that described the removed cross-check 2026-07-19 05:53:05 +02:00
Marc-André Bombeck 4183202ed3 Delete the citation-enforcement chain that never ran
generateInsight() parsed against the strict aiInsightResponseSchema,
cross-checked that every recommendation's metricSource appeared in
citations[], and threw InsightSchemaError on a second failure. Its only
caller was runWithFallback(), which had no production caller — every
live surface runs runRawCompletionWithFallback. So none of it executed.

Wiring it into the live path was the alternative and it is not viable:
the live insightResultSchema has no citations[] field at all and treats
metricSource as optional, so the cross-check would have rejected
essentially every generation rather than tightening anything.

Removed: runWithFallback and its param/result types, generateInsight
and its private helpers, findUncitedRecommendations,
findRecommendationsMissingRationale, the never-thrown InsightSchemaError
and the isHardProviderFailure branch that excluded it, and
computeConfidence — a deterministic override that only ran inside the
dead wrapper.

Kept: buildRetryCorrectionMessage, which comprehensive-generate sends on
its own retry leg; the file it lived in is renamed to match what it now
holds. aiInsightResponseSchema stays as the shape that message
describes. Note the retry prompt asks for a stricter payload than the
live validator requires — that mismatch predates this and is left alone.

computeCitationCoverage is untouched and still live: it is a metric, not
a gate, and v1.30.24 wired it into the comprehensive post-parse step.

Chain-runner coverage the deleted tests carried (last-working cache,
empty-input guard) is ported onto runRawCompletionWithFallback, which
shares the machinery.
2026-07-19 05:50:57 +02:00
Marc-André Bombeck e31c66e959 fix(security): scrub wide events at the builder, encrypt the replay cache
Three leaks of the same class - data leaving the process unprotected.

addMeta and addExternalCall wrote their values verbatim while setError
and setHttp scrubbed, so the builder's redaction contract held for two
entry points out of four. The finished event is serialised whole to
stdout and the log store, which made meta a trusted sink by accident: a
Telegram request timeout put the bot token there, and the AI provider
chain fed upstream error bodies through it. Both now scrub, at any depth.

Scrubbing cannot help with the second half. A gateway that echoes the
request it rejected echoes the prompt, and on that path the prompt
carries the health snapshot; no pattern separates that from any other
text. Upstream bodies are therefore classified rather than quoted - the
diagnostic question is which rejection occurred and whether it repeats,
which a classification answers without the content.

The idempotency replay cache stored response bodies unencrypted. The
create paths echo their own decrypted payload, so cycle notes, mood text
and allergy reactions sat in cleartext for 24 hours in a column that
lands in every backup; the existing guard missed it because it matches
secret shapes and health data is not secret-shaped. Bodies are encrypted
now, and when encryption is unavailable the response is not cached at
all rather than written in the clear.

chore(release): v1.30.18
2026-07-19 01:28:20 +02:00
Marc-André Bombeck d82862b384 feat(insights): weave ECG into the briefing narrative and daily rail
Fold the ECG silo into the insights narrative without crossing the
non-diagnostic line — the app reports the recording device's own verdict
and the existence/count of recordings, never an interpretation of the trace.

- Feature snapshot: a bounded, device-verdict-only ECG descriptor (recording
  count, device-verdict distribution, latest recorded-days-ago + average HR)
  flows into the nightly briefing prompt. The waveform never enters the
  payload; the grounding gate admits the pre-computed counts so a
  device-attributed sentence stays grounded. A prompt rule (EN + DE)
  constrains any mention to the device's attribution and forbids reading
  morphology, measuring intervals, or emitting a verdict of the app's own.
- Pulse cross-link: a device-attributed pointer from the resting-HR / pulse
  page into the ECG viewer, un-mounting when no recordings exist.
- Daily rail: an ecg_new_recording item surfaces once when a recording landed
  within the last day — calm, info-level, deep-linking the viewer. Gated on
  the insights module; the DTO carries no waveform.
- i18n across all six locales; non-diagnostic disclaimer copy per language.
2026-07-17 08:19:17 +02:00
Marc-André Bombeck 13464b512e chore(release): v1.28.28 — OpenAI-compatible gateways work; a re-keyed night can no longer vanish
The Local provider speaks the standard JSON wire: response_format by default,
one self-healing fallback per endpoint when a server rejects it (dialect
cached per base URL) — LiteLLM, OpenRouter, vLLM, LM Studio and plain Ollama
all work from the same form, and the form + provider docs now say so. A
gateway that shims a Claude-family model behind a synthesized tool call is
parsed from the tool-call arguments instead of yielding silently empty
insights.

The insight token budget is configurable (INSIGHTS_MAX_TOKENS, default 2500,
clamped 500-8000) and unified across all six call sites; a reply cut off
mid-JSON returns a distinct truncation error instead of a generic parse
failure. A briefing withheld by the number-grounding check now says so on the
card instead of pretending nothing was generated. The provider test button
answers 4xx with a bad_request category instead of claiming the endpoint was
unreachable.

Sleep repair follow-up: measurements carries a second full unique index — the
natural key (type, measured_at, source, sleep_stage) — so a key-format
migration could tombstone the old rows while their re-keyed replacements
collided with that index and were silently dropped (live incident: a
full-history re-sync erased a user's sleep). Planned creates now run a
natural-key rescue probe: a match, live or tombstoned, is updated in place —
fresh value, the new externalId, deletedAt cleared. A rescue-probe failure
holds the watermark so nothing is lost past the overlap.
2026-07-11 10:36:02 +02:00
Marc-André Bombeck d7e616eaa8 feat(ai): add operator-shared central Codex connection with per-user opt-in
Introduce an operator-level ChatGPT-subscription (Codex) credential that the
operator connects once and shares server-wide, plus a per-user switch to route
AI egress through it.

- Schema (migration 0235): three encrypted columns on AppSettings for the
  operator's central Codex access/refresh tokens and the ChatGPT-Account-ID
  claim, plus expiry/connected-at/status; a per-user useCentralCodex flag
  (default false). Register the new encrypted columns in the rotation registry
  and script.
- Resolution: add the admin-codex chain type; append it to a user's resolved
  chain only when they opted in AND the operator connected it. Tokens refresh
  server-side against the singleton row (CAS-style), same primitives as the
  per-user Codex path.
- Budget: bill an admin-codex primary against the operator cost cap, not the
  user plan — it drains the operator's account.
- Consent: admin-codex is server-managed external egress, gated by the existing
  consent machinery (self-snapshot and document surfaces alike). No existing
  consent path is weakened.
- Document order: rank admin-codex with the subscription tier (last), so the
  document class stays local-first.
- UI: admin device-code connect/disconnect card (cookie-only admin routes) with
  honest copy on account pooling, shared rate limits, and training-by-default;
  a per-user opt-in switch shown only once the operator has connected it, off by
  default, behind a one-time honesty confirm.
- i18n across all six locales; unit tests for the chain type, budget cap,
  consent gate, vision capability, and document order; a mocked e2e for both
  the admin connect surface and the per-user switch.

The central credential's tokens are encrypted at rest and never returned to a
non-admin API; the opt-in only takes effect once the operator has connected the
shared account.
2026-07-10 00:10:56 +02:00
Marc-André Bombeck 46f8c71ee5 feat(labs-ocr): read PDFs on the configured provider, retire the separate scan provider
Lab-report OCR now rasterizes a PDF to page images for any non-Anthropic
vision provider, exactly as the document vault already does — Anthropic keeps
its native PDF block, every other vision provider (codex/OAuth, OpenAI, local)
reads the rendered pages. The capability probe reports `pdfSupported` whenever
the picked provider reads PDFs natively or the server-side rasterizer is
available, so the "scan a PDF" affordance shows for a codex provider too.

Drop the dedicated document-scan (Lab-OCR) provider: labs OCR always uses the
main configured provider chain. Removes the settings card, the `aiOcr*`
resolution branch, the GET/PATCH handler fields, the OpenAPI schema fields, the
i18n keys across all locales, and the five backing `User` columns (migration
0234). The on-device tesseract fallback is untouched.

Dashboard hero: stack the overdue-dose action directly beneath the verdict
text (tight gap, left-aligned) on every viewport instead of pushing it to the
far container edge on desktop.
2026-07-09 23:29:06 +02:00
Marc-André Bombeck 1e2b1d2c6a fix(ai): treat gpt-5.x-codex slugs as vision-capable
The document/lab-OCR vision gate excluded every `-codex` slug from the
GPT-5.x line as text-only. That is stale: the `codex/responses` wire reads
an `input_image` block on these slugs (docs/codex-protocol-spec.md plus the
current model lineup), and the 5.2-Codex line is vision-tuned. An operator
pinned to a `-codex` slug therefore resolved no vision-capable provider and
hit a 422 "No vision-capable AI provider is configured" when reading a
document with AI.

Drop the `-codex` exclusion from the gpt-5 branch so a codex-only chain
resolves a document-vision provider; keep the `-mini`/`-preview`
reasoning-slug guard. Lab-OCR on a `-codex` pin gains vision the same way.
2026-07-09 13:41:59 +02:00
Marc-André Bombeck 813dc2547b feat(documents): add per-user automatic AI reading opt-in
Add the documentsAutoAiRead per-user Boolean (migration 0231, default
false) that authorises ambient AI reading of uploaded vault documents.
OFF by default keeps the vault local-first for every self-hoster.

- schema column + migration 0231_documents_auto_ai_read
- documentAutoReadEnabled(userId) read helper
- GET/PATCH /api/auth/me/documents-auto-ai-read toggle route; the ON
  write mints an ai_full consent receipt for the audit trail
- the document consent gate short-circuits an external pick when the
  toggle is ON; a local pick stays ungated and OFF still requires a
  receipt for any external egress
2026-07-09 10:32:59 +02:00
Marc-André BombeckandMarc-André Bombeck c825d4b501 feat(documents): per-egress notice + document-AI capability probe
Add GET /api/documents/inbound/capability (document provider order + vendor-blind
egress class), repoint the vault capability hook to it, and show a per-egress
notice in the detail sheet before a document read leaves the machine. Cover the
document provider order, the egress consent gate, and the notice with tests.
i18n x6 + OpenAPI regenerated.

Co-Authored-By: Marc-André Bombeck
2026-07-08 23:59:56 +02:00
Marc-André Bombeck 8fc71620e8 feat(insights): guideline interpretation registry for per-metric assessments
Add a typed, hand-curated registry mapping metrics to guideline
interpretation bands derived strictly from the health knowledge base —
band edges, plain labels, valence, direction-of-good, and a source tag,
each entry citing the KB file + section it came from. A metric the KB
gives no numeric band for gets no entry, so its assessment stays
personal-relative rather than inventing a threshold.

The band position is computed server-side (`classifyBandPosition`, strict
upper-edge semantics + edge-hugging proximity), and `buildInterpretationBlock`
renders the table + placement into the assessment prompt so the model states
where a value sits and what the band means instead of reciting counts.
`interpretationBandEdges` exposes the edges for any numeric grounding gate.

Covers visceral fat, resting heart rate, blood oxygen, respiratory rate,
body temperature, sleep duration, waist (sex-split), waist-to-height,
pulse-wave velocity, and BMI. Tests pin every band edge against the
guideline value so a silent edit breaks a test.
2026-07-07 00:36:25 +02:00
Marc-André Bombeck bb72cbe092 fix(insights): stop the daily briefing vanishing on grounded vitals
The daily-briefing number-grounding gate strips the whole dailyBriefing block
to null when it finds a restated figure it cannot trace to a pre-computed
server value. Its allow-set covered signalsOfDay plus the glucose / labs /
preventive-care / workout / grip / waist / pain aggregates, but omitted the
core vitals blocks — weight, blood pressure, pulse, body fat, mood, sleep,
activity, rate-pressure product, seasonal variation, historical comparison —
even though the briefing prompt is fed all of them.

A verdict-first briefing that restated a blood-pressure average or a resting
pulse (figures every BP-tracking user has) therefore tripped the gate on every
run: one ungrounded number strips the entire briefing while the rest of the
insight refreshes, so the surface reads as "every other text updated, only the
briefing is gone".

Admit every figure the server pre-computes for those blocks into the allow-set.
The anti-fabrication guarantee is unchanged — only server-computed numbers are
ever added, so a genuinely invented value is still flagged and stripped.
2026-07-01 18:51:36 +02:00
Marc-André Bombeck 6c27e053b7 test: route the global fetch stub onto the undici pinned engine
The requirePublicHost egress paths now dispatch through undici's own
fetch, so the existing global fetch stubs in the AI, GeoIP, moodlog,
send, and ntfy suites no longer intercept them. Delegate undici's fetch
back to the installed global stub so each suite keeps asserting its
request shape and canned responses without exercising the network.
2026-06-28 15:18:20 +02:00
Marc-André Bombeck 556f442724 fix(insights): honor the per-user AI timeout and keep the last good narrative
The daily briefing and the per-metric status assessments ignored the
per-user response-timeout setting: they hardcoded their per-surface
AI_BUDGETS default, so raising the timeout for a slow self-hosted or
local backend had no effect and the surfaces still aborted early. Only
the coach threaded the setting through.

Resolve the effective timeout from User.aiResponseTimeoutSeconds in one
shared helper and thread it onto every non-coach generation call:

- runStatusCompletion is the single chokepoint every status / reference
  surface funnels through (per-metric cards, the batched assessment, the
  derived assessments, the period narratives), so resolving it there
  applies the setting to all of them at once — on both the upstream
  call's own timeout and the outer wall-clock cap that previously clipped
  a raised value at 60 s.
- The comprehensive briefing generator and the user-initiated regenerate
  route thread it onto the main generation, the JSON retry, the grounding
  retry, and the paragraph re-roll.

On a failed generation the briefing keeps its last good text — no cache
row is written, so the previous payload stays intact — and records a
dated, append-only failure marker (no migration; reuses the audit-log
JSON pattern). The read path compares the marker against the last
successful generation so it can tell a held briefing from a current one.
2026-06-28 12:42:35 +02:00
Marc-André Bombeck b91649d6de feat(ai): UI-configurable response timeout, local streaming, dedicated OCR provider
Add three per-user AI settings, all configured in the app (no new env vars),
behind one migration (0198):

- Response timeout (seconds). A per-user upstream timeout threaded onto the
  provider call. A local/self-hosted backend can take more than the legacy
  60 s default to load a model on the first request, which timed the Coach
  out before the first token. Defaults to a generous ~180 s when unset;
  validated to 10–600 s.

- True token streaming for the local OpenAI-compatible client. The client now
  POSTs with stream:true and parses the SSE delta chunks, emitting tokens as
  they arrive, with a per-idle-gap timeout instead of a whole-call ceiling and
  a graceful fallback to the buffered path when a server cannot stream. The
  Coach route runs the provider call inside the response stream behind a
  keepalive heartbeat so a reverse proxy no longer idle-drops the connection
  during model load or generation. Grounding, refusal, and sentinel-stripping
  still run on the complete reply before any token reaches the client.

- Dedicated document-scan (Lab-OCR) provider. Off by default — the OCR path
  uses the main provider chain. When enabled, lab-report processing resolves a
  separate provider/model/key, with a hint when the chosen model is not
  vision-capable. The new key column is AES-256-GCM at rest and registered in
  the encrypted-columns registry and the rotation script.

i18n: EN + DE translations for the new Settings → AI strings (other locales
carry interim copies to keep locale parity green).
2026-06-27 15:21:40 +02:00
Marc-André Bombeck 5674f10b2f feat(coach): quote the real BP range, address by name, render action cards
Feed the user's own usual blood-pressure range — the median plus or minus
MAD band, computed from the readings already in the snapshot — into the Coach
context with a clause to quote those bounds verbatim, so a band is never
invented from window means. Thread the stored first name into the prompt and
surface it sparingly on roughly one turn in three, never as a rote greeting.
Add the context-layer rule that names the relevant context before a
suggestion, a single-chart-token allowance that works on every provider, and
a per-turn opener hint.

Wire the already-shipped suggested-action SSE frame to render: the streaming
hook collects it and the message thread shows the action card, mirroring the
reminder-suggestion path. Extend the eval golden set with band-verbatim,
chart-token, and no-overclaim cases, and pin a red-team case proving the
chart-token syntax cannot smuggle a dose past the outbound screen.
2026-06-27 14:40:07 +02:00
Marc-André Bombeck b393527cac feat(insights): verdict-first briefing, richer score cards, grounded opener rotation
Replace the fixed three-beat assessment skeleton with a menu of moves the
model orders freely, and reframe the daily briefing from per-signal
enumeration to a verdict-first read that weaves the few salient signals into
one associative story. Score cards now name both the helped and the hurt
contributors, translate the band into a forward read for the day, and close
on one grounded nudge; the deterministic fallback gains the same band-meaning
sentence so provider-less and demo accounts read as well.

Add a hash-seeded opener-archetype rotation (deterministic, testable) so
consecutive cards and days no longer open the same way, a day-rotated sampling
seed for the period narrative in place of the fixed reference seed, and a
shared paragraph formatting contract. Add a score-number grounding gate and
extend the briefing grounding allow-set to the glucose, labs, preventive-care
and workout aggregate blocks so real figures can be cited without opening the
gate. Gate the daily score warm on an input fingerprint so a quiet day skips
the model call.
2026-06-27 14:39:54 +02:00
Marc-André Bombeck 7e48e2c26d fix(insights): stop the daily briefing going blank on slow generations
The comprehensive insight that feeds both the daily briefing and the
insights trend narrative is a single reasoning-heavy generation over the
full feature snapshot. On a large account it runs past the provider
client's fixed 60 s timeout and is aborted mid-stream, so the chain
reports an all-failed outcome, no cached block is written, and the
surface falls back to its static placeholder on every visit. The warm
paths bounded the same generation tighter still (45 s) and counted the
failure with no observable signal, so the blank state was permanent and
silent.

Make the per-request upstream timeout configurable
(CompletionParams.timeoutMs, default 60 s for every existing caller) and
raise it to 120 s for the comprehensive surface only, across all three
generation paths: the on-demand regenerate, the on-demand warm, and the
nightly pre-generation. Raise the warm-pass budget to 130 s so the
provider's own timeout governs a genuine completion rather than clipping
it early. Annotate the comprehensive failure in both the generator and
the nightly loop so a generation that keeps failing is queryable instead
of leaving the surface empty without a trace.
2026-06-27 12:37:25 +02:00
Marc-André Bombeck 8b7648bdd0 fix(coach): never 500 on a provider failure; fix Codex tool-call wire
A Codex backend 400 on the richer Coach tool-call request (and the daily
briefing) propagated out of the provider chain runner unwrapped: a 4xx other
than 401/403/429 was not classified as a hard provider failure, so it bypassed
AllProvidersFailedError and rethrew from the chat route as an HTTP 500. Plain
status-card completions, which never send a tools array, were unaffected.

Harden the failure path so a provider error can never surface as a 500:
- Treat any 4xx a provider wire returns as a hard failure so it cascades and is
  wrapped in AllProvidersFailedError. InsightSchemaError (the strict-JSON 422
  surface) stays the only non-hard class, so that diagnostic is preserved.
- The chat route now also classifies a tagged provider error that reaches its
  catch unwrapped and emits the graceful coach.provider.* SSE frame instead of
  rethrowing. Only an untagged error keeps the 500 + error-reporter path.

Make the Codex 400 diagnosable: fold the redacted upstream response body into
the thrown error message and the chain wide-event, so the field the backend
rejects is visible in logs (it was previously dropped).

Fix the Codex request shape behind the tool-call 400:
- Send the required strict flag on each Responses-API function tool.
- Replay prior assistant turns as output_text, not input_text, per the
  Responses API multi-turn contract — the violation only surfaced on round two
  of the tool loop, which is the path that broke.

Codex-only; the OpenAI, Anthropic, local and mock providers are untouched.
2026-06-27 10:26:10 +02:00
Marc-André Bombeck 9c3b119695 merge(FIX-D): v1.20.2 audit fixes 2026-06-26 09:28:43 +02:00
Marc-André Bombeck c304268a9e fix(ai): tag empty-reply and stream-failed provider throws with a sentinel status
The four AI clients threw bare Errors on an empty reply (and, for Codex, on a
stream-level failure) with no httpStatus, so the provider-chain classifier saw
the same null-status shape as a genuine transport failure (ECONNRESET, DNS,
timeout) and could not tell them apart in observability. Attach a sentinel
`httpStatus: 0` plus a `kind` discriminator (`empty_response` /
`stream_failed`) on those throws. The cascade is unchanged:
`isHardProviderFailure` already treats a status <= 0 as a hard failure and
walks to the next provider — this only sharpens the logs.
2026-06-26 09:27:42 +02:00