A restored account got a Coach that had never spoken to it. The
conversations, every turn in them, and the record of which vault
documents a thread was grounded in were all classified as backed up and
travelled with nothing. The register's own entry for the messages called
them the largest volume of free text an account owns after its
documents.
The three travel together because two of them mean nothing alone: a turn
without its thread has nowhere to hang, and an attachment names a
pairing rather than a thing.
documentScoped is the field that makes this more than lost history. It
is a permanent fence — set when a conversation is created through the
fenced endpoint or gets its first document, and lowered by no code path
anywhere, not by detaching every document and not by deleting one. A
restore that let it default to false would hand the tool loop back to
precisely the conversations the fence exists to keep away from it, and
nothing in the interface would look wrong afterwards. So it is carried
verbatim, restored verbatim, and asserted per thread rather than in
aggregate: a restore that wrote false everywhere would still return both
threads and every turn and satisfy every count.
Prose follows the contract the rest of the payload already uses. A
disaster-recovery file carries the stored ciphertext; a portable file
carries the text, because a portable export exists to be readable by the
person who owns it, exactly as it already decrypts medication notes,
mood notes and document summaries. A single row under a key the instance
no longer holds is named rather than thrown, so one unreadable turn
cannot cost someone the rest of the transcript.
Ids ride in both modes, as the reminders do, so an attachment resolves
and the facts, plans and reminders still owed have something real to
point at. The restore runs after the documents for the same reason, and
an attachment naming a document the file does not carry is dropped and
reported instead of failing the whole file over one link.
Verified by breaking it: forcing documentScoped to false on the way back
fails the round trip on that field by name.
The titration history was classified as backed up and travelled with
nothing. An account restored from a backup showed the current dose and
no record of how it got there: the ramp, the reductions, the note the
prescriber gave for each step. For a long-running drug that history is
often the more clinically interesting half.
Export writes the rows nested inside their drug, ordered by
effectiveFrom, so a portable restore that mints fresh ids needs no
remap. The note follows the contract the side effects already use: a
portable export decrypts it for a human reader, a disaster-recovery
export carries the ciphertext verbatim, and a restore never writes it
into the legacy plaintext column.
Order is the content here, not decoration. Two rows on the same drug in
the same unit, 5 mg then 10 mg, describe a ramp; the same two rows
returned in the other order describe a taper. Counting them back cannot
tell those apart, so the round-trip test asserts the sequence and the
values, not the count.
MedicationDoseChange moves from the coverage-pending register into the
two-ended list, which makes the round-trip registry incomplete until a
row is seeded and counted. That is a compile error, not a review note.
`MedicationPauseEra` was classified as backed up and travelled nowhere. The
spans where a drug was deliberately paused are the difference between "stopped
taking it" and "was told to stop taking it", and nothing else in the payload can
reconstruct them: an era is a decision, not an observation, and no intake row
records its own absence.
Without them the compliance recomputation reads a deliberate pause as missed
doses, so a restored account looks non-adherent for a stretch during which it
did exactly what it was told.
Both ends land together. The eras ride nested inside their medication, like the
schedules and side effects beside them, so a portable file that mints fresh
medication ids needs no remap — Prisma binds each child to whatever id the
parent actually got.
## The open era is the point
`resumedAt` is nullable end to end and the writer always emits it. An open era
means the drug is paused right now, and readers run it to the present. The
restore passes the null through rather than closing it at restore time: a pause
that was still running when the backup was taken is still running afterwards,
and closing it would silently turn an ongoing decision into a historical one.
Counting alone cannot catch that — a restore that closed the era would still
hand back two rows. So the round-trip test seeds one closed era and one open
one and asserts both field by field.
## What is proven and what is not
Moving the model out of `COVERAGE_PENDING` and onto `TWO_ENDED_MODELS` makes
the round-trip test's registry incomplete, which is a compile error. Verified
by watching `tsc` fail with TS2741 before the count-back was written: the
coupling the plan's comment describes does hold.
The round-trip test itself needs Postgres via testcontainers and has not been
run here. Typecheck, lint, format and the 21458-test unit suite are green; the
integration proof comes from CI.
`withIdempotency` has made writes safe to retry since v1.5.x. It was described
in route comments and in no schema, so an app generated against
`docs/api/openapi.yaml` could not learn that a retry was safe. The iOS side
asked for it; the mechanism does not change, only what it says about itself.
34 operations now declare `Idempotency-Key` and the `X-Idempotent-Replay`
header, plus the 409 that refuses a duplicate still in flight. The description
leads with the one silent failure in the mechanism: a key that misses
`^[A-Za-z0-9_\-:.]{8,128}$` is IGNORED rather than refused, so the write
proceeds unprotected and looks exactly like a request that carried no key.
## What the work turned up
Three things the contract had wrong or missing, none of them the thing being
built:
- `/api/insights/chat` describes `withIdempotency()` in its header comment and
applies it nowhere. The Coach chat is not retry-safe. Its prose says it is.
- `/api/measurements` exports GET, POST, PUT and DELETE and wraps only POST.
Publishing the header per PATH would have promised a safe retry on two
methods that do not honour one — the dangerous direction, so the guard works
per OPERATION.
- `/api/insights/feedback` and `/api/admin/backups/{id}/restore` are not in the
document at all. Both are named in the guard's exemption list with a reason,
not filtered out silently.
## The guard
`idempotency-contract-publication.test.ts` reads the expected set out of the
route tree on every run — nothing is hand-listed, so it cannot fall behind the
code. It fails in both directions: a new wrapped route that forgets to publish,
and a published claim no handler honours.
A fourth assertion checks the exemption list itself is still true. It earned
its place immediately: the first draft exempted the whole `/api/medications`
family on the strength of a grep for path literals that came back empty. The
paths are not written as literals there; the emitted document carries 19 of
them. The test went red, the claim was wrong, the list is two entries now.
Measure the artefact, not the source that builds it.
The guard pins the installed version alongside the three source facts the
72-hour reader depends on, so a dependency bump has to stop and be looked at
rather than sliding past. It did its job here: 12.27.0 tripped the version
line.
Checked the release against 12.26.0. The retention plan is untouched, all
three assertions still match the shipped dist/plans.js verbatim. What moved
sits in the fetch path (group concurrency reworked to a keyed JSONB lookup,
schema names now routed through normalizeSchemaName / resolveSchemaName) and
in start_after, which becomes inclusive so a job written with the default
start time is fetchable on the next statement. None of that touches the
failed-row ledger.
So the assumption holds and the pin moves with it.
The hero card on the start page told anyone without a sleep source, every
morning, that last night's sleep was not in yet. The freshness rule only
asked whether last night's sleep had landed, so a record that has never had
a night in it, or whose owner has stopped wearing the tracker, read as
permanently waiting. Not everyone owns one, and people put them away.
Waiting now requires that sleep is actually reaching the record: a night
within the last seven days. A flat battery or a few nights without the
tracker keeps the note, since tonight's sleep really is still expected. A
week of silence retires it.
The same rule settles the day itself. It used to park on `provisional`
forever for those records, which the daily push line reads too, so the
notification carried the same untrue "last night's sleep is still missing"
aside.
Two existing tests pinned the old behaviour by name ("is provisional when
sleep has never been recorded", "stays provisional when sleep never
arrives"). They described the defect rather than a contract worth keeping,
so they now assert the opposite, and a third covers the boundary between an
ordinary gap and a source that has gone quiet.
The schemas stay: the insight result still parses recommendations
through them. Only the exported aliases went unused once the card that
consumed them was removed, and the dead-code gate says so.
The production dead-code check has been documented rather than enforced
since PR #691, because its list mixed real debt with false positives.
Triaged against the shipped tree, six of them are genuinely unreachable
and go now.
Five died when their host was retired and the leaves were left behind:
the recommendation card with its confidence meter and feedback control,
the grid that mounted them, and the suggested-prompts chip strip that
left the hero band in v1.18.7. The sixth, the comparison toggle, has had
two regression tests pinning its absence since v1.4.22, because the
preference it once set is a Settings concern now.
Deleting them freed sixteen translation keys in every locale, a pinned
entry in the success-affordance register, and two entries in the Coach
gate fixtures. Each of those was found by a guard rather than by
reading, which is the point of having them.
The adapter under signals/adapters stays: knip reports it as unreachable
because only a test imports it, but that test is the registry invariant
it exists to prove, and deleting it would remove a check rather than
dead weight.
Two findings from the deferred-audit list, both live since July.
A FHIR lab quantity stamped the UCUM system unconditionally while the
code was only added when the analyte map resolved a symbol for exactly
that unit. A receiver was told the unit came from UCUM and then handed
no UCUM code, which is a coding claim the bundle cannot back. System
and code now travel together, on the value and on both reference-range
bounds.
The doctor-report export accepted a selection that admits nothing. The
panel disables its own submit in that state, so only a hand-built
request reached it, and it rendered an empty artefact while spending a
rate-limit slot. This route has no documents-only mode that would make
an empty leaf list a legitimate scope, so it refuses with 422. The
share-link route, which does have that mode, is untouched.
Apple Health exports write ECG CSVs in two shapes. The importer only
understood the paired Lead,Voltage form; the single-column form (Lead
and Unit as metadata rows, a blank separator, then one bare reading per
line) failed every row as malformed and the whole recording was skipped.
The parser now treats a blank line after Lead and Unit metadata as the
start of the waveform, reads one bounded numeric sample per row, and
converts the declared unit to integer microvolts before the existing
amplitude and safe-integer bounds. The paired layout parses exactly as
before, and an unknown unit fails with a fixed message.
Refs #794
The rewritten notification-status description ran to two sentences and
130 characters — the card-description guard is right, the slot holds one
meta sentence. Folded to one em-dash sentence in every locale. The new
documents_retokenised job fact joins the outcome allowlist with its
reason.
The knip blind spot (ignoreExportsUsedInFile) left eight symbols carrying
an export nothing outside their file consumes — the modifier goes, the
symbols stay. The score series module goes whole: its only importer was
its own guard test, whose composite- and config-guarding suites survive
in place.
DocumentContentIndex.tokenizerVersion gains the consumer that was the
point of storing it: the content-index job now re-tokenises rows whose
version trails CONTENT_TOKENIZER_VERSION, from the decrypted index text
along the same path the rotation script uses, keyset-paginated and
fault-isolated per row — a future tokenizer bump migrates the index
instead of silently splitting it. ImportJob.uploadSha256 turned out to
already have its dedupe consumer (both kick-off routes short-circuit on
same-bytes non-failed jobs); its comment now names that instead of
pretending to be write-only. The remaining write-only columns carry
their honest annotations in the shape the audit sweep parses.
The entries gained a soft-delete this release; both backup ends follow in
the same one. A portable export omits tombstoned readings so a restore
cannot resurrect them; a disaster-recovery payload carries the tombstone
and the restore writes it back, so the account returns exactly as it
stood — undo affordance included.
Four small debts from the audit tail. The FHIR bundle's missing
Immunization resource gets the same written-decision paragraph the visits
exclusion carries — deliberate follow-up, not oversight. The service
worker's offline allowlist gains /api/daily/digest (both copies, policy
module and sw.js, plus the test) so the Today rail renders on an offline
reopen. The two Telegram plumbing tables that link to an account by a
bare userId string are frozen as a closed NO_FK_BY_DESIGN set in the wipe
guard — a third such table now fails the test instead of passing rule 1
silently. And deploy.md documents the manual digest-pin bump end to end:
imagetools inspect, the env update, and the three-probe /api/version
verification with the too-fast-deploy warning.
The restore dialog asked for its typed confirmation on nothing but a
filename and a date; the counts only surfaced in the audit row after the
rows were already replaced. A read-only summary endpoint now decrypts and
schema-validates the stored file through the same helpers the restore
itself runs and answers the summarizeBackup counts, and the dialog shows
them — per section, with an other-records line so a future summary key
can never silently vanish from the preview. A failed preview states its
own absence and never blocks the restore.
Refs A3-8. The per-item PHQ-9 / GAD-7 / WHO-5 / SCI answers have been
stored encrypted since the assessments shipped, and nothing ever read
them back — the clinical detail (item 9 included) was invisible. A new
by-id read decrypts them for whoever holds the mind read grant (the same
grant that already serves the mood diary's prose and the item-9 flag),
degrades to an honest items-unavailable state on any decrypt or shape
problem, and the history rows gain an expandable breakdown rendering the
official instrument texts the check-in flow already carries — nothing
retranslated, no alarm styling on item 9 beyond what the house already
does.
Rides along: the guard inventories learn the branch's new routes (frozen
counts moved with reasons), the reminder-engine test harnesses carry the
ledger delegate, and the custom-metric restore toast keys sit in their
right namespace in all six locales.
The hero card composition was fixed: score panel first, the worth-a-look
rail beneath. A reminder-driven person asked for the reverse. The stored
dashboard layout gains an optional hero field ('score' default,
'reminders' promotes the rail into the hero slot), validated as a closed
enum, preserve-when-absent on write, omitted from the blob at the default
so untouched accounts serialize byte-identically. The choice sits in
Settings beside the other hero controls; the promoted rail degrades to
the calm all-clear line when nothing is due.
Refs #778. Two honesty gaps from the first-run report: the web onboarding
presented Apple Health like a web connector although the connection only
completes in the iPhone app, and the initial backfill gave no sign of
progress, so a working first sync looked broken.
The onboarding source step now says plainly that Apple Health is set up in
the iPhone app after onboarding, without dead-ending the flow. The
integration card gains a server-authoritative progress summary — rows
accepted, oldest reading reached, and an honest flowing/waiting line
derived from the last accepted batch. Nothing the server does not know is
shown: no percentage, no ETA, no invented throttle state; a quiet stretch
reads as waiting for the phone, not as an error.
The reminder cadences were the oldest entry on the coverage-pending
register: carried in name, restored never — an account came back silent
until every reminder was rebuilt by hand. Both models are two-ended now,
in the house shape (both ends in one file, reminders-backup.ts): every
column travels, nextDueAt verbatim rather than recomputed, the completion
ledger beside it, tombstones in disaster recovery only, and a portable
export omits a tombstoned reminder together with its ledger rows.
Encounter.reminderId and VaccinationRecord.reminderId stop being dropped
on restore: both remap against the restored reminders and fall to NULL
with a skip report only when the file genuinely lacks the row. The wipe
plan names the ledger child-before-parent; the classification guard,
completeness guards and the integration round trip all demand the new
coverage (proven failing with the section removed).
Refs #223
Document search does whole-token equality over a blind index, so
'Blutdruck' never found the discharge letter that says 'blood pressure'
and 'hypertension' missed every German Arztbrief. A curated German/English
medical synonym list (vitals, common labs, imaging, specialties, document
words — static, no vendor names) now expands the QUERY tokens before they
are hashed: the stored index, its tokenizer version and the schema are
untouched, the expansion happens ahead of the one-way hash, and recall
simply widens. Typed tokens always survive the expansion cap.
Refs A3-11. Deleting a logged custom-metric value was the one hard delete
left on an entry surface — everywhere else a deletion tombstones and the
toast offers Undo. The DELETE verb now stamps deletedAt, every read
(list, latest-value, counts, revive check) filters the tombstone, and the
new entries/restore endpoint clears it scoped to owner and parent metric —
a foreign, live or mismatched id is a silent no-op, never an existence
leak. The history table's delete toast carries the Undo action.
Refs #223. A due Vorsorge item could only be done, deleted or ignored;
Telegram's postpone moved the date with no trace. Both decisions are
first-class now, and both are honest:
- POST /{id}/skip (no body): the interval restarts from the skip instant
through the recurrence engine, lastSkippedAt is stamped, skipCount
increments, any snooze clears. lastSatisfiedAt is NEVER touched — done
and skipped stay distinguishable forever, enforced in the primitive and
pinned by tests.
- POST /{id}/snooze ({until: YYYY-MM-DD}): the server resolves the day to
the notifyHour in the profile timezone and moves snoozedUntil and
nextDueAt to the same instant, so every due-state consumer follows
without knowing snooze exists. At least tomorrow, at most five years.
- GET /{id}/history: the new append-only completion ledger
(MeasurementReminderEvent), one row per satisfy from every engine path
and per skip, onTime derived at write time against the due instant that
was current when the event landed.
Two anchor fixes close the backdated-event gaps: the auto-resolve floor is
now the later of last satisfy and last skip (an old reading synced late no
longer resolves the cycle the user just skipped), and a backdated real
result re-anchors the next due at the later of its own instant and the
skip decision.
Screenings stay skippable and snoozable — the satisfy-side 409 guards
claimed fulfilment; a skip claims the opposite. Appointment reminders 404
on all three routes, in the lookup, like every by-id sibling. /checkups
gains the postpone sheet (quick chips, date field, the skip zone with its
own confirm) in both view branches, with muted meta lines and no card
colour change. The MCP preventive-care tool carries the resolved fields;
the OpenAPI contract publishes all three paths and the widened DTO.
Ten operations the spec owed: the seven mood-entries routes (list, create,
read, edit, delete, restore, bulk-delete) whose /bulk twin was already
published, GET /api/mood/linked-context (a cross-section read through the
mood URL that grant-scope reviewers need visible), and POST /api/import
(the spec documented polling an import job with no way to create one).
Every schema mirrors the handler's own Zod source; the envelope, refusal
and error semantics are read off the routes, not invented. Request-body
ids are bound instances now — a bare .meta() call clones in Zod 4 and
never reaches components.schemas.
- The record-settings contract, slug-list, and section-frame tests
follow the slugs that left the registry (layout children resolve
their headings from the layout namespace, like production).
- The orphaned settings.sections.<module>.title/description keys leave
all six locales; the reverse-coverage guard flagged them the moment
their nav entries stopped existing.
- The operator-cap ledger read fails open on a read error — the
reservation layer still meters against the same ledger, and a
transient blip must not take the fallback chain down.
- The reaper queue joins the subsystem surface; its healed count and
the sweep's nag_dismissed join the job-fact vocabulary.
- The intake-event contract fixture carries the full row the resource
now declares; the workouts empty-state test asserts the
source-neutral cue; the persister poll gates on rendered content
first.
- The tiered AI context's band reader now falls back to a live grouped
aggregate when a rollup band is empty (the module doc promised
replace-with-fallback from day one; only the replace half existed), so
a coverage-miss or never-folded YEAR partition no longer hands the
model empty history. Far-from-UTC users get the day-grain band keyed
on their own calendar (the same ±3h guard the correlation readers
carry).
- The slim analytics slice splices measurements older than the 5-year
fold window into its all-time count/min/max/mean: the rollup tier
only covers the window, so "all time" silently truncated for long
histories while the live fallback told the truth. A type whose only
rows are pre-fold no longer reads as never logged.
- The restore summary counts practitioners, encounters, the three link
tables, and vaccinations — restored in full since 08-01 but absent
from the report, so a file carrying visits or an Impfpass read as if
those sections were empty.
- A portable export that cannot open a field's ciphertext still nulls it
fail-soft, but now names the loss in the file's own decryptFailures
manifest — an export with a lost emergency note is no longer
byte-identical to one where the note was never written.
- A timezone change enqueues the medication-compliance re-fold: the
rollup keys its rows by the local day minted under the OLD zone, and
the coverage probe compares counts, not keys, so nothing else could
notice the stale bucketing.
- The measurement-reminder tick claims its slot in the record-event
ledger BEFORE any provider egress (the documented claim-first pattern
from reminder-dedup.ts); a crash between dispatch and the nextDueAt
advance can no longer double-send.
- mood-entries/bulk classifies a lost P2002 race as the duplicate it is
(with the winning row id) instead of a skip carrying a raw Prisma
message — the row IS stored, the racing twin wrote it. The workouts
batch already re-derives per-entry status from RETURNING.
- The vault list's content-index and thumbnail probes ride the same
Promise.all as the link loads: four independent grouped queries, no
serialized round-trips on a 4s-polled list.
The six measurement routes carried eight copy-pasted rollup-recompute +
status-invalidate blocks — the documented two-ended failure class
waiting for the next surface to remember one leg and forget the other.
All of them now call afterMeasurementMutation (the helper the deletion
fix introduced), and the two write surfaces that bypassed the hook
entirely join it: the nightly Recovery/Stress/Strain score rows and the
PHQ-9/GAD-7 assessment projection now recompute their (type, day)
bucket on the tick that writes them.
- The provider-chain walker now checks the day's recorded spend against
OPERATOR_COST_CAP before invoking an operator-funded admin-* entry. A
request whose primary provider runs on the user's own key reserves
under the generous user-plan ceiling; a fallback hop (or health-ledger
reorder) onto the operator's key could then spend past the operator's
cap unchecked. An exhausted cap skips the candidate like a failed hop.
- summaryState PENDING gets a giver-upper: the detail serialiser
degrades a PENDING older than an hour to UNAVAILABLE (which offers the
manual generate action), and an hourly reaper persists the same heal
following the orphan-ImportJob reconcile precedent — a job dead past
its retry budget no longer leaves the sheet saying "generating"
forever.
- MedicationIntakeEvent declares the full row both intake responses
actually return: autoMissed, attributionSource, injectionSite,
doseTaken, inventoryConsumption, externalId, updatedAt, syncVersion,
deletedAt were served but undeclared.
- GET /api/medications/{id}/intake is published (query schema, paged
envelope); the spec only knew the POST while the client-facing
history list has consumed the GET since v1.4.37.
- WorkoutListEntry's sportType/startedAt/endedAt/durationSec become
nullable, matching the DTO — declaring them non-null was a
strict-decoder bomb for the Swift client.
- OpenAPI regenerated.
A medication whose slots carry different doses (a whole tablet in the
morning, a half at noon — #219) was consumed correctly but reported
wrongly: the wire shipped only the raw nullable per-slot column (clients
had to derive the inheritance), stockDosesRemaining divided by the
medication-level units alone, and no runway figure existed outside the
low-stock push.
- Every schedule now carries resolvedUnitsPerDose, resolved server-side
exactly like the intake consumption resolver.
- stockDosesRemaining divides the units pool by the schedule-weighted
average units per dose.
- runwayDays (whole days of supply under the slot-aware burn rate) rides
the list AND detail payloads; the detail GET also gains the stock
aggregates the list already had.
- The low-stock engine runs the same slot-aware math, so the push and
the wire cannot disagree; the web cards, table, and detail Übersicht
prefer the server figure over their client estimate.
- OpenAPI regenerated with the new fields.
- The Coach verbosity picker is disabled while the concise tone forces
brief, with a hint saying so, instead of a live control that silently
does nothing.
- A fresh document upload flashes its new row like the duplicate path
always did; the settle decision is a pure exported helper with a test.
- /about gains its two inbound links: the /privacy footer and a
Settings→About credits row (the GeoLite2 EULA wants the attribution
reachable from the running app).
- The eight settings slugs shadowed by permanent redirects (the seven
layout children + sharing) leave the slug registry: no more
prerendering unreachable pages, no dead component wirings, and the
SectionPlaceholder fallback that could never render is gone. The
redirects, section components, and layout-hub subpages are unchanged.
- The daily Coach sweep now charges each ignored day against
surfaceCount and auto-dismisses at three — the cap the schema comment
promised since v1.22 but nothing enforced.
- The welcome gate compares the stored disclaimer acknowledgment
version against the current one, so revised disclaimer text
re-prompts; /api/auth/me carries the version for the client.
- slope90 and anomalyCount leave the analytics wire: slope90 had no
reader anywhere (computed on every request, in SQL twice), and the
slim path could never compute anomalyCount (it always reported 0).
anomalyCount stays as an in-process field on summarize() for the
insights feature builder, which is its only real consumer.
The evaluator stubs hoist properly for vitest's factory rewrite, the
WHOOP webhook route test's prisma double learns the pre-image read the
delete branch now performs, and the connector-token card description
shrinks to the one-sentence, 120-character shape the card guard
enforces — still saying what the mint truthfully does.
The bearer MINT matcher demanded a literal apiToken.create( in the very
file that documents how a whitespace-wrapped call walked past its T1
sibling; it is whitespace-tolerant now, asserts a non-zero match count,
and the always-true filter beside it is gone. The T1 resolver matcher no
longer demands tokenHash first in the where object. The English-leak
guard pins a non-zero key floor per bundle so a broken walker cannot
pass as no-leaks-found. The two audit-row integration suites poll for
the fire-and-forget rows instead of sleeping a fixed span, with one
deliberately widened grace kept for the absence assertion. And the
fixture fuses with 2027 horizons (suggested-actions next-due, the
printed-expiry trio) are now-anchored so they can never age into
inverted assertions.
The Withings arm of the backfill deleted the source's entire
SLEEP_DURATION history while its re-sync fetches only the trailing 30
days regardless of fullSync — every older night was raw data loss with
orphaned rollup buckets on top. The delete is now bounded to the
re-sync window and the touched rollup span is recomputed afterwards;
WHOOP keeps the whole-history clear because its full sync genuinely
re-imports every night.
api-handler imports the record-session fence and the fence threw
RecordSessionChangedError / SharingAccessDeniedError imported back from
api-handler — the one real import cycle in the tree, working only by
evaluation-order luck. HttpError, the SharingAuthError family and
StepUpRequiredError now live in api-errors.ts (importing nothing but
the fence contract), api-handler re-exports the identical class objects
so no importer moves, and the fence imports the leaf. A guard pins both
the identity and the leaf structure.
Every apiHandler route accepts the header and the wrapped write routes
dedupe on it, yet almost no client call site sent one — a replayed
create could land a duplicate dose or vaccination and shift the series
index and booster-due date behind it. apiPost and apiPatch now mint a
per-call crypto.randomUUID() key unless the caller set its own; reads
stay bare.
get_preventive_care passed a reminder's label and location — the user's
own free text — unfenced beside a fenced sibling, and get_lab_history
shipped the document-derived canonical analyte raw while sanitizing the
valueText next to it. Both are the marker-forging class the fence
doctrine exists for: fence label and location like the appointment
reason, scrub and sanitize the analyte like the default labs path.
Both hourly sync tails left the per-user analytics, correlations,
targets, derived and achievements caches serving pre-sync bodies until
their TTL lapsed — the correlations route documents the exact invariant
these tails were breaking. Mirror the Fitbit tail: mark the cells stale
whenever rows landed, on partial failures too.
tsc rejects spreading unknown[] into a zero-parameter vi.fn; give the
context-evaluator stubs a variadic signature and cast the recorded call
tuples where the tests read them back.
Three silent gaps in the import accounting, one commit:
The reproductive samples of an export land in recordsRead but were
discarded without a counter when cycle tracking is off — the result now
names them (samplesSkippedModuleDisabled). A cycle flush that failed
partway zeroed its stats AFTER day-by-day writes had landed; the flush
is now fault-isolated per day and reports daysFailed plus the first
failure reason instead of claiming nothing happened. And the import
card hardcoded skipped: 0 while the worker has computed, persisted and
served per-class skip counters since v1.15 — it now renders them,
splitting deliberate exclusions (unsupported and deferred types, a
disabled cycle module) from genuine refusals (unparseable, out of
range, failed writes), with a per-type breakdown. Only refusals tip
the outcome tone.
The evidence disclosure stopped honouring showEvidenceByDefault in
v1.4.27 and the owner surface retired the control with it — but the
managed-record coach form still offered the switch, silently governing
nothing. Remove the control, the managed-schema pick, the response
field, and the label in all six locales; the strict schema now refuses
the key outright.
A Coach reminder captured with NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED,
NEXT_SLEEP_LOGGED or NEXT_APP_OPEN confirmed to the user and then never
fired: the daily sweep skipped context triggers and no other evaluator
existed.
Build the evaluator. The three logging cues resolve on evidence — a
live matching-type measurement logged after capture — and ride the same
eventful reminder-satisfy hook every ingest path already fires, with the
daily sweep re-running them as the idempotent backstop. NEXT_APP_OPEN
resolves on the nudge-status read (the poll that mounts with the app
chrome), owner-only so a delegate's visit never satisfies it, with a
capture grace so the session that created the reminder does not count as
the next open. Every trigger delivers through the surfacing transaction
extracted from the sweep: conversation + assistant message + status
flip, all-or-nothing.
The iOS tracker was told both endpoints were in the spec; neither was
registered. Publish POST /api/vaccinations/{id}/booster (201 mint /
200 re-anchor with the resolved MeasurementReminder DTO) and
GET /api/vaccinations/suggest (the server-decided one/many/none
verdict), and regenerate the YAML. A structural guard now derives the
expected path set from the route files so the next vaccination route
cannot ship unpublished.
The biomarker catalog mints name and unit from labResult.analyte —
model-transcribed free text out of an uploaded document. Both rode into
the status generator raw, the name in system-prompt position, so a
hostile PDF could smuggle instructions past the fence the coach
labs-snapshot already applies to the same fields. Sanitize both at the
snapshot and the system-prompt site.
A soft-deleted measurement never reached the rollup tier: the WHOOP
delete webhook tombstoned rows without recomputing the dirtied buckets,
and the freshness watermark probe filtered tombstones out, so the
deletion's updatedAt bump was invisible and the ghost aggregate read as
fresh forever.
Extract the post-write tail every measurement surface repeats into
afterMeasurementMutation (rollup recompute + status-insight
invalidation, both best-effort), call it from the WHOOP delete webhook
with the (type, measuredAt) pre-image collected before the updateMany,
and drop the deletedAt filter from the watermark probe so a tombstone
counts as staleness.
Both paths read identical fixtures — per-(day, source) DAY rollup rows
derived from the same raw rows the fallback reads — and must produce
equivalent daily series: the plain daily-mean case, the multi-source
day (the all-source weighted mean, never a ladder-collapsed single
source), and the pre-sum_value mean-times-count compose. A far-from-UTC
profile timezone must force the raw path so day keys stay profile-tz
keyed; the near-UTC midnight band's single-day attribution shift is
pinned explicitly so it stays a documented decision rather than drift.
Coverage misses and empty in-window bucket reads fall back per channel;
sleep and the cumulative types never enter the rollup query.
Every assertion family was broken on purpose and observed failing with
the site named before restoring green; the record sits in the test's
doc comment. The route test mocks the new coverage probe and bucket
read as empty so every channel rides the raw path unchanged there.
The discovery scan read every raw measurement in the 180-day window for
all nine measurement channels and bucketed them into daily means in JS,
paying tens of thousands of rows on every cache miss (a CGM glucose
channel alone can exceed the read cap). The DAY rollup tier already
stores those per-day aggregates, so eligible spot channels now read one
pre-aggregated row per day per source and compose the all-source
count-weighted mean — the exact reduction toDailyMeans applies.
Read-swap replaces the raw read per channel, with fallback-on-miss:
structural ineligibility (SLEEP_DURATION's per-night reconstruction,
the cumulative types' device-type collapse axis the rollup rows do not
carry), a far-from-UTC profile timezone (the v1.4.38 W-A near-UTC
guard), a coverage-probe miss, or an empty in-window bucket read all
route that channel to the existing raw path. The SWR cache cell stays
in front; the swap only lowers the cost of a cache miss.
placeholderData keeps the previous range painted while a health-chart
range switch fetches, paired with its truth condition: the container
dims and goes inert while isPlaceholderData is true, so the old range
cannot masquerade as the new one. The mood chart needs no pairing — its
range is a client-side slice over one static-key query and never drops
to a skeleton.
/checkups and /mood gain the dashboard-template RSC prefetch: the
primary list read moves into a shared lib read (route + wrapper consume
one query, so filter/order/wire shape cannot drift), the wrapper honours
the DASHBOARD_SSR_PREFETCH kill-switch, resolves identity through
getUnswitchedSession, JSON-round-trips the dehydrated state, and fails
soft to the bare client on every error path. Page-prefetch unit tests
pin the seeded keys and the fail-soft arms; the SSR hydration spec
gains an assertion per page.
The achievement-unlock poll drops to five minutes (in-app writes already
invalidate the cell; the poll only covers out-of-band ingest), and the
plan-proposal poll gains the advisor-style bounded attempt ceiling so an
open thread stops polling after its budget.