`data-latest-rate` is a production attribute whose only reader was the browser
suite, so a change to the rate's shape would have surfaced three minutes and a
whole server away from the gate that should own it. One unit test now renders
the chart over a loaded window and asserts the root carries the last day's
rate.
Two comments were wrong. The card's take / skip buttons do not collapse to
icons at narrow widths — both render their label unconditionally beside the
icon — so only the true half of the reason for addressing them by `data-slot`
survives: the labels are translated copy.
And the Enter press in the wizard's times-of-day helper is not decoration.
The field's overlay input holds what is being typed and hands it to the form
only on Enter or on blur (`src/components/ui/time-field.tsx:175-184`), while
the add button is disabled on an empty draft — so a fill followed straight by
a click waits out the actionability timeout on a button that never enables.
Proven by removing it: the flow died at exactly that click. The line stays,
with the reason written beside it.
The dashboard assertion called itself "the assertion that they cannot drift
apart silently" and then pinned 50. They have already drifted. The tile's
denominator is the day's expected slots, which a deliberate skip does not
leave, and its numerator counts only rows carrying a `takenAt`
(`src/lib/analytics/schedule-anchored-compliance.ts:141-164`). The card's
engine — `src/lib/analytics/compliance.ts`, the documented single source of
the percentage — drops a skip from the denominator outright
(`src/lib/analytics/compliance/ledger.ts:43-47`), which the counts nine lines
earlier already assert. One afternoon, two answers. The note now says that,
names which engine is authoritative, and the number stays pinned as what the
server's daily buckets really produce rather than as agreement.
The refusal flow was named for a schedule that does not exist and proved
something else: the 404 comes from the ownership guard, before the body is
read and before any schedule code runs. `expect(refused.data).toBeNull()`
proved nothing either — the error envelope always carries a null `data`. So
the test is renamed for what it does prove, the empty assertion is dropped,
and the case the old title promised is added: a pin onto an instant that is
not one of the medication's slots, refused with the documented 422.
Two separate ways the flows could not deliver what they promised.
The card read polled for up to seventy seconds and escaped a missing figure by
reloading the page — which re-mounts the list and issues the very batched
compliance call it was waiting out, so each poll spent one more of the
allowance it was short of. Meanwhile the budget is thirty seconds tripled by
`test.slow()`, so one ride-out consumed the whole test and the run died on a
Playwright timeout rather than on the "the card never painted an adherence
figure" message the note promised. The inner poll was also wrapped in a second
one with a thirty-second budget, and `expect.poll` does not abort an in-flight
callback, so a seventy-second call ran to completion inside it.
With the journey on its own account there is nothing to ride out: the read now
waits twenty seconds for the attribute and fails with the diagnosis attached.
The nesting is gone too — the step that waits for the card to re-read its
schedule polls the attribute directly.
The other one is the day boundary. A clock-anchored slot is inside its own
on-time window by construction, so the TAKE is always today's; the dashboard
assertion is not, because it reads the latest day and a day's expected count
carries no now-cap. Cross midnight between the take and the final read and the
latest day is a fresh one with two doses scheduled and none taken, which
answers 0 where 50 is asserted. The flow is a couple of minutes long, so it
declines to start in the last ten minutes of the day and says why.
The batched compliance read allows thirty calls a minute per ACTOR, and the
journey spends that allowance twice over: it writes doses and then reads the
resulting rates back off the same cabinet. On the shared account two siblings
spend it as well — `refetch-nav.spec.ts` mounts `/medications` unstubbed and
drives a client-side round trip back to it, and `delegated-writes.spec.ts`
opens `/medications?new=1` on a SECOND session row of the same account, which
is a different session but the same actor and therefore the same bucket. Add a
serial group's retries and thirty reads inside one minute is reachable rather
than theoretical.
The cabinet has the same shape of problem in the other direction: the clear
before each test deletes every medication row of the account it runs on, and
that is today the account every other authenticated spec holds.
So the journey gets its own account and its own jar. The rate-limit bucket,
the medication cabinet and the dashboard tile's denominator become private at
once.
Two fixture repairs ride along, because they are the same helper:
- The clear now goes out as the app's own DELETE per row instead of one raw
`DELETE FROM medications`. Three fifteen-minute per-user cells hold
medication state and a raw statement evicts none of them; the file survived
only because every test happened to write through the API afterwards, which
is an accident of ordering rather than a property. The rows are still
enumerated in SQL, because the list route serves a memoised shape and a row
it has not caught up with would hide from it.
- `ageMedication` takes both stamps from its caller instead of deriving them
from `CURRENT_DATE`. That resolves in the database session's zone — UTC in
CI — while the plan dates the spec sends are computed in the browser's
pinned Europe/Berlin, so between midnight and 02:00 the row silently started
a day earlier than the caller asked for. The docstring now says `starts_on`
is overwritten, not adjusted.
The catch-all angle-bracket pattern read as an HTML sanitiser to static analysis. The annotation Apple writes is a number, so the pattern now matches exactly that, and a unit with anything else between the brackets stays unknown instead of being silently trimmed into a lookup.
`PATCH /api/managed-profiles/{id}` had none, while creating a record and
inviting a Guardian have both been capped at ten an hour since they shipped —
one family answering the same abuse three ways. It matters more here than the
count suggests: `updateManagedProfile` opens a transaction and takes an
advisory lock on the id it was HANDED before it decides the caller is not a
Guardian of it, so an unthrottled caller can name any id and make the database
hold a lock about it.
The refusal now lands before the id is read, on the same bucket convention and
with the same ceiling as creation. The GET beside it stays unlimited, matching
the guardian roster read next to it: both are cheap identity reads a Guardian
may already make. The 429 is documented on the contract and the edit form's
message mapping grew the arm creation already had.
A managed record with no recorded language opens the edit form's language
control on the actor's, which is the same starting point the creation form
uses. The diff then compared that control against the record's NULL column, so
every save of such a record carried `locale` — a guardian correcting only the
timezone silently recorded their own language as the record's, and the audit
row said the language changed.
The comparison is against the value the control opened on, frozen at mount.
Nothing is sent unless somebody moves it.
The fields component was keyed on the query's `dataUpdatedAt` under a comment
saying the key stopped a refetch landing mid-edit from replacing what somebody
is typing. It did the reverse: the timestamp moves every time the query
resolves, so invalidating the `managed-profiles` prefix from anywhere — the
guardian list on another row is enough — remounted the fields and threw the
half-typed name away.
Keyed on the record's id instead, which is stable for as long as the form is
about the same record.
The `AccountPayload` description still claimed that under an active switch the
payload describes the caller "their preferences, their modules, their
identity", which stopped being true when the module map and the cycle flag
started answering for the active record — `ModuleMap` on the same spec said
the opposite. The iOS client reads this file.
Both descriptions now say the same thing: identity and preferences are the
caller's, `modules` and `cycleTrackingEnabled` are the active record's, and
both are masked to the sections the active grant opens. Spec regenerated.
The Modules save posted `cycleTrackingEnabled` on every submit, and the route
writes it whenever it is present. An explicit boolean beats the sex-derived
default in the cycle gate, so a guardian turning Labs off for a record created
without a recorded sex wrote cycle OFF as a deliberate answer; setting the
record's sex to FEMALE afterwards then left cycle off with nothing on screen
saying why. It also listed `cycleTrackingEnabled` as changed in the record's
audit trail on every save of any module.
The flag now rides only when it differs from the value the form opened on,
which is what the edit form's `managedProfileEditPatch` already does. The
decision moved into a pure `managedModulesPatch` so a test can see it — what a
form does NOT send is invisible in its markup.
Publishing the active record's module map and cycle flag on the account
payload was right for a whole-record grant and too generous for a narrowed
one. `accountAccess.active` is set for any live grant, level and scope
included, so a delegate scoped to `measurements` read whether the owner
tracks their cycle, their screeners, their illness episodes and their
supplement intake, and the cycle flag derives from the owner's recorded sex.
Both fields are now masked to the sections the active grant names: a closed
section answers `false`, which is the answer a module that is off already
gives and the answer the browser's route inventory has always acted on. An
unscoped grant names the whole record and is unaffected, so the own-record
and whole-record payloads are byte-identical to what they were.
The masking table lives beside the sharing vocabulary rather than inside it,
and runs one way only: no value in it can turn a module on, admit a read or
widen a grant.
The percentage on a medication card is the number this product is for, and
nothing in the browser suite walked the path that produces it: the wizard
specs stub `/api/medications` and stop at the POST body, and the compliance
engine's own tests are unit tests over pure functions. The join was the part
nobody checked.
Three flows against the seeded account, serial and in one project because
they read counts back off a cabinet they own:
- a twice-daily medication created through the real wizard, one dose taken
and one skipped from the card, and the figure read back on the card, in
the intake history and on the dashboard tile;
- a dose recorded against a schedule that does not exist, refused, with the
rate exactly what it was before;
- a plan due on two weekdays whose other five days stay out of the
denominator, with a daily twin given the identical doses as the control —
without it, "100 %" would also be what an engine that counted no days at
all would say.
The dose times are derived from the clock rather than picked from the
wizard's suggestions: the card offers one dose, so with fixed times there is
always an hour at which that dose belongs to yesterday or tomorrow, and the
day the write lands on decides what the tile reads. Each medication is aged a
few days as well — compliance is reconstructed from the creation stamp
forward, so a medication created a second ago reads the empty-set 100 % and
no take can move it.
Four surfaces gain data attributes so the journey addresses controls and
figures rather than translated copy: the card's take / skip buttons, the
card's two adherence rates, the intake-history row status, and the dashboard
tile's most recent day, which existed only as bar geometry. Presentation is
unchanged; the compliance block's fixed-geometry guard normalises the two new
rate attributes alongside the ones it already did.
E2. `loadOffhostConfig` parsed BACKUP_RETENTION_DAYS, clamped it to a
minimum of one day and put it on the config object, where no caller read
it. Neither annotation fits: it is not deliberately internal, because
there is no internal use, and no consumer is pending. The design forbids
one outright — the worker's grant excludes DeleteObject on purpose, so
nothing here can act on a retention window without giving a compromised
worker the ability to wipe the history. A clamp that guards nothing only
read as an enforcer the worker is not.
So the field and the clamp go, and the reason sits where the parse was.
The variable keeps its place in the two example files and on the compose
whitelist: it is the number the operator sets the bucket's own lifecycle
rule to. The docs table said "defaults to 30", which implied the app had
an opinion; it now says the app does not read it.
E1. Two halves of one correction. `.env.example` still said backup objects
older than BACKUP_RETENTION_DAYS are pruned, which no code does, so a
self-hoster reading that file alone still expected the worker to keep the
bucket tidy. It now carries the same sentence as the production example.
And that sentence overreached in the other direction: "the worker never
deletes an object" is not true of `_healthcheck/<ts>.bin`, the 1-byte key
the Test-connection button writes and then deletes. The delete swallows
its own failure, so an operator who granted exactly the three documented
actions leaves one orphan per press and is never told. Both files now say
"a BACKUP object", and name the one key and what happens without
DeleteObject on that prefix.
Low, review 2. An account with one out-of-range candidate is refused
whole, rows and audit row included, but the dry run counted its rows into
"N row(s) would be repaired" — so the number an operator read before
--apply was larger than the number --apply could ever produce. The refused
accounts are now counted and reported separately, and the per-account line
says its rows are not in the total.
The refusal itself told the operator to check the Health app "before
forcing anything". There is no --force and never was. It now names the
only route that exists: re-run with the matching --unit if the archive
really was in another one.
U1. The new `unit` description on the batch entry said `value` is read in
the unit HealthLog stores the identifier in. Sixteen mappings disagree:
`convertToDbUnit` runs on every entry, and a waist circumference arrives
in metres to be stored in centimetres, an oxygen saturation and a body fat
arrive as a fraction to be stored as a percent. A client generated against
that sentence sends 98 and stores 9800.
The description now says what happens: `value` is read in Apple's own
HKUnit for the identifier, HealthLog applies its storage scaling
afterwards, and `unit` is recorded for audit rather than validated
against. Two worked examples, because the percent-shaped ones are the pair
that bites. YAML regenerated.
Low, review 2. The self-test walked every leg but never once asked whether
the redactor actually holds. The excerpt path is the only thing between a
token in a failure body and the run log, so it gets its own case: a body
carrying this project's own key prefix must come back with the token
replaced.
Low, review 2. The delete leg asserted HTTP 200 and printed "account left
clean". An instance that answers 200 and keeps the row would have printed
the same line, which is the failure mode the journey exists to catch.
The leg now reads the id back and requires a 404. The self-test carries a
mock that answers 200 without deleting; with the read-back removed that
case goes green, which is the proof the check can fail.
S2. The legs ran in one loop that returned on the first red one, so a
failed read back never reached the delete and the probe account kept the
row. Worse, the id was assigned only after the write's await returned: a
POST that timed out once the server had already committed left a row the
script could not even name.
Legs 3 to 5 now sit in a try/finally. The finally sweeps whatever the
delete leg did not, and when there is no id it asks the instance for the
row by a marker the write puts in `notes`, unique per run and known before
the request goes out. The sweep never throws — the red leg is the verdict,
and it says out loud what it could not remove.
--self-test asserts the mock holds no rows after every case that got as
far as the write, so a red read that skipped the delete is caught.
S1. The journey signs in with a real account and POSTs that account's
password to whatever BASE_URL names, and the workflow took BASE_URL as
free text. Anyone with repo write, or a leaked token, could dispatch the
run at a collector of their own and keep a working credential; secret
masking guards the log, never the egress.
The host now has to be on a short const allowlist in the script, checked
before anything is sent, with exit 2 and a named refusal otherwise. The
workflow input is a choice between the two hosted instances, so the form
cannot offer a third; the script's own check still stands behind it for a
dispatch that goes round the form.
--self-test covers the refusal, the prefix lookalike a startsWith check
would have waved through included.
The other half of the switched-payload change, found by the integration
test that asserted the door was gone and got the door. `isNavDestinationVisible`
short-circuited on `sharedRecord`, so a switched session skipped the module
filter entirely and every destination the grant's scope allowed was offered.
That was right while the map was the actor's: a delegate's own preferences
must not hide a domain the record granted. The map is the record's now, so
the objection is gone and the line is what makes a guardian's toggle visible
inside the profile it was set for. Scope decides which doors the grant opens;
the map decides which of them the record uses at all.
The read-only profile summary needs its marker checked against `!sharedRecord`
rather than reached through the removed short-circuit, and the case that
pinned the old reading is turned round with the reason written out.
Refs #939
`auditLog` serialises `details` with `JSON.stringify` into a text column,
so the assertion was reading a field off a string and finding nothing.
Refs #939
The client's notification service extension replaces the medication name
on the lock screen, and iOS routes a payload through an extension only
when the alert carries `mutable-content`. The dispatcher has set the flag
on every alert it builds since the category work for the v0.5.4 client,
but the only assertion on it sat inside a category test, where dropping
it would have failed nothing and the name would have quietly reappeared
on the lock screen of a device that asked for it to be hidden.
Two cases now say it out loud: the medication reminder carries the flag,
and so does another event type, because that is the shipped behaviour and
narrowing it would take a capability away from the client rather than
tidy something up.
The native client replays its offline outbox with the same
Idempotency-Key on every attempt, and `POST /api/medications`, the
side-effect log and the inventory register ran the handler again on the
replay: after a lost success response the account was left with a
duplicate medication, a duplicate symptom entry or a second pen in the
supply count. The dedupe on `(externalSource, externalId)` only ever
covered a MIRRORED create; a manually entered medication carries
neither field and had nothing to collapse a retry onto.
All three wrap in `withIdempotency`, the composition the batch ingest
already ships, and the three operations publish the header so a client
generated against the contract can tell that a retry is safe. Zod
parsing and `requireRecordAuth` are untouched.
An integration test drives each route twice against real Postgres and
asserts what a status check cannot: one row in the table, and a replayed
201 carrying `X-Idempotent-Replay: true`.
Post-publish verify proves the published image boots. Nothing proved that
on a running instance a person can sign in, write a reading and read it
back — which is exactly the shape of the defects this line keeps
rediscovering: a value written and never read.
`scripts/synthetic-journey.mjs` walks that journey in plain Node 22 with no
dependencies, so it runs from a workstation, a runner or the deploy host.
It warms `/`, `/api/health`, `/api/version` and the measurements list first
and discards those timings (the first hit on each route after a deploy is a
cold start), then asserts five legs in order: the served version equals the
expected one, the password flow signs in through a cookie jar, a weight
reading is written with an Idempotency-Key, the same row reads back with
its value, unit and source, and the reversible delete leaves the account
clean. One line per leg with the elapsed time; the first failure ends the
run non-zero with the status and a body excerpt that has every token shape
redacted out of it.
The workflow is `workflow_dispatch` only, with `base_url` and
`expected_version` as inputs and the credentials read from repository
secrets. It is deliberately not wired into the deploy path: the target
instance and the secrets are the maintainer's call.
Mutation proof, against a production build served from
`.next/standalone/server.js` on a throwaway Postgres.
Green:
warm 35 ms (discarded)
ok version 2 ms serves 1.38.13
ok sign in 78 ms session cookie held
ok write 64 ms wrote cmtup9lwu0007zhovn0mxajdz
ok read back 8 ms 77.7 kg (MANUAL)
ok delete 27 ms account left clean
synthetic journey green
Read leg broken deliberately — the same server behind a throwaway forwarder
that empties the `GET /api/measurements` list and changes nothing else:
ok version 4 ms serves 1.38.13
ok sign in 38 ms session cookie held
ok write 40 ms wrote cmtupa4ck000gzhovwnij64fz
FAILED read back 8 ms read back: the list came back without the reading
status 200
body {"data":{"measurements":[],"meta":{"total":0}},"error":null}
synthetic journey RED on "read back"
Version leg broken the same way, by asking the instance for the previous
tag:
FAILED version 3 ms version: instance serves "1.38.13", expected "v1.38.12"
synthetic journey RED on "version"
`--self-test` carries that proof in the repository: it runs the journey
against an in-process instance nine times, once with nothing broken and
once per broken leg, and fails if a broken leg still comes back green. The
workflow runs it before it touches the real instance.
Gates: typecheck, `node --check`, lint (the three known warnings, no
errors), prettier, the workflow and CI-determinism guards.
The glucose display unit had thirty-odd readers and, until recently, no
control to set it. Nothing in the browser suite walks the path a person
actually takes: capture a reading in mg/dL, switch the account to mmol/L,
capture a second one, and read both back — each converted, none of them
reinterpreted — in the list and on the dashboard strip.
The journey covers that end to end, plus the boundary: a value past the
plausible ceiling is refused by the server, the capture keeps what was typed,
and the list is exactly what it was.
One reading arrives with no meal-time context, the way a meter export or a
device sync delivers one. It reads back correctly in the list; the tile it
should paint does not exist yet (#943), so the tile assertion is skipped by
issue number rather than softened into something that passes.
Three supporting changes:
- The list rows carry what they hold — type and resolved display unit on the
row, a slot on the value cell — so the journey pins "this reading now reads
in mmol/L" structurally instead of matching translated copy that also paints
through a different subtree on the card list. The meal-time options carry
their enum for the same reason.
- The sharing-marker weight rows were seeded by handing a Date to a
`timestamp without time zone` column, which stamps them in the host's zone.
On a machine east of UTC that put them hours in the future, where every fold
whose window ends "now" skips them, and the type stayed uncovered — which
keeps the snapshot's thick phase cold and the whole tile strip short. Seed
the instant as UTC, the shape the app's own writes use.
- The file mutates one shared account and reads global verdicts off it, so it
runs in a single project alongside the visits and immunization journeys.
Upload a synthetic export.zip on the import card, wait out the queued
job through the card's own status poll, and read the imported readings
back off the measurements list with the units the parser resolved.
Weight, the blood-pressure pair and the step count are asserted; the
walking-distance case is written and skipped, naming issue #944 — a
record written in km lands as metres today, and the assertion is kept
at full strength so the fix has something to turn green.
The refusing control uploads a valid ZIP with no export.xml: the card
surfaces the extractor's own refusal and the account's Apple Health row
count does not move.
Measurement rows now carry data-measurement-type / -value / -unit
beside the rendered cell, on both the desktop table and the mobile
list, so an assertion binds to the reading rather than to whatever the
locale formatter made of it. The import card's error line gets a test
id for the same reason.
The runbook said the worker prunes objects older than BACKUP_RETENTION_DAYS. It never calls DeleteObject; its grant is PutObject, GetObject and AbortMultipartUpload, on purpose, so a compromised worker cannot wipe the history. The sentence now says what expires objects and what the variable is for.
BACKUP_S3_REGION and BACKUP_RETENTION_DAYS are both on the compose whitelist and
both read by the backup job, but the example file listed neither — so an
operator following it had no way to learn that the region defaults to auto, or
that the retention number is what the bucket's own lifecycle rule has to be set
to, since the worker never deletes an object itself.
The settings profile form painted the server's refusal under the input
and left it there until the next save, so a value the person had already
corrected still read as refused. The onboarding baseline step drops that
sentence while they type; the account card now does the same.
Only the edited field's slot goes: the other fields the same save
refused were not touched, so their sentences still stand. The guard
derives both sets from the form itself, so a new rejection slot without
a clear fails.
Review finding L3.
The guard read `* 1000` anywhere in the two mapping files as a
re-implemented kilometre factor. Both files also turn seconds into
milliseconds, so an unrelated conversion would have failed it for the
wrong reason and the next person would have deleted the rule.
A rescale now counts only in a length context: a distance identifier on
the same line or within the five above it, which covers the body of a
distance-named helper. The offending line is named in the failure. The
matcher's limit is written next to it — a length conversion in a helper
named nothing like a distance, more than five lines below its signature,
still slips through.
Checked both ways against the real file: a millisecond conversion passes,
a `km * 1000` helper fails.
Review finding L4.