Merge the eight reviewed audit fixes that were cut a week ago and never
landed. Main has moved on since, so each fix was re-checked against the
current tree and each of the five conflicts resolved by understanding
both sides.
Kept, still open on main:
- Proxy early exits now carry the transport security header subset via a
shared helper. Reconciled with main's /api/health worker-only carve-out.
- Batch measurement ingest bounds the entry instant so a future-dated row
can no longer win "latest reading" or poison the rollup buckets.
- Web Push dials through the pinned safeFetch dispatcher with
requirePublicHost, closing the last user-controlled-host egress.
- Mood bulk upsert and its tag/factor links run in one transaction, so a
rejected rating rolls the mood row back instead of leaving a tagless row.
- Full backup carries NutrientIntakeDay with its unit and source. Folded
into main's portable-export / disaster-recovery split.
- Live daily-series fallback restores the min/max spread band the tiered
read already emitted.
- Coach stream aborts its in-flight reader on unmount and resets send state
on a conversation switch.
- Manual water writes share the day-bound helper, nutrient add clamps after
the increment, and the thiamin cap drops to a real supplement ceiling.
- Compliance point window widens symmetrically instead of wrapping overnight.
- Oura, Polar and Fitbit token helpers classify an empty or non-JSON 2xx
body as a handled transient error.
- WebAuthn relying-party fallback to localhost is gated to development.
- Chart-token strip list is derived from the allowlist and pinned by a test.
- Regression tests pin the snapshot-cache tenant key, the GlitchTip
path-secret redaction and the Withings credentials delete.
Superseded by main, tests re-pointed at the current shape:
- The intake CSV import was rewritten on main to run through a background
job with a single-active-job admission gate. The future-date bound is
re-applied at request validation and the per-user limiter is kept for
parity with the bulk twin; the old synchronous-write test is replaced.
The previous commit spliced the DATA INVENTORY manifest into the tool-mode
authoritative payload set unconditionally. That made the payload set
non-empty on a turn where the model answered WITHOUT calling any tool
(empty tool results), which flipped the verifier from dormant to active —
grading the reply against an inventory that carries per-domain sample counts
only, never the snapshot's pre-computed averages. A figure the model
correctly narrated from the snapshot (a 30-day systolic average) was then
stripped to "[unverified]" — the exact false positive this work exists to
prevent, and a regression from main's "no tool figures this turn → skip,
prompt-level grounding is the backstop" contract.
The inventory must only WIDEN an already-active authoritative set, never
ACTIVATE it on its own. Gate activation on a real present figure block
(a pinned workout-evidence block or a present tool result) exactly as main
did; when active, the inventory counts still join the set so a plain count
restatement stays grounded.
Adds a route-level regression test (fast suite) proving the verifier stays
dormant when no tool returned figures, plus companion unit tests documenting
that a real tool section grounds the average while a counts-only inventory
does not.
Three confirmed false-positive shapes in findUnverifiedCoachNumbers/
stripUnverifiedNumbers were replacing legitimate prose with the literal
"[unverified]" placeholder even though the underlying data was grounded:
- A correctly-computed delta, midpoint average, or percent change between
two of a payload's own headline aggregate figures (e.g. "down 3.5 kg from
your 7-day average") was never itself a raw leaf in any tool result, so it
was flagged as invented. The authoritative set now also carries the
pairwise delta/average/percent-change between a SINGLE payload's own
named aggregate fields (latest, avg7, avg30, avgSys30, allTimeAvg, ...),
scoped per payload so a weight delta cannot ground itself against an
unrelated metric's figure.
- The always-sent DATA INVENTORY manifest (tool-mode base context) carries a
sample count per domain, but that count was never added to the
authoritative set the way the pinned workout-evidence block already is —
so a plain restatement of a domain's count ("you've logged 42 BP
readings") was flagged whenever the model didn't also call that domain's
retrieval tool in the same turn. The inventory entries now splice into
toolResultPayloads exactly like workoutEvidence.
- A calendar-date ordinal ("July 21st") or a numbered-list marker ("1. Cut
back on sodium") tokenised as a bare number and, when ungrounded, got
soft-stripped mid-word into visibly broken text ("July [unverified]st").
Prose is now masked for these two narrow, high-precision shapes before
numeric extraction; the original text used for stripping is untouched.
A genuinely fabricated figure (unrelated to any grounded point, inventory
count, date, or list marker) is still caught and stripped — covered by new
adversarial cases alongside each fix.
Investigated and ruled out: multi-round tool-loop result accumulation (all
present results across every round already reach the verifier); unit
conversion (the app resolves and serves one consistent unit, so there is no
independent conversion step for the model to get wrong). The Daily
Briefing's separate withhold-a-whole-section grounding gate is unrelated to
the reported symptom (a different failure mode, whole-line omission vs.
inline replacement) and was not touched.
The named-risk-engine pattern in the outbound safety screen used
score2? (the 2 optional), so it matched bare "score" too — any mention
of one of this app's own computed, documented scores (Sleep Score,
Readiness Score, Health Score, Recovery Score, Stress Score, Strain
Score) tripped the bank meant only to catch a fabricated named
clinical-risk calculator (SCORE2, Framingham, ASCVD, QRISK).
Require the literal "SCORE2" instead. The other risk patterns —
percentage-shaped risk claims and the other three named engines — are
untouched, so a genuinely fabricated risk figure or a named
risk-calculator claim still blocks.
Closes#587.
The harness landed in v1.30.35, after the slices that built this
release were already cut — so the reaction line's prompt module was
never registered, and its own docblock's warning against the
two-locale collapse was untested. Registering it surfaced a real gap:
the meaning-first instruction lived only in the system prompt's own
words, never in the user prompt in the family's shared phrasing, which
is where the rest of the surfaces carry it and where the tone contract
checks for it. The German body carried no such instruction at all.
Threads locale through the user-prompt builder so the fixed
instruction line matches the reader's own reply language, and wires
the per-user per-day opener-archetype rotation the milestone spec
called for and the first implementation had not connected.
Four suites, each mutation-checked against a reverted protection:
- route-workout-scope: the workout row is read exactly once across a ten-turn
conversation that keeps sending the id, the where clause always names
userId, another user's id resolves to nothing and the chat proceeds, and
the section sits inside the health-data fence.
- workout-evidence: the numbers-only closure. An instruction-shaped
sport_type — reachable through the restore path, which types the column as
free text — folds to "other" and never reaches the block; a free-text leaf
at any depth throws.
- page-launch-params: an unknown ?scope= is ignored silently, and ?workout=
yields to both ?c= and the fenced ?doc= path.
- composite-score-anatomy-coach: every score sheet carries an opener, a
scope narrowed to the composite's inputs, and auto-send.
resolveLaunchState's shape assertion gains the workoutId field.
The spine dispatches a generator-free enqueue; the worker resolves the
provider. Five gates stand in front of it in ascending cost order — both
modules on, ten-minute duration floor, a hard count of four a day in the
user's own timezone, an input hash over the deterministic evidence, and the
token ledger inside the shared status chokepoint. Each refusal returns a
status rather than throwing, because retrying against a ceiling that does not
move until midnight is an unbounded loop.
The detail route only reads: a workout with no row serves null and renders no
card, which is every workout that predates this and every re-synced one. An
undecryptable paragraph degrades to no card rather than failing the page.
Today gains two additive-nullable digest fields. `justIn` names the day's
newest arrival while it is still news — under three hours old, after which the
record is simply current rather than new. `reactionLine` carries a single
generated sentence about that arrival and replaces the hero's standing lead for
the rest of the local day, never appending a second paragraph, so the hero stays
one lead line tall and nothing below it shifts.
Nothing here polls. The open dashboard picks the change up on the cadence it
already runs — a state change, not a notification.
The generated sentence is bounded before it is written: the arrival spine's
unique row per kind per local day IS the throttle, the worker re-checks the
same claim before spending, and the reservation is reconciled on every exit
including each failure path. Without a provider, without consent, or against an
exhausted budget the row stays line-less and the surface falls back to the
deterministic lead — the chip and the provisional-to-final flip are unaffected.
The chip's time string is formatted client-side after mount; `justIn.at`
crosses the wire as an ISO instant so the server never renders a local
wall-clock time the browser would disagree with.
Status cards and derived-score sheets read one way: they state a verdict and
end there. The metric pages already carried the hand-off; the score sheets
passed neither an opener nor a scope, so they painted no action at all.
- Score sheets (sleep / readiness / recovery / stress / strain) gain the same
auto-sent opener the metric pages use, each scoped to the inputs that drive
the composite rather than to a block it has no equivalent of.
- Workout detail's coach launch carries the workout itself. The chat request
gains an optional workoutId; on the FIRST turn the route narrows by
{ id, userId } and pins ONE bounded evidence section onto the snapshot.
Later turns reuse the pinned snapshot, so per-turn work stays constant in
conversation length.
- /coach?workout=<id> covers the full-page and maximize paths.
The workout block deliberately does not take the fenced transport the stored-
document path takes: a workout row is the user's own sensor record, and the
projection admits no free text. That is enforced, not assumed — sport_type is
a TEXT column, so it is re-asserted against the closed sport union, metadata
is excluded apart from its numeric zone durations read through a Zod slice,
and a recursive guard throws on any non-numeric leaf.
Own-history sport averages move to src/lib/workouts/sport-context.ts so the
narrative and the detail page cannot quote different figures.
The evidence block is a numbers-only server projection: zone seconds, the
workout-window heart-rate shape, route climb and a same-sport 90-day own-history
median. Free-text metadata never enters it, and the sport string is narrowed
against the closed enum first, so the payload carries no text the user did not
generate through a measured field.
The prompt composes the shared assessment base and adds the session contract:
device-attributed, own-history-comparative, effort acknowledged where the
numbers show it, and no training prescriptions of any kind. Registered in the
tone harness beside the assessment cards.
Each of these was already correct in the tree and had nothing asserting it, so
a refactor would have regressed it silently.
The decrypted-snapshot LRU keys on userId, but only same-user memoisation and
scope-change recomputation were covered — dropping the userId segment would have
served one account's health snapshot to another with a green suite. The project
has shipped one cross-user cache-leak fix already; this is a repeat class.
The GlitchTip forwarder redacts path-segment secrets in the url it sends. The
query-string strip was tested; the path-segment pass was not, and that is the
one that reaches a durable external incident UI.
The Withings credentials DELETE narrows its connection-row delete to P2025 and
rethrows everything else, so a real failure can no longer null the client
credentials and report a successful disconnect while the encrypted OAuth tokens
survive. The route had no test file at all.
The no-signal floors were the coldest text the product renders. They run when
there is nothing to ground against, and they opened on a bare order — "Measure
blood pressure at rest and under comparable conditions", "Evaluate mood trends
over several weeks" — so the one moment the surface has nothing to say was also
the one moment it told the reader what to do.
They now open on what the surface can honestly say: no assessment is available
right now. That is the meaning-first opening for this case, and it is the only
one available, because a floor with no signal may not imply a read. What
follows describes how the measure behaves rather than ordering the reader
around. The signal-grounded lines above them are unchanged.
The prompt version bump re-fingerprints the assessments cached under the old
biomarker prompt; the per-card input hash folds the constant in, so the
regeneration is one-time and paced by the nightly warm.
The German prompt-injection test asserted only inside `if (decision.refuse)`,
so it stayed green whether or not the filter fired. Un-guard it, drive it from a
case table, and widen the two German patterns to cover the determiners that
walked straight past them ("saemtliche", "die", "deine").
The Oura / Polar / Fitbit token helpers cast a possibly-null `json` to the token
type on a status-only success gate. A 2xx with an empty or non-JSON body handed
callers a null they dereferenced, surfacing an unclassified TypeError instead of
a handled integration error. Narrow explicitly and classify as transient.
The per-biomarker assessment was the last surface in the family building its
prompt inline: a five-line scaffold that skipped the base system prompt, took
no opener hint, and instructed the model to "state the current value" — so the
one card where a bare number reads most like a verdict was value-first by
instruction while every sibling had moved to meaning-first.
It now composes exactly as the seven bespoke cards do. The new prompt module
carries a METRIC section that states the one thing this card needs and the
others do not: its snapshot has no signal block, so the read comes from the
pre-computed range placement plus the direction across draws.
Also point both archetype-driven cards at a registered budget. The generic
metric card and the biomarker card each carried temperature/maxTokens inline
at 0.45/1000, so the registry that documents every surface's ceiling was
silently wrong about the two widest assessment surfaces.
The single-marker no-key fallback replaces the multi-metric overview tip the
card used to render — text about watching several metrics at once, on a card
showing one lab value.
Three surfaces where the words no longer matched the code, plus the missing
web control for a preference the server already honours.
Delete confirmations. Four dialogs promised permanence on surfaces that
render an Undo: the measurements and mood bulk bodies, the lab-result body,
and the illness-episode body (which said "cannot be undone" sixty lines
below the code that fires the Undo toast). All four now use the same
sentence the single-row measurement and mood dialogs already used. A sweep
of every other confirmation in the tree found the rest accurate — the
biomarker cascade, the mood-tag purge, and the account purge keep their
warning because those deletes really are unrecoverable.
Sleep debt. v1.19.0 replaced the summed shortfall with a running balance
that credits catch-up sleep; the labels still described the old sum, so the
word contradicted the number for anyone who slept in after a short night.
Corrected in all six locales, plus the places the old semantics had
propagated: the Coach system prompt, the OpenAPI field descriptions the
native client reads, and four module docblocks.
Glucose reference band. `hasDiabetes` selects the tighter reference band and
grounds the Coach, but no web surface set it — a web-only account was pinned
to the non-diabetic scale with no way out. The control goes on Targets,
above the per-metric editor, because it answers the same question that page
answers and the two are ordered: it picks the default band, an override
below beats it. The copy says what it is — a reference band, not a
diagnosis, never inferred from a reading, no clinical action taken on it.
The passkey list was reported as always reading "never used". It does not
reproduce: the column is selected by the list route and stamped on every
verified assertion, both since v1.23.0. Pinned with tests on both halves so
the contract cannot quietly regress.
The retry suffix still described the strict insight wrapper that was removed
long ago: it demanded citations[] and warnings[] — fields insightResultSchema
does not have — required metricSource, rationale, id and severity on every
recommendation when all four are optional, and named none of the five fields
the live schema actually requires (classification, findings, correlations,
dataQuality, disclaimer).
So the one corrective pass steered the model away from the contract it was
being corrected towards. A reply that obeyed the correction could not
validate, fell through parseComprehensiveResult's raw-object escape hatch,
and the downstream surfaces read an unvalidated payload.
Describe insightResultSchema and nothing else, and carry the contract as data
in RETRY_CONTRACT_EXAMPLE so a test can assert prompt and validator agree.
The Today hero stayed on its all-clear line while a dose was actually due:
the dose-window item only fired once the slot was already overdue, so a
dose the user could take right now produced no rail item at all. Emit the
item from the moment the dose becomes takeable — the on-time window's lead
plus the early grace, the same instant logging it starts crediting the
slot — and give the two faces distinct wording and status: a due dose reads
calm and informational, an overdue one keeps the warning. A settled day
still emits nothing, and a cached block whose anchor has since passed keeps
rendering neither face. Push reminders are untouched; they still fire only
on overdue.
The briefing grounding gate discarded the whole block over one number it
could not verify, and it could not verify plenty of legitimate ones: the
allow-set was a hand-maintained list of feature blocks that had already
drifted from the prompt three times, and still omitted medication
compliance, the context counters, the correlation coefficients, every
coverage object and the comparison snapshot — all of which the prompt hands
the model verbatim. Derive the allow-set by walking the prompt payload
instead, so grounded means exactly what it says: a figure the server gave
the model. A fabricated number still matches nothing.
Fix the disposal to match. A content-failed retry left a hole while a
transport-failed one fell back to the previous cached briefing, though in
both cases the rejected text is discarded and the previous briefing passed
this same gate when it was written. Fall back on both paths, and only
report the briefing as withheld when there is genuinely nothing to show.
The on-demand route had no fallback at all; it now mirrors the background
generator that writes the same cached payload.
Four locales received English on surfaces whose translations were
already complete, because the resolving code was a de/en binary.
- Medication reminder push and the Telegram inline-keyboard labels
admitted only de/en, so es/fr/it/pl got the English template on every
channel. Route the resolver through a shared coerceLocale().
- The Coach's trailing "reply now in X" clause said English for any
non-German locale, overriding the correct system-prompt directive.
Read the language name from a shared map instead.
- Coach refusal copy was a de/en constant pair, answering four locales
in English at the one turn the Coach is declining to help. Move the
copy into the bundles; the EN/DE constants now derive from them.
- The sleep-stage chart formatted axis ticks as en-US for four locales;
the cycle calendar and the device-score tile followed the *browser*
locale rather than the app locale.
- Three components carried a byte-identical duration formatter that
branched on German. Lift it to one key-driven helper.
Polish needs a third plural tier: 2-4 takes a "few" form a two-form
scheme cannot express, so "2 godzin temu" read as "2 of-hours ago".
Add a pluralKey() helper driven by Intl.PluralRules and a Few tier to
the relative-time and stale-hint keys. The five locales whose rules
have no "few" category resolve exactly as before by construction, and
their Few values mirror Other. Teach the reverse-coverage guard to
expand a pluralKey() base into its three tiers.
generateInsight() parsed against the strict aiInsightResponseSchema,
cross-checked that every recommendation's metricSource appeared in
citations[], and threw InsightSchemaError on a second failure. Its only
caller was runWithFallback(), which had no production caller — every
live surface runs runRawCompletionWithFallback. So none of it executed.
Wiring it into the live path was the alternative and it is not viable:
the live insightResultSchema has no citations[] field at all and treats
metricSource as optional, so the cross-check would have rejected
essentially every generation rather than tightening anything.
Removed: runWithFallback and its param/result types, generateInsight
and its private helpers, findUncitedRecommendations,
findRecommendationsMissingRationale, the never-thrown InsightSchemaError
and the isHardProviderFailure branch that excluded it, and
computeConfidence — a deterministic override that only ran inside the
dead wrapper.
Kept: buildRetryCorrectionMessage, which comprehensive-generate sends on
its own retry leg; the file it lived in is renamed to match what it now
holds. aiInsightResponseSchema stays as the shape that message
describes. Note the retry prompt asks for a stricter payload than the
live validator requires — that mismatch predates this and is left alone.
computeCitationCoverage is untouched and still live: it is a metric, not
a gate, and v1.30.24 wired it into the comprehensive post-parse step.
Chain-runner coverage the deleted tests carried (last-working cache,
empty-input guard) is ported onto runRawCompletionWithFallback, which
shares the machinery.
Three things the write surface claimed but did not hold.
Scope: the run bodies never re-checked ctx.canWrite. Enforcement was
real but lived entirely in the registration decision, and the test
asserted registration only — so a refactor that registered the write
tools unconditionally and meant to refuse at call time would open the
surface with the suite still green. Wrap every run body in a scope
guard at the registry level, so a new write tool inherits it, and drive
the run bodies directly from a read-only context in the test.
Free text: medication names, doses, treatment classes and lab analyte
names were interpolated into tool prose with no delimiter. A lab
analyte name is transcribed out of an uploaded document, so a hostile
document chooses it, and the write tools ride the same wire. Fence the
assembled prose with the shared data-fence markers and scrub short
result titles, which would leak markers into a host's result list.
Confirm: the header called the confirm flag a PROTOCOL-LEVEL CONFIRM
GATE. It is a client-asserted boolean with nothing binding it to a
prior preview. Binding it to a server-issued preview token would prove
only that a preview call happened, which any client can do, so it buys
no human-in-the-loop for a round trip and server state the stateless
transport does not want. Describe it as the convention it is and name
what actually bounds the blast radius.
enforceBudget and recordSpend have no callers left. Both defects they
carried recurred every time a surface copied them: a TOCTOU window
between the read and the bump, and a cap parameter defaulting to the
operator ceiling so an omitted argument rationed a self-hoster paying
for their own key. Deleting them means a future surface cannot reach
for the older, simpler-looking helper and reintroduce either.
getDailyTokenSpend goes with them — it existed to feed the read half.
The ledger-poisoning clamps recordSpend carried (NaN, negative,
fractional token counts) move to reserveBudget, where the property now
lives. Refresh the doc comments that still narrated the old flow.
The red-team corpus probed only user messages and model replies. Neither is
the channel that reaches the prompt unguarded: a lab analyte, panel or unit
name, or a medication label, none of which the account holder authors when
the row was committed from an uploaded document.
The free-text guard's file list was hardcoded and had rotted — three of its
four entries were no longer reachable from the snapshot, and it covered one
of the dozen files that actually emit free text, which is why the lab fields
went unnoticed. Derive it from the import graph instead; it now covers 75
files, with a floor assertion so a resolver break cannot silently empty it.
Deriving the list immediately surfaced a second leak: features.ts shipped a
raw medication name and dose into the snapshot medications block.
Also fix sanitizeForPrompt itself. It deleted control characters, which
welded neighbouring tokens together and destroyed the word boundary its own
injection patterns anchor on, so a payload preceded by a newline survived
because of the newline. Replace with a space and collapse before matching.
The Coach SNAPSHOT was spliced into the prompt bare. Its JSON leaves are not
all server-computed: lab analyte, panel and unit names are transcribed by a
model out of an uploaded PDF and stored verbatim as the biomarker name, so a
hostile document chooses those strings. The about-me block has been fenced
since v1.16.1; extend the same pattern rather than adding per-field escapes.
- Move the fence markers and the wrapping primitive into a shared data-fence
module. Content is scrubbed against every known marker, not just its own
pair, so text in one block cannot forge another block's boundary.
- Fence the SNAPSHOT with an explicit data/instruction frame.
- Fence the OCR text handed to the document extractor; the previous
'OCR TEXT:' label was a label, not a boundary.
- Sanitise analyte, panel and unit at the labs source, which feeds both the
snapshot prompt and the labs_read tool result, and the medication label in
the adherence storyline.
- Capture a ---REMEMBER--- sentinel as proposed rather than active. It was
the only model-driven write with no confirm step, and the reminder recall
block fed it back into every later turn. Proposed reminders are already
excluded from that block and already carry a confirm control.
The provider chokepoint every status and reference generator funnels
through ran with no budget accounting at all. Nine generator families
egressed behind it — the specialised status cards, the generic metric
cards, biomarker cards, the batched assessment, the derived scores, the
period narrative, and the off-request Coach memory workers (rolling
summary, fact extraction, plan proposals) — and none of that spend landed
on the daily ledger or met any ceiling. On an operator-key account it was
unmetered operator spend.
Reserve and reconcile around the provider call, using the same atomic
upsert the Coach and document paths use. The ceiling follows the cost
owner: a chain whose primary is the operator's own credential draws the
operator ceiling, everything else draws the user-plan ceiling, so a
self-hoster on their own key is never rationed by the operator's bill.
The status enqueue keyed its de-dupe singleton on (user, metric, locale).
The locale on a status read is client-chosen, so each of the six
supported locales opened its own slot and one legitimate generation pass
could be multiplied into six. Key on (user, metric) and carry the locale
in the payload instead.
The Coach re-shipped the full snapshot on every turn past the history
cap, not just the turn that crosses it, so a long conversation paid the
per-turn snapshot cost the snapshot-once model exists to remove. Re-ground
on the crossing only, tested as a narrow window rather than an equality
because a turn advances the count by one or two depending on whether the
reply persisted.
Give /api/ai/test a daily ceiling and a ledger entry. Its burst bucket
bounded rate but nothing cumulative, and with an empty body it resolves
the same chain generation uses — which can be the operator's credential.
Correct the memory-refresh worker comment, which asserted a budget check
inside the chokepoint that did not exist.
The coverage check hung off generateInsight, whose only caller runWithFallback
has no production caller left -- every live surface routes through
runRawCompletionWithFallback instead. The admin quality dashboard was reading a
metric nothing emitted. Widen the input to the structural shape both payloads
share and call it from the comprehensive post-parse step, so the annotation
reflects real generations.
Writing the locale tests found three gaps the English-only banks hid: Polish
verb suffixes fell outside the ASCII word class, a trailing word boundary after
an accented character never closed, and German subordinate clauses put the verb
last so the natural phrasing of a dose instruction never matched. The payload
collector also skipped legacy bare-string recommendations entirely.
The briefing gate grades numbers only, and only the paragraph, signals and
key-findings blocks -- so a digit-free risk assertion with a dose suggestion
attached passed it untouched, and recommendations and summary were not graded
at all. Screen every readable prose field against the insights contracts.
A safety violation cannot be surgically stripped the way an ungrounded number
can: the offending sentence may sit in any field, and deleting one would leave
the rest referring to it. So the background generator fails the whole run and
persists nothing, leaving the previous cached payload in place, while the
user-initiated route returns an explicit error for the same reason.
The causal bank lived in the narrative validator as an English and German list,
so the no-causation rule was enforced in code on one surface and for two of the
six locales it ships in. It now comes from the shared screen, which carries all
six, and the narrative also gets the dose and risk-score contracts it never
checked -- persisted prose with a dose imperative in it is as unsafe as a chat
reply with one.
Red-team cases carry their language. The German outbound case was probed as
English, which would have reported a pass the guard never earned.
The outbound safety screen ran on the Coach only. A dose-change imperative or
a fabricated clinical risk score written onto a per-metric, biomarker or
derived-score status card persisted as that day's cached assessment, while the
identical sentence in the Coach was caught and replaced -- the only transform
between provider and persistence was whitespace normalisation.
Move the pattern banks into a shared screen that takes the reader's locale.
The old signature took no locale, so the dose-refusal and risk-score contracts
had no enforcement for fr/es/it/pl even though the ground rules ship in all six
contract files. Banks now cover all six locales and each screen runs the
reader's bank plus English, since a provider often answers in English
regardless of the language directive.
Status cards withhold on a trip: serve the deterministic stub and persist no
model text. Nobody waits on a background-generated card, and persisting a
refusal would pin it to the card for the day.
One suite per gate: the rich reads omit with an honest module_disabled
reason, the correlations reader omits and touches no channel, the
doctor-visit resource and prompt refuse without assembling the record,
and the clinician share collapses to its documents-only state. Each
asserts the operator availability path alongside the user toggle, and
that the gate short-circuits before the engine runs rather than building
the answer and withholding it.
The structural test pins that every metric the rich reads can resolve has
either an owning module or a written review decision, and is explicit in
its own comment about what it cannot prove — it does not show that any
read calls the gate. Writing it surfaced an unowned HRV_RMSSD, the
fallback type an RMSSD-only account resolves to, which would have served
recovery data the primary type refuses.
The MCP surface reached three data families that deliberately bypass the
Coach snapshot and so inherited none of its module narrowing: the
doctor-report aggregate behind healthlog://report/doctor-visit and the
doctor_visit_summary prompt, the rollup-backed rich reads (compare_metric,
get_metric_baseline, detect_changepoints), and the correlations reader.
A disabled module — or one an operator switched off server-wide — still
egressed to a third-party assistant.
Gate at the read rather than per call site. The module-to-data ownership
table moves out of the Coach snapshot builder into
lib/modules/measurement-scope so every read gates off one map; the rich
reads resolve metrics through a module-aware resolver; the correlations
reader gates on insights itself, which also closes the same gap on the
Coach chat tool and the metric-page read strip.
Per-domain reads omit with an honest module_disabled reason; the
whole-record aggregate refuses, mirroring /api/export/health-record.
collectDoctorReportData gains a fail-closed backstop on its own module
key. The clinician share link, found while tracing the aggregate's
callers, had the same gap and now collapses to its documents-only state.
The self-context clarifying questions and the AI-composed proactive nudge
both reached a provider without a consent check of any kind. Either could
resolve to the operator's server-managed credential (admin-openai or the
shared central Codex), so a user with no AI credentials and no receipt on
file had health data leave the server on the operator's key.
The questions path is the sharper of the two: its prompt carries the
complete Coach snapshot. Gate both with the chain predicate pair the status
path already uses, on the coach surface. The gate is skip-shaped on both —
the questions fall back to the deterministic hints, the nudge keeps its
deterministic template — so a missing receipt costs the user nothing but the
model's phrasing. BYOK, local and ChatGPT-OAuth chains stay ungated; that
egress is the user's own act.
Move the questions path off the read-then-write budget check onto the atomic
reserve/reconcile pair, so two concurrent saves can no longer both observe a
sub-cap spend and both call the provider. The cap now follows the chain's
cost owner instead of always charging the operator ceiling.
Three leaks of the same class - data leaving the process unprotected.
addMeta and addExternalCall wrote their values verbatim while setError
and setHttp scrubbed, so the builder's redaction contract held for two
entry points out of four. The finished event is serialised whole to
stdout and the log store, which made meta a trusted sink by accident: a
Telegram request timeout put the bot token there, and the AI provider
chain fed upstream error bodies through it. Both now scrub, at any depth.
Scrubbing cannot help with the second half. A gateway that echoes the
request it rejected echoes the prompt, and on that path the prompt
carries the health snapshot; no pattern separates that from any other
text. Upstream bodies are therefore classified rather than quoted - the
diagnostic question is which rejection occurred and whether it repeats,
which a classification answers without the content.
The idempotency replay cache stored response bodies unencrypted. The
create paths echo their own decrypted payload, so cycle notes, mood text
and allergy reactions sat in cleartext for 24 hours in a column that
lands in every backup; the existing guard missed it because it matches
secret shapes and health data is not secret-shaped. Bodies are encrypted
now, and when encryption is unavailable the response is not cached at
all rather than written in the clear.
chore(release): v1.30.18