The metric-page "Coach read" strip and the unscoped-hero seeded opener are
Coach surfaces, so both routes now call requireAssistantSurface("coach")
alongside the insights module gate. With the Coach turned off they return
the same refusal every other Coach route does, instead of lingering as a
"Coach read" line while the assistant is disabled. Restores the coach
route-gate inventory invariant.
The hero score card and the daily briefing accept tension, returnToBand, and
memory props, but nothing populated them server-side, so the lines never rendered.
Resolve all three server-authoritatively on the dashboard snapshot DTO (iOS rides
the same payload):
- tension — fires when the readiness composite's contributors disagree (a strongly
favourable one alongside a strongly unfavourable one), reusing the readiness
components the wellness ring grades and the shared disagreement detector.
Suppressed under a clinical red-flag (a coincident-deviation that no illness
explains) so the red-flag path is never reconciled into a calm verdict.
- returnToBand — runs the streak detector across the salient vitals and surfaces
at most one metric that has come back inside its own personal range after a
genuine prior out-of-band run.
- memory — maps the machine-derived coach memory block to the briefing's recall +
forward-look.
The blocks are locale-agnostic (contributor keys + a metric type); the insights
hero localises them through the existing readiness-contributor and metric-name
labels before handing the cards their resolved strings. Each value omits rather
than guesses. The snapshot cache key now carries the locale for the recall prose.
Replace the hidden composer prefill with a visible scope affordance: when
the Coach is launched narrowed to a metric, the hero shows a scope pill
naming that metric plus a tappable seed question, and the drawer sources
rail surfaces the same scope line. When the Coach opens unscoped, resolve
today's most notable derived signal server-side and offer it as a tappable
opener; fall back to the neutral greeting when no signal crosses the gate,
so the blank box is never cold and never fabricated.
Add an internal GET /api/insights/coach/seeded-question that reuses the
briefing's derived-signal detector to select the opener server-side; the
client renders the resolved DTO.
Render a compact two-line "Coach read" above each metric sub-page chart:
an own-baseline line ("Your usual range is X–Y; today's Z sits within /
above / below") and one top lagged-association line from discovered
correlations, framed descriptively. It makes a metric read seen-in-context
rather than isolated.
The strip is server-authoritative: a new GET /api/insights/coach-read
resolves the band via the median ± k·MAD baseline engine (with its 7-day
history floor — below it the strip says "still learning your range" rather
than inventing a range) and the strongest on-metric driver from the
existing FDR-controlled, effect-size-floored correlation discovery (no
lowered floor; the line is omitted when nothing clears it). The client
component only renders the resolved DTO, so web and iOS read identical
numbers. DTO shapes and the pure selection logic live in a client-safe
sibling shared by the component and the unit test.
Wired through the HealthKit metric scaffold (every generic metric page) and
the bespoke weight, blood-pressure, and pulse pages.
The post-hoc numeric verifier only graded the tool path, so local /
no-tools providers got no grounding net. Grade the no-tools prose
against the snapshot the model was shown — its structured sections,
which already carry the correlations-snapshot block — and flag any
cited number that traces to no snapshot leaf. Non-blocking: the count
is annotated under coach.prose.number_unverified and the unverified
token soft-stripped, never a stream-killer. Only runs when the full
figures were delivered this turn; a cheap follow-up has no fresh set
to grade and no-ops. Tool-path behaviour and tolerance unchanged.
The correlation discovery matrix gained two non-MeasurementType channels
(medication compliance, symptom severity), but the Coach get_correlations
reader still filtered its measurement query by hand and never built the new
channels. The channel keys leaked into the Postgres enum cast and threw for any
user with medication or illness data.
Extract the route's two channel-series fetchers into a shared module
(correlation-channel-series.ts) so both consumers build the channels identically,
then wire the reader to derive its type filter from discoveryMeasurementTypes and
fold the fetched compliance + symptom series into the discovery input. The reader
now humanises the channel keys to "medication adherence" / "symptom severity".
Render daily medication-compliance rate and illness symptom-severity as
first-class series in the FDR discovery engine, so the adherence-dip →
symptom-flare link (and compliance → vital drift) can finally surface.
Compliance pools every active medication's dose-history ledger into a per-day
taken/(taken+missed) rate, re-keyed to the user's display timezone. Symptom
severity reads the illness day-log functional impact, zero-filling healthy days
only across real episode spans so a user with no episodes yields an empty
series rather than a constant. Both flow through the existing n >= 20, BH-FDR,
effect-size, and shrinkage gates unchanged; a sparse series degrades to absent.
Each channel forms its own metric family (no self-lag tautology) and keeps the
association-not-causation framing. Add discoveryMeasurementTypes() so every
caller drops the non-measurement channels before a Prisma type filter.
The Coach may point at a published /learn guide, but only a slug in the
catalog. That was a prompt instruction with no enforcement — a model could
hallucinate /learn/lower-your-cortisol and ship a dead link. Add a deterministic
post-filter on the assembled reply that keeps a published slug verbatim and
strips any reference whose slug is not in the catalog, tidying the prose around
the removed link. Makes the catalog's impossible-by-construction claim an
enforced guarantee.
Each retrieval tool re-scoped the snapshot to a single source, so its cache key
never matched the inventory's full-source build — a four-tool turn paid four
extra full snapshot builds on top of the inventory's. Thread the inventory's
probe scope to every tool so a per-tool read lands the one cache entry the
inventory already primed, collapsing the turn to a single build (a window
override still gets its own correct build). Raise the loop's round cap from two
to three so a sequential cross-metric why-chain isn't starved; the per-round
token budget is unchanged and the absolute ceiling tracks one above so the
final round still forces prose.
The tool-mode path dropped the launch scope's sources: the inventory probes the
full source set, so a Coach opened from a metric page saw the whole inventory
and could roam, while only the no-tools path honoured the narrowing. Inject a
one-line FOCUS hint naming the launched domain(s) into the tool-mode user turn
so the model prioritises that metric and fetches its figures first, with every
other domain still reachable if the question leads there. Empty on a generic
open, so the prompt prefix is unchanged on that path.
The daily AI-token gate was a flat 25,000/day for every provider. That cap
exists to bound the OPERATOR's API bill, but a ChatGPT-OAuth (Codex) or BYOK
chain egresses on the USER's own plan/key and costs the operator nothing, so
gating it on the operator-cost ceiling is wrong. A single gpt-5.x reasoning
turn legitimately reports 20k-40k gross total_tokens (re-sent system prompt +
inventory + tool defs per round + hidden reasoning, summed across rounds), so
one or two turns exhausted the flat cap and locked the user out of a plan they
pay for.
Classify the chain's cost owner by its primary provider via resolveDailyCap:
an admin-openai primary (operator pays) keeps the operator-cost cap; every
user-egress primary (codex/openai/anthropic/local) gets a generous
abuse-only ceiling. Threaded into the Coach chat gate and both OCR-extract
modes.
Raise the operator-cost cap 25,000 -> 200,000 so the operator-key path also
survives a normal day of reasoning turns, keeping gross-token accounting
rather than reworking the reconcile math.
Subtract cached input tokens at reconcile: the Responses-API gross total still
includes prompt-cached input the user did not re-pay for. The codex client
already parsed cached_tokens; sum it through the tool loop and bill
total_tokens minus cached.
The Daily Briefing strips any number absent from its server-computed
figures; the Coach's tool path had no equivalent, so a transcription or
paraphrase drift (a tool returns systolic 128, the reply says ~138) could
ship. Collect the numeric leaves from every present tool result this turn,
extract the numbers the reply asserts, and soft-correct any that match no
fetched figure to a neutral placeholder — annotated, non-blocking, and a
no-op on a qualitative turn or the no-tools path where there is nothing to
grade against. The bounded retrieval loop now returns the present results'
payloads so the check has an authoritative figure set.
Accept dateFormat on the profile PATCH field-by-field, echo it from
the profile + /me reads, mirror it into the client auth user, and add
the field to the OpenAPI request/response schemas.
Thread the request abort signal through runCoachToolLoop into each
per-round completion so a mid-generation client disconnect tears down
the upstream call instead of paying the full token cost. Completes the
no-tools-path wiring to cover the tool-retrieval path as well.
Regenerate the OpenAPI spec for the trimmed OCR-capability reason enum.
The Coach SSE turn ran the provider call to completion before the stream
existed and never wired it to the request's abort signal, so a mid-generation
client disconnect still paid the full upstream token cost. Thread an optional
`signal` through CompletionParams and singleUserTurn into each client's
safeFetch (composed with the per-call timeout via AbortSignal.any), and pass
`request.signal` from the chat route's no-tools path. An early disconnect now
tears the upstream request down instead of generating into a closed
connection. Omitting the signal leaves behaviour unchanged (timeout-only).
Drop the unreachable text-only-model reason from the OCR capability
enum and its DTO type. Replace the literal NUL delimiter in the Fitbit
dedup map keys with a named constant so text tooling stops treating the
file as binary (behaviour unchanged). Correct the Coach tool-executor
docblock, which overstated cross-tool snapshot-cache sharing.
The app-logs, audit-log and host-metrics admin endpoints parsed their
query with a throwing parse(), so a bad parameter surfaced as a 500 and
a reported incident. Switch each to safeParse and return the standard
422 validation envelope, matching how body parsing is handled.
Text-mode OCR structures browser-extracted text rather than running a
vision call, so it reserved and reconciled the full vision budget even
though the spend is far lower. Add an ocrExtractText ceiling and reserve
that in text mode, and refund the reservation in full on a clean extract
failure instead of charging the reserved estimate.
buildComparisonSnapshotForUser read a user's entire measurement history
per type with no date floor, then fed each series to summarize(). The
snapshot only consumes avg30 / avg30LastMonth / avg30LastYear, and the
widest of those reaches into the [365, 395)-day window (ageMs < 395 days
in meanOfWindow), so nothing older than 395 days can change a result.
Add a 400-day measuredAt.gte lower bound to every per-type read in both
copies of the builder — the page-blocking POST /api/insights/generate
handler and the shared comprehensive-generate path the nightly
pregenerate cron drives. The fan-out runs all seven snapshot types via
Promise.all, so on multi-year accounts the unbounded scan was tens of
thousands of rows per type. 400 days keeps a 5-day floor over the
strict-less-than 395-day reach.
The OpenAI client pinned `response_format: { type: "json_object" }` on every
call. The Coach tool loop's forced-final round runs with `tool_choice: "none"`
and expects plain prose, but JSON mode coerced the reply into a JSON object —
the user saw a JSON blob or an empty reply instead of the coach's answer.
Send `response_format` only when the caller opts in with `responseFormat: "json"`
and no tools are present, mirroring the Anthropic client's prefill gate. The
JSON-consuming callers that relied on the implicit mode (insight generate +
briefing retry, medication extraction, the connection-test probe) now pass the
flag explicitly; the prose Coach path and every tool round stay out of JSON
mode. Codex already omitted the flag and is unaffected.
Replace the Coach snapshot-stuffing with a small base context plus a data
inventory and six on-demand retrieval tools (metric series, glucose,
sleep, medication compliance, labs, illness recovery) the model calls as
needed, then answers. Cuts first-turn input ~35-65% while preserving
grounding and every gate; tools return a structured present:false rather
than throwing, so the model cites only what it fetched. Falls back to the
snapshot path when a provider lacks tool support.
Replace snapshot-stuffing in the Coach turn with a small base context plus
on-demand retrieval. The model receives the full system prompt, a tool-mode
grounding addendum, and a tiny DATA INVENTORY manifest listing which domains the
user has logged and which tool fetches each; the figures themselves are pulled
only when a question needs them.
Six read-only tools wrap the existing server-authoritative snapshot builder:
get_metric_series (blood pressure / weight / pulse + the additive series),
get_glucose_panel, get_sleep, get_medication_compliance, get_labs, and
get_illness_recovery. Each returns a structured { present: false } for an absent
or opted-out domain rather than throwing or returning an ambiguous empty list,
so the model can always tell missing data from real data. Module and cycle gates
are enforced at the builder, so opted-out domains are unfetchable.
A bounded loop runs the call -> tool-result -> answer cycle: at most two rounds
with a hard cap of three that forces a final answer, parallel tool execution,
and only the final round streamed. The day's budget reserves the per-call
ceiling times the round count and reconciles the summed tokens; the atomic
reserve and reconcile primitives are unchanged. All inbound gates (rate limit,
refusal, replay scan, consent, budget) stay before the loop.
The legacy snapshot path stays alive as the no-tools fallback: tool mode is on
only when every provider in the chain supports tool-calling, so a chain that
includes a local model falls back to the prior behaviour verbatim. The tool
trace is persisted onto the assistant message provenance.
Retarget the Fitbit integration from the Google Health API (which needs
annual CASA review — a wall for self-hosters) to the classic Fitbit Web
API with PKCE and rotating refresh tokens, so a self-hoster can connect
with an instant developer app (migration 0191 adds the PKCE verifier).
Transport-only fork; the ingest, dedup, source-priority, backfill and
poll paths are reused. Intraday HR and skin temperature are not mapped
(no honest slot); the integration stays experimental.
Connect mints the PKCE pair, stashes the verifier on the OAuth-state row, and
sends the S256 challenge to the authorize endpoint. Callback reads the verifier
back and presents it on the token exchange, refusing cleanly when it is absent.
The connection-test probe hits the classic /1/user/-/profile.json endpoint.
Replace the CompletionParams { systemPrompt, userPrompt } string pair with a
structured { system, messages[] } request across all five AI clients. One
refactor unlocks three things: provider prompt-caching (Anthropic cache_control
on the stable system prefix, OpenAI/Codex automatic prefix cache, with
cachedInputTokens surfaced for observability), the tool-calling wire surface,
and clean multi-turn.
Each client maps the new shape to its own wire and degrades unsupported
features silently: the local OpenAI-compatible server never receives a tools
field (most reject unknown keys), and prompt-caching is best-effort everywhere.
CompletionResult gains toolCalls, finishReason and cachedInputTokens; the tool
types and per-provider mapping ship now, the tool-use loop does not.
Vision inputs fold into the user turn's content parts via singleUserTurn, with
each provider's pre-existing block ordering preserved. The corrective-retry
paths append to the last user message rather than a flat string. The Coach
keeps assembling one user turn so its snapshot-once and grounding behaviour is
unchanged. No wire schema visible to API consumers changed.
Behaviour is unchanged across every surface; the full suite stays green.
Two related changes to the Telegram bot's data path.
One chat ⇄ one account. `User.telegramChatId` is now UNIQUE (migration
0189, defensive pre-clean of any pre-existing duplicate binding). The
inbound webhook resolves the user from the chat id, so a chat shared
across two accounts would route a reply or button tap ambiguously. The
index is over a nullable column, so an unlinked account (null chat id)
never collides — only two accounts binding the same non-null chat id are
rejected.
Numeric measurement capture. A numeric reply to a measurement reminder is
now captured as a Measurement (`source = TELEGRAM`, added to the enum in
0189). On "Erledigt" for a reminder that names a single-value metric, the
bot offers an optional value prompt via force_reply; a numeric reply is
written through the shared `logTelegramMeasurement` helper — same range
guard, canonical unit, cache + rollup refresh as the manual route. The
reply binds to the reminder and user through a TelegramPromptContext keyed
on the prompt message id (the same strict chat/message binding the
mood-note path uses); the userId is never read from the payload. Comma
decimals are tolerated; non-numeric and out-of-range replies get a calm
hint and write nothing; the reply self-cleans on the ~30-min sweep. Blood
pressure (two values) is out of scope for the single-numeric path and
keeps its satisfy-only flow.
The v1.19.0 hourly heart-rate bucket
(`stats:HKQuantityTypeIdentifierHeartRate:<UTC-hour>`) shipped avg-only:
one PULSE row carrying the hour's mean bpm. Add the hour's min/max so a
client can render the intra-hour range without re-uploading raw samples.
- Two nullable columns on `measurements` (`value_min` / `value_max`),
migration 0188. Set only on a well-formed hourly HR bucket row; null for
per-sample readings, per-day cumulative totals, and manual entries.
- Batch ingest threads the spread through the same unit conversion as the
average and persists / overwrites it alongside `value` on the bucket
row, pinning it null everywhere else.
- The series reader returns the spread for `kind=pulse` (null on a
per-sample PULSE row); the OpenAPI Zod contract documents both fields on
the batch entry and the series point. The avg path is unchanged.
Add averagePerNight to the SleepRhythmResource OpenAPI schema so the new
average-per-night card's field is part of the iOS-consumed contract (the
endpoint already returned it; the Zod source had not been updated, so the
in-sync check could not catch the omission).
Tag each vital return with whether it deviated in the illness-adverse
direction and tally only adverse returns when naming the recovery driver,
so a neutral move (e.g. weight drift) can no longer be surfaced as the
vital someone recovered ahead of.
The cross-episode summary already computes per-vital physiological returns
but the card surfaced only one bare line. Thread the dominant returning
vital through the aggregate and the DTO so the copy reads 'you tend to feel
better before your resting heart rate settles' instead of a context-free
gap figure.
The aggregate tallies which vital produced a real return across the
episodes that qualified for the typical-gap median and reports the most
frequent one (deterministic alphabetical tiebreak); the driver is named
only when a gap actually clears the magnitude gate. Zero new compute — the
returns were already in hand. Adds the localized copy in all six locales.
The cross-episode typical-gap median counts an episode toward its
qualifying-days floor via the per-episode coverage `historyDays`, which
counts a day if ANY banded vital has a reading. A WEIGHT-only episode (no
illness-adverse direction) could therefore clear the floor and feed the
median despite carrying no illness-relevant signal, weakening the honest-
sample claim.
Compute a distinct-adverse-day count in the engine — days with at least
one notable reading in the metric's adverse direction — and feed that as
the qualifying-days floor instead. Reuses the engine's single adverse-
direction predicate (now exported as `isAdverseDeviation`); a neutral-
direction vital never counts a day. The insufficient arm contributes 0,
which is correct since its gap is null and never reaches the median.
The full SNAPSHOT block (every cluster plus labs, illness, cycle and the
reference-grounding lines) was interpolated into the prompt on every turn,
so a one-word follow-up re-paid the entire grounding prefix. Ship it only on
the first turn, or once the history window starts eliding turns; on a cheap
follow-up send a short pointer back to the snapshot the model already received
earlier in the same conversation. The prior turns ride the transcript, so
grounding and cross-metric correlation are preserved while a follow-up turn
sheds about 6,000 tokens. Record snapshotSent and promptChars on the reply
annotation so the cut is observable.
The canonical rotation registry (src/lib/crypto/encrypted-columns.ts) is the
source of truth its guard test cross-checks; add EcgRecording.waveformEncrypted
there (the script already references it) and drop the redundant parallel guard.
Update the sleep/rhythm route test to the v1.19.0 rolling 5-night balance
(nightsCounted 5, debt 300) instead of the old 14-night cumulative sum.
The cumulative model summed every nightly deficit over a 14-night window
with no credit for catch-up sleep, so a sleeper a modest amount short on
most nights carried hours of standing debt that never recovered while the
pattern held. Replace it with a running balance over a 5-night window:
short nights add their capped shortfall, nights above need pay the balance
down at a sub-linear recovery rate, floored at zero. The headline now
reflects what is still owed tonight and shrinks as the sleeper catches up.
Extend the self-cleaning Telegram bot beyond medication. A mood reminder
now carries a 1-5 inline keyboard plus a "Note..." force-reply and a
"Later" action; a tap logs a MoodEntry (source TELEGRAM, server-side,
user resolved from the linked chat, idempotent on a per-tap externalId)
and confirms with a calm callback toast, no chat echo. An optional
free-text note attaches to that entry via the prompt-context correlation.
A measurement reminder carries "Done" (satisfies the cadence) and
"Later" (pushes nextDueAt forward) — completion and snooze only, no value
capture.
Prompts and replies self-clean in ~30 minutes: the window drops from 1h
and moves to a shared helper, the sender schedules an unanswered prompt's
deletion at send time, and the existing 15-min sweep also prunes expired
prompt-context rows. The webhook keeps the shared-secret + timingSafeEqual
+ rate-limit-before-secret posture; every write callback authenticates via
the stored chat binding. The Telegram chat id is added to the egress
redaction denylist.
The comprehensive briefing's bounded feature read (extractFeatures) was
paid more than once where it should not be: the on-demand route and the
shared generator each run a downgrade ladder that re-reads with identical
arguments, and a nightly warm has no shared object across consumers in a
single tick.
Add a request/run-scoped feature cache: withFeatureCacheScope opens an
AsyncLocalStorage store, getCachedFeatures memoises the first compute per
(userId, day, includeRaw, sinceDays), gated on a cheap salient-input
fingerprint so a mid-scope ingest recomputes and a probe failure fails
safe. Outside a scope it is a pure passthrough. Wired into the
comprehensive generator, the on-demand POST handler, and the nightly
per-candidate loop + forceWarmUser. Coach keeps its own snapshot.
Define the go-forward aggregated heart-rate upload shape so clients can
stop posting one Measurement per raw HealthKit sample. A heart-rate row
keyed `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start-hour>`
carries the hour's average bpm as a single PULSE row and rides the
existing `stats:` overwrite path: a re-post within the hour overwrites
the value instead of duplicating, identical to the per-day cumulative
rows. The hour boundary is UTC so the key is timezone-stable.
The batch route rejects a row that targets the heart-rate bucket prefix
with a malformed hour suffix as a per-entry skip
(`malformed_hr_bucket_id`) rather than persisting an un-overwriteable
row; well-formed buckets, per-sample uuid ingest, and the per-day
cumulative overwrite path are all unchanged.
Average-only for now — the measurement row carries a single value
column, so hourly min/max would need a schema change and is left as a
follow-up. Documents all three externalId shapes plus the `updated`
status in the OpenAPI contract.
The medication detail clients computed the supply headline in the browser
via summariseSupply, risking web/iOS drift and pulling the shared math
into the client bundle. The GET /api/medications/[id]/inventory route now
computes the canonical SupplySummary server-side through summariseSupply
(the single source of truth) and ships it in the DTO; the Bestand and
Übersicht surfaces render the ready figures. Adds the MedicationSupplySummary
schema to the inventory list response and regenerates the OpenAPI contract.
The v1.18.11 briefing now folds illness-episode and cycle state into the
prompt via buildBriefingIllnessCycleContext, which resolves module gates
through memoizePerRequest and queries cycleProfile. The generate route test
mocks neither getEvent nor the cycle/illness models, so every case threw once
the new call path ran. Stub the context module to no output and add the
getEvent export to the logging-context mock so these cases stay scoped to the
provider and cache behaviour they assert.
Serve achievements via SWR and precompute Berlin day-keys to kill the per-row
Intl cost; switch dashboard/summary to cachedSwr; lazy-load three charts that
bypassed the dynamic boundary.
Achievements: the bucket was 60 s hard-TTL while the app-wide unlock
notifier polls every 2 minutes, so every poll landed on an expired
entry and fired a cold ~400 ms rebuild in the foreground. Add a
10-minute stale-while-revalidate window and read the route through
cachedSwr; the route already persists pendingUnlocks outside the cached
factory, so a stale serve never skips an unlock write.
Achievements builder: the vitals array ran through several full-array
passes, each re-deriving a Berlin day-key (and hour) per row via
Intl.DateTimeFormat. Compute each row's day-key once and thread the
parallel array into the green-day, weekly-consistency, engagement, and
hidden passes; the hidden pass reuses a single precomputed hour array.
Results are byte-identical to the per-row derivation (parity test added).
dashboard/summary: read through cachedSwr so the iOS-polled endpoint
serves its existing 1 h stale window instead of re-paying the cold
rebuild every 60 s on the plain cached path.
Charts: route the three recharts consumers that bypassed the shared
next/dynamic boundary (lab biomarker chart, sleep stage stacked bar,
recommendation rationale charts) through dynamic imports with matching
loading skeletons, taking recharts off the first-load JS for
/labs/[id], /insights/sleep, and the insights recommendations surface.
Bound the comprehensive briefing's feature read to a 400-day window instead of
walking the full measurement history on every generation. All-time min/max/avg
are sourced from one grouped aggregation over the whole history, so the
allTime* fields stay honest while the unbounded row materialisation is gone;
the oversize downgrade ladder remains as the safety net.
Fold illness-episode and cycle state into the briefing prompt, reusing the
server-authoritative builders the Coach already assembles. Both are
module-gated and cost nothing for users without them, so a mid-illness or
mid-cycle user finally gets context-aware prose.
Ground daily-briefing numbers against the signal features, guard Coach replies
against fabricated doses and risk scores, gate the period narrative, feed the six
status cards a precomputed signal block, add SSE cancellation, and cap the Coach
snapshot read.
Apply the outbound safety screen to the assembled Coach reply before
persistence and streaming, with a per-turn audit row on a block.
createSseStream gains a cancel handler + a shared AbortSignal: a client
disconnect now flips the signal so the token-emit loop stops enqueuing
into a closed connection and any provider fetch wired to the signal is
torn down. The chat emit loop honours it between frames.