The SDK renders every tool's Zod shape with a fixed draft-07 target and
stamps "$schema": "http://json-schema.org/draft-07/schema#" on each
inputSchema and outputSchema. Clients that validate with a 2020-12-only
validator refuse that dialect when the tool list arrives, so every tool
was unusable there (#1170).
Wrap the tools/list handler once, after the last tool is registered, and
re-render each advertised schema from the same Zod shape with the
draft-2020-12 target, without a $schema key. A schema with no $schema is
2020-12 under the MCP spec and still compiles in a draft-07 client. All
other fields of a tool entry pass through unchanged, and the SDK keeps
validating structuredContent against the Zod shape, so tool results are
unaffected. For the current surface the 54 rendered schemas differ from
the previous ones only by the dropped key.
The new guard reads tools/list through a real client for a write-scoped
session, rejects any draft-07 marker or construct, and compiles every
schema with Ajv's 2020-12 build (ajv added as a dev dependency at the
version the SDK already resolves).
Below md the score ring sat centred under the Today facts as a 168 px
dial, where the bottom navigation covered it. The hero is now one
grid with two arrangements: on a phone the ring is a compact 80 px
dial at the top right beside the lead and signal line, flush with the
card's trailing edge, and the Today facts run the full card width
under both; from md up the ring spans both rows of the trailing
column exactly as before (desktop captures are pixel-identical).
Without a lead the facts take the leading cell, so the ring never
sits beside an empty row.
ScoreRing gains a `responsive` size (80 px, md:168 px) whose label is
left out below md; the ring keeps its aria-label and the surrounding
link names it. The meta lines under the phone dial are capped to its
width, so the lead keeps at least 180 px beside it at 360 px.
Each fact now stacks its muted label above its foreground value on
the reading edge at every width, instead of a label-left /
value-right row that broke long values into a ragged right-aligned
block. Rows stay at the 44 px tap floor below md.
The Today overview e2e covers 1440, 390 and 360 px in both themes
for an AI lead, a long AI lead, an AI-off day and a quiet day, and
asserts the dial size, its place beside the lead, symmetric insets,
full-width facts and stacked 44 px rows on a phone.
Inside a chat turn every tool read lands on the turn's shared full-source
snapshot when its window matches. On an account with many synced series
that build passes the prompt budget, and the budget pass collapses blocks
to a bare unit and then to an `{ omitted }` marker. get_metric_series
sliced the marker out and answered `present: true` with it, so the model
was told the readings exist and was given no figure. The same held for
sleep, glucose, compliance and workouts.
- The snapshot result names the blocks the budget pass cut. A tool whose
block was cut in the shared build reads it again from its own source at
the same window.
- A read scoped to one source condenses its own blocks last and never
swaps them for the marker: it keeps a summary (first, last, min, max,
mean, change) taken before anything is cut, the newest daily values,
weekly means folded into monthly means, and names each cut.
- get_metric_series never answers present with a block holding no figure;
that read reports retrieval_failed instead.
- A chat series read carries the readings it counted, so the step shows
them, and on a long or condensed window a note on what it covers. The
tool description and a new tool-mode rule tell the model to call
get_metric_table itself for longer history instead of asking.
- The screen-reader comma between step parts is unselectable, so a copied
row no longer reads "Weight · ,all time".
MCP output is unchanged: the source-snapshot equality suite stays green.
A statement that says "today", or compares today against a baseline,
now only rests on a reading taken on the current calendar day in the
reader's zone. Yesterday morning's pulse read at nine in the evening
was narrated as today's: the signals of the day accepted any reading
from the last two weeks, and the Today overview, the coincident
deviation flag and the health status counted yesterday as current.
- computeSignalsOfDay drops a metric with no reading today, so the
briefing prompt and its content hash never see a stale value as the
day's; the prompt says so in both languages.
- The digest serves no cached briefing written on an earlier day and
drops a reading-backed signal whose metric was not measured today,
so the web hero, the iOS topSignal and the morning push agree.
- The Today overview's vitals line and deterministic lead, the
coincident flag and the health status deviations use the new
isFromToday rule (calendar day, not elapsed hours).
Signal deltas now carry each metric's display precision: the prompt
input is rounded (whole bpm and mmHg, one decimal of kg), and the
delta string under the hero lead is re-read at that precision in the
reader's locale ("+34 bpm", not "+33,72 bpm").
Every text-entry control (Input, Textarea, Select and NativeSelect
triggers, the date and time fields, the calendar's dropdowns, the Coach
composer and the hand-built note fields) now marks focus by turning its
border the new neutral input-focus tone, about 5:1 on the dark
background and 4:1 on the light one, with no ring. A text field matches
:focus-visible on every mouse click, so the ring was on for everyone.
An invalid field keeps its red border and draws its destructive ring
only while focused. Buttons, links, checkboxes and switches keep their
rings.
text-field-focus-guard.test.ts fails when a text-field class string
brings a focus ring or a primary/ring-coloured focus border back.
A standalone PWA on a tablet reserves the status bar in the top bar but
not in the docked panel's header row, which could tuck its title and
icons under the status bar; the new-chat button sat without the home
indicator inset. Both now reserve the same insets as the top bar.
The docked panel was part of the page, below the top bar. It is now a
column of the shell beside the content column, portalled through a new
side-panel slot: it runs from the top of the window to the bottom, flush
right, and the top bar ends at its left edge. Shut, the panel is gone
entirely and the top bar runs to the window's edge. The panel's header row
is the top bar's band, so the two bottom borders draw one line; the panel
takes the sidebar surface and border, like the rail on the left.
The top bar says where the reader is on desktop: "Coach", then the open
conversation's title, truncated. The panel toggle is its last item, 12 px
from its trailing edge, so it stands against the open panel and at the
window's edge when the panel is shut; it carries a tooltip and is 28 px
beside a fine pointer, 44 px with touch.
New chat is a round 48 px primary button with a plus, 24 px in from the
panel's corner (16 px in the phone sheet), with a tooltip. The top bar's
action slot and the new context and side-panel slots share one portal
helper.
Reading a stored reply back drops a view chip whose table the answer
already shows with its toggle, but the resolver reads the raw stored
chips. A tap from a client that still shows such a chip is answered, by
design: the chip was a valid offer when it was stored. The test pins both
halves for "as a table" and "as a chart".
The condition moves out of the shell into isFullBleedPage(), a pure
helper with its own test: /coach renders edge to edge; /coach/plans,
/coach/conversations, a trailing slash, look-alike paths and other
routes keep the padded frame, and so does a refusal on /coach (outside a
shared record, module off).
Focus opens the details tooltip, and the click that Enter or Space then
fires toggled it shut again. A click with no pointer-down before it is a
keyboard activation and now always opens; a tap or a mouse click still
toggles, and Escape closes it.
The flag helper sat between the module gate's doc comment and the
component, so the comment read as its own. It is now
switchedOffModule(), which returns the owning module when it is off and
has a doc comment of its own above the gate's; the gate narrows on its
result directly instead of looking the module up twice.
The panel header buttons shrank to 28 px from md and the conversation rows
to 36 px from sm, so a touch tablet wide enough to dock the panel got
pointer-sized targets. They now shrink only under pointer-fine: 28 px
header buttons, a 36 px gear in the drawer, 36 px rows and row menus
beside a mouse or trackpad, the 44 px floor with touch at any width.
A unit test refuses a width breakpoint on a control size in the three
panel files; an e2e case measures the docked panel at 1280 px with touch
and with a fine pointer.
A table the answer displays with a chart renders its own chart/table
toggle, and the "Show as a chart" / "Show as a table" chips under it
repeated that control. The server no longer derives a view chip for such
a table; a table the answer only used, folded under "Data used", still
gets "Show as a chart", since that brings it into the answer. A reuse
turn shows its table with the toggle and so offers no view chip either.
The rule is one predicate in a leaf module, applied where chips are
derived and where a stored reply is read back, so a reply stored before
the change offers the same chips as a new one on every client.
The tokens and model no longer sit as right-aligned text under a Coach
answer. The row is one left-aligned group: copy, read aloud, try again, a
details icon, then the time. The details icon is a focusable button whose
tooltip names the model and the token count; it opens on hover, on
keyboard focus and on a tap, and a second tap, a tap elsewhere or Escape
closes it. Strings in all seven locales; the joined tokens-with-model
string is gone.
The toggle shows the panel-right open glyph while the panel is open and
the close glyph while it is shut, both mirrored so the chevron points the
way the panel moves on a click. Docked and open, the toggle keeps the
panel's width as its right margin, so it sits beside the panel's leading
edge and travels with it (200 ms, none under reduced motion). Labels and
aria-expanded are unchanged.
The Coach page cancelled the shell's container padding with negative
margins, so <main> kept its reserved scrollbar gutter and painted a dead
strip to the right of the panel on classic-scrollbar platforms. The shell
now renders /coach full bleed: no centred container, no padding and no
reserved gutter, and the page fills what <main> leaves below the top bar.
A refusal card (shared record, module off) keeps the padded frame.
The panel header takes one 12 px inset with a gap of 8 between title and
icons, a text-lg title, and 28 px icon buttons beside a pointer (44 px on
touch). Conversation rows take the control radius and a 36 px pointer
floor.
The same-hours standing averaged each day as sum / n. Pulse already folds
its day as the mean of its local hours' means, and the day-aggregate reader
returns that as dayMean; a type moved onto that reader would have been
compared raw. The standing now reads dayMean where the reader carries one.
Text with no space inside the budget was cut at 160 characters and then
given an ellipsis, so the lead came out 161 long. The ellipsis now counts
toward the budget: a space at the edge still ends a whole word, and
unbroken text is cut one short.
The same-hours comparison cut the earlier days at the current clock time.
At two in the afternoon a day holding only its fasting reading was then
held against mornings plus lunch, and read as below its range again. The
earlier days are now cut at the local time of today's latest reading.
A range read this way is the usual range for this time of day, not the
whole-day band other surfaces show. The deviation carries a basis, the
health-status API publishes it as `basis` (`day` or `sameHours`), and the
Today lead and the health-status card name the range as the one for this
time of day, in all seven locales.
Key rotation rewrote every ciphertext column through a plain Prisma update,
in the in-app corpus walk and in the rotation script alike, so a rotation
stamped the run time on every encrypted row: the Coach panel then listed
old conversations as recent, and every client re-pulled measurements and
mood entries whose sync cursor had moved. Both now read each row's
updatedAt and write it back, matching on it, so a row edited since the
read is left for the next run instead of being stamped. The models that
carry the column are listed beside the registry and checked against the
schema, and the updatedAt guard now covers the rotation writes.
The terms that decide whether the Today lead already names the top
signal's metric were bare stems: "risen" and "chosen" matched the Polish
"sen" (sleep), "impulse" matched "puls", "Passing" matched the Italian
"passi", and the Korean "잠" matched "잠시" (a moment). A lead about
weight then dropped a sleep headline. Every term now has to start a word,
whole-word terms have to end one (Unicode-aware on both sides), and the
Korean terms carry their particle.
Today's glucose mean was held against a band built from whole days. By
mid-morning the day holds a fasting reading and nothing after, so an
ordinary morning read as "below your range". A glucose day still in
progress is now compared with the earlier days cut at the same local clock
time, read in SQL so a dense stream is averaged whole rather than through
its newest fifty rows. With fewer than seven earlier days that have
readings in those hours, today gets no verdict. The day-aggregate reader
gains the local-clock cut; an integration case pins it against the
in-memory fold in a half-hour zone.
The title encryption backfill moved each Coach conversation's title into
ciphertext with a plain Prisma update, which stamps updatedAt. The Coach
panel orders and groups conversations by that column, so threads untouched
for months showed up under Today. The memory summary refresh did the same
in the background.
Storage-only rewrites now carry the row's own updatedAt and match on it, so
a turn that lands meanwhile wins and the row is retried on the next pass:
conversation titles, practitioner contacts, measurement and mood notes
(where updatedAt is the sync cursor), inventory notes, and the mood-note
script. The summary refresh writes through raw SQL. A guard freezes both
rules: every backfill write on a model with @updatedAt names the column or
is listed with the reason the stamp is wanted, and every conversation
write outside the person's own actions keeps it.
When most days in the window carry the same value, the median ± k·MAD band
has no width: the coach strip read "Your usual range is 61–61 bpm" and the
next ordinary reading of 62 counted as outside it. The shared band builder
now keeps the edges at least the measurement's own day-to-day wobble apart
(per metric, in its canonical unit, falling back to one display step), so
every surface built on it reads a real range and classifies against it.
The reported spread stays the measured dispersion.
The chart's label map covered eleven measurement types and fell back to
the raw enum for the rest, so a resting-pulse chart with its trend
overlays on showed "RESTING_HEART_RATE" in the trend line, the legend,
the tooltip and the spoken summary. Types outside the chart's short
labels now take their measurement label, which exists for every type in
every locale.
The muted line under the lead repeated what the lead had just said: a lead
about blood pressure sat above "Your latest blood pressure is sitting in
the optimal band · ↓ ~10 mmHg…". The web client only dropped the headline
when the lead quoted it word for word, and other clients had no rule at
all. The digest now carries `signalLine`, built next to the lead: when the
lead already talks about the top signal's metric (matched in every shipped
language) or says its headline, only the delta remains, and with no delta
there is no line. The hero renders the field verbatim.
A lead past the 160-character budget was sliced at a character count, so
a long briefing sentence reached the hero as "…sleep landed r…". Model
text now leads only when it fits whole: the reaction line keeps the
complete sentences that fit, the briefing leads with its first sentence
when it fits, and otherwise the headline or the deterministic sentence
takes the slot. Only when no source fits is the first sentence shortened,
at a word boundary. The budget is unchanged, so the hero keeps its
height.
/coach?settings=data opened the conversations sheet from the first
render. The server and hydration renders do not know the viewport and
take the narrow layout, so on a desktop the sheet was open until the
docked panel replaced it; only the batching of the hydration re-render
kept it off the screen. The deep link now opens the sheet only once
hydrated on a viewport that does not dock the panel; on a docked one the
panel is revealed as before.
The desktop deep-link spec records every mount of the sheet from the
first byte and expects none; a phone deep-link spec covers the sheet
path and serves as the watcher's positive control.
The digest is polled every 120 seconds. Its cycle line read the whole
cycle history, 90 days of day logs and wrist temperature on every poll,
outside any cache, and the coincident-deviation engine walked its eleven
vitals one after another, two queries each.
The cycle read now rides the digest extras cell, which every cycle write
already hard-evicts; the cycle preferences route evicts it too, and a
module switch does through the health-score invalidator. A record without
a cycle profile stops after one point read. The per-vital reads run
together and are folded in registry order, so the result does not depend
on which finished first.
On a seeded four-year account (1.25M measurements, 52 cycles) under a
792 MB heap, the warm digest went from 9.9 to 5.0 ms and the vitals engine
from 11.1 to 4.6 ms (medians of four interleaved rounds).
The digest line is the body of the morning push and lands on a locked
phone. It took the briefing's first sentence, the top signal's headline
or the deterministic signal sentence verbatim, so a resting heart rate or
a blood pressure average could sit on the lock screen, and its score
floor stated the score.
A candidate with a figure in it is now passed over for the next, and the
floor says the score is ready instead of what it is. The hero's lead in
the app keeps its numbers. Tests that pinned the numeric line are turned
around, and the push builder is checked on a digest whose lead carries a
reading.
The lead filter dropped any sentence containing the score's digits, so a
score of 52 threw out "You slept 7 h 52 min" and a resting heart rate of
52 bpm. A sentence now repeats the ring only when it names the score (in
any shipped language, or as "out of 100") and carries the number on its
own, not as part of a time, a decimal or a measurement with a unit.
The steady line compared the newest stored score with the number on
screen but not with its band, version or composition, so a score whose
band moved overnight at the same number was still called steady. A run
that reached back past the 120 days read was also told as if it started
there.
The newest stored day now has to match the ring in value, band and,
where the digest carries them, algorithm version and composition. A run
the read cannot see the start of is marked as open (steadyAtLeast on the
digest score) and the hero says "at least" in seven locales.
The derived block shortened each engine's window to the lookback limit,
so a 7-day limit produced a readiness computed over 7 days instead of the
30 the ring on the dashboard uses, under the same name. A block whose own
window does not fit is now left out, as the lookback rule says, and the
coincident-deviation flag follows its 30-day baseline the same way.
The GLP-1 block sent every dose change with its decrypted note whatever
the lookback limit said. Dose changes are entries, so only those inside
the limit reach the prompt; the dose in force stays as the current dose.
Jumping to the latest message pinned the thread first, which hides the
button behind aria-hidden, and only then moved focus to the composer.
Focus now leaves the button before its state changes.
The hero reads only digest.lead. A digest the service worker cached
before the update carries no such field, so the hero lost its lead line
until the next fetch. Such a digest now falls back to the reaction line
or the briefing lead it was built to show; a current digest whose lead
is null stays silent.
The provider and model selects in the Coach's settings already lock while
a write is in flight; the reasoning select did not, so a second PATCH
could race the first and one change could be lost.
Settings → Coach pointed at the gear with a muted paragraph. It now says
how many data areas the Coach reads and how far back it looks, from the
saved preferences, next to an Open Coach settings button that lands on
that section.
The facet triggers and the clear control were 32 px beside a 44/40 px
search field, below the phone tap floor. They now share its height and
reach 44 px square on a phone, and the browser's own clear button no
longer sits on the search icon when the field shrinks.
The overview sat 12 px under the lead sentence; it now keeps a section's
distance (16 px, 24 px from md). On a phone each fact row is a link and
now meets the 44 px tap floor.
Bottom toasts landed on the round New chat button for their whole
lifetime, so the delete Undo blocked the next action. While the
conversations panel is open, docked or as a sheet, bottom toasts rise by
the button's footprint.
The model fields carried the form label's colon and the lookback picker
an uppercase section-style label and a fit-content 36 px trigger. All four
fields now share one unadorned label and the full-width 44/40 px control.
buildMedsTodayBlock ended the day at local midnight plus 24 hours. On the
day the clocks go back that stops an hour early, so a dose between 23:00
and midnight was neither projected nor counted in the doses tally. The
exclusive end is now the next local day's start, as the digest loader
already computes it.
parseCoachPrefs parsed the stored blob as a whole and returned the
defaults on any invalid field. A lookback value this version does not
know therefore dropped the metric exclusions with it. Each field now
falls back on its own, and the exclusion and cluster lists keep their
known entries, so drift narrows what the Coach reads instead of widening
it.
The deploy runbook's rollback section notes the one case this cannot
cover: v1.40.1 and earlier still parse the blob whole, so an account that
saved the new "Last year" lookback loses its exclusions there.
The earlier-tables block names each stored table with its window and row
count. The list was filtered by module and exclusion only, so a table
stored before the limit was set or narrowed still told the model how much
history it covered. It now answers to the same range test show_result
uses, and the history-reach guard checks both the filter and that the
call site passes the limit.
The operator's shared Codex is appended to the resolved chain but is never
a chain entry, so the Coach's provider select had no option for it and
showed its first option as the one answering. The answering provider now
appears as a disabled option, with a note that the operator manages the
connection.