Commit Graph
6778 Commits
Author SHA1 Message Date
Marc-André Bombeck 367bc3fca5 fix(onboarding): say what happens after adding an as-needed or record-only medication 2026-09-25 10:03:10 +02:00
Marc-André Bombeck ff3ef9147c fix(coach): give no supply span for a GLP-1 medication kept as a record 2026-09-25 10:03:09 +02:00
Marc-André Bombeck 0e242cfea1 fix(telegram): do not offer or record doses of a record-only medication 2026-09-25 10:03:09 +02:00
Marc-André Bombeck 37c6b0e144 fix(medications): never mint a slot from before the live schedule era
The projector floored today's slots only on an empty archived era, the
reminder worker and the next-due reads on the newest revision boundary,
and the auto-miss pass on the creation instant alone. Switching tracking
back on and then moving a dose time the same afternoon therefore put the
morning dose on the card, and the next day's pass stamped it a miss; a
same-day times edit on any medication did the same.

One helper now names the live era start (the newest active revision's
boundary) and every path floors on it: the projector, the worker, the
doses card, the medication list, the MCP schedule tool and the auto-miss
pass, which leaves a placeholder minted after the switch for a slot
before it.
2026-09-25 10:03:08 +02:00
Marc-André Bombeck 1d1570733e fix(medications): leave record-only doses out of the nudge, the status model and the BP correlation
Three readers compute a rate straight from intake rows without reading a
schedule: the weekly compliance nudge, the adherence series behind the
general status, and the BP-versus-compliance correlation. They now read
tracked medications only. The structural guard covers intake-row readers
as well as schedule readers, with a whitespace-tolerant matcher, a floor
on the match count and a reviewed allowlist.
2026-09-25 10:03:08 +02:00
Marc-André Bombeck e03cf7c2c8 Merge branch 'feat/medication-record-only' into release/v1.39.1 2026-09-25 09:37:27 +02:00
Marc-André Bombeck 854bdd5ca1 Merge branch 'feat/restore-background-job' into release/v1.39.1 2026-09-25 09:32:28 +02:00
Marc-André Bombeck d4c6e0fab2 docs(api): describe how a record-only schedule is kept on update 2026-09-25 09:25:40 +02:00
Marc-André Bombeck f482fc403c test(medications): replay the shipped app's schedule save on a record-only medication 2026-09-25 09:25:40 +02:00
Marc-André Bombeck e5236d2e6a fix(medications): keep a record-only schedule unless the write names trackIntake
The shipped iPhone app cannot see the schedule of a medication kept as a
record, because it is served with an empty schedules list. Its editor opens
such a medication as daily at 08:00 and, once any schedule field is touched,
sends a full replacement built from that default, plus asNeeded and oneShot,
and never trackIntake. On a record-only medication the update route now
drops schedules, asNeeded and oneShot from a write that does not name
trackIntake and applies the rest. The web schedule editor and the wizard
name the flag on every save.
2026-09-25 09:24:38 +02:00
Marc-André Bombeck 94ec9ab932 Merge remote-tracking branch 'origin/release/v1.39.1' into feat/medication-record-only 2026-09-25 09:20:15 +02:00
Marc-André Bombeck 0a5fa4b2ae fix(crypto): page the key-rotation walks by id, not by cursor
The corpus walk and the rotation script paged with Prisma's cursor and skip.
Prisma resolves that cursor by reading the page's last row again, so when
that row was hard-deleted before the next page (a backup replaced, a cached
response expired), the next page came back empty and the walk ended early.
The scan then counted fewer rows than the database holds, which could read
as nothing left on the old key while rows it never reached still were.

Both walks now ask for the rows whose id is greater than the last one read,
which needs no row to still exist.
2026-09-25 09:17:25 +02:00
Marc-André Bombeck 04a683ae07 fix(medications): refetch the daily reads after the track-intake switch 2026-09-25 09:15:18 +02:00
Marc-André Bombeck 2311f9b47d fix(insights): leave record-only medications out of the adherence storyline 2026-09-25 09:05:48 +02:00
Marc-André Bombeck 0095fda216 test(medications): count only a use of the intake-tracking vocabulary, not its import 2026-09-25 09:01:01 +02:00
Marc-André Bombeck b4c2706ec9 feat(medications): keep a medication as a record without tracking intake
A per-medication trackIntake flag (migration 0351, default on for every
existing and new row). With it off the dose, dates and schedule stay stored,
but nothing derives a due dose from them: the reminder worker, the manual
reminder check, the today projector, the auto-miss pass, the low-stock pass,
the doses card, the today list, the outstanding-dose badge, compliance and
its heatmap, the doctor report, the Coach, the BP-status gate, insights,
targets, achievements, the efficacy view and the MCP schedule tool all skip
it.

The medication wire serves such a medication with schedules: [] and the
stored rows in an additive recordedSchedules field, so a client that arms
local reminders from schedules stays silent without an update. Switching
tracking off or back on archives a schedule revision at that instant, the
untracked stretch with an empty payload, so it never counts as missed and
the projector does not put a dose from before the switch back on the card.

A structural guard freezes the set of schedule readers: each one filters on
the flag, derives from dueSchedules, shapes the wire with scheduleWireFields,
or is allowlisted with the reason it derives nothing due. The flag travels
through backup, restore and the medication CSV export.
2026-09-25 08:57:10 +02:00
Marc-André Bombeck 700de6d228 feat(medications): add the track-intake switch to the medication pages
The detail page leads the schedule tab with the switch and a one-line
explanation of each state; with tracking off it drops the reminder card,
the next-dose and adherence rows, and shows the stored schedule as the
record. The wizard summary carries the same switch. Cards, the table and
the detail header mark a record-only medication, which leaves the take and
skip buttons, the take-all set, the log-dose dialog and the dashboard quick
add. Strings in all seven locales.
2026-09-25 08:57:10 +02:00
Marc-André Bombeck 4897752c3c fix(backup): date a restored medication by its earliest record when the file has no date
Portable files exported before v1.39.1 carry no creation date for a
medication, so the restore stamped the restore time. The dose history reads
that date as the day the medication began to exist, and every miss before
it, including the day before the restore, dropped out of the record.

When the file has no creation date, the restore now uses the earliest thing
the file records for the medication: its first dose (scheduled, taken or
written), the start of its first schedule revision, its course start, its
first pause, dose change or side effect, and never a date after the restore.
A file that records nothing about the medication keeps the old default.
2026-09-25 08:53:38 +02:00
Marc-André Bombeck d7bc873691 fix(backup): answer an upload too large to store with 413
An uploaded file whose encrypted copy passes the size one stored backup may
have on this host (a fifth of the heap limit) stopped the store with
BackupBlobTooLargeError, which the route let through as a 500 and reported
to the error tracker. It is now a 413 with the numbers and the remedy in the
message and meta.errorCode backup.upload.too_large, and the refusal is
audited like the others. The store rolls back, so nothing is kept.
2026-09-25 08:51:00 +02:00
Marc-André Bombeck 3abbad048e fix(backup): never run a restore again after its data committed
A restore commits its transaction and then rebuilds the chart tiers. A
worker that stopped during that rebuild left the job running with a stale
heartbeat, and the boot sweep and the admission check treated it like any
interrupted job: the sweep queued it again, so the second run deleted and
re-wrote the account and lost whatever was written in between, and a second
interruption closed it with "rolled back, nothing was changed", which was
false.

The job now records `committedAt` (added to migration 0347, which has not
shipped) and awaits that write before the rebuild starts. The sweep and the
admission check close a committed job as `failed_after_commit`, never queue
it again, and evict the account's caches. The rebuild itself is left to the
nightly and boot passes that already rebuild missing tiers.

The module comment now says what an interrupted restore does (restarted
once, then failed) and what happens to other writes while a restore runs:
new rows are written at once, updates to rows the restore already deleted
wait on their lock until it commits or rolls back.
2026-09-25 08:49:45 +02:00
Marc-André Bombeck c327ae81d5 Merge branch 'feat/procedure-history' into release/v1.39.1 2026-09-25 08:23:34 +02:00
Marc-André Bombeck 25099a57b6 fix(checkups): lead a procedure card with what was done
In the procedure history every row carried the kind as its heading and
again as its badge. A procedure now leads with its reason, and the
practice moves to its own line beneath the body site.

Refs #1025
2026-09-25 08:18:06 +02:00
Marc-André Bombeck 0b28498d83 feat(doctor-report): add a surgical history section
New structured leaf SURGICAL_HISTORY in the history group and in the
standard template: every procedure that took place, oldest first, with
its date, what was done (the visit's reason), the body site with its
side and the outcome. Lifetime reference data like the immunization
record, so it ignores the report window. The PDF prints it as a table
and the clinician share page renders it; saved selections do not widen.

The FHIR bundle carries no Procedure resource, on the same terms as
visits: no REST route or capability entry exists for it.

Refs #1025
2026-09-25 08:11:42 +02:00
Marc-André Bombeck ad0d50fa28 feat(visits): record procedures and surgeries with a body site
A visit can be filed as a procedure or surgery (new EncounterKind
PROCEDURE) and carry a body site in the person's own words, encrypted at
rest, plus an optional side (LEFT, RIGHT, BOTH). An existing visit
switches to the new kind by an ordinary edit, keeping its date, reason,
outcome and links.

- migration 0349: enum value, laterality type, two nullable columns
- GET /api/encounters/procedures: the DONE procedures, newest first,
  searched after the decrypt over site, side and reason, with body-site
  choices and a total computed over the whole history
- create and edit accept bodySite and laterality on any kind
- /checkups gains a Procedures tab with a body-site filter; the visit
  form offers the site for a procedure and keeps it in view on any
  visit that carries one
- backup and restore carry both fields; the body site is registered as
  ciphertext and rotates with the key
- MCP get_visits narrows by kind and reads the whole record for
  procedures when no window is named; the Coach visit block names a
  procedure's site
- i18n in all seven locales, OpenAPI regenerated

Refs #1025
2026-09-25 08:11:37 +02:00
Marc-André Bombeck 934131ee3e Merge branch 'feat/restore-background-job' into release/v1.39.1 2026-09-25 08:10:06 +02:00
Marc-André Bombeck cb69f662ab fix(measurements): attribute named sleep stages from a bridge token to EXTERNAL
The named-stage arm took the body's source and so wrote MANUAL under a
scoped credential, while every other arm of the route resolves such rows
to EXTERNAL. It now resolves the source from the credential as well, and
a bridge segment stays apart from the same reading from Apple Health.
2026-09-25 08:04:31 +02:00
Marc-André Bombeck d0e321c54a Merge branch 'fix/v1391-contract-and-ingest' into release/v1.39.1
# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/routes/measurements.ts
2026-09-25 08:04:30 +02:00
Marc-André Bombeck 7e654175b6 test(contract): pin the v1.39.1 wire changes as additive for shipped clients
Decoders written against the shapes the shipped iPhone builds read still decode every payload this release changes, and the old shapes still satisfy the new document: the new batch-entry fields and the error code stay optional, the profile time zone stays a required string, and the named sleep-stage answer sits beside the old create response rather than in place of it.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck f443ab6615 feat(measurements): accept sleep stages by name on POST /api/measurements
A bridge such as Tasker or Home Assistant could only send sleep stages
through the batch route, in HealthKit's terms: the category identifier,
a stage codepoint from 0 to 5, and a value it had to work out in
minutes. It can now post one segment to `POST /api/measurements` with
`type: "SLEEP_DURATION"`, a `sleepStage` of `IN_BED`, `ASLEEP`, `AWAKE`,
`CORE`, `DEEP` or `REM`, and either `startDate` + `endDate` or
`measuredAt` (the end) + `value` (minutes).

The row is the one the batch route writes for the same segment, and the
dedupe is the batch route's: the opposite-source same-reading check,
then one reconcile over both unique identities. Without an `externalId`
the server derives one from the stage and both instants, so a re-post
answers 200 `duplicate`, a corrected start answers 200 `updated`, and a
new segment answers 201 `inserted`. The narrow `measurements:write`
token is accepted and still writes only MANUAL rows. Every other body
on this route behaves as before.

Requested in #972.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck be112df972 fix(profile): report the time zone the server uses on /api/user/profile
`/api/auth/me` has reported the resolved zone, the stored one or the
instance default when the stored value is unusable, while
`/api/user/profile` returned the raw column. A client reading the
profile could cut days differently from the server, and sending the
value back in a PATCH was refused.

Both reads, and the profile PATCH answer, now go through one
`resolveStoredTimezone`. The field stays a string and always holds a
valid IANA name. The web account editor already reads the resolved
zone from `/api/auth/me` and saves through the validating timezone
route, so it shows the zone in use and can only write a valid one.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck 0cc73d3c88 fix(ingest): treat upsert_failed as a retry in the nutrient and cycle batches
The nutrient batch docs told a client to log and drop an `upsert_failed`
entry. The route means the opposite: a write group failed on the
server's side and nothing was stored. The docs now say to send the
entry again on a later sync, and keep the three validation skips
terminal.

A retry under the same Idempotency-Key used to replay the cached
failure, because the 200 carrying `upsert_failed` was cached like any
other. Both the nutrient and the cycle bulk routes now mark that
response `no-store`, as the measurement batch already does for
`failed`, so the retry writes again.

The cycle bulk route already answered its validation refusal with
`cycle.bulk.invalid` and an over-cap batch with `cycle.bulk.too_large`.
Both are now pinned by tests and named in the route's 422 response.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck a7682a9fed fix(measurements): name the batch validation refusal and the band an entry broke
The validation 422 from `POST /api/measurements/batch` now carries
`meta.errorCode: "measurement.batch.invalid"`, beside the existing
`too_large` and `source_not_permitted` codes, so a client can file the
rows as permanently refused instead of holding its anchor on a guess.

A `value_out_of_range` entry now also carries `convertedValue`, the
reading after conversion into the stored unit, and `range`
(`min`, `max`, `unit`), the band it was checked against. Both appear
only on that skip. A fraction sent where a percent was expected is now
visible in the response instead of looking like a sensor glitch.

Status codes, the envelope and every existing field are unchanged.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck 74dedee645 fix(import): write Apple Health spot rows without Prisma's per-call heap
Each `createManyAndReturn` flush of the export import left about 5 MB of
heap behind until some two dozen calls had piled up. A 300 000-record
synthetic import under a 524 MB heap settled at 53 MB retained; a run of
back-to-back 600-row calls with nothing between them reached 212 MB.

Spot rows now go through the column-array `INSERT … SELECT FROM unnest`
the restore already uses, with `ON CONFLICT DO NOTHING RETURNING`, which
is what `createMany({ skipDuplicates })` emits. The same import holds
11.5 MB and finishes in 35 s instead of 55 s. Ids are minted in the
cuid() shape, defaults are written as the client would write them, and
integration tests compare the returned rows and the resulting table with
`createManyAndReturn` for fresh rows, conflicts on either unique
identity, and duplicates inside one call.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck 47e9f7e81c feat(backup): run a restore as a background job with progress
Restoring a large account was one request: 94 s for 1.25 million readings.
A reverse proxy that cuts a request at 60 s (nginx's default) showed the
operator an error for a restore that went on to finish on the server.

The restore request now checks the typed confirmation, that the backup
exists and that its stored copy opens, admits one job for the account and
answers 202 with its id. The `backup-restore` queue runs the restore and
records its phase, counts and outcome on a `backup_restore_jobs` row
(migration 0347), which `GET /api/admin/backups/restores` and
`GET /api/admin/backups/restores/{id}` read back.

What a restore checks and writes is unchanged. The body moved from the
route into `restoreBackup` (src/lib/export/restore-backup.ts): every
refusal still happens before the first delete and the replacement is still
one transaction, so a failure part-way leaves the account as it was. While
it runs the account keeps showing its current data, and other writes are
neither refused nor held, as before.

- One restore per account: a partial unique index admits one queued or
  running job, and a second request gets 409 `backup.restore.active` naming
  the running job. A per-account advisory lock inside the transaction holds
  even if a worker thought gone is still writing.
- Time budget: the job's expiry is two hours and a file whose transaction
  limit would run past three quarters of it is refused before anything is
  deleted (`jobDeadline`, beside `jobBudget`).
- Resuming: the job writes a heartbeat every two seconds. A boot sweep
  queues a job whose worker stopped again from the start, at most twice,
  then fails it as interrupted. The admission check releases an abandoned
  job so it never holds the account's slot.
- The staged file is the stored `DataBackup` row, already encrypted at
  rest. It stays whatever the outcome, so a retry needs no new upload. The
  job records a digest of the copy and refuses one that was replaced while
  it waited.
- Failures carry a stable code and a sentence that says what to do.
- The console follows the job: phase and counts while it runs, the outcome
  after, and after a reload it finds the job again from the server. A poll
  that fails on the network is never shown as a failed restore. A web
  process separate from the worker evicts the account's caches when it
  first reports the restore finished. The success line goes through the
  outcome module, so an empty file is not drawn as a tick.

The model is registered in the backup plan (not carried) and the wipe plan
(wiped with the account); the queue in the maintenance registrar, the
subsystem surface and the job-fact allowlist; the new code in the error
catalogue; the routes in the OpenAPI contract. The tests that pin restore
semantics now drive the real route and the real job through one helper, and
`backup-restore-job.test.ts` pins the asynchronous contract itself.
2026-09-25 07:39:06 +02:00
Marc-André Bombeck ee23409b77 Merge branch 'fix/v1391-cycle-and-links' into release/v1.39.1 2026-09-25 07:38:34 +02:00
Marc-André Bombeck 17c31c37a5 fix(links): offer the unlink undo inside the sheet
The picker lives inside a modal sheet, and a toast sits outside it where
its button cannot be pressed while the sheet is open. The undo is now a
line under the chips that stays until the next change. The calendar's
intercourse diamond also grows a step so it reads as a diamond on a
phone rather than a dot.
2026-09-25 07:30:36 +02:00
Marc-André Bombeck 92ad5513c0 chore(db): renumber the placeholder repair migration to 0350
0346 is taken by the external-source migration that reached main first.
The repair touches only intake rows and depends on no other migration of
this release.
2026-09-25 07:25:29 +02:00
Marc-André Bombeck 702a13873d Merge branch 'main' into release/v1.39.1 2026-09-25 07:21:34 +02:00
Marc-André Bombeck 47e2520b9f Merge pull request #892 from Antiheld86/feat/measurement-source-external
Give ingested measurements their own source value
2026-09-25 07:21:30 +02:00
Marc-André Bombeck 6e05f86e7b fix(vaccinations): show a dose's linked documents and keep them on edit
A dose opened from the list carried no document links, since only the
detail read has them. The edit sheet showed an empty picker, and saving
any change sent that empty set, which the route reads as unlink
everything. The sheet now reads the dose's own links before it shows
them, and a save leaves the links alone unless they were changed.

A link to one dose or visit (/vaccinations?dose=, /checkups?visit=) now
scrolls to it and opens it for someone who may edit it.
2026-09-25 07:10:56 +02:00
Marc-André Bombeck 31f0d7c9fb fix(links): make the X the only unlink and let a chip open its record
A link chip was one button with an X drawn inside it, so tapping the
chip to open the linked record removed the link. The label now opens the
record (a dose, a visit, a condition or a document) and a separate X,
labelled with what it removes, unlinks, with an undo in the toast. Each
chip also carries its record's date, so several doses of one vaccine no
longer read as identical chips. The fix sits in the shared picker, which
is every chip list with this shape.
2026-09-25 07:10:50 +02:00
Marc-André Bombeck 0390d124cb fix(cycle): mark intercourse and other logged entries on the calendar
An intercourse entry left no trace on the month grid and the legend had
no entry for it. The day now carries a diamond marker, and anything else
logged that day with no marker of its own (a test, contraception,
spotting, a temperature, a mucus or cervix reading, a note) shows a
hollow ring, so a day with one of those logged no longer looks empty.
Both are named in the legend and in the cell's accessible label.
2026-09-25 07:05:03 +02:00
Marc-André Bombeck adec5bd045 fix(cycle): carry every logged day entry on the calendar read
The calendar grid carried flow, symptoms, BBT, the ovulation test and the
mucus reading, and dropped the rest of the day log: intercourse, spotting,
the pregnancy and progesterone tests, contraception and whether a note
exists. The published contract also promised the three cervix signs while
the route never sent them.

The intent fields resolve through the same reader the day-log read uses,
so an encrypted envelope reads the same on both, and the grid is served
under the same cycle grant as the day log. Additive fields; the contract
test pins the wire keys to the published schema.
2026-09-25 07:02:14 +02:00
Marc-André Bombeck 510dbfe273 chore(db): renumber the external-source migration to 0346
0342 sorts before migrations 0343 to 0345, which are already applied on
v1.39.0 instances. The next free number keeps the order linear.
2026-09-25 06:55:22 +02:00
Marc-André Bombeck 1205540585 chore(db): renumber the external-source migration to 0342
0336 was taken by the off-host backup ledger while this branch waited; the
schema change itself is unchanged.
2026-09-25 06:55:18 +02:00
Marc-André Bombeck e7597c7ac1 docs(changelog): leave the entry to the release commit
The text is unchanged and waits with the other release drafts; keeping it
in the branch made CHANGELOG.md collide with every release that shipped
while this waited.
2026-09-25 06:55:18 +02:00
Antiheld86 a099d813ee Document what EXTERNAL does to a contested day, and pin it
Review caught a behaviour change this branch causes but never wrote down.
`pickCanonicalSourceRows` takes the first ladder source present in a day's
bucket and filters the day to that source. Before, a typed reading and a
bridged one were both MANUAL, so both survived. Now MANUAL ranks, EXTERNAL
does not, and the bridged row leaves the canonical set on any day that also
has a typed one.

That reaches every surface needing one source per day — the daily totals
behind the charts, the health score, the doctor report, correlations, mood
crosstabs and personal-record detection. A day with only bridged readings is
untouched: nothing ranked is present, so the picker's pass-through keeps
everything.

The changelog says so in the user's terms, including the part that is a fix
rather than a change: two indistinguishable MANUAL rows were both counted,
which inflated a running total on a day somebody did both.

Four cases pin it, because it is emergent from a rank ladder and stated in
no single place: the contested day, the bridged-only day, the both-MANUAL
shape the change moves away from, and a stored ladder that names EXTERNAL
being honoured. Ranking EXTERNAL turns the first two red, which is the
review anybody adding it to a ladder should have to pass.
2026-09-25 06:55:17 +02:00
Antiheld86 9fc1413181 docs(measurements): label EXTERNAL and update the guide and contract
The measurements list already badges any row whose source is not MANUAL and
already builds its source filter from the shared enum, so both pick the new
value up on their own. What it needs is a name: `EXTERNAL` reads as a leaked
enum constant, and it is the only new value a user is expected to operate,
since it is an option in the filter and a badge on every bridged row.

Both label catalogues gain an entry. They are keyed by the enum, so a new
source without a label is a type error and the coverage guard fails the
suite — which is exactly what happened here, and is the reason this carries
a settings label too even though EXTERNAL sits on no priority ladder and so
never renders there.

The settings card's scope note and the Home Assistant guide both claimed
readings arrive "as if you had entered them by hand", which is no longer
true. Both now say the rows are marked as coming from an external device and
are still yours to correct, and the guide spells out that the payload must
not name a source at all.

The changelog entries go under Unreleased as Changed: v1.38.1 shipped the
token, so these describe a change to something people already have. They say
what that means in practice — rows written before this keep the old label
and cannot be sorted out afterwards, a batch re-push can duplicate because
the dedup key now carries the source, and an automation sending an explicit
`source` field has to drop it.
2026-09-25 06:55:17 +02:00
Antiheld86 112a029443 test(measurements): cover EXTERNAL provenance on both write paths
The write-smoke suite is `satisfies Record<MeasurementSource, SourceCase>`,
so growing the enum is a compile error until a case exists; the new one
goes through the ordinary single-entry POST, which is the production path.
It carries the trap this suite sets for you written down: `beforeEach`
seeds a session cookie and the caller resolver is cookie-first, so arming
only the authorization header resolves as that session and quietly writes
MANUAL — the case would then pass without testing anything. Both hardcoded
totals move 14 → 15.

In the bearer-scope suite the two rows that asserted MANUAL now assert
EXTERNAL, and three cases join them: a body naming MANUAL is refused on
each route (the widening from "names APPLE_HEALTH" to "names anything",
which nothing else covered), and a bridge-written row is still value-
editable by its owner through PUT. That last one guards the split between
the write allowlist and the correctable set — folding the two constants
back together value-locks every bridge row behind a 409, and this is what
notices.
2026-09-25 06:55:17 +02:00
Antiheld86 f94d358ade feat(measurements): write EXTERNAL from both ingest write paths
Under a `measurements:write` credential the row's source is now decided by
the server on both routes, and the body may not name one at all.

The 422 widens from "names APPLE_HEALTH" to "names any source". It was
narrow only because the forced value was MANUAL, so a body saying MANUAL
was never actually overridden. Now it would be — and overriding a client's
explicit assertion quietly is the thing both routes' comments rule out,
while honouring it would defeat the change: a bridge that kept sending
MANUAL would stay indistinguishable from a typed reading. Status and
errorCode are unchanged, and the documented Home Assistant payload sends no
source at all.

The single-POST predicate keeps reading the raw body: the create schema
defaults `source` to MANUAL, so after parsing "said MANUAL" and "said
nothing" are the same value. Both of that route's create sites resolve the
source, the array arm included — that is the combined BP + Pulse path a
bridge uses, and missing it would leave exactly those rows unbadged.

Two consequences worth naming. Bridge rows no longer join the
MANUAL/APPLE_HEALTH cross-source merge, so they can't be swallowed by a
rule written for the iOS adopt-on-pair case. And a bridge posting at the
same instant as a hand entry now lands both rows instead of a 409, which is
honest and does not double-count: the rollup collapse is per
(type, day, source) and MANUAL outranks the unlisted EXTERNAL.
2026-09-25 06:55:17 +02:00