Commit Graph
4920 Commits
Author SHA1 Message Date
Marc-André Bombeck e7fdcc208e test(targets): pin the route over the folded glucose and heart-rate reads
The route test mocked Prisma and asserted a raw glucose findMany. The
glucose and resting-pulse inputs now come from their SQL readers, so the
test mocks those, feeds the pinned response through the in-memory fold,
and asserts that neither stream is read as rows.
2026-10-01 08:54:20 +02:00
Marc-André Bombeck b2c484e25e fix(series): bucket heart-rate variability and blood oxygen like pulse
The dense rule in /api/measurements/series covered pulse and glucose
only. Heart-rate variability and blood oxygen read every raw row for
any window up to 3650 days, and a watch or ring that writes them every
minute makes that the same unbounded read pulse had.

Both kinds now fold per local day beyond 90 days and per local hour
inside 90 days once the window holds more than 10 000 readings. A
bucket keeps the point shape of its kind: only pulse carries
valueMin / valueMax. Statistics stay computed over every reading, and
a sparse series inside 90 days is unchanged. The OpenAPI description
names the four kinds.
2026-10-01 08:52:10 +02:00
Marc-André Bombeck 8949606de9 fix(cycle): read the insights crosstab as per-source day aggregates
GET /api/cycle/insights read a year of raw readings for its outcome
channels, glucose and heart-rate variability included. On a sensor
account that is over 100 000 objects per request.

The crosstab uses each local day's sum and count after picking one
source and device per day, so the route now reads readSourceDayAggregates
like the coach's cycle snapshot does, and each row carries its reading
count into metricDayMap.

The integration test runs the route over the fold and over every raw
reading handed in as a one-reading group, and compares the answers.
2026-10-01 08:50:31 +02:00
Marc-André Bombeck 7f2349e56f fix(targets): fold the glucose year and the heart-rate month in SQL
The targets page read a year of raw glucose readings to keep the newest
one per meal context, and thirty days of raw heart rate to derive one
resting figure per day. A sensor reporting every five minutes puts
105 000 glucose readings in that year and a watch recording once a
minute puts 43 000 heart-rate readings in the month, and the page is
read on every visit from the web and the app.

Glucose now reads one row per context (DISTINCT ON) for the newest
value and one row per context and local day for the thirty-day mean
and the consistency strip; the strip takes day sums directly through
rollupConsistencyFromDays. Heart rate reaches the resting-pulse card
through readRestingPulseProxy, the SQL fold the insights page already
uses, merged per day with the native resting rows exactly as before.

The cards are unchanged: the integration test rebuilds them from the
raw readings through the in-memory folds and compares.
2026-10-01 08:47:18 +02:00
Marc-André Bombeck 02be4d3dff Merge branch 'main' into fix/ocr-review-date-validation 2026-10-01 00:20:48 +02:00
Marc-André Bombeck c777e9e9e4 Merge pull request #1065 from MBombeck/fix/v1396-small-fixes
Quick-add follows intakeActionable; hourly buckets for dense CGM inside 90 days
2026-09-30 23:57:44 +02:00
Marc-André Bombeck 20752fb4bb Merge pull request #1066 from MBombeck/fix/backfill-lane-expiry
Keep a full-history import from running beside its own retry; tombstone purge steps around a restore
2026-09-30 23:46:16 +02:00
Marc-André Bombeck 85d11bc364 Merge branch 'main' into fix/v1396-small-fixes 2026-09-30 23:27:25 +02:00
Marc-André Bombeck a5d19bfe98 Merge pull request #1064 from MBombeck/fix/backup-summary-manifest
Answer the restore preview from counts kept with the backup
2026-09-30 23:25:26 +02:00
Marc-André Bombeck a0c290e140 fix(jobs): let the nightly tombstone purge step around a running restore
A restore clears every row of the account, tombstones included, inside one
long transaction under the account's exclusive restore lock. The nightly
purge deleted expired tombstones across accounts, one statement per batch,
without that lock: on an account under restore it waited on the restore's
row locks for as long as the restore ran, past its statement timeout, or
deadlocked with it once each side held rows the other needed.

Each batch is now deleted one account at a time, in a transaction that
first takes the restore lock in shared mode, the same order the
consolidation passes keep. An account under restore is refused at once,
kept out of the rest of the run, and purged the next night. Still one
delete per account per batch, never one per row. The job reports how many
accounts it deferred.

The integration test holds a stand-in restore on one account while the
purge runs: the purge returns without waiting, leaves that account alone
and purges the other. With the lock call removed it waits out the hold.

Refs #1031
2026-09-30 23:11:03 +02:00
Marc-André Bombeck 029d6d881c fix(jobs): keep a full-history import from running beside its own retry
The shared admission queue sent its jobs without an expiry, so every one
took pg-boss's fifteen minutes. Against pg-boss 12.34, a handler that runs
past its expiry is not stopped: the job is failed, its abort signal fires,
the worker and group slots are freed, and the retry of the same job starts
in the same worker beside the run it replaced. A per-minute heart-rate
history is thousands of pages, so on a slow link one import turned into up
to four copies of itself, on the one queue meant to run a single import at
a time.

- The admission job now carries a sixteen-hour expiry, sized for the
  longest legitimate import and under pg-boss's 24-hour ceiling.
- Each run holds a session advisory lock on its identity (kind, provider,
  account) on a connection of its own. A delivery that finds it taken does
  nothing and says so in the log; the running import stamps its marker or
  is offered again at the next boot. Ending the connection or the process
  releases the lock.
- The Google Health backfill runs under the job's budget and abort signal.
  The page walk asks before each request, stops there, and reports the run
  truncated, so it throws without the completion stamp and is retried.
- The drain moves out of the registrar into its own module so it can be
  driven against a real pg-boss.

The integration test pins the pg-boss behaviour and runs the lane's
handler with a two-second expiry: without the lock the import ran twice.

Refs #1023
2026-09-30 23:11:03 +02:00
Marc-André Bombeck 731e073284 fix(measurements): hour-bucket a CGM glucose series inside the 90-day window
A sensor reading every five minutes puts about 26 000 glucose readings in
a 90-day window, and the per-kind series sent every one of them raw. The
hourly bucketing that already applies to a dense heart-rate stream now
applies to glucose too, with the same 10 000-reading cap: the hour's mean
converted once to the user's unit, statistics over every raw reading.
A fingerstick meter stays far under the cap and keeps its raw points.

The doctor report keeps glucose raw inside 90 days. Its clinical panel is
computed from the raw readings of every source, while the day path works
from canonical-source day buckets, so the two do not give the same figures
when a meter and a sensor overlap.
2026-09-30 23:01:57 +02:00
Marc-André Bombeck ca10f2e5df fix(medications): dashboard quick-add offers only doses the server calls actionable
The dashboard and capture-picker intake form filtered the medication list
on `active && trackIntake`, so a course that had ended (still active,
nobody paused it) was offered for a dose while the cards, the table and
"take all" already followed the server's `intakeActionable`. The picker
now renders from that one answer as well.

When medications exist but none offers a dose today, the empty state no
longer claims there are no medications; it says nothing is due and
points to the medication history for a past dose.
2026-09-30 22:58:19 +02:00
Marc-André Bombeck a2fceb5585 fix(backups): answer the restore preview from counts kept with the copy
The restore dialog asked GET /api/admin/backups/{id}/summary for the
contents of a copy, and the route read the whole stored copy to answer:
every piece opened, every measurement parsed, the rest validated. On an
account of 1.8 million readings on slow hardware that took about two
minutes. The dialog stopped waiting after 15 s and said the contents
could not be read, and its automatic retry made the server read the
whole copy a second time.

The copy passes through the process once anyway while it is written, so
the preview is worked out there and kept on the row (DataBackup.preview,
migration 0364): the summarizeBackup counts, the schema version, and
the inner key ids with the sections they sit in and one sample value
each. Every creation path goes through storeBackupBlob (the weekly and
manual pass, and an upload), so every new copy carries one. The column
is plain JSON: counts and dates only, and the samples are ciphertext
that stands for what is inside the copy, which a key rotation must not
re-seal.

The route answers from it without opening the copy. Both verdicts are
taken again on every read, from what the preview keeps: whether this
release restores the schema version, and whether this server holds the
keys the copy needs. The pieces are still counted against the row, so
a copy that lost one is refused as the restore and the download refuse
it. A preview names the copy it describes and is ignored once the copy
changes; converting a single stored value into pieces carries it over.

A copy written before this has no preview. It is read whole once, as
before, and the result is kept on the row, so it is slow only the first
time; concurrent requests for the same copy share the one read.

Only a file that fails the schema answers 422 "Backup payload failed
schema validation". Read refusals keep their codes; anything else is
the server failing and answers 500.

The dialog no longer retries the preview, waits up to 55 s, and when it
stops waiting on an older copy it says the copy is still being read and
to open the dialog again in a few minutes.

Refs #1031
2026-09-30 22:51:02 +02:00
Marc-André Bombeck e7166cb3c6 feat(tokens): mint a workouts:write token for headless workout ingest
A bridge that pushes workouts from a watch vendor had no credential that
reached POST /api/workouts/batch: the route called requireAuth() with no
scope, so under the fail-closed default only a cookie session or a
wildcard token got through, and a measurements:write token was refused
403. The route's header still described the pre-fail-closed behaviour.

Add a separate workouts:write scope rather than widening
measurements:write, so a token already handed to a scale does not start
accepting workouts.

- POST /api/tokens/workouts mints it, mirroring the measurement and
  document mints: cookie session plus a fresh proof, a literal permission
  array, 10 mints a minute, at most 10 live tokens (409
  tokens.workouts.ceiling_reached), a one-year default lifetime, audited.
- POST /api/workouts/batch names the scope. Sessions and wildcard tokens
  behave as before. A scoped caller's workouts are stored as EXTERNAL,
  resolved from the credential, and a batch in which any entry names a
  source is refused 422 (workout.batch.source_not_permitted), the same
  rule the measurement batch applies. EXTERNAL is on no source ladder, so
  when the phone recorded the same session the phone's copy is shown.
- Settings > API offers a workout token card next to the measurement one,
  and the endpoint list shows the batch route.
- Guards: the mint site, the declared scope vocabulary, the recent-proof
  routes, and a new check that only the batch declares the scope.
- OpenAPI documents the mint, the batch's narrow-token behaviour and both
  error codes; docs/api/README.md lists the workout and document mints.

Refs #1054
2026-09-30 22:21:46 +02:00
Marc-André Bombeck 1627aa6c57 fix(labs): translate the review validation messages and narrow a test lookup 2026-09-30 22:02:02 +02:00
BernhardBuckel eaced8226b fix(labs): block a lab-scan save while a selected reading is incomplete
The review dialog silently dropped any selected reading that could not be
written (no date, no unit, no value) and saved the rest. When nothing was
left it showed "Select at least one reading to save", which described a
different situation and said nothing about what was missing.

A save is now all-or-nothing over the selected readings. When one is
incomplete, nothing is written; the dialog states how many readings need
attention, marks each missing field (aria-invalid, inline message, red
border, required marker on the label) and moves focus to the first one.
Marks appear after a save attempt and follow the edits. The "nothing
selected" message is kept for that case only.

Validation is a pure function next to the payload mapping, so the rule that
blocks a save and the rule that builds the payload cannot drift apart.

Adds EN and DE strings; the other locales are left to the maintainers.

Refs #1055
2026-09-29 20:31:22 +02:00
Marc-André Bombeck add6d1fadc fix(restore): order the restore and the consolidation passes by one lock
A restore on a freshly booted instance failed its clearing step with a
deadlock (40P01) while the boot-time step consolidation was folding the
same account. A consolidation day updates or mints the day's total and
then soft-deletes the day's per-sample rows; the restore deletes every
reading of the account in one statement. When the restore reached a
day's samples before its total, each side held what the other needed
next.

The restore already took an advisory lock per account before its first
delete. Every day transaction of the passes built on runConsolidation
(step and mean consolidation, the per-sample cumulative drain, the dense
intraday retention) now takes the same lock in shared mode as its first
statement. A restore that starts mid-day waits for that day to commit;
a day that would start while a restore runs is refused at once, and the
pass leaves that account for its next run instead of folding rows the
restore is about to replace. The passes still share the lock with each
other.

A new integration test builds the lock order that deadlocked, with a
row trigger holding the consolidation on the day's total while the
restore starts; before this change the consolidation was cancelled with
a deadlock. A guard test requires every transaction in a pass on
runConsolidation to open with the shared lock.

Refs #1031
2026-09-28 00:34:11 +02:00
Marc-André Bombeck f2569b6fdf fix(restore): carry at most a hundred readings per insert statement
Prisma's debug logger keeps the arguments of its last hundred calls in
memory whether or not logging is on, and the client and the Postgres
adapter both log every statement with its parameters. A restore that
wrote 1 000 readings per statement therefore kept a copy of about the
last 100 000 readings alive for the whole measurement write: about
60 MB on 1.8 million Apple Health readings, next to a V8 heap limit of
524 MB in the default 1 GB container.

The bulk insert now splits whatever it is handed into statements of at
most 100 rows, which holds about 7 MB. The Apple Health export import
goes through the same helper and gets the same bound. The write costs a
little time: 94 s instead of 76 s for 1.8 million readings on a fast
host.

Refs #1031
2026-09-28 00:06:47 +02:00
Marc-André Bombeck 6836830038 fix(doctor-report): report readings, not day buckets, for a dense type
A type served from day buckets (pulse or glucose past 90 days, and since
the previous change a dense pulse stream on any window) is drawn one point
per day, each the day's mean at the day's start. The summary line counted
those points as measurements, and the FHIR export stamped the last day's
mean as the latest observation: a value and a time nobody measured.

The day-bucket stats now carry when their newest reading was taken
(`latestAt`). `latestReading` returns that reading when it is there, and
the summary line counts each type's readings from its stats. Raw series
are unchanged: their stats count equals their points and their last point
is the newest reading.
2026-09-27 21:20:24 +02:00
Marc-André Bombeck 3458100ac4 test: give the report's pulse-window count a mock and a typed route call 2026-09-27 21:16:20 +02:00
Marc-André Bombeck 0e991c3640 fix(measurements): fold a dense pulse stream inside the 90-day raw window
Past 90 days every pulse reader already folded in SQL. Inside it, three
still read every raw heart-rate row, which on a watch that records a
reading a minute is 130 000 rows per read (#1023):

- The insights page's resting-pulse proxy, used when an account has no
  resting heart rate, walked the whole window to take one percentile per
  day and held the event loop for over a second per view. It now folds in
  Postgres (`readRestingPulseProxy`) with the same percentile, rounding,
  minimum-sample rule and day anchor.
- The per-kind series sent every reading: 14.7 MB for 90 days. A pulse
  window with more than 10 000 readings is now bucketed per local hour,
  mean plus low/high band, with stats still over the readings.
- The doctor report drew every reading into one sparkline. A pulse window
  over the same cap now takes the per-day path longer reports already use.

Sparse pulse (a cuff, hourly imports, a watch sampling every few minutes)
stays under the cap and keeps its raw shape.
2026-09-27 21:07:29 +02:00
Marc-André Bombeck 821c2eb884 test(google-health): pin a heart-rate walk past a thousand pages 2026-09-27 20:43:15 +02:00
Marc-André Bombeck e34741c077 fix(google-health): write a dense collection page by page during a backfill
A full-history backfill read every heart-rate point of an account into one
array, mapped it into a second, and wrote the lot in one call; the cycle's
deferred-rollup ledger then kept one entry per written reading. On a watch
that records a reading a minute that is over a million points, and the
backfill ran a 1 GB heap out of memory within seconds, restarting the
instance it shares with the app (#1023).

The metric walk now hands each page to its write before requesting the
next, and both the Google Health and Fitbit ledgers collapse to one key per
touched type-day as they record. The heart-rate walk's page ceiling rises
from 1000 to 10000: at one point a minute the old ceiling is under two
years, a longer history always ended truncated, and the backfill retried
the whole walk on every attempt and every boot without completing.
2026-09-27 20:42:54 +02:00
Marc-André Bombeck 6f6c4ffff0 test(jobs): re-pin the pg-boss retention assumptions to 12.34.0 2026-09-27 19:51:46 +02:00
Marc-André Bombeck 7d82a32fa9 fix(coach): keep the result table's scroll area reachable from the keyboard 2026-09-27 15:59:42 +02:00
Marc-André Bombeck 98f2875f8e test(coach): strip step markup until stable 2026-09-27 15:45:14 +02:00
Marc-André Bombeck 833fb0b465 fix(workouts): report no route when its track cannot be read 2026-09-27 15:11:43 +02:00
Marc-André Bombeck f98f8eaa24 Merge branch 'fix/chart-all-range-meaning' into release/v1.39.4 2026-09-27 14:22:57 +02:00
Marc-André Bombeck 206da07e8c Merge branch 'fix/meds-ended-and-mental-health' into release/v1.39.4 2026-09-27 14:18:09 +02:00
Marc-André Bombeck 6f37cbdb23 fix(backup): carry each medication's category through backup and restore
The clinical category a person picks for a medication lives in a side table
created at runtime, outside the schema. The backup builder and the restore
only walk schema models, so no backup ever carried it: after a restore every
medication read as "Other".

Both backup formats now carry the category inside its medication, and the
restore writes it back in the same transaction as the medication. A file
from before this change restores as before; a category this server does not
know reads as Other instead of refusing the file.

The backup plan gains a list for tables created with raw SQL, and a check
that every such table in the source either maps to a schema model or has a
verdict there, so the next one cannot drop out of backups unnoticed.
2026-09-27 14:17:42 +02:00
Marc-André Bombeck 7b5ea8b353 fix(charts): name a folded point of steps as a daily average
A week or month point of a cumulative metric is the average daily total,
which keeps the axis comparable with the 7, 30 and 90 day tabs. The
caption said "Weekly avg", which reads as an average week's total. Steps,
energy, distance, flights and daylight now say "Daily avg per week" or
"per month" on the caption and in the tooltip, in all seven languages;
level metrics keep the plain average.
2026-09-27 14:08:50 +02:00
Marc-André Bombeck b45a668cf4 fix(charts): serve the All range's coarse buckets as their average day
The All range is read from the WEEK or MONTH rollup tier once a history is
long enough, from DAY rows for a short one, and from the live table on a
coverage miss. The chart folds every path into the average day of each
week or month, but a tier row carried a cumulative metric's whole total
and a level's reading-weighted mean. A month of steps therefore read about
thirty times higher for a long history than for a short one, under a
caption that said average.

A tier row now carries the average day of its bucket on every tier: a
cumulative metric's total over the days that hold a reading, a level's
mean of its daily means. The rollup fold counts the bucket's days and adds
up their means for it. Only the web chart's series reads (the chart
endpoint and the dashboard batch) use these rows.
2026-09-27 14:08:49 +02:00
Marc-André Bombeck e94e9b94a8 fix(medications): keep a medication's category when it is edited on the web
The add and edit wizard had no row for the thyroid, digestive, skin and
sleep-aid categories, so such a medication opened on "Other" and saving any
change, even a new dose time, rewrote its category to OTHER. The wizard
now offers a row for every category the app knows, translated in all seven
languages.

An edit also sends the category and treatment class only when the category
row was changed. A stored value the wizard cannot show, such as one a newer
server or another client wrote, now survives an unrelated edit.
2026-09-27 14:01:00 +02:00
Marc-André Bombeck c7e53176c1 Merge branch 'fix/v1394-review-correctness' into release/v1.39.4 2026-09-27 14:00:04 +02:00
Marc-André Bombeck a224875ed9 feat(medications): add mental health as a medication category
Antidepressants, anxiolytics, mood stabilisers and ADHD medication had no
category of their own and ended up under "Other". MENTAL_HEALTH joins the
clinical category list: the add and edit wizard offers it as its own row
with a brain icon, the card badge names it, and it is translated in all
seven languages.

The category is a text value, so no migration is needed. Clients that
read the category as a string show the new value; an app that does not
know it yet falls back to its own label for it.
2026-09-27 13:51:39 +02:00
Marc-André Bombeck 8295dfe9b6 fix(medications): stop offering a dose once the course has ended
A medication whose end date had passed still showed taken and skip in the
table and on its card, because the action row only looked at whether the
medication was active. The schedule engine already stops minting doses
after the end date, so reminders, the doses card and the today list were
quiet, but the medication itself kept inviting a dose that is no longer
part of the course.

The server now resolves one rule and publishes it on the list and detail
reads: `courseStatus` (UPCOMING, CURRENT or ENDED, in calendar days on the
user's clock, both ends inclusive) and `intakeActionable` (active, intake
tracked, course includes today). The table and both card variants render
their actions from it, the take-all flow skips such a medication, the card
and table name an ended course with an "Ended" badge, and no overdue
escalation is raised for it. A course that ends today keeps its actions
for the whole local day.

The detail header's "Ended" status now follows the same field. It compared
the end date's UTC midnight with the clock and so called the whole last
course day ended. Logging a past dose from the history is unchanged.
2026-09-27 13:49:21 +02:00
Marc-André Bombeck eda87fd4a7 fix(sync): expire delta cursors issued before a restore or a data wipe
A restore writes rows back with their original updatedAt, behind every
paired client's watermarks, and removes rows without leaving tombstones.
A client that kept its cursor would miss both on its next incremental
pull. The same holds after the person deletes all their data.

`users.sync_reset_at` (migration 0363) records when the record was last
replaced: the restore stamps it as its last write and again right after
the commit, the per-account and the admin wipe stamp it inside their
transactions. Cursors now carry the time they were issued, and
/api/sync/changes answers its existing `cursorExpired` to a cursor issued
before the reset, or one without an issue time on an account that has
one. The comparison is on the issue time, because after a restore a
freshly drained cursor still carries old watermarks.
2026-09-27 13:46:38 +02:00
Marc-André Bombeck 26352001b9 chore(coach): drop an unused type import from the method line 2026-09-27 13:43:39 +02:00
Marc-André Bombeck 4c7e5bc318 fix(coach): keep the reply's bubble when its persisted copy arrives
The persisted copy of a streamed reply used to mount as a new bubble, so
what the reader had done on the streamed one (the chart or table view, the
open steps list, the evidence panel) reset when the conversation refetch
landed. A turn now keeps one key from its first streamed frame to its
persisted copy, both render in the same wrapper, and the copy keeps the
live tables instead of refetching them, so React keeps the one bubble.
The wrapper stops being a live region once the turn is settled. The
150 ms twin-hiding window this replaces is gone.
2026-09-27 13:40:55 +02:00
Marc-André Bombeck 21a2e2fcef fix(coach): keep the chart on "as a table" so the chart is offered back
"As a table" used to drop the table's chart, so the reply offered no way
back to it. The copied table now keeps its chart and carries view: table,
which the panel opens on the table (the chart one tap away on the toggle)
and the chips read to offer "as a chart". show_result with view table
does the same. The field is optional on the result metadata; a client
that ignores it shows the chart first as before.
2026-09-27 13:36:48 +02:00
Marc-André Bombeck 5e9c4fe8d8 fix(jobs): allow the free-text backfill's address and audit counts as run facts 2026-09-27 13:32:59 +02:00
Marc-André Bombeck 14bc609712 test(coach): follow the comparison read and the question pointer in the dialog scenarios 2026-09-27 13:32:59 +02:00
Marc-André Bombeck b1629e2ad3 refactor(coach): keep the exclusion gate in its own module beside the snapshot builder 2026-09-27 13:32:58 +02:00
Marc-André Bombeck daf386fe7f fix(backup): use the renamed after-transaction allowance for the chart rebuild 2026-09-27 13:32:58 +02:00
Marc-André Bombeck 5173049130 Merge branch 'release/v1.39.4' into fix/v1394-review-correctness 2026-09-27 13:27:59 +02:00
Marc-André Bombeck ee5c44d9d7 fix(coach): name a night in the prose by the key the sleep table uses
The Coach's recent nights carried the wake instant in UTC as their date,
while the sleep table under the answer keys a night by the local day it
ends on. East of UTC the two named different days for the same night.
The snapshot now gives the night's own key.
2026-09-27 13:27:51 +02:00
Marc-André Bombeck 7e91596f02 perf(coach): send the table rules only when there is a table
The recheck and follow-up rules now ride the tool-mode prompt only once
the conversation holds a table, or from the round after the turn produced
its first one. The clarifying-question rules are shorter and English for
every locale, like the other instructions to the model; the German copy
of them and of the recheck rule is gone. A turn without tables sends
about 1 300 characters less on every round.

The golden transcripts change only in the system prompt's hash and
length (4 509 to 3 187 characters; 4 728 to 3 280 for the German turn).
2026-09-27 13:26:16 +02:00
Marc-André Bombeck 42e658f775 test(coach): pin the stored dialog id shapes 2026-09-27 13:22:29 +02:00
Marc-André Bombeck 6a458ac5b0 fix(coach): hold restored dialog ids to the shapes the server mints
Table refs, step ids, chip ids, a chip's anchor ref, clarification choice
ids and a reused table's source are now validated against their exact
server-minted patterns when a stored reply is read back. Some of them are
written into the model's context on a later turn (the earlier-tables list,
the "keep looking" fetched list), so a stored row edited by hand can no
longer carry text into a prompt through them.
2026-09-27 13:22:09 +02:00