The evidence disclosure under a Coach answer now carries one muted
line: which sources and windows the turn read, how many readings, and
how they were aggregated ("Blood pressure, last 90 days: 142 readings,
weekly averages · Sleep, last 30 days: no readings in this window").
The server builds it from the recorded steps and tables in the request
locale; it holds catalog strings and counts only, never a reading, and
a source that turned up nothing says so.
The tool-mode prompt gains a recheck rule: when the person questions a
figure, name the source and window, read it again (or reuse the stored
table), and correct the answer if the two differ.
A reply may end with a ---CLARIFY--- block (kind metric, window or
context, 512-byte cap). The server keeps only metric choices the record
holds, drops the question when fewer than two remain, takes window
choices from the presets, screens the question text like any reply, and
never offers a second question right after one. Choice labels come from
the catalog, never from the model.
An answer arrives as clarification { messageId, choiceId? }; it resolves
against the stored question on the conversation's latest message and
adds one server-written line to the next turn's context. The card sits
above the composer, announces politely and takes no focus.
A reply that is only the block, with no question before it, is now an
empty reply instead of falling back to the unstripped text.
The streaming hook handles the step, result, followUps and clarification
frames and sends followUp and clarification on the tool route only. The
bubble gains slots for the step list, the tables and the method line; the
thread offers chips under the latest assistant turn only; the conversation
shows the clarification card above the composer and marks a typed answer
as answering it. The tables of a persisted message load lazily when it
scrolls into view, seeded from the stream for the turn that produced them.
The new components render nothing yet.
Adds every string the dialog needs up front: step labels, table titles,
columns and controls, the method line, the follow-up chips and their
setting, the clarification card, and the caption of an answer reused from
a stored table. A key catalog shared by server and client names each one;
a test holds the catalog and the bundles equal in both directions.
GET /api/insights/chat/{id}/messages/{messageId}/results returns the
tables one assistant message read, narrowed to the conversation's owner
like the conversation read, with a table withheld as module_disabled when
its domain's module is off and as unavailable when it cannot be read. A
data read: no AI capability.
The published document now describes every stream frame as the
CoachStreamEvent union, including the suggestedAction frame it never
named, the new step, result, followUps and clarification frames, their
components, the provenance fields, the followUp and clarification request
fields, and the reuse provider type.
Each later piece of the dialog gets its own module, answering nothing for
now: the step builder, the table projection and chart choice, the result
ref marks, the follow-up derivation, sentinel, resolution and continue
chip, the reuse turn, the clarification parse and answer, the method line,
and the four prompt addenda. The turn pipeline calls them where they
belong, so the frames go out in the documented order (step, token,
provenance, result, cards, clarification, followUps, done) and a blocked
turn drops its tables, chips and clarification. With every piece empty
the stream and the prompt stay byte for byte as before.
The retrieval loop takes onCallStart and onCallSettled callbacks, fired
for every call with a turn-wide index; a throwing callback is swallowed.
The trace carries each call's schema-validated arguments (never the raw
string, never persisted), the result says whether the answer was forced
at the round cap, and a seedCalls parameter is accepted but not read yet.
The home location of the environment module was rounded to 2 decimals,
about 1 km, which places a home in a neighbourhood rather than the town the
schema comment promised. The weather archive it feeds is gridded at 9 to
25 km, so the extra precision bought nothing.
Every stored coordinate of the module now goes through the same 1-decimal
rounding (about 11 km north-south, 7 km east-west at 50° N): the home, the
travel periods, the geocoder's results, the per-day weather rows, and a
restore of a file written with finer values. Migration 0361 rounds the
existing users, travel periods and per-day rows; running it twice changes
nothing more. The answers keep their shape, plain numbers. The API
descriptions and the schema comment say 1 decimal now.
A new nullable column, coach_messages.results_encrypted, holds the tables
of values a turn read as AES-GCM ciphertext; only their metadata rides the
plaintext provenance. readMessageResults serves them to the owner, each
table whole or withheld with a reason. The provenance restore learns the
steps, method, table metadata, chips, clarification and the forced-answer
marker, entry by entry against the wire schema.
The column is registered for key rotation, carried through both backup
formats (ciphertext in a disaster-recovery file, readable JSON in a
portable one) and goes with its row on conversation delete and wipe.
Adds the TypeScript types for the four additive stream frames and their
provenance fields, a Zod mirror for the published document held equal to
the types by a type test, and a guard that no newer frame reuses a key the
shipped native client decodes. The chat request accepts followUp and
clarification, inert for now.
The practitioner entry of the backup payload gains the optional
locationEncrypted and phoneEncrypted fields. The rotation script names each
new column once, at its call, so a dropped call is noticed rather than
covered by a comment.
The route keeps the front door: auth, the coach capability, the body
read and schema, the rate limit, the locale and the inbound refusal
screen. Everything after it moves into one module per step:
conversation, context, chain, budget, model, reply-guards, cards,
provenance, persist, sse, errors and pipeline. The capability re-check
at the egress site stays owned by the route and is handed to the
pipeline, so the capability inventory still reads both calls there.
The stream producer now writes through a TurnEmitter created inside
createSseStream and passed to the model step, which is where live
progress frames will come from. Nothing writes to it early yet.
No behaviour change: the golden transcripts pass byte-identical, the
existing route tests pass unmodified, and every annotate, audit and
error-code name appears as often as before.
Guards follow the code. The fence call-site check reads turn/context.ts,
the German-default locale ban covers every file in turn/, and the
fenced module-graph test now asserts that turn/sse.ts and turn/errors.ts
reach neither the tool registry nor the snapshot builder. That test
named lib/ai/coach/tools.ts, which stopped existing when the registry
became a directory, so its negative check could not fail; it points at
tools/index.ts now, and a new sanity check fails if the tool route can
no longer reach the path it names.
The GPS track of an outdoor workout was stored readable, and its first and
last points are usually the person's front door. The phone number and address
in the address book of doctors and practices were readable too, while the
note beside them was already encrypted.
Migration 0361 adds workout_routes.geometry_encrypted (the geometry loses
its NOT NULL) and practitioners.phone_encrypted / location_encrypted, plus
partial indexes on the backfill's discovery predicates. A track is sealed as
one binary AES-256-GCM value bound to its purpose; the contact fields use the
same Bytes codec as the note. The practitioner's name, practice and specialty
stay readable, because the picker searches and sorts on them.
Every writer stores ciphertext only: workout batch ingest, practitioner
create and edit, and both restore formats. Readers prefer the ciphertext and
fall back to the old column for rows not yet migrated: the workout detail
(map, splits, GPX), the activity insight's climb figure, the practitioner
DTO behind the address book, visits and vaccinations. The wire shape does not
change. An edit's audit row now names a changed phone number or address
without recording either value.
free-text-encryption-backfill covers the three columns in the same pass,
finding a route through its workout. All three are registered for key
rotation (registry, CLI script, and the in-app rotation). A disaster-recovery
backup carries the contact fields as ciphertext only; a portable export
carries them readable. Workout routes are still not part of a backup, as
before. The old columns are marked for removal in a later release.
Drive POST /api/insights/chat through the real SSE stream and the real
reply guards for every branch a turn can take: tool mode with present,
drifted and missing figures, the no-tools path, inbound and replayed
refusals, the budget refusal, each provider failure class, a cancelled
turn, the outbound block, every sentinel in its valid and malformed
forms, learn-link scrubbing, the three no-provider paths, a workout
scope, the post-call defect path, and the refusals that answer before
the stream opens.
Each transcript records the frames the client receives (heartbeat
comments dropped) and the ordered side effects: persisted messages,
budget reserve and reconcile, audit rows, annotate names with their
meta keys, and the provider inputs as prompt fingerprints.
The delegated-writes spec checked for the invitation form's level control
right after page load, before the form had painted, found nothing and
skipped every test on every run, CI included.
- Give the journey its own account pair, seeded and reset in global setup,
so it no longer invites the same pair as the account-sharing journey
(a second live grant between two accounts is refused with 409).
- Wait for the form through its data-slot and fail when the WRITE level
control is missing instead of skipping.
- Revoke the journey's grant in afterAll.
- Keep it out of the mobile project in the config rather than through a
runtime skip.
Add two checks so a skip cannot hide a spec again:
- e2e-conditional-skip-guard.test.ts requires every conditional
test.skip/test.fixme in e2e/ to carry a reason and refuses a bare
test.skip().
- scripts/check-e2e-skipped-specs.mjs reads the Playwright JSON reports of
both shards in the e2e gate job and fails when a spec file skipped all of
its tests across every project, apart from an allowlist with reasons.
An admin page stacks independent cards, and a card that threw while
rendering replaced the whole page, so a field missing from the off-host
card's payload hid the snapshot table beside it. Every card in an admin
section now sits in its own boundary and fails as an error card with a
retry that remounts it; the rest of the page keeps working.
The invitation journeys, the backup journey (archive, snapshot download
and restores) and the delegated doctor report acted on jars signed in once
in global setup, so they passed or met the re-proof gate depending on how
far into the run they started. Each now acts from a session of its own,
made stale before every gated press, and answers the dialog with the
password; the delegated report asserts the 401 before re-proving over the
wire. The helper gains expireRecentProof, reproveWithPassword and
actWithReproof.
The off-host card reads pendingDeletions and lifecycle, which the route
always sends. The mock predated them, so the card threw and the page never
painted its rows. The mock now carries a refused deletion and a bucket
without an expiry rule, so the scan reaches both new lines.
A report for a past window lists a medication only if its course had begun
by the window's end. The fixture's drug was entered on the day of the run,
so it was rightly absent from a report for last year and the control that
expected it failed. The course now starts before that window. Generating the
report also meets the re-proof dialog from a stale session every time,
rather than only when the run has been going for five minutes.
Connecting an assistant and making a share link ask for a sign-in or
re-proof within five minutes. The shared jar is signed in once in global
setup, so the specs passed early in a run and failed later. They now start
from a session of their own signed in ten minutes ago: the consent page
withholds Allow and links to the confirm page, where the password re-proof
returns to the consent request; the share form opens the re-proof dialog
and creates the link after the password is given.
The address is what single sign-on links an existing account by, and
changing it took the password or a session younger than five minutes on
every account. A remembered browser skips the second factor at sign-in and
still makes a fresh session, so the password alone could move the address
of an account the second factor was meant to protect.
On an account with a second factor only a second factor or passkey
completed in the last five minutes counts now; the password is not
checked. The refusal names it (second_factor_required in rejectedFields,
or 401 profile.update.emailSecondFactorRequired when the address was all
that changed), and the profile form opens the re-proof dialog with the
factors the account holds, then saves again, no longer offering the
password field. Accounts without a second factor keep the password or a
recent sign-in. A token cannot change the address of such an account;
the app does not change it.
The purge requests and the off-host key-use ledger are not keyed to a
user, so truncating users never reached them. A file that reads them
whole, the purge job or the encryption status view, saw rows earlier
files had left and failed in a full serial run.
With TRUST_PROXY_HOPS=0 behind a proxy that sends only X-Real-IP, every
client address resolves to nothing and all anonymous sign-ins share one
rate-limit bucket, with nothing in the log to say why. The first request
that carries a forwarding header the setting does not let HealthLog read
now logs one warning naming the header and the setting to change.
X-Real-IP stood in for a missing X-Forwarded-For at any hop count. It is a
single address, so it is now read only with TRUST_PROXY_HOPS=1; with two
or more hops the chain the count describes is X-Forwarded-For.
The upload, import and native batch routes that bypass the proxy now carry
Cross-Origin-Resource-Policy and Cross-Origin-Embedder-Policy like every
other response, and so do the proxy's early exits.
The env examples described an unset TRUST_PROXY_HOPS as right for a
directly exposed host; unset means one hop, and that host wants 0.
The per-account password limit refused every attempt once ten failures
had landed in fifteen minutes, counted before the password was checked.
Ten guesses from two addresses therefore locked a password-only account,
the app's password sign-in included, and repeating them kept it locked
for as long as anyone cared to.
A place the account has signed in from before is now neither counted nor
held back: a remembered browser, a phone presenting the device id a login
of this account was issued to, or an address a session or device login of
this account came from in the last thirty days. Anywhere else gets five
free failures a day and then a wait that doubles with each failure, from
thirty seconds to at most fifteen minutes, instead of a wall. The per-IP
limit is unchanged, and an unknown identifier is limited the same way on
its hash.
Under an SSO-only policy, a password sign-in to an account other than the
one a pending link names is now refused before the password is checked,
so that exception no longer tells right passwords from wrong ones.
Allowing an AI assistant hands it a standing credential to the record
that outlives the session, the same class of act as minting a token, but
the consent took a live session alone. A stolen session could connect
its own assistant and keep reading after the owner signed everything out.
The consent page now shows the Allow form only when the session signed in
or re-proved within five minutes (with the second factor on an account
that has one). Otherwise it links to a new confirm page that runs the
same re-proof dialog as the rest of the app and returns to the exact
consent request. The allow decision checks again and mints nothing
without it; deny needs no proof. The confirm page only ever returns to
the consent endpoint.
Offering access to the record took a live session alone, and an
invitation outlives the session that sent it: whoever held a stolen
session could invite their own account and keep a way into the record
after the owner signed everything out. POST /api/account/grants now takes
the recent-proof gate at every level (a sign-in or re-proof within five
minutes on the browser, with the second factor on an account that has one;
a step-up elevation on a token, which no shipped client uses here), and
the invite form opens the re-proof dialog and sends again.
Sign out everywhere now withdraws every invitation the account offered
that nobody has accepted. Accepted grants are left standing on purpose:
each is a person the owner chose, often a carer who still needs the
record, and a sign-out button must not quietly cut them off. The response
lists them as grantsKept and the sessions card shows each with a one-click
End access.
The staging sweep removed any staged upload older than seven hours. An
import can wait in the queue behind another account's import for longer
than that, and when its turn came the file was gone and the import failed.
The sweep now keeps every upload a queued or running import still names,
and the extracted XML while an import is past the queue. When the queue
cannot be read, it removes nothing that tick.
GET /api/export with type=all (the default), POST /api/export/health-record
and GET /api/fhir/Patient/$everything hand over the whole record but took a
live session alone, so a stolen browser session could still walk off with
everything the full-backup gate was meant to protect. A browser session now
needs a sign-in or re-proof within five minutes, with the second factor on
an account that has one, and the doctor-report panel opens the re-proof
dialog and retries. When a manager builds the report for a record they
manage, their own session is the one asked.
On a token the shipped app keeps working: health-record and $everything
take the token as before, including a fhir:read token, and the recent-proof
guard names both. GET /api/export?type=all, which no shipped client calls
with a token, wants a step-up elevation there. Single-type exports are
unchanged.
The API reference now also describes the enrollment rule for accounts
with a second factor and the passkey refusal it adds.
A reminder's first-due date could only be sent as a date-time, and the
web form sent the browser's local midnight, so a browser set to a zone
other than the profile's could move the day. The API now also takes a
bare calendar date and stores it as that day's midnight in the user's
zone, and the web form sends the date itself. A date-time still means the
instant it names.
Since a snooze holds only the slots whose window had opened when it was
set, a snooze set ahead of a dose, such as "remind me later" in the app
before the dose is due, held nothing and the reminder came anyway. A
snooze set while no window of the medication is open now holds the next
slot to open. One set while a window is open still holds only that slot,
so a morning skip that snoozes to the end of the day leaves the evening
dose reminded.
On an account with a second factor, TOTP setup, security-key registration
and passkey registration no longer accept the password, a password re-proof
or a session younger than five minutes. A remembered browser skips the
second factor at sign-in and still creates a fresh session, so the password
alone could add a key that then satisfies every gate the factor protected.
Only a TOTP code, a security key, a passkey or a completed second factor
within five minutes opens enrollment there; the Bearer arm of TOTP setup and
security-key registration asks the same of its elevation. Accounts without
a second factor keep the previous rules.
The refusal names the proofs the account can give, and the enrollment
dialogs offer exactly those. A proof that verifies now gives its attempt
back to the shared re-proof budget, including the current password on a
password change.
FREQ=MONTHLY;BYDAY=MO means every Monday of the month, but the runway
counted it as one dose a month, so a Monday medication's supply read about
four times longer than it lasts and its low-supply notice came late. A
weekday without an ordinal now counts as every such day in the month
(yearly rules per BYMONTH month), a weekday with one (1MO, -1FR) as one
day, and BYSETPOS as the number of positions it picks.
Since the supply runway stopped counting an as-needed schedule as a daily
dose, a medication taken only as needed had no runway and never raised a
low-supply notice. It now warns once when two doses or fewer are left,
with its own line naming what is left, and re-arms after a refill like any
other medication.
A medication's start and end dates are calendar dates, but the report
compared them against the window's instants. West of UTC the window
begins hours after UTC midnight, so a course that ended on the first day
was left out, and one that started the day after the window was listed.
Both now compare against the window's first and last day in the report
zone.