The cycle read returns the projected period starts after the next one,
each with its band, under upcoming; the tool description for MCP clients
and the Coach did not say so.
The calendar read, the Coach and MCP cycle block and the coming calendar
feed now share one loader for the forecast's inputs (cycles, day logs,
overnight wrist temperatures) and one gate (`gatedPrediction`: no
forecast while prediction is off or raw-chart mode is on). The Coach
block reads the calendar's default day-log window, so its next period
is the date the calendar page shows, and resolves the luteal length
through the engine's own clamp.
`projectCycles` extends the engine's next-period forecast a few cycles
ahead. Cycle 1 is the engine's result unchanged; later starts are
anchored on it from the fractional median length, so six cycles out
carry no rounding drift; later bands widen as the SD of a sum of k
lengths with an estimated mean, with the engine's sparsity penalty, and
never narrow. The list stops where a band passes ten days or half a
cycle, holds the next period only while the engine is still learning or
for perimenopause, and is empty once the next period's band has passed
with nothing logged.
`/api/cycle/calendar` publishes it additively as `projection` and
`projectionState`; the Coach block carries it as `upcoming`.
A read whose readings all lie past the lookback limit was reported on its
step as outside_window, and its fetch entry carried no reason, so a client
could not tell an empty window from data the limit keeps out. The step now
carries reason outside_reach and the fetch entry mirrors it. The method
line and the web trail keep the window sentence; the answer names the
limit as before.
Four instruction shapes passed the outbound dose screen in every language:
a negated intake ("Don't take atorvastatin tonight", "Nehmen Sie kein
Atorvastatin"), a pause ("Take a break from enalapril"), an increment with
no target ("Add 4 more units of insulin"), and a measured volume of a liquid
drug form ("Take 10 ml of the syrup").
Each bank gains the shapes, anchored on a medication noun, a drug unit or a
liquid drug form rather than on the verb. A negated intake counts as a hold
only when the clause closes or a time word follows and no protective
qualifier sits in the object ("a double dose", "more than", "half"), so the
standard warnings still pass. `ml` stays out of the drug units: the liquid
shape needs a syrup, solution, drops or medication noun beside it, so
"drink 500 ml of water" passes.
The Russian and Korean banks now know the generic-name stems in their own
script (INN_STEM_CYRILLIC, INN_STEM_HANGUL), and the English object slot no
longer crosses another language's negation, which had blocked the Dutch
warning "Stop niet met ramipril zonder overleg".
End a dose unit at a Cyrillic letter, so «3 месяца» no longer reads as
"3 МЕ". Keep ml out of the drug units as the other banks do, and stand
the target-dose shapes down on a dietary noun (water, sodium, caffeine),
which blocked plain nutrition advice. Cover the stop and inject verbs
(перестаньте, бросить, колите) the English bank already blocks.
The labs block and a single-reading read now build a reading from the same
select and the same shaping, so one reading fetched by its id renders exactly
like the latest reading of a marker.
- baselines: take main's entries, keep the two ru-driven line counts (coach-prose-grounding 1091, insight-generator 1115) and regenerate the function baseline.
- messages/ru.json: translate the five unit-less markers keys the release added (labs.unit.none/noneHint, labs.ocr.errorUnitMismatchNoUnit, labs.ocr.unitDiffersBadgeNoUnit, recordImport.unitlessHint) — parity is 11 159 keys again.
Dose bank
- DRUG_UNIT learns мг / мкг / мл / ЕД / МЕ, so "2,4 мг" anchors a dose again.
- The bank matches verb-first Russian word order too ("принимайте 2 таблетки",
"пропустите дозу метформина", "начните с 5 мг"), and the Cyrillic drug names
people actually see on the box; the deferral words ("спросите врача",
"как назначено") stand those shapes down.
Cyrillic-safe regexes
- \w and \b are ASCII in JavaScript: every stem in the ru bank and in
population-norms/ru.ts is [а-яё]*, and the word edges are lookarounds
(?<![а-яёa-z]), so «высокий риск» and «нормальный диапазон» match again.
- coach-prose-grounding: Russian month names in the genitive, a space as the
thousands separator in parseLocaleNumber and numFrag, and Cyrillic-safe
edges around the written-date pattern.
Registered everywhere a locale has to be
- targetLanguageName() returns "Russian", so the coach, the period narrative
and the document descriptions stop saying "write in English".
- i18n-key-tool.mjs, split-description.mjs, LOINC name normalisation (it
stripped Cyrillic, so no Russian lab name resolved), HOST_TLDS (ru/by/kz/ua),
no-key-fallbacks (32 tables), country-label, the AUDIT-C standard drink.
- docs/api/openapi.yaml regenerated.
Questionnaires
- The PHQ-9 / GAD-7 source comments no longer claim no validated Russian
version exists (Zolotareva 2023; Pfizer's official screeners).
- WHO-5 items are gender-neutral: «чувствовал(а)».
- "body fat" and "fat mass" both read «Жировая масса»; body fat is now
«Процент жировой массы», which is what the two metrics measure.
Tests
- ru in the 13 locale tables, plus mood, onboarding, instruments, clinician
view, status summary, describe screen, citation coverage and LOINC.
- i18n-no-dot-separator: the ru bundle is exempt from the dash rule (a spaced
dash is Russian punctuation, not a fragment join); the middle dot still
fails there.
- Two 800-line baselines raised by the 12 and 5 lines this change adds.
pnpm typecheck, pnpm openapi:check and the full suite (36 316 tests) pass.
Every place a lab unit is printed beside a value now goes through one
helper, so a reading stored in "1" reads "3.6" and not "3.6 1": the
list, history and detail, the chart and its tooltips, the reference bar
and range notes, the trend card, the timeline, the day view, the
clinician page, the doctor-report PDF and the changes card. Where the
unit stands alone (a marker's subtitle, a picker row, the merge preview)
it reads "No unit".
The Coach's lab block, the get_labs history and the visit-prep summary
hand the model an empty unit for such a value, so it never quotes the
"1" either.
Refs #1313
get_labs readings (the latest, earlier values in full, trajectory readings)
carry the fetch ids of what they link to, with linksTruncated; each linked
record is described once per answer in linkedRecords, because one report
usually holds a whole panel and would otherwise repeat on every reading.
fetch lab: and fetch med: list their links like the other record kinds, and
get_medication_schedule rows carry their id and links inline. get_visits and
get_illness_recovery rows carry visit: and condition: ids, so a row's links
are one fetch away. The labs and illness blocks gain an opt-in to carry row
ids; the Coach never asks for them.
Full Russian catalog (11,154 keys, 1:1 with en, identical nesting) and every
locale wiring point the app declares, so ru is a first-class locale instead
of an English fallback:
- messages/ru.json, project.inlang/settings.json, i18n boot script
- i18n config / load-locale, format-locale (ru-RU), date-fns ru
- labs: parse-reference-range RANGE_PROSE for Russian lab sheets
("3,5 - 5,0", "от 3,5 до 5,0", "до 5,6", "не более 5,6")
- AI: citation-coverage normative stems, coach system prompt and insights
reply footer (Russian prompts must override the hard-coded
"Respond in English"), population-norm patterns
- safety: outbound-screen Russian imperative/dose/unit/causality bank
- mental health: PHQ-9, GAD-7, WHO-5 and SCI wording
verify-message-catalogs.mjs passes for all 10 locales.
A call that ran beside the one that learned the lesson read today's
selection instead and failed with the 400. The matcher now also wants the
refusal to name max_tokens, so a refusal of another field that echoes the
request does not switch the token field.
Refs #1304
An OpenAI-compatible gateway entry pointed at an Azure OpenAI v1 endpoint
was treated as a generic gateway and sent max_tokens and temperature, which
a reasoning deployment refuses with a 400. An Azure v1 endpoint now takes
the api.openai.com contract for a reasoning model, and any gateway that
refuses max_tokens for a model is asked again with max_completion_tokens
and remembers it, which covers deployments named something of their own.
Refs #1304
The dialect cache gave a day to every value that says the reasoning
field is refused, and that includes the combined "field refused, reasons
regardless" value, which one answerless reply writes. One reply could
therefore stretch a lesson to a full fresh day, against the rule that a
lesson read from a single reply keeps the hour.
A lesson now says what taught it. A 400 refusal gives a refused field
its day. A reply that upgrades a refused field to the combined value
keeps the refusal's remaining lifetime rather than a new day or the
hour, so the refused request does not come back early either.
Every write to the per-sign-in Codex memory checked the account digest,
but the reads did not. A credential reconnected to another ChatGPT
account therefore tried the old account's working model first, sorted
by its refusals and trusted its model list, until the first write
rebound the entry, and the vision gate kept answering from the old
account in the meantime.
The client reads (working model, refusals, model list) now take the
credential and treat an entry learned under another account as empty.
The vision gate holds no account id, so the connect routes rebind the
entry and the disconnect routes drop it, and the gate reads the entry
as its process last bound it.
A fixed preference list goes stale at every retirement: when GPT-5.5
retired for ChatGPT sign-in on 2026-10-14, every model the shipped list
named was refused and Codex answered 503 until a release changed it. The
working model also sat in one slot for the whole process, so two sign-ins
on different plans overwrote each other's model and the vision gate read
whichever had answered last.
The client now reads the models the account is offered the way the Codex
CLI does (GET /backend-api/codex/models?client_version=..., same sign-in
as the responses call; shape from codex-rs/protocol/src/openai_models.rs)
and walks: CODEX_MODEL first; our preference order kept to what is
offered; the other offered models in the list's priority order, leaving
out hidden, specialist and frontier-tier entries; the rest of our order
last, so the walk never holds fewer models than before. With
CODEX_MODEL_FALLBACK_CHAIN set the order is the operator's and the list is
not read.
The list is a hint, never a gate. The read goes through safeFetch with a
5 s bound and a 4 MiB cap, is shared by concurrent requests, cached per
sign-in for an hour, and a failed read waits ten minutes. Any failure
leaves the static walk, one ai.codex.models_unreadable warning per sign-in
reports it, and each request that builds the walk annotates a pinned
codex.models.* shape.
The working model, refused models and the list are kept per stored
credential (a person's connection, or the operator-shared central one),
bounded by TTL and a 1000-entry LRU, with a digest of the account id so a
credential reconnected to another account starts empty. A model the list
offered that then answers model_not_found goes to the back of that
sign-in's line for six hours. When the list names a model's input
modalities, the Codex vision gate reads them; otherwise the model family
decides, as before.
Refs #1287
The reasoning-field planning and the refusal learning are a self-contained
part of the Codex wire. Moving them out of codex-client.ts leaves room for
the per-sign-in model selection that follows without growing a file the
size ratchet already holds at its limit.
Refs #1287
A gateway that refuses the reasoning field does so by its configuration,
so the lesson now holds for 24 hours instead of one. At the hour every
expiry cost one refused request, a few a day on a gateway that runs the
briefings unattended. Lessons read from a single reply keep the hour.
Refs #1279
The createdAt field and the locale import grew files the size ratchet
holds. The account payload's insurance-number decryption moves into
its own helper, the import joins the zod one, and two comments lose a
wrapped line, so no baseline has to rise.
The Coach chat, the document chat and the fenced chat declared their
locale field as de/en, so a body naming French, Korean, Brazilian
Portuguese or any other shipped language was refused with 422 before
resolveServerLocale, which carries every locale, ever saw it. The same
pair sat on the profile update, so an account could not store any other
language there either, and the stored language is what the cookie-blind
background work (the Coach nudge, notification texts) writes in.
All four now take z.enum(locales), and the published contract for the
metric, insight and biomarker status queries and the profile update
lists every UI locale instead of two.
OpenAI retired the 5.x line from Codex for ChatGPT sign-in in steps: GPT-5.2
and GPT-5.3-Codex on 2026-05-26, GPT-5.4 and GPT-5.4 mini on 2026-08-31,
and GPT-5.5 on 2026-10-14. The backend answers a retired model with 400
model_not_found and "not supported when using Codex with a ChatGPT
account".
Pin that exact refusal as a reason to try the next slug, pin that an
account refused the head of the default list walks on to the next rung
and caches it, and keep the long-retired models off the default list.
gpt-6.1-sol, a rung on the default list, answers reasoning "off"
(effort none) with 400 unsupported_value and lists low as its lowest
effort (seen 2026-10-24). The client must stay on the slug, retry with
low and remember it, not walk on as if the model were retired.
Background jobs ask for medium reasoning at most, and status cards ask for
none. A model that cannot be switched down (GLM behind a vLLM gateway, for
instance) ignores both, spends the whole room thinking and returns only
reasoning, so the daily briefing and the status cards fail as
reasoning_only and the chain pauses. The Coach on High works because it
leaves 12k tokens to think in.
The chain runner now marks every call of a job walk as unattended, in one
place rather than at each call site. The gateway and local clients give
such a call at least the High thinking room once its model has answered
with reasoning alone. The room has to ride on the #1267 repeat with none:
one call may send two requests, and a once-a-day job meets an expired
lesson every morning, so a room that waited for the unswitchable lesson
would never reach the first briefing of the day. It is a ceiling, not a
charge: a model that does stop at none never touches it. Calls a person is
reading, the Coach included, keep the room of their own level, and the
requested effort is unchanged.
The room calculation both clients shared is now one function.
Reported by @Tio61 in #1276.
The capability gate's check for an account pending deletion added a
point read of users to every request that resolves AI capabilities, one
statement over the budget on /api/auth/me, the dashboard snapshot and the
batch ingest. The usage-level read already loads the record's row once
per request; it now carries the deletion lock too.
The translation files merge key by key (no key changed on both sides), the
backups list carries both the database dump and the pre-wipe copy window,
and the backup-restore doc keeps the shorter rewrite plus the dump in the
bucket and a lifecycle rule that covers database/.
The MCP server instructions promise that the person's free text comes back
wrapped in USER_TEXT markers, but get_medication_schedule returned
medication names and doses as plain strings, and get_labs returned analyte,
panel and unit names that can come straight out of an uploaded document.
Text written to read like an instruction reached the connecting client's
model unmarked. The marker scrub also removed only the exact uppercase
spelling, so a lower-case or spaced marker survived inside fenced text.
Both tools now fence those fields, and the scrub removes a marker in any
case and with any spacing inside the brackets.
The Coach snapshot attached its labs block regardless of the module, a
decision from before Labs could be switched off, while the snapshot's own
prelude says a disabled domain never reaches the model. With Labs off, the
Coach prompt, its get_labs tool, the daily briefing's flagged-marker block,
the biomarker status note, the reaction line to a new panel and the MCP
lab_trend_brief prompt all still read lab values for a model.
Each of those reads now asks the labs module first and reads nothing when it
is off: the Coach tool answers module_disabled, the briefing omits the
block, the status note neither writes nor serves text, the reaction line
gets no value, and the MCP prompt refuses like get_labs and the doctor-visit
prompt already did. The data routes stay as they are. A structural guard
holds every lab-table reader under the model-input paths to the gate or to
a listed reason its rows never reach a model.
Deleting an account, a guardian deleting a managed profile, and "Delete
all data" were final the moment they were confirmed: the account row
cascaded into every table and every stored copy, and the off-host objects
were purged in the same breath. Nothing short of rolling the whole
instance back could return one account.
ACCOUNT_DELETION_GRACE_DAYS (14 by default, at most 30, 0 erases at once
as before) now sets a window:
- Account and managed-profile deletion lock the account (migration 0488:
deletion_requested_at, deletion_purge_at, deletion_requested_by_id).
Every session, refresh token, API token, connector, share link and
step-up elevation is revoked in the same transaction, and the record,
its stored copies and its off-host objects are left as they are.
- Sign-in is refused with 403 auth.account.deletion_pending and the
erasure date, after the credential was proved and before a second
factor is asked for. The session and Bearer resolvers and the acting-
account resolver refuse a pending account too, so a credential minted
in the moment before the lock is useless.
- POST /api/admin/users/{id}/undo-deletion (cookie-only, audit-logged)
clears the lock. The admin users list reports the pending state.
- The hourly account-deletion-purge job erases accounts whose window has
ended, exactly as the immediate deletion did, with the off-host purge
request committed alongside. The Guardian invariant is decided again;
a refused erasure stays pending and is reported.
- A wipe first stores the whole record as a PRE_WIPE_<ms> copy through
the writer the weekly copy uses, keeps it out of the wipe, and holds
its off-host purge back with a new not_before on the request. The
ordinary restore brings the record back; restoring that copy withdraws
the held purge. The purge job deletes the copy when its window ends.
Background work skips accounts pending deletion through one shared
predicate (src/lib/account-deletion/active-account.ts): every reminder,
digest, Coach, sync, backfill, rollup, score and backup enumeration
filters with it, and account-deletion-enumeration-guard.test.ts fails on
a query in background work that neither filters nor says why it need
not. Notification delivery and the AI capability loader refuse a pending
account as chokepoints behind it, and the Withings, WHOOP and Telegram
webhooks and the notification quick actions ignore one.
The privacy summary publishes the window. Two pieces of the reminder tick
and the off-host pass moved into modules of their own to keep the files
inside their size ratchet.
The labs block reads the whole record, or up to the lookback limit, not just
recent readings, and a named read also comes back empty when no marker
matches. The header of the labs snapshot and the note at its call site
still described the earlier twelve-month window.
The turn records up to 64 steps while the read path kept only the first
48, so the tail of a long answer's step list vanished on reload. Both ends
now read one MAX_TURN_STEPS constant, and a unit plus an integration test
round-trip a full 64-step answer through the stored provenance.
A status run is one completion, so its ceiling was the completion ceiling
under a second name. The Local client also re-exported a JSON-mode helper
nobody imported from it.
A gateway or local entry can carry its own reasoning setting (#1126), sent
on every call that has no level of its own. An endpoint that refuses
reasoning_effort answered each of those calls with a 400 that was never
learned, because only the call's own level counted as sent: every status
note, every summary and every other level-less call failed, for as long as
the setting stood.
The refusal is now learned whichever level put the field on the wire, by
the same path as any refused field: the call is asked once more without it
inside its two-request budget, later calls send no field for that endpoint
and model, a model seen reasoning unasked keeps its thinking room, and one
ai.provider.reasoning_field_refused line says so per lesson instead of a
failure per call.
Refs #1279
The health ledger records failures per account and provider, and since
background work leaves out a benched provider, an operator who fixed the
instance-wide provider in the admin AI settings still saw every account's
background work sit out the pause the broken setup had earned, up to a day.
Only a person's own provider settings and the Codex sign-ins forgot the
record.
A successful save of the admin AI settings now forgets what the ledger
recorded for admin-openai on every account and closes the status notes'
negative windows, the same reset a person's own save makes for theirs
(clearStatusNegativeWindows without an account covers every record). The
ledger comments that still said a benched row only deprioritises now say
what it does for background work.
Refs #1279
A status run whose whole chain was benched for background work sent no
request, yet the caller opened the note's negative window as for any
failure and raised its failure streak. The streak doubles the window up to
two hours, so a note kept waiting long after its provider recovered.
AllProvidersFailedError now says when no hop sent anything (sentNothing:
every provider benched, or refused by the operator cost cap). The status
chokepoint then holds the note for the first window without touching the
streak (holdStatusNoteWhileBenched), and the caller's own failure write
lands inside that window and neither moves nor counts it. A run that
reached a provider counts as before.
Refs #1279
A chain whose first provider reasoned without answering and whose second
one answered billed only the answer: result.tokensUsed is the answering
call's alone, and the failed hop's spend, already reported on its
FallbackHop, was dropped by every caller that reconciles the day's ledger.
The raw runners now report tokensSpent on success too, the sum the failed
hops before the answer reported. The briefing, status, Coach (no-tools path
and every tool-loop round) and medication-extract reconciles charge it on
top of the answer. On failure, the Coach turn ledger's close keeps what the
failed chain reported spending instead of refunding everything, and the
medication-extract and document-chat routes charge it too, as the status
and briefing paths already did. Document indexing resolves a single provider
and already charged its failed call.
Refs #1279
canStartCourse answered true on the day a course was ended with End
course, while a course starting that day shares its last day and the
start refused it with 422 overlap. The start is now offered from the
day after the last course's end, the first day validation and the
engine accept.
A course ended at an instant is over for every rule that asks about
today, whatever the zone it is read from: after a move west the course
rows read CURRENT while the medication's own status read ENDED. The
course rows, the coach summary, the overlap rule and the previous-end
read take the instant-aware reading.
Refs #1280
The on-demand briefing (POST /api/insights/generate) runs through the
briefing chokepoint, which named its chain walk "job" for the background
share of the day's ceiling. Since background work leaves out a provider the
health ledger benched, a person pressing "Re-run analysis" got no request at
all: the call failed with a connection refusal that never happened, and a
refused credential was never reported back.
The chain runners now take the walk apart from the budget surface: a call
can be rationed as a job and still have a person waiting on it
(interactive). runBriefingCompletion takes the flag and the on-demand
route's calls (the first pass and the grounding retry) pass it, so they keep
the job share and walk a benched provider at the tail, as background-bench
promises for interactive walks. Every other job-surface caller is a worker
or is enqueued; the status, narrative and warm routes only read and queue,
and document reads and lab scans resolve a single provider without a chain.
Refs #1279
A reply that ran into the token ceiling with unlabelled text was read as
reasoning the server did not separate whenever the ceiling held room for
reasoning, which is every call that carries a level, Off included. A model
that does not reason at all (llama3.1) lost its own truncated answer: the
call failed as reasoning_only, the briefing never reached its JSON-shape
retry, the Coach named the wrong error, the failure counted toward the
bench, and the client learned to ask with "none" for an hour.
Such text is now reasoning only once the endpoint and model have shown that
they reason: a reasoning field or a think tag in an earlier reply, or a
lesson that says so (knownReasoner). A sighting on a request that asked to
reason speaks only for requests that ask too. Otherwise the text is the
answer, cut off, with finishReason "length".
A lone closing </think> now splits a reply only where the chat templates put
it, at the start of a line, or anywhere once the model is known to reason. An
ordinary answer that mentions the tag in a sentence stays whole.
Two expectations encoded the old rule and move: the Coach loop case whose
first reply is unlabelled template-opened text from a model never seen
reasoning, and the Local case that read a never-seen model's cut text as a
thought. The streamed think-block splitting moved into local-reasoning.ts.
Refs #1276
A gateway that refuses reasoning_effort outright (LiteLLM in front of
Ollama: "does not support parameters: ['reasoning_effort']") in front of a
model that reasons without answering sent requests until something outside
stopped it. The answerless rule learned to ask with "none", the refusal of
that "none" was learned as "send no field" over it, the next request came
back answerless again, and each lesson undid the other.
Each call of the Local and the OpenAI-compatible gateway clients now carries
one request budget through every repeat (request-hops.ts): at most two
requests, after which the call ends with the failure it has. A structural
guard holds both clients to sending only through the budget and to handing
it to every repeat.
The refusal is now learned as what it is: an endpoint that refuses the
field, in front of a model already seen reasoning unasked, keeps both facts
(FIELD_REFUSED_REASONS_REGARDLESS), so it is never asked with "none" again
and keeps its thinking room. An answerless reply from an endpoint that
refuses the field is final. When the refused "none" was the repeat of an
answerless reply, the call fails as reasoning_only, which is what it ran
into. The refusal handling of both clients lives in wire-refusal.ts.
A status run that hits its bound now aborts the call it gave up on instead
of leaving it running detached.
Refs #1279
The ledger stamped last_failure_at, last_ok_at, next_retry_at and
updated_at with the database's NOW(), while every reader compares them
with the app's clock: the runner's skip hints and the background bench,
the admin provider-health card, the server-health tri-state and the
support report. A backoff or pause therefore sat wherever the skew
between the two clocks put it. With the app clock pinned, a five-minute
backoff lasted until the database's clock plus five minutes, months away,
or was over before it began.
Both writes now bind the instant they took as a parameter
(`$N::timestamp`, the naive UTC columns), the same one-clock rule the
summary reads follow. The escalating pause arithmetic is unchanged.
The pause integration test pins the app clock years behind and ahead of
the database's and checks the stored instants, a backoff that lifts after
five minutes, a pause that benches background work for its full length,
and a success the server health reads as recent.
Refs #1279