Release v1.38.15 - #946
Merged
Merged
Release v1.38.15#946
Conversation
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
Turning a module off for a profile you look after wrote a column nothing on screen was reading. GET /api/auth/me published the ACTOR's module map while switched, and the navigation, the dashboard tiles and the insights sections all gate on it — so a guardian who turned Cycle off for the child kept seeing a Cycle destination inside the child's record, and a guardian who tracks it saw one there whether or not the child did (#939). The map and the resolved cycle flag now answer for the record the session is inside. They are not display preferences: every surface they gate shows the record's data, so the record is what decides. Locale, theme and layout stay the actor's. The record is taken from the grant-validated active entry rather than the session's own selector, so a revoked switch cannot publish somebody's configuration, and with no switch the payload is what it was. Cycle also gets a row on the guardian's Modules surface, which it never had. It is a delegated module, so its state is the record's own cycle profile and modulePreferences.cycle is refused — which left a guardian no way to turn it off for a record whose sex says it should be on. The family names the real column instead of a blob entry the gate ignores. Refs #939
Picks up the managed-profile read and edit endpoints, the sex field on the create body, the record-scoped module map on the account payload, and the delegated cycle field on the record's module settings. 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 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
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.
…ation 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 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.
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 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.
… a record 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.
`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.
The doctor report had unit coverage for the renderer and route coverage for the envelope, and nothing that pressed the button. Two browser journeys close that. `doctor-report.spec.ts` seeds a few weeks of blood pressure and weight, one allergy and one medication through the page's own `fetch`, picks a window in the export panel and generates. The saved download is asserted as a real artefact — `%PDF` magic, a `Content-Length` that agrees with the bytes, a length no empty document reaches — and then parsed back to text, so the period line, the blood-pressure section and the drug list are read out of the document rather than inferred from a 200. The period is checked as a span, so the assertion survives the account's timezone. Its refusing control is a window with no readings: still a valid, readable report that names the period and carries no blood-pressure section, with the reference data that is not window-scoped as the control against a blank page passing. `doctor-report-delegate.spec.ts` covers the access boundary the route draws at MANAGE. A delegate inside a record it may only read is not offered the export card at all, and the route refuses the request that card would have sent with `sharing.access.denied`. The positive control is the same delegate, the same payload and the same switched-record path against a record it manages, so the one variable between pass and refusal is the access level. Both requests carry the record-context headers the shipped client sends: without them the fence refuses with the same status and the same error code, which would make the refusal pass for the wrong reason. Seeding is idempotent — a re-posted reading collides on its natural key and answers 409 — so a second local run and a repeated run both stay green. The delegate journey takes its own cookie jar because it moves the session's record selector, and both specs run in one project.
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.
… day 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.
… refusals 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.
…wo notes `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.
Generating a report writes: the route remembers the scope and the practice name on the account row, and the export panel a later visit opens is therefore the collapsed "repeat run" state. The journey was generating on the shared fixture account, which is the account `settings-export.spec.ts` asserts the first-run state against in nine places — an expanded picker, an empty summary, a disabled button. The two files run in an unconstrained order under two workers, so the first symptom was an order-dependent red in CI; against a persistent local database the flip never cleared and the red was permanent. The journey now seeds, generates and reads back on `e2e-report-owner`, with its own cookie jar. The shared account and the whole-record fixtures also get `report_selection_json` and `last_report_practice_name` cleared on every seed, which repairs a database an earlier run already dirtied and keeps a delegate's scope choice from being left on a record owner's row. The account's date order is pinned rather than left to the locale, and its export bucket is purged beside the share-link and login buckets: the 10-per-hour ceiling is shared by every export route, keys on the actor and outlives a run by an hour, so repeated local runs would otherwise meet a 429 the spec reports as a missing artefact.
…e level The delegate spec asserted the absence of the export card inside a READ-level record and presented that as the UI half of the MANAGE boundary. It is not. `/settings/gesundheitsakte` is a personal destination, and the shell answers it with the "not part of shared access" panel at every adult grant level — read, write and manage alike — so the same two absences would pass unchanged inside the record the delegate manages. The assertion could not fail for the reason it named. Both arms now open the page. Inside the managed record, where the route answers 200 for the same request, the card is still absent: that is what makes the destination the cause. The level-sensitive statement stays where it belongs, on the route's 403, and the file header now says which half proves what.
The period assertion hardcoded a slash-separated month-first rendering. The field order comes from the account's date-format preference, so a DMY account prints dots and fails with a message claiming the report carries no period at all, while an en-GB rendering matches, swaps the two fields and computes a nonsense span in silence. The order is now read off the account and the separator is captured once and back-referenced, so all three renderings parse and none can be read as another. `AUTO` is refused rather than guessed at: the fixture pins an explicit order, and an account that lost it should say so. The empty-window control also asserts the span it got. It had two silent fallbacks under it — the panel sends a day count when a date field is empty, and the route falls back to its 90-day default on a bound it cannot parse, neither a 422 — and both landed on a window that holds the seeded readings, so the missing-section assertion below was passing on the fixture's luck.
The mobile exclusion carried an inherited second reason — that mobile emulation emits no download event for the panel's anchor click. The precedent it came from contradicts itself: the JSON example-download test in `settings-export.spec.ts` asserts a real download event in the mobile project, and that spec also measures this very panel's tap targets there. The honest statement is that the panel's mobile layout is covered elsewhere and this journey is about the artefact. The bytes are read off the download because the saved file is what the person ends up with, not because a consumed body reads back empty — Playwright buffers the response independently of the page. And the filename assertion covers the name the client anchor invents, not the route's `Content-Disposition`; the two differ by a word, so it is not contract coverage of the header.
Enrol TOTP from the settings surface, sign out, sign back in through the challenge, and step up for a recovery-code rotation — in a browser, against a real server, on a throwaway account the journey seeds and drops itself. Four refusals ride alongside the four positives: a wrong code at the challenge (401, no session cookie, and the ticket's attempt counter moved), the enrolment code replayed (401, refused by the monotonic totpLastStep floor), the rotation once the session's mfaVerifiedAt has aged past the five-minute window (401 with meta.errorCode auth.stepup.required), and a recovery code presented a second time (401). Each was proved to fail by removing the control it stands on and watching that one assertion break. Codes are computed from the enrolment secret with the otpauth dependency the server verifies against, at explicit timestamps, so nothing waits on a step boundary: the sign-in code is generated one step ahead of now, inside the ±1 drift window and necessarily above the replay floor. The fixture reaches Postgres for the two things that have no endpoint by design — ageing the step-up stamp, and emptying the per-IP auth buckets this journey would otherwise exhaust. The account carries the Playwright project name so the two browser projects never drive one factor state. Adds data-testid hooks on the TOTP card, the login step, and the password form so the spec asserts on stable attributes instead of rendered text.
A refused client had nothing to back off against. `rateLimitHeaders()` emitted `X-RateLimit-Remaining` and an ISO `X-RateLimit-Reset`, no `Retry-After` and no cap, and only about 70 of the 199 route files that consult a limiter attached even those two. The rest answered with a bare prose envelope, including every endpoint an offline replay queue hits: the four batch ingests, `/api/sync/changes` and `/api/auth/refresh`. A first-run Apple Health import trips the 60-per-minute batch ceiling reliably, and the only strategy left to the client was a blind backoff. Threading headers through 129 handlers is 129 edits the next new route forgets, so the verdict is carried instead: `checkRateLimit` records it in a request-scoped cell and `apiHandler` dresses any 429 that leaves without headers. A route that already built its own is left alone — `Retry-After` is the presence test, and it comes from the same builder. `Retry-After` is whole seconds rounding UP (RFC 9110 §10.2.3), floored at zero. `X-RateLimit-Limit` is new and needs the cap, so the limiter result carries it. `X-RateLimit-Reset` keeps its ISO form: it is the wrong convention for the name, but it is on the wire today and changing it is a decision of its own. The cell lives in its own leaf module because 177 test files replace `@/lib/rate-limit` wholesale with a mock factory, and `apiHandler` must not depend on anything those factories stand in for.
`/api/measurements` and `/api/labs` sort on one non-unique column and
page with `skip`/`take`. When the sort key ties, Postgres does not
promise the same order for the query that fetches page N and the one
that fetches page N+1, so a client paging its own history can receive a
row twice and never receive another — silently, on both sides.
Ties are not hypothetical here. The partial unique index forbids two
live measurements of the same type at one instant, but the tie that
occurs in production is the cross-type one: a daily `stats:` rollup
posts steps, distance, flights and active energy at the identical
timestamp, and an unfiltered history list pages straight through the
block. A lab panel drawn in one sitting shares `takenAt` across every
analyte in it.
Appending `{ id: <same direction> }` to the `orderBy` makes the order
total. Same fix on the five siblings that page the same way — medication
intake history, illness day-logs, custom-metric entries, reminder
history, mental-health assessments — because the defect is the shape,
not the two routes it was found on. No wire change; `/api/sync/changes`
already ordered this way.
The fixtures seed ids in an order unrelated to insertion order, so the
assertion on order inside the tied block can only hold if the secondary
key is doing the work: with the tiebreaker removed the new file fails on
all three cases.
The two compliance engines disagreed about a deliberate skip. The dose-history ledger (`tallyComplianceFromLedger`, behind the medication card, the dose history, the doctor report and the Health Score pillar) has excluded a skipped dose from the denominator since v1.15.9 — a skip is a pause, not a miss. The schedule-anchored engine behind the dashboard tile and the adherence storyline built its denominator from the raw expected-slot count with no skip term, so it counted the same slot as expected-and-missed. A twice-daily day with one dose taken and one skipped read 100 % on the card and 50 % on the tile. The skip term now lands in the engine, not at the call sites: the day's expected count drops the doses the user skipped, capped at the minted slots, and a day whose every expected dose was skipped falls out of the buckets the same way it falls out of the ledger denominator. The schedule anchoring, the per-day buckets and the duplicate-log cap are untouched — only the skip treatment moved. `skipped` outranks `autoMissed`, mirroring `reconstructDoseHistory`, so a dose the auto-miss cron marked forgotten still counts against the rate. A new parity test feeds one fixture (twice daily, one taken, one skipped, one missed) to both engines and pins them to the same figure, so the class cannot come back through either side; both new cases were confirmed to fail against the previous behaviour. The tile-engine suite gains the corrected figure.
…lope Every documented 422, 401, 403, 409, 410 and 429 points at one `ErrorEnvelope`, and that schema declared `data`, `error` and a `meta` closed to `requestId` + `errorCode`. Two things were wrong with it. `returnAllZodIssues` has sent a third top-level key since v1.4.42 — `details.issues`, one entry per rejected field — from 166 route files. It exists so a client can fix three bad fields in one round-trip instead of three, and the doc comment says so, yet no component schema anywhere declared it. A client generated from the contract had no field to read it from, so the feature was invisible to exactly the consumer it was built for. It is now a schema, with the `unrecognized_keys` list and the promise that no rejected value is echoed back. And `meta` was closed while real refusals put more in it: `removedIn` / `replacedBy` on a 410, `module` beside `module.disabled`, the per-integration context on a failed connection test. Closed plus those keys means the response was invalid against its own published schema and a strict decoder is entitled to reject it. It is open now. No wire change; both halves describe what the server already sends.
`GET /api/sync/changes` returns five domains — measurements, mood, intakes, cycleDays, cycles — and has since the cycle module landed. The registered schema declared three, closed the object and required only those three, so `cycleDays` and `cycles` did not exist in the contract: full day-log and cycle payloads, both upserts and tombstones, computed and serialised on every single pull, invisible to any client generated from the spec. The body was also invalid against its own schema, since the object was closed against the two keys it always sends. The day-log and cycle DTOs were already published for the cycle routes, so the two upsert arrays reuse them and only the tombstone shapes are new. The module's own description and the operation summary were stale in the same direction and now name all five. This is the two-ended change the contributor notes describe: the writer shipped, the contract half did not, and no gate noticed because every gate proves the other end. No wire change.
Response drift was structurally undetectable. `openapi:check` proves the YAML matches the registry and the route-coverage guard proves the verb is listed; both pass when the registered schema disagrees with what `apiSuccess()` sends, and the coverage guard says so in its own doc comment. That is how `/api/sync/changes` shipped five domains against a three-domain contract and how the error envelope went years without declaring the field the multi-issue 422 was built for. Two bodies, the two that were broken: a full `/api/sync/changes` page with live and soft-deleted rows in all five domains, its `cursorExpired` short-circuit (a second shape under the same schema), and a 422 carrying more than one issue. `safeParse` alone would not have caught either finding, because Zod strips undeclared keys instead of refusing them — a schema short of the wire parses cleanly. So the parsed value is compared back to the raw body; a strip means the contract is missing something the server sends. Removing `cycleDays` / `cycles` from the sync schema, or `details` from the envelope, fails all three cases.
Both asserted the single-column `orderBy` the offset lists used to build, so they held the defect in place: a test that pins the shape a fix has to change is not evidence, it is a lock. Turned round to assert the total order, with the reason at the call site.
The replay control submitted the enrolment code at the login challenge and asserted a 401. That 401 is the same answer the route gives a code that has simply drifted out of its ±1-step window, and by the time the journey reached the challenge — a rotation, a refused rotation, a sign-out, a page load and an Argon2id verify later — the enrolment code usually had. Out of window, the refusal happens before the monotonic floor is ever consulted, so the control could pass with the floor deleted. Both TOTP refusals now assert the verdict the route records itself, auth.mfa.failed with details.replay, written and awaited before it answers: false for a code that never matched the secret, true for the replay. Flipping that verdict while leaving the refusal in place turns the positive half red, which is what the old assertion could not do. The replayed code is the one the previous sign-in just spent, so the window is a property of the journey rather than of the wall clock: the accept is taken at a step boundary when the current step has too little left, which bounds the wait by one period and leaves the pair at least two. The rotation stages no longer press Escape to get the confirmation out of the way. They assert the card closes it, by test id.
The journey emptied every `auth:%` bucket before each sign-in, which is wider than it needs and takes the register, passkey and reset buckets with it — a future spec measuring one of those throttles would find its evidence deleted by an unrelated sign-in. The clear now names the two surfaces this file spends. It also left the throttle as the one auth control the journey rides on and never sees: no spec in the suite asserted a 429, and this is the only one that posts a real login at runtime. A closing stage spends the cap from an empty bucket and asserts the answer that goes over — 429, with the bucket's own remaining and reset headers. It costs nothing: the account is dropped immediately after and nothing else signs in from there. The bucket is keyed per IP and every spec on the machine is that IP, the sibling browser project included, so the measurement is taken in one project and a round disturbed by the sibling is retried rather than asserted. Two probes emptying and filling one bucket beside each other were seen to measure their own interference instead of the limiter.
The spec re-declared the server's algorithm, digit count and period as literals, so a change to any of them would leave the spec generating codes the server no longer verifies and blaming the product. It imports the three exported constants instead; the recovery-batch size stays a literal, because its module pulls in the database client and would drag the generated client into the Playwright process. The two credential fields on the login form are addressed by test id now, like the submit button beside them, and the recovery toggle at the challenge is renamed to the house's noun-verb order.
…urfaces One journey across the whole path: an account writes readings in three domains and files a PDF, downloads its own record as the passphrase-sealed archive, an admin snapshots it from the backups console, and the snapshot is restored over an account that has since lost a reading and a dose and gained one the snapshot never saw. The assertions address what the documentation promises rather than what the implementation happens to do. The archive is checked byte by byte against the HLX1 header in src/lib/export/passphrase-archive.ts — magic, version, KDF id and the Argon2 cost the file carries — because "a file arrived" is equally true of plaintext JSON under a new extension. The restored reading and dose are checked by their ORIGINAL ids, because a restore that re-created them under fresh ones would have written a different record that reads the same. The reading created after the snapshot is asserted GONE: the runbook says the restore replaces the account's data tables, and replacing is not merging. Two refusing controls. A stored copy with one flipped ciphertext byte is refused with the route's decrypt failure and leaves the record untouched — the envelope is AES-256-GCM and its tag covers every byte, so the flip is caught before the transaction opens. A delegate holding whole-record READ inside the account gets the not-covered panel instead of the export card, never reaches the console, and is refused 403 by the list, the manual run and the restore, the last of them naming the real snapshot so the refusal cannot be mistaken for a missing row. Waiting is on the job's own evidence: the console's endpoint is polled until this account's stored copy is newer than the click, so nothing in the file guesses at a queue. The journey has its own account, its own delegate and its own cookie jars; only the reset between repetitions and the deliberate corruption reach Postgres directly, because a browser cannot express either. The backups console gains data-testid hooks and a data-backup-id on each row so the spec addresses controls rather than translated labels.
The request-scoped capture kept the last verdict of any kind and fell back to it when nothing had refused. That fallback fires in exactly one state — no limiter refused this request — which is the definition of "this 429 came from somewhere else". So a daily AI budget that is spent until midnight, an hourly generation quota, and a provider-side 429 relayed onward all went out carrying our own bucket's numbers: a remainder that says there is room left and a Retry-After measured against an unrelated window. On the AI paths the captured window can also expire while the provider call runs, so the dressed delay could land at zero and tell the client to retry immediately into the ceiling that had just refused it. The cell now keeps refusals only. A request every bucket let through reports nothing, and a 429 from another cause leaves undressed, which is correct: there is no bucket to describe. The two-limiter rule is unchanged — the last refusal wins, and a refusal still outranks a pass. The unit case that pinned the fallback is turned round, and both directions now run through the real wrapper: a limiter refusal gets the headers, a 429 raised without one gets none. The integration file gains the two shapes it was missing — a route that consults two buckets must report the one that refused, and a route whose 429 comes from the daily budget must carry no rate-limit headers at all.
`resetAt` is written by Postgres as `NOW() + interval` and subtracted from the app process's clock, so a row that still refuses can read as already expired here. The old floor let that render as `Retry-After: 0`, which tells a client to retry immediately into the ceiling that just turned it away — a contradiction of the status it rides on, and a hot loop for anything that honours the header. One second is the honest minimum on a 429. The rounding-up rule and the reason for it are unchanged; only the floor moves.
… covers Three refusals still answered 401 with prose alone: the Bearer re-read on `/api/auth/me/sessions`, and the wrong-second-factor arm on both MFA step-up routes. The shared 401 description told clients the code is the field to branch on, so a client that took that advice landed in its default arm on all three — and on the MFA pair that is the difference between re-prompting for a digit and signing the user out over a typo. The sessions re-read is the same missing-credential fact the gate already names, so it reuses `auth.missing`. The MFA pair gets a code of its own, `auth.mfa.code_invalid`: none of the existing ones fit, because every other 401 code asks the client to discard something, and this one asks it to keep the session and try another code. The description narrows to match what is actually true. The generic gates name a reason; a route that checks a credential of its own — sign-in, the MFA challenge exchange, a password confirmation — still answers 401 with prose, and the text now says so instead of promising a field those responses do not carry.
… correctly The comment on the measurements list justified the tiebreaker with an Apple Health import stamping long runs with one instant, and said the unique index forbids two live measurements of the same type at that instant. The index is `(userId, type, measuredAt, source, sleepStage)`, so the named example is the one case it does block — while ties survive across `type` (the `stats:` daily rollups), across `source` (two writers landing together) and across `sleepStage`. Same conclusion, honest premise. The per-day drill-down on the same route still sorted on `measuredAt` alone. It slices in memory rather than with `skip`, so nothing drifts between pages, but two identical requests were free to render a tied block in different orders, and a list that reshuffles on refresh reads as data changing. Three of the tiebreakered routes had nothing pinning the order: custom-metric entries, the per-medication intake history (both sort arms, so the NULLS LAST pin cannot be lost to the tiebreaker) and the mental-health assessment history. Each now asserts the `orderBy` the way the illness day-log test does.
The shared 429 named `Retry-After` and the `X-RateLimit` triple in prose, so a generated client got no typed accessor for the field the same sentence tells it to back off on. They are a `headers` block now: `Retry-After` and the cap and remainder as integers, the reset instant as a date-time. The text also says what a 429 from a ceiling that is not one of these buckets carries — nothing, because there is no bucket to describe. Two route descriptions still listed the pre-change header set: the ingest 429 named only `X-RateLimit-Remaining` / `X-RateLimit-Reset`, and the document suggest 429 named only the reset instant beside `meta.retryAt`. Both are short of the wire in exactly the direction this work exists to close.
…ointer Three consent-surface tests mocked the shared AI-consent bucket at 60 where it is 20, and the analytics-read and MCP mocks named 60 against real caps of 120. Nothing asserts the number today, so nothing was wrong on the wire — but the mocked verdict now carries `limit` all the way to `X-RateLimit-Limit`, and the first test that reads that header off a mock would pin the fiction. The response-shape file's header pointed at a document that is not in the repo; it says what it means directly instead. It also now records the one thing the gate cannot see: `meta` is a loose object on purpose, so undeclared keys inside it survive the parse and the strip comparison does not reach them.
…d the corrected error contract
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.
A `DataBackup` row that fails to decrypt answered 500 on the restore, the download and the preview. It is not a server fault: either the key that wrote the copy is no longer in `ENCRYPTION_KEYS` — a rotation that dropped the legacy entry before the script reported zero rows, the case the rotation runbook warns about — or the stored bytes are not the ones that were written. Every other bad-input arm on those routes already answers 4xx, and the 500 also paged the error reporter as if the process had broken. All three now answer 422 with `meta.errorCode = backup.payload.undecryptable` and one shared sentence, so the three routes cannot drift into three wordings for one condition. The download's second 500 stays: a payload this instance wrote and cannot parse really is a fault here. The two routes that open a stored copy move from the unpublished list into the contract, because the refusal is now a promise an operator can look up rather than whatever the handler happened to answer. The restore publishes its `Idempotency-Key` header with them.
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.
Two gaps in `backup-restore.md`. It said the restore "replaces the account's data tables" only in a paragraph about grants, so nothing stated the consequence for the account's own record: the rows it gained after the snapshot was taken are deleted with the rest of their class. And the console restore was named once in passing — `## Restore` documents only the off-host script — so an operator restoring from the browser had no procedure to read. Both are written down now, including which button does what, that the restore targets the snapshot's owner rather than the admin running it, that instance-wide settings ride along only when the dialog's opt-in is ticked, and what a copy this instance can no longer decrypt answers.
…ision
One browser journey for the web arm of notification delivery: an account
configures its channels on the settings surface, a due medication reminder
runs through the product's own sweep, and the dispatch decision is read back
off that account's `push_attempts` ledger via the admin diagnostic.
What it pins:
- the enabled channel delivers (`result: ok`) and the message really leaves
the process, asserted against a local SMTP responder rather than against
the ledger row alone;
- a channel that is configured and switched OFF is never attempted, and its
own test control is inert on the surface;
- the APNs arm is attempted and skipped, and the ledger's reason
distinguishes the transport refusing (`apns_not_configured`) from the
client-managed opt-in refusing (`client_managed`) — with email still
delivering under the opt-in, which is the published "APNs leg only"
contract;
- an ntfy relay URL inside a private range is refused with 422, leaves the
stored config untouched, and writes no attempt.
Email is the channel exercised for real because it is the only one that can
be: every account-supplied delivery target crosses the SSRF floor
(`isPublicUrl` plus `safeFetch({ requirePublicHost: true })` with a
connect-time address pin), so ntfy, the generic webhook and Web Push refuse a
local stub by design, and Telegram and APNs address hard-coded third parties.
Email's transport is operator configuration, so `e2e/setup/smtp-stub.ts` can
stand in for it end to end. The config points the e2e server at that stub.
The journey carries one skipped test at full strength: the medication detail
surface decides between the reminder switch and the client-managed chip on
`notificationPrefs.medication.clientManaged` read out of `/api/auth/me`, and
that payload does not carry the field. The flag therefore reads as false for
every account and the chip never renders, so a person whose phone owns the
reminders is still shown a server-side switch that no longer decides anything.
The write path and the dispatch decision are both correct; only the web
reflection is missing its end.
The account and its jar are seeded in global setup, and its channels, devices,
ledger and preferences are reset before every test so the counts are about the
run in front of them.
…promises The file claimed it stayed inside its own account. "Backup now" enqueues the pass for every account on the instance, so the header says that now, and says what is confined instead: the restore's transaction, and the instance-wide settings the dialog's opt-in leaves alone — which the journey asserts is clear before it confirms, because a default that flipped would hand this file the power to rewrite the document size cap for every spec sharing the server. The run's own answer is asserted where it happens. Without it a missing worker cost two minutes of polling and then blamed the backup pass for a 503 about something else; the failure now names the endpoint in two seconds. The rest are the assertions that pinned incidentals rather than promises: the Argon2 cost comes from the constant the writer exports, so a legitimate cost bump is not a red spec; the document is found by id rather than by position; the poll compares the row against the row it saw before the click rather than against the runner's clock; the non-admin bounce is pinned by pathname; the empty-state CTA has its own test id, so nothing has to pick between two buttons that share one; the deliberately corrupted copy is deleted when the control is done with it instead of sitting in the console's list; and the fixture orders by id after the timestamp so a tie cannot present as a change.
`POST /api/admin/notifications/reminder-check` walked every active medication on the instance and dispatched a reminder for each overdue slot it found. That is the operator button's intended behaviour and it stays the default, but it left any other caller — a browser journey, a smoke check — writing into every account's delivery ledger at once with no way to say otherwise. The body now accepts an optional `userId` that narrows the sweep. It can only narrow: the route is `requireAdmin()`, cookie-only by construction, so a Bearer caller never reaches it however wide its scope, and the selector feeds the Prisma `where` rather than the authorisation. A bodyless POST — what the admin panel sends — is unchanged. An unknown key is a 422 instead of a silently instance-wide run. Published in the contract alongside the notification diagnostic, so the scope is readable rather than folded into the console-only exemption.
…nt payload The medication detail's notification section decides between the server-side reminder switch and the client-managed chip on `notificationPrefs.medication.clientManaged`, read off `GET /api/auth/me`. That payload never carried the field — the preferences live on their own endpoint, which is not on the app-boot path — so the flag was `undefined` for every account: the chip never rendered, and a person whose phone owns the reminders was shown a switch that no longer decides anything. The write path and the dispatch gate were correct the whole time; only the web reflection was missing its end. The payload now carries the fully-resolved block from the same resolver the dedicated endpoint uses, so the two cannot disagree, and an account that never opted in reads `false` rather than absent. No extra query: the session already loads the row the column sits on. The pair guard over this payload gained the direction that could not see it. It asked that every published field has a reader; a reader with no field was invisible. It now also reads the response types clients declare for this endpoint and holds them against what the route publishes. A typed guard was tried first and does not work here — each consumer declares its own local interface, so the compiler checks every file against its own private claim and agrees with all of them.
…ves instance settings alone and the 422 for an unreadable copy
The journey's isolation went one way only. `test.describe.configure` orders the tests inside the file; the suite is fullyParallel with two workers, so everything else runs beside it — and its own trigger, the admin reminder sweep, walked the whole instance and dispatched for every account that had an overdue dose. With the mail env pointed at the local responder, that would have started sending real messages for accounts this file has never heard of, and the exact-equality assertion on the accepted recipients would have been reading them. The sweep now names the account it then counts, and the comments that defended the opposite direction say what the exposure actually is. Its own server, too, and for two reasons. The `SMTP_*` vars decide whether the Email card renders at all, so on the shared server they put a card the axe scan and the card-anatomy sweep had never measured onto `/settings/integrations` for every spec that opens the route. And unset, `HEALTHLOG_PROCESS_TYPE` starts the pg-boss reminder scheduler inside the process serving requests, whose every-fifteen-minutes tick either mints the missed-dose row that makes the sweep skip its own dispatch or dispatches beside it. A third server carries both: mail configured, `web`, and only this spec talks to it. The shared server keeps `all` on measured grounds rather than taste. Worker status is per-process and in-memory, so a `web` process reports the worker stopped, `/admin` renders that row destructive-red, and axe fails it at 3.97:1 against the muted tile — a real contrast defect any operator running the documented web/worker split already sees, and not this branch's to fix. That limit is written where the server is defined. Also here: the stub draws its port per run instead of pinning one, and says so by name when the port is taken; teardown ends its sockets and survives a setup that never got as far as starting it; the ledger reset stops deleting rows whose subject is this account but whose recipient is somebody else, and clears intake events explicitly rather than through a cascade; the attempt poll refuses a window larger than the twenty rows the diagnostic returns; and the header names the precondition the file cannot enforce and the internal reasons it pins on purpose. Verified against a production build and a throwaway Postgres: the journey three times over at one worker, then the a11y and settings geometry specs at two. Both assertions broken once to see them fail.
…d reminder sweep and the published notification preferences
…ives The determinism guard counted two Playwright servers and would have refused a third by deletion; the notification journey's mail-configured, scheduler-free process is the third. Pin it by its two distinguishing variables and assert the shared servers carry no mail configuration.
The admin snapshot paints its worker row in `--destructive` whenever the
process serving the page is not the worker: a split deployment running
`HEALTHLOG_PROCESS_TYPE=web`, or a worker that stopped answering. The token
is AA on a card (4.85:1 in the light theme) but the stat tile lays a
`bg-muted/50` wash over that card, and axe measured the tinted wording at
3.97:1 there, under the 4.5:1 AA floor. `e2e/a11y.spec.ts` went red in
exactly that state and green in every other one, which is why the console
could ship it.
The status grids now name a tone instead of spelling a class at every call
site. Success and warning keep their tint, both clear AA on the tile.
Destructive takes the shape the other status rows already use, the
medication detail row and the invite chips: the token paints a small
indicator and the wording stays in `text-foreground`. The word itself
("Stopped", "Error") states the condition, so nothing hangs on the colour
alone. The database, Umami and GlitchTip rows sit on the same surface with
the same tint and move with it.
The success-affordance guard's two pinned entries for the status grids went
stale in the process: the tint they carried now lives once in the tone
table, so the pins follow it there.
Checked against a standalone build on a throwaway database: the whole a11y
spec green in both themes and both viewports with `HEALTHLOG_PROCESS_TYPE`
at `web` (the failing state) and at `all`. jsdom cannot measure a contrast
ratio, so the new unit test pins the token choice for both arms instead.
…d an API that says when to come back Version anchors, the OpenAPI document and the changelog entry. Refs #939.
…both surfaces The adherence journey pinned the tile at the figure its engine produced before a skip left the denominator; with both engines held to one number the tile reads what the card reads.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The rest of #939 for managed profiles, one figure for medication adherence on every surface, the API's manners toward a client that retries, and four more journeys in the gate. Refs #939.
Managed profiles (#939 parts 2 to 4).
PATCH /api/managed-profiles/{id}(name, date of birth, language, timezone, sex), sex at creation, modules switched per record through/api/record-settings/modules, navigation following the record's own map. Under a scoped grant the account payload'smodulesandcycleTrackingEnabledare masked to the sections the grant opens (src/lib/sharing/module-disclosure.ts), and a newmoduleAccessmap says why a module is off (enabled/disabled/not_granted/unavailable) with the boolean invariant asserted over every grant shape. The Modules save no longer freezes the cycle derivation; the edit form keeps input across a refetch; a timezone-only edit no longer writes the actor's language.Compliance parity. The dashboard tile's schedule-anchored engine counted a deliberate skip as expected-and-missed while the ledger (card, history, doctor report) has excluded it since v1.15.9; the tile engine agrees now and one test feeds both engines the same fixture.
API.
Retry-Afterand theX-RateLimit-*triple on exactly the 429s the limiter produces (recorded per request, attached inapiHandler; a relayed provider 429 keeps its own shape); a unique tiebreaker on seven offset-paged lists;ErrorEnvelopeallowsdetails.issuesand openmeta,SyncChangesResponsedeclarescycleDaysandcycles, and a test parses real responses against the published schemas; stableerrorCodeon the generic auth refusals;POST /api/admin/notifications/reminder-checkcan name one account;GET /api/auth/mepublishes the resolvednotificationPrefs(the client-managed chip read a field that was never sent), and the payload guard now checks readers without a publisher too.Backups. The console restore writes instance settings only behind an explicit checkbox (
restoreInstanceSettings, default off); an unreadable copy is a 422 withbackup.payload.undecryptableon restore, download and preview; both routes are in the OpenAPI document; the runbook says what a restore replaces and describes the console path.Settings. The TOTP card's confirmation dialogs are controlled and close when the request settles, so a refused rotation or disable shows its reason. The admin status rows use the foreground token for text with the tone on the indicator (the destructive text measured 3.97:1 with the worker absent).
Journeys. Medication adherence, doctor report, second factor with step-up, backup and restore, notification dispatch; each on its own account, each with a refusing control, each broken once to prove it fails. The CI determinism guard pins the third Playwright server the notification journey drives.
Six adversarial reviews ran on the branches that make up this release (
.planning/2026-09-10-*-review.md); every Critical, High and Medium finding is fixed here. Gates on the final tree: typecheck, lint (three baseline warnings), format, openapi:check, unit, the full integration suite run serially, build.