A refused address change returned before the update was built, so every
other field in the same request went with it. The settings form posts
the whole profile at once, so somebody who had spent the budget could
not save a timezone or a height for an hour and was told it was about
email addresses. The address is now dropped and the rest of the save
lands, reported through the existing rejectedFields key with the code
`rate_limited`; the 429 is kept for a request that asked for nothing
else, where a partial success would be a lie.
The comment claiming an audit row beside each question was inverted:
both refusals returned before the row is written, so only the answers
that told the caller nothing were being recorded. The two answers worth
detecting write their own rows now.
The stored address is normalised before the "has it changed" comparison.
Registration writes it as typed, so an account created with capitals did
not match its own address and paid for its first ordinary save.
Background insights can no longer use up the day before somebody asks a question; a fifth of the daily limit is held back for what a person does by hand.
Changing the profile address asks whether another account already holds
it, and the answer is 409 versus 200 — an existence check over every
address on the instance, open to any signed-in caller. Neither route
that reaches it had a ceiling of any kind and the API wrapper supplies
no default, so the only bound on a sweep was the network.
Both routes now share one per-account bucket, ten an hour, charged
before the question is asked so a refused caller learns nothing. Only a
request whose address actually differs from the one on file is counted,
so re-saving a settings form that carries the unchanged address stays
free. The refusal is the standard 429 with Retry-After and the
X-RateLimit triple, carries `profile.update.emailRateLimited`, and is
published on both operations.
The provider-health route answered a spendToday payload that nothing rendered,
which is the pattern the two-ended rule exists to stop. It is also the screen
the operator opens when a surface refuses on budget, and the one mixed number
could not answer the question that raises: a day at 1.24 million tokens is
alarming until the card says that 151 200 of them were the instance's own key
and the rest ran on the users' own plans. The operator ceiling is enforced
against the second figure. Copy lands in all seven locales.
The done screen's assistant panel stops claiming that the instance's
provider already serves the person. A tri-state read over the operator's
own provider ledger decides between a one-tap offer that mints the
consent receipt, a plain "not answering right now" note, and today's
setup link; anything unknown, any read error, a managed profile and an
operator who has switched the assistant off all fall to no offer.
# Conflicts:
# e2e/setup/global-setup.ts
A provider with no sync history reports "unknown" instead of "connected",
so a fresh account is no longer told a device it never linked is already
delivering; the connect step resolves the sync verdict into four honest
tiles, records the flow's result only for a connection that is actually
serving, points an unconfigured provider at its credentials, and steps
over a source the account already has.
The getting-started checklist's data queries follow the same setup rule as
its visibility, so a settled account stops being told to do what it has
done; a failed completion leaves the confirm screen usable; an empty
multi-select is no longer recorded as an answer; the confirm screen names
the modules a run would take out of the navigation; the settings links it
offers work before the first completion; and demo mode can fill the
baseline it is shown.
The screen listed what the answers switch on and said everything else
stays one click away under Settings, Modules. That is true of
reachability and false of the navigation entry: a module the answers do
not ask for is written off by the completion's derivation.
It now names those modules, read through the registry's own merge rule
and intersected with the map the navigation shows today, so nothing is
claimed about a module the account payload says nothing about.
A sharing invitation carries no link and nothing is e-mailed — it is addressed
to an account that already exists on this instance, and the person accepts it
from inside their own session. The card's input hint already named the account
requirement and left the rest unsaid, so an inviter looking for a link to send
had nothing to find and no reason to stop looking. The card now names where the
invitation turns up, in all seven locales.
The step decided "connected" from the ledger's `state` and had one piece of
copy for every outcome: "the first sync is in progress". That sentence was
false for a connection delivering for months, and worse for one the person
has to repair — the screen said everything was fine while the grant was gone.
`connectSourceView` resolves the envelope entry into one of four tiles and
says whether the flow may record its result. Only `pending_first_sync` and
`fresh` may; `stale` is acknowledged without being claimed as new; `stalled`,
`failing`, `reauth_required` and `parked` name the state and link at the page
that can change it. An unresolved envelope paints no tile at all.
A source the instance cannot connect — a BYO provider with no client pair, a
shared-app provider with no app — now points at the credentials instead of
offering a "Connect" button onto a card that has none.
`POST /api/auth/register` read no session at all, so a request carrying a valid
`healthlog_session` was handled exactly like an anonymous one and the
unconditional `createSession` at the end replaced the caller's cookie with the
new account's. An admin opening his own invite link to check it was signed out
of his admin account and into a stranger's onboarding, his previous session row
still valid server-side and no longer reachable.
The route now resolves the optional session first and refuses with 409
`already_authenticated`, before the rate-limit bucket is spent and before the
invitation is consumed, so a signed-in visitor neither burns a use nor locks
the door for the person the invitation is for. The register page renders an
explaining panel with a sign-out button in place of the form, so nobody has to
fill three inputs to discover the refusal; signing out returns to the same
invitation rather than to the login screen. Copy lands in all seven locales.
The card read one field and made two claims from it: that insights work,
and that no setup is needed. The first was a presence read - it knew a key
existed, never that it answered - and the second skipped the consent
receipt the egress actually needs, so the person who believed the note met
a consent.ai.required refusal on their first briefing.
Four branches now, one button. The offer appears only when the server says
the shared provider is known to work and nothing forbids it, and the tap
posts the affirmative web intent. An unhealthy or unknown verdict paints
'not answering' with no button. When the offer is refused for a reason that
is not health, the panel stays neutral rather than blaming the provider.
The note survives for the one state it was ever true of: consent on file.
The sample, the ladder, the 'fully useful without' line and the setup link
are identical in every branch. Copy in all seven locales.
A Nightscout self-host on host networking with
NIGHTSCOUT_PRIVATE_ORIGINS=http://localhost:1337 worked before and must
keep working unchanged; an operator who lists an exact loopback origin
with its port has made the same deliberate decision as one who lists a
LAN origin. The never-grantable floor is now the unspecified address,
link-local and the metadata endpoint only. Loopback (127/8, ::1,
localhost) is grantable when listed exactly, still resolved and pinned
at connect time with redirects forbidden, and still refused when not
listed.
::1 is answered before the embedded-IPv4 unwrap, which read it as the
IPv4-compatible spelling of 0.0.0.1 and refused it as unspecified. The
pinned Nightscout transport test runs the real floor again. Each refused
range keeps its own test; loopback moves to the grantable-when-listed
set with a test that an unlisted loopback is still refused, and a
Nightscout grant of http://localhost:1337 is asserted to parse.
Refs #947
"Finish without the profile" completed the flow with no record id, and
the route then derived the child's answers onto the guardian's own
record. The server now decides: answers given for somebody else with no
record to apply them to complete and latch the flow without deriving or
seeding anything, whatever the client sent. The checklist carries the
profile instead and reads none of those answers as the guardian's own.
A record id the caller does not guard answers 404 like every
id-addressed managed-profile route, one answer for missing, not managed
and not theirs alike; the contract documents the field, the 404 and the
422 text. Pinned by two integration cases and a second child journey.
A loopback, link-local or metadata target answered
private_origin_not_approved with a message telling the user to have the
operator list it, and the parser refuses such an entry forever: a dead
end the route test even pinned. Those targets now answer
private_origin_not_grantable (catalogued, on all four routes, translated
in seven locales) with a message that says no grant can open them and
to use the relay's LAN address. Both senders carry the reason into the
outcome so the test button forwards the same code.
The malformed-entry warning printed the entry verbatim, which is where
a pasted token or userinfo sits; the parser now hands callers scheme
and host only, plus the reason in words. The Nightscout list throws a
named error with that redacted entry and reason, and the connect route
answers it as a 422 with the message instead of a bare 500, so a
self-host that listed http://localhost:1337 reads what to change.
The two settings cards translate a coded save refusal like the test
button does; the docs and the changelog name the sub-host and sibling-
port narrowing; the dispatcher floor tests assert the policy marker so
a real NXDOMAIN cannot pass for a refusal.
Refs #947
The save routes evaluate the same policy the senders do. A target on a
private network the operator has not listed is a 422 with
meta.errorCode = private_origin_not_approved and a message that names
NOTIFICATION_PRIVATE_ORIGINS, instead of the generic "Invalid data"; a
listed private address saves although the plain public floor would
have refused it. The settings schemas take the target predicate as a
parameter so the validator module never reads the environment; the
exported constants keep the public floor for the OpenAPI contract.
The test routes forward the sender's refusal as the same 422 and code
instead of a bare 500, so the card can say why the test failed rather
than sending the operator to the wide-event log. A delivery fault is
still a 500. Both test routes and the dispatcher reach the same sender
function, and a structural test holds them there.
The OpenAPI descriptions name the code on all four routes; the test
button's error map gains the code in every locale.
Refs #947
Every key the flow, the checklist rows, the Settings card and the
module-off action added to the English bundle now exists in German,
Spanish, French, Italian, Polish and Korean, with the same placeholders.
The forty-seven keys the five-step wizard left behind — its carousel,
goal picker, source grid and step counter — leave all seven bundles,
and the English-leak guard drops the one entry that named them.
`POST /api/mental-health/assessments` is fenced on `mind` and writes one row
into `measurements`: the instrument's total, minted `COMPUTED` and keyed on the
administration it came from, so the trend reads through the chart and rollup
tier without the encrypted item answers going anywhere. A grant scoped to mood
and mind therefore adds a point to the owner's measurement series, and nothing
said so.
Left in place rather than fenced. Withholding the projection the way the
booster mint is now fenced would file a screener whose own total is missing
from the record — an inconsistency inside the section the grant does open, to
avoid a derived point in one it does not. So it is documented instead, in the
three places it is decided or agreed to: the declaration, the section's own
definition, and the consent copy in all seven locales, which is where a
widening belongs. The published operation says it too.
The badge read the enabled flag plus a DSN that parses. Neither says anything
about the target accepting a report: a wrong public key, a wrong project id and
a host that stopped answering all parse, and every one of them showed green
with a line naming the host. That is the false reassurance an operator finds
out about when they go looking for a crash that was never reported.
Nothing in the tree recorded an outcome, so there was nothing to read. The
sender now stamps one next to the DSN it used — instant on a success, a short
classification on a failure. Never the response body: that is text from a
remote host and has no business in this database or on this page.
The badge is green only while a report has left inside the last fortnight,
amber while a target is set and nothing has come back, and it names the reason
underneath. The clock lives with the ledger, so the verdict is resolved in the
route and the card paints what it is handed. A failure that a later success
superseded is not shown at all. Pressing Test records like any other send, so
the button proves the target on demand.
On the release that adds the ledger the table is empty, so on a host that has
been uploading nightly for months every account read Never, under a sentence
saying no copy had ever reached the bucket. That sentence is untrue on exactly
the morning an operator opens the new card for the first time, and it is a
claim the host cannot make: what is empty is the record, not the bucket.
The run now records every account it walks, not only the ones an object came
of. A walk that produced nothing moves the attempt instant and leaves the
success columns alone, so the two cases separate: no row at all is no record
yet, a row without a success is never. Never now means what the word says —
a run looked at this account and has never put anything there.
The card names the last check on the never row, so the state is a fact with a
timestamp rather than an assertion. Runbook and all seven bundles carry the
same two sentences. The integration file proves both arms against real
Postgres, including that a failed run leaves the previous good copy on the
row.
There is no default error-reporting DSN in this tree and there should not be:
a self-hoster's crash reports must not leave their host because nobody told
them not to. That part was already right, so nothing here adds a default.
What was wrong is what the console said about it. The GlitchTip card showed a
green "Configured" badge as soon as a DSN existed, whether or not the switch
that actually sends anything was on, and it never said where reports go. An
operator reading that badge has been told their errors are being collected
somewhere, which may be true of neither half.
The badge now tracks reports actually leaving, and one line under the switch
states the case: the host they go to, or that they stay here, or that tracking
is on with no target. The host only — the public key in the DSN answers a
different question. The deploy runbook says the same thing for a
maintainer-operated instance.
The nightly off-host job reports counts — uploaded, failed, total — and a
count cannot name the account it is failing for. A cohort where one object
has been refused every night for a month reads as "99 uploaded, 1 failed"
each time, which looks like weather rather than like an account with no
disaster-recovery copy at all.
The worker now writes a ledger row when an object lands (account, instant,
size), and Admin → Backups gains a second card that rates every account
against the nightly schedule: fresh inside one run, due when one run
produced nothing, stale when two did not, never when no object has ever
landed. On a host without BACKUP_S3_* the card says off-host backup is not
configured and stops there.
The verdicts are computed from the ledger, never from a bucket listing: the
worker's grant is deliberately PutObject + GetObject, and an admin page must
not put the operator's credentials on its render path to state a number the
database already holds.
Two places claimed a stored payload cannot leave the page: the card's own
description ("payloads are not downloadable from this page") and the listing
route's comment ("admins can trigger a re-snapshot but not download another
user's payload from the UI"). Both have been false since the download button
shipped — every row carries one, and the route hands back the decrypted JSON.
The description now says what happens instead: sizes are the encrypted blob,
Download decrypts the snapshot for the admin who asks, the ciphertext stays in
the database. Seven catalogues, and the route comment points at the route that
owns the payload.
A disaster-recovery snapshot carries the singleton `app_settings` row —
registration, the MFA requirement, the default locale and timezone, module
availability, the notification and AI configuration, the document size cap and
quota — and the restore wrote it back as a side effect of restoring one
account. An operator putting a single record back on Wednesday reconfigured the
whole host from Monday's file, silently, with nothing on screen saying so.
Rebuilding a host from a snapshot is a real case, so the write stays: it is now
`restoreInstanceSettings: true` on the request and a checkbox beside the typed
gate, off unless the operator ticks it. Restoring an account and reconfiguring
the installation are two decisions, so they are two answers.
The 422 confirmation sentence and the dialog copy drop the instance-wide claim;
the opt-in carries it instead, in all seven catalogues. Integration coverage
runs both arms: without the flag the settings row keeps its own values, with it
the file's values land.
`GET /api/auth/me` publishes a boolean per module, and three different
situations collapse into the same `false`: the record has the module switched
off, the active grant's sections do not open it, or the operator switched it
off for the whole instance. A client holding the boolean has to guess between
them, so the empty state either says nothing or says something that may be
untrue -- offering a delegate a switch that is not theirs to reach, or telling
an account holder to turn on a module the operator removed.
`moduleAccess` carries the reason beside the booleans, which keep their exact
meaning: `enabled`, `disabled`, `not_granted`, `unavailable`, resolved
outside-in so the reason published is the one nothing further in can change.
`modules[key]` stays exactly `moduleAccess[key] === "enabled"` for every key,
and the two halves are built independently -- the boolean still runs through
`maskModulesToSections` -- so the test of that pairing is an assertion rather
than a restatement of a derivation. It is checked on the pure builder across a
matrix of grants, records and operator blobs, and again on the real route for a
scoped delegate, an unscoped one, and an operator-disabled module.
Both ends in one change, because a payload field with no reader is the failure
this repository keeps rediscovering. The reader is `ModuleDisabledNotice`, the
shared module-off empty state the empty-states audit asks for: it names the
reason in the reader's own language and offers the enable control only when the
record's own switch is what is off, since neither of the other two is fixable
from that page. The nutrients page adopts it and drops its bespoke block, which
had been handing every delegate a button that would refuse them.
Registered in the OpenAPI registry as `ModuleAccessMap` and carried on the
account payload; the native contract gains a field and loses nothing, since a
Bearer request carries no switch and never reads `not_granted`.
A managed profile could be created and deleted and never changed. A name
typed as a placeholder was permanent, a household that moved kept the
timezone the record was created in, and there was no way to correct a date
of birth somebody finally had (#939).
PATCH /api/managed-profiles/{id} takes the fields creation takes, every
one optional and at least one required — an empty body is a refusal rather
than a save that audits a change nobody made. It carries the gate the rest
of the family carries: a cookie session with a fresh second factor, an
active Guardian grant on the record, and the same byte-identical 404 for
"no such record" and "not yours". A GET beside it answers the record as it
stands, because the account payload names a managed record and nothing
else, and a form filled from that would have had blank fields to send.
The new field is gender. Leaving it out of v1.37.0 meant every managed
record started with the column NULL, which is what the cycle module
derives its default from: a guardian creating a record for a child could
neither record the answer nor turn the module off from there. It reuses
the column and the three values every other surface writes, so there is
one word for one column rather than two.
Refs #939
The onboarding baseline step flashed a toast naming one refused field
and walked on to the done screen, stamping the account as set up over
a value the server never stored. The value the person typed was gone
with the step.
A refused field now writes its reason into that field's own error
slot, marks the control invalid, and holds the step until the person
corrects it. Every refused field is named, not only the first, and the
reason comes from the validator code rather than the validator's own
prose, in all seven shipped languages.
`glucose_context` is nullable, but every surface that fanned out over the
four enum members treated the named list as exhaustive. A meter synced
through Apple Health writes no HealthKit meal-time metadata at all, so an
account can hold nothing but rows the list cannot match.
Add one shared notion of the untagged bucket — the name the Coach's glucose
block already used — with a resolver that files NULL, blank and unrecognised
values under it, and a grouper that walks the named contexts first and the
untagged bucket last. The untagged bucket borrows the RANDOM threshold: an
untagged reading is a spot reading, and a fifth threshold would give the user
a second editor row judging the same readings by a different number.
Labels in all seven locales.
Refs #943
useRecordCapabilities answers per section now. canWriteDomain and
canManageDomain read the two lists the account payload publishes; in
one's own record they are always true, and under a refused, pending or
unproven context always false. canManage becomes the coarse switch,
true when any section is manageable, and is what the banner and the
navigation read.
Every add, edit and delete control names its own section: labs, mind,
profile, illness, cycle, medications, measurements, documents. Creates
that a WRITE route answers (visits, practitioners) ask canWriteDomain.
The generic delete button, the selection bar, the capture picker, the
dashboard quick entry and the bottom nav take the section from their
callers. Controls whose routes resolve the caller rather than the
record (settings shortcuts, chart overlay preferences, custom metrics,
the Today rail, OCR, efficacy targets, credential tabs) ask for the
caller's own record instead, and the customise-shortcut guard now
requires that shape.
The vault has no delegated write route, so its controls stay withheld
under every grant, and the documents page says so in one sentence
where the upload control would be, in all seven locales.
`User.glucoseUnit` has existed since v1.2 and around thirty surfaces read
it — the dashboard tiles, the glucose page, the targets panel, the CSV and
FHIR exports, the doctor report, the Coach snapshot, the low-reading push
copy. Nothing could ever write it. No route, no schema, no field on any
form, so every account resolved to the mg/dL default and the mmol/L half
of the app was unreachable code. Anyone who reads glucose in mmol/L
converted in their head on every screen.
This is the missing end: a `GET`/`PATCH /api/auth/me/glucose-unit` scalar
on the pattern the unit-preference endpoint next door established, and a
dropdown in the profile form beside it. Deliberately its own control
rather than a branch of metric/imperial: metric countries are split on
which unit they read glucose in, so one dropdown would get half of them
wrong.
Storage is untouched and stays untouched. Every reading is written in
mg/dL by every ingest path and the unit only selects a display branch, so
nothing already recorded is reinterpreted. The entry form moved with the
preference, which is the half that could have hurt: it asks in the chosen
unit and inverts through `toCanonicalMgdl` on submit, so a 5.3 typed by an
mmol/L reader is filed as 95 mg/dL rather than as 5.3 — a value the
plausibility band accepts in silence and every surface reads back as a
severe hypo.
The contract gains the endpoint and nothing else; the account payload
already carried the field.
The provider-health ledger has recorded the HTTP status of every failure
since v1.11.0 and nothing has ever read it. The operator card could say
three of five chains were failing without saying whose problem that was,
which is the one thing the operator needs: a 401 is a credential only they
can replace, a 429 or a 503 is the provider's afternoon and waiting is the
right move.
The fold carries the status of the failure whose instant the row already
shows, not an aggregate over the column. A MAX of two HTTP statuses is
arithmetic on a label, and a status describing a different failure than
the one on screen would send the operator to the wrong place. The column
joins the grouping key instead, and the fold picks the group belonging to
the newest failure.
Blank where there was nothing to record: a connection that never completed
has no status, and a chain that has since recovered had its status cleared
along with the rest of its failure state.
The weekly pass can stop and leave no trace where anyone looks. The
handler's own error never runs when the job is killed rather than thrown;
`recordError()` increments an unnamed counter; and the admin status page's
failing-queue panel looks back 72 hours, which for a weekly queue means a
permanent failure is on screen three days in seven. The backups page
itself showed an ordinary row with an ordinary timestamp, because a copy
made six weeks ago reads exactly like Sunday's until somebody subtracts
the dates.
The listing now carries two facts that fail at different times: the age of
the newest scheduled copy, which never leaves any retention window, and
the terminal state of the last scheduled run, which is the only place the
reason is written down. `readLastQueueRun` takes the newest run rather
than a window, so a weekly queue's outcome stays readable until the next
one replaces it, and it unwraps the value pg-boss stores a thrown error
under, so the page says "job timed out" instead of raw JSON. The
failing-queue reader gets the same unwrap.
Manual uploads are excluded from the cadence judgement by the shape of the
input, so an ad-hoc upload cannot make a dead cron look alive, and a fresh
instance is not called overdue for having no copy yet.
The listing also stops reading every blob into the web process to measure
it; `octet_length` answers that in the database, which on an instance with
large records was its own way of running out of memory.
The mood page called the plain `pearsonCorrelation` — strength banded from
|r| alone above a floor of five pairs, no significance test — so five days
of noise sloping the right way arrived at the reader as a "Strong" badge,
with n and r behind an info icon. The guarded twin was written for exactly
that failure and names it in its own comment, and the weight, blood-pressure
and mood-status surfaces had already moved onto it. The mood page had not.
Route the page's five cross-metric channels through
`significantPearsonCorrelation` (n >= 20 AND p < 0.05) and carry the reason
a pairing was refused, so the card can distinguish "still building" from
"twenty days and nothing in them" instead of showing the same blank for
both. New copy in all seven locales.
Add a structural guard that fails when a new caller of the unguarded helper
appears outside a written allowlist. Two callers remain on it, each named as
open work rather than as a blessing: the /insights envelope and the AI
feature payload both still band at n >= 5, and moving them changes payload
shapes their own tests pin.
The operator card offered a free-text base URL with an OpenAI placeholder
and no word about what else it takes, so an Anthropic endpoint looked
unsupported even though the field accepts that host. One line beside the
existing hints, in all seven locales, plus the changelog entry.
Settings, Sources listed GOOGLE_HEALTH, OURA, POLAR and STRAVA by their
internal name: the catalogue was written for seven sources and every
provider added since fell through to the raw value. The measurement list
carried the same fall-through in its source filter and its table column,
and the medication intake history read "via APPLE_HEALTH" for a dose
synced from Apple Health.
The three catalogues move into one module keyed by the enum, so an
omission is a type error rather than a raw value on screen, and the sleep
discrepancy tooltip drops the private brand-name map it had grown to work
around the gap.
The card read like API documentation sitting in a settings page: it named
the Authorization header, spelled out both endpoint paths, and listed the
scope's limits as four clauses. None of that is what someone deciding
whether to mint a token needs, and the endpoint paths were already in the
API endpoints card immediately above it.
Rewrite to the shape the MCP connector card next door uses — a short
title, one line on what the token is for, one line saying it is shown
once, and one sentence on what it can and cannot do. Drop the endpoints
note entirely rather than rephrase it, since the card above owns that.
Say the useful consequence instead of the mechanism: readings arrive as
if entered by hand, so they can be corrected. That is the thing a person
will actually notice.
Translations were revised across all seven locales — the previous German
in particular read as translated English rather than as the rest of the
app, which uses short informal sentences.
Since v1.30.17 there has been no self-service path to a token that can
write a measurement. That release made the Bearer scope default
fail-closed, which closed a real privilege-escalation hole, and removed
the generic mint at POST /api/tokens along with it. The generic mint had
been the only way to get a token that could reach /api/measurements, so
a local sensor pushed through Home Assistant or Node-RED lost its door
while the OAuth integrations kept theirs.
Add a narrow scope accepted by exactly two routes, both writes, both on
the holder's own record.
The scope constant is a leaf module beside the surface that consumes it,
matching FHIR_READ_SCOPE and the MCP scopes. requireRecordAuth grows an
options object rather than a sibling function, because the sharing
surface guard keys every leg on that one identifier and a second name
would mean two copies of the delegable declaration. AuthContext carries
the admitting scope as a single nullable string rather than the token's
permission array, so nothing downstream can re-derive an authorisation
decision from it.
A scoped credential is refused a record selector before any grant is
read. Placing the refusal above the sharing check rather than beside it
means no future widening of what a grant permits can reach one of these
tokens.
Rows are attributed MANUAL. An entry that explicitly names APPLE_HEALTH
is refused 422 rather than relabelled: that source is half the
(userId, type, source, externalId) dedup key the iOS client writes into,
it participates in the cross-source merge, and it gates the HealthKit
sync fields. For the same reason a scoped write no longer advances
User.lastSyncedAt, which the phone reads to size its undelivered window.
Also closes a narrower gap found while building this. The idempotency
wrapper resolved a replayed delegated response against a live grant
without considering which credential was presenting it, so a cached
response about a shared record could be returned to a token the handler
itself would refuse. No write was reachable that way; the response body
was.
Guards: T3 gains the mint site, T4's vocabulary gains the scope and
learns to read the requireRecordAuth form (without which this change
would open a way to widen a route that the closed vocabulary could not
see), and a new T5 freezes the set of delegable routes naming a scope.
The sharing surface guard's AST matcher reads only the first two
arguments, so it needed prose but no code.
A pillar can leave the set behind the number without anybody choosing it:
a window rolls past its floor, a module goes off, a source goes quiet. The
delta already refuses to narrate that as a health event, which stops a
false "you dropped six points" and puts nothing in its place. The level
moves, the panel shows a different number, and the cause is nowhere on the
surface. Under the adaptive minimum this matters more rather than less,
because compositions are smaller now and sit closer to their floors.
The report carries `compositionNotice` when today's set differs from the
last local day the account has a stored score for. Optional and nullable,
the additive shape the score's wire has used six times, so a payload
cached before it existed stays valid. It names what left and what joined
as pillar ids; the surfaces already own the labels, and the reason a
departed pillar stopped counting comes from that pillar's own row in the
same report rather than from a second vocabulary.
Two changes deliberately do not raise it. A method move and a recipe
change each already have their own note, and telling someone twice about
one event teaches them to ignore both.
The key is `health-score:v<version>:composition:<hash of the sorted set>`
and rides the existing dismissed-item ledger, so a dismissal holds until
the set moves again rather than until tomorrow. The read excludes today on
purpose: the row for today is written by the same request that computed
the report, so comparing against the newest row would find today's own
composition on the second load of the same page and the note would vanish
between two views of it.
This gives `HealthScoreRecord` its first production reader. Since it
shipped it has been written on every scored read and consumed by nothing
but backup and restore, which is the write-only shape the contributor
notes call a liability and which nothing in the gate can notice, because
every check proves one end. The round trip is proved against real
Postgres: raise the note, wipe the table, restore it through the admin
route, get the same note back, and hold the dismissal across it.
Rendering is one muted line on the score card and a card of its own on the
settings page, beside the recipe note rather than inside it. Deliberately
not a push.
Four fake Prisma clients gain the new read. They are doubles of a client
the reader really does call now; leaving them short and guarding the call
site instead would have been a shim for a caller that does not exist.