A damaged or foreign-key route ciphertext threw out of readRouteGeometry, so
one bad track answered the workout detail with a 500 and failed the activity
insight job. It now reads as no track with a warning on the request event, and
still never falls back to the readable column.
A table the answer referenced opens under the prose as its chart; the
others sit under Data used in the evidence disclosure and open as
tables. The plot is one image whose name points at the table view, two
series carry legend buttons, and the chart loads through the shared
chart runtime. A metric chart token for a metric a shown table already
covers is dropped.
When the tool loop forces an answer at its round cap, the reply leads
its chips with "Keep looking". Tapping it runs a normal turn, under the
budget gate as ever, with two server-written context lines: the question
that was left unfinished (at most 1000 characters) and what the forced
turn already read, each table by the name show_result takes, so the
continued turn shows those again instead of fetching them twice.
An answer is continued at most once. The continued reply records the
message it continues in metricSource.continuationOf and offers no
further "Keep looking", even when it is forced again; a chip on a reply
that is itself a continuation is refused.
Up to three chips, derived on the server from what the turn read: the
other view of a table with a chart, the period before, a year earlier,
a wider window, and a metric a discovered pattern links to. A chip is
offered only when its read was done this turn and the record holds the
metric; the year-ago and wider-window chips also need the record's
first reading to reach back that far. The model may name chips in a
FOLLOWUPS block, but only to reorder ones the rules already allow; every
label is rendered from the catalog.
A tapped chip resolves against the chips stored on the conversation's
latest reply. "As a chart" and "as a table" are answered from the
stored table without a model call or a budget reservation: the rows are
copied unchanged and the message is tagged providerType "reuse". Other
chips run a normal turn with a server-written context line. A chip that
is no longer current falls back to a plain message.
A setting turns the chips off (absent means on).
A table over time is drawn as a line for a level metric and as bars
for a total, with at most two series in one unit. Category counts get
bars, horizontal above six categories, the top eight and one other.
Fewer than two or more than 400 rows, or no numeric column, stay table
only. show_result's view now counts: table drops the chart, chart turns
an earlier day table into counts per range with a histogram (fixed
widths for the common metrics, Freedman-Diaconis otherwise, none below
ten values).
The line that resolves an answered clarifying question was already
appended to the system prompt on both paths; nothing proved it for the
no-tools (local model) path. Three route-level cases now do: the local
prompt and the tool-mode prompt both carry the server-resolved line, and
a turn without an answer carries neither. Removing the line from the
local prompt fails the first case.
The golden harness gains a coachMessage.findMany mock for this; it
defaults to no rows, so no existing transcript changes and the golden
snapshots are untouched.
The result-table caption test used the regex dotAll flag, which the
TypeScript target rejects (TS1501); it matches across lines with
[\s\S] instead.
The method line merged a repeated read by keeping the larger count. With
show_result that could let an earlier answer's count stand over the read
this turn just made. A fresh read of the same source, window and period
now always wins, whichever order the steps ran in, and the two are never
added. An entry that rests only on an earlier table says so ("from an
earlier answer"), including a reused table no step accounts for.
The new catalog string is in all seven locales.
get_metric_table now gets a step like every other read: the domain is
its validated metric, and once the table is in, the window, period and
granularity are the ones the table was built with (after the executor's
defaults). The count is the readings the table folds, taken from the
summary the server computed, so a read past the sixth table still counts.
A step whose call produced a table now names it (resultRef), for the
projected tables of the older tools as well.
show_result gets its own step. It reads nothing new: while it runs it
names no domain, and once the stored table is found it takes the source
of the read that built it and the name the copy carries in this turn. A
table that could not be shown is a failed step with the reason, never an
empty read.
The property test now drives show_result too and checks resultRef; the
coverage test gives each tool the smallest arguments it accepts instead
of passing empty arguments to a tool that requires a metric.
The table shows its caption, scoped headers, right-aligned tabular
numbers and a dash for a missing reading, twelve rows before Show all.
Copy writes TSV and an HTML table together, falling back to TSV, with
formula starts neutralised and separators flattened inside cells.
get_metric_table reads one metric as days, weeks or months through the
chart reader, cut in the user's zone and collapsed to one source per day,
with null for a period without a reading. The model reads a bounded
summary; the full table rides the turn, encrypted at rest. show_result
shows a table an earlier reply of the same conversation fetched, resolved
only against that conversation's own messages. Workouts, labs, sleep and
adherence results project into tables, earlier tables are named in the
context by metadata alone, and result:rN marks are stripped from the reply.
The small chevron above the first nav entry becomes a full footer row that
heads the utility group: directly above Admin for an administrator, directly
above Settings for everyone else. PanelLeftClose / PanelLeftOpen mark the two
states; the label reads Collapse while expanded and stays for screen readers
only in the icon rail, where the tooltip names the action. aria-label switches
between collapse and expand; the stored choice keeps its storage key.
The width transition now honours reduced motion. Adds the expand and
sidebar-action labels in all seven locales.
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.
While a turn runs, the header names the read in progress with a soft
shimmer; when it ends the list folds into "Looked at N sources". Each
row reads domain, window, and the count or the reason nothing was
found. Completed steps are announced through a polite status region at
most once every 1.5 s, and a reloaded conversation stays quiet. The
spinner and shimmer stop under reduced motion.
Each tool call now emits a running step as it starts and its final
status as it settles: found with a server-counted number of readings,
empty with the reason, or failed. The no-tools path shows one step for
the record summary. Steps carry catalog labels rendered in the request
language, closed-enum domains and windows, and integers only; a lab
analyte, a value or a model-written argument never reaches one. At most
twelve per turn. They persist on the provenance, so a reload shows the
same list.
The conversation window reaches the step so a call without a window
names the one it actually read. The no-tools transcripts gain the
summary step; a new transcript pins found, empty and failed steps ahead
of the first token.
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.
A restore of an account with 1.9 million readings, most of them deleted
by the nightly consolidation and still awaiting the purge, was cancelled
in its clearing step: `DELETE FROM measurements` ran past the 60 s
statement timeout every pooled connection carries, and the whole restore
rolled back with a message that named no cause (#1031).
Most of that statement was not the delete. Two columns point into
`measurements` with ON DELETE SET NULL and had no index, so Postgres
scanned `personal_records` and `ecg_recordings` once per deleted reading:
38 of 40 s on a seeded account with 300 personal records. Migration 0362
indexes both, which also speeds up the tombstone purge and account
deletion.
The restore transaction now sets its own statement and idle-in-transaction
limits to the transaction budget with SET LOCAL, and the budget counts
the rows the account holds as well as the rows the file carries. A
rolled-back restore names what the database refused it over (timeout,
lock, constraint, storage, connection, other) on the job row, the
console, the audit row and the wide event.
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.