The determinism guard reads the job that runs the browser, which is now
the shard matrix rather than the gate that only carries the required
check name. The report artifact is named per shard so the two uploads
cannot clobber each other.
The single job passed at about eighteen minutes against a twenty-minute
cap, and the settings geometry measurements pushed it over, so the run
died at the timeout with everything to that point green. Same remedy as
the integration suite, split rather than raise: two Playwright shards,
each with its own database and production build, and a gate job carrying
the exact name branch protection requires that fails when any shard
failed or was cancelled.
The dead-code sweep found the window type exported and unread. It was
unread because the surface that offers the choice carried its own copy
of the same three numbers: the chart could not import the calculator's,
since that module reaches the database client two hops down and a client
component pulling it in would ship Prisma to the browser.
Both now come from the dimension table, which is pure by construction
and which the chart already imported. One declaration of 7 / 30 / 90,
read by the calculator that averages over them and by the control that
offers them, so the two cannot drift.
Three small things the audit named together.
The mood reads spread the whole row, so adding the wire keys shipped
every level-A value twice — once as the column name and once as the key
the write path accepts. A client would have picked one and been stuck
with it. The column spelling is stripped now, and the create response's
key set is asserted so it stays stripped. The published contract is
unaffected either way; these routes are not in it.
The dimension series carried its own copy of the day-key subtraction
that the freshness module already exports. One copy now.
And the reason a stale dimension ships with a notice instead of being
dropped the way the status surfaces drop one is written at the builder:
a one-line card has nothing left to say about a value it cannot narrate,
while an open conversation can say when the value is from — and silence
there would leave no way to tell an unanswered dimension from one nobody
has looked at in a week.
The snapshot stamps every metric block with an asOf built from the
newest reading in it, which for mood is the newest ENTRY on the 1-5
axis. Somebody who taps a face each morning and opens the sliders once
a week therefore carries a block stamped as current and a stress
reading six days old, and a reader taking the block stamp as covering
the dimensions states last week's answer as today's.
The block now names the precedence in one line beside the dimensions,
and each dimension keeps its own age. Present only when there are
dimensions to explain.
An entry always carries a five-point label, so it always implies a
pleasantness value: the create path derived one from a null and the
capture sheet still showed a clear control for it, which meant a button
the server undid on the way to the database.
One rule on both routes now — a null asks for a value that cannot exist
and gets the derivation, on create and on edit alike — and the control
is gone for that field. The other four are genuinely optional and keep
theirs.
Matches what syncVersion, syncedAt and the row stamps already do a few
lines above: a key the file does not carry is a key the file predates,
not a key the file says is empty. Reachable when one file addresses the
same row twice, where the second entry now adds without erasing what
the first one set; an explicit null still clears.
Worth stating plainly, because the audit that asked for this expected it
to protect live rows from an older file: it does not, and it cannot. The
restore deletes the owner's whole mood partition before it rebuilds, so
by the time the update arm runs there is nothing live left to protect. A
test now pins that contract too, so nobody reads the omission as a
guard it is not.
The staleness test rebuilds the block shape from the pure calculator, so
it proves the calculator and the freshness rule agree and nothing else:
hardcoding the freshness flag in the feature builder, deleting the
notice that goes with a stale value, or dropping the whole block left it
green. This calls extractFeatures against real rows instead.
Both failures were watched. With the flag hardcoded true the unit test
still reported four passing while this one named the six-day-old stress;
with the notice removed it named the missing instruction.
The single create's upsert restated all five columns on its update arm,
so a client re-posting an entry with only a label and a timestamp — the
phone, whose build has no sliders — cleared the stress, energy,
connectedness and stability somebody had answered on the web. The batch
and import paths already preserved them, so the two disagreed.
One answer now, and it is preserve: absence in a request means nothing
to say, an explicit null takes an answer back, and pleasantness still
follows the label because the request always carries one. The create
schema accepts the null that makes clearing expressible.
A re-post that omits a dimension is asserted to leave it standing, and
that assertion has been watched to fail against the old arm.
The inverse flag is read by the trend legend and by the assistant block;
the function that flips a value through it had no caller. It comes back
with the surface that needs a combined view, not before.
The pillar reads PHQ-9, GAD-7 and WHO-5 and cites the papers behind
every threshold. A daily self-rated slider is now available on nearly
every mood row, so leaving it out reads as an omission unless the
reason is written down: the two are different kinds of evidence, and
averaging them would weaken the line that makes the figure worth
showing to a doctor. Recorded at the pillar and at the columns.
- the level-A blocks ride the mood aggregate the coach and the briefing
already share, so the ages are fixed once in the data preparation
rather than once per sentence
- every block carries its newest value, the day it belongs to, how old
it is and whether that age still backs a present-tense claim, through
the same freshness rule the vitals surfaces use
- a stale dimension carries the instruction in words as well as in a
flag, because that is the sentence this exists to prevent
- the 1-5 scale line is untouched; the new axis describes itself, with
the anchors resolved from the bundle the person read
- the trend page and the assistant run the same calculator, so a number
in a sentence cannot disagree with the number on the chart
- one engine: the dimension series is a sibling calculator of the mood
aggregate hub and rides the same bounded read, so no second summary of
mood can disagree with the first
- 7 / 30 / 90-day means per dimension, plus the daily series behind them
- a dimension nobody answered is named as not recorded rather than drawn
flat through the middle of the scale, and a zero counts as an answer
- each line keeps the orientation the person answered in; the legend
says which way the inverse one runs
- the card rides the existing module-gated aggregate read and query key
- the five faces are untouched: pick one, save, done in a tap
- a closed section below them opens onto five sliders; picking a face
suggests a pleasantness value and moving that slider takes the
suggestion over, while the face selection stays where it is
- an untouched slider writes nothing, and clearing one takes the answer
back rather than leaving a middle value behind
- the list's edit dialog shows and changes the same five, so nothing is
written on one screen and invisible on the other
- correcting an entry no longer re-derives pleasantness when the label
did not move, which used to overwrite a hand-set value on any edit
- factor ratings are no longer offered on a new entry and nothing was
deleted: existing ratings still render, edit and feed the crosstab
- six locales, and the dimension table's keys are declared dynamic
- Slider wraps the Radix control the way Switch and Progress already do;
no new dependency
- SliderField adds the label, the value and the two scale anchors, which
are what make a 7 mean anything
- a null value renders as unanswered rather than as the value the parked
thumb sits on, and zero renders as the answer it is
- the announced text carries the scale, not only the number
- the backup select and the payload carry all five, on both the portable
and the disaster-recovery arm
- the restore writes what the file holds and nothing else: a file from
before these columns restores them empty rather than deriving them
- the CSV appends them after the existing columns so nothing shifts under
a spreadsheet, and an unanswered value is an empty cell rather than a
zero, which is a real answer on this scale
- the round trip asserts each of the five by value, including a zero
The supply-chain section said the publish workflow runs on every push to
main. That trigger was removed in v1.4.34, when the squash-merge and the
release tag each kicked off their own build of the same SHA; the
workflow has fired on v* tags only since. Pull requests still build the
image, and do not publish it.
The resolver queried the online provider first and read the mounted
GeoLite2 databases only when that missed. Every document describing the
feature said the opposite, including the self-hosting runbook and the
admin notification that tells an operator their login IPs are leaving
the server. So an operator who mounted the databases in order to stop
that egress kept all of it, and nothing said so: the location resolved,
the admin overview filled in, and the only difference was where the
address had been.
Now the local databases answer and the provider only ever sees an
address they could not place. A host with the City database mounted and
a hit makes no request at all — including when the ASN database is
absent, because reaching out for a carrier string would send the address
off the host anyway, which is the thing the mount was for.
IP_GEO_LOOKUP_DISABLED=1 remains the full off-switch: it disables the
online step only, so an offline hit still resolves and an offline miss
returns nothing rather than going online.
The wide-event warning on the fallback path named ipwho.is even when the
operator had pointed IP_GEO_LOOKUP_URL somewhere else; it now names the
host the runtime actually queries. The admin notification copy needed no
change — it fires only when the databases are absent, and then every
lookup does go online.
The two cases in geo-asn.test.ts that pinned the old order are the
watched red for this change.
The admin "Delete all data" action carried its delete list inline. That
list named nine tables, which was most of the schema on the day it was
written, and it stayed at nine while the schema grew to a hundred and
twenty-two models. Laboratory results, documents, cycle logs, journals,
mood, workouts, sleep and Coach conversations all survived the wipe an
operator runs before handing a box on, and the toast said it was done.
The per-account wipe had the same defect and grew a derived plan for it.
The admin route now reads that plan: ADMIN_WIPE_MODELS is WIPE_MODELS
minus a single documented subtraction, so a model added to the schema
lands in both wipes at once.
- AccountGrant is the one difference, with its reason in the plan: it is
the only access path to a managed profile, and this action preserves
accounts by definition. Deleting every grant would leave records
nobody can open, administer or delete.
- The audit history now goes with everything else. It is a record of who
did what from where, which is exactly what clearing a box is for; the
receipt written after the transaction commits replaces it.
- One transaction with the same budget as the account wipe. A
half-applied wipe is worse than none, because the person is told it
succeeded either way.
- The result line reports rows and tables instead of nine fixed names,
which is how nobody noticed: it read the same whether the wipe had
cleared nine tables or ninety.
Guards, both watched red before landing:
admin-data-wipe-completeness.test.ts fails when a user-scoped model
reaches neither the wipe nor a written exemption, and when the route
names a table inline again. The integration guard seeds every table in
the schema for two accounts, runs the route, and asks the database what
is left — drop one model from the plan and it names the surviving table.
- the migration fills mood_a1 from the five-point label for every row
that has none, and leaves the other four columns empty because there
is nothing in a label to derive them from
- guarded on the column being empty, so re-applying the migration is a
no-op rather than an overwrite of an answer somebody gave since
- the test reads the CASE arms back out of the .sql and compares them to
the runtime map in both directions, so neither can drift alone
- rows are asserted by value after the statement runs, and again after a
second run, including one hand-set value that must survive it
Two cycle suites compute their relative day keys with local Date arithmetic
under TZ=UTC while the routes resolve today in the user's zone, falling back
to Europe/Berlin when the fixture sets none. For two hours every evening the
two clocks disagree about the date and every day-of-cycle assertion is off
by one — the suite was red from 22:00 UTC to midnight and green the rest of
the day. The fixtures now carry an explicit UTC timezone so the test's clock
and the route's clock are the same clock at any hour. Reproduced red at
23:07 UTC, green with the pin at 23:19 UTC.
- one helper resolves the level-A values for a write; the five writers
call it instead of carrying their own copy of the derivation
- the create schema takes a1 to a5, and a value the user set wins over
the derived one; the batch request shape is untouched
- both arms of both upserts restate the values, so a re-post cannot
leave a stale reading beside a mood that has since changed
- the JSON import derives from the row's own label rather than the
file's score, which is the one place the two can disagree
- correcting an entry's label re-derives pleasantness the way it
re-derives the score; an explicit null takes an answer back
- a round trip through all five writers against real Postgres asserts
the rows agree and that nothing invents the other four values
Two shards cleared in about fourteen minutes on a quiet day, but with six
pull requests running side by side the larger shard hit the same 25-minute
guard the split was meant to escape. Three shards keep the biggest slice
around sixty files, which clears the guard with real headroom on the
slowest runner observed. The gate job is unchanged.
- migration 0310 adds mood_a1, stress_a2, energy_a3, connection_a4 and
stability_a5, all nullable, no table rewrite
- the schema comment states the four things a later reader needs: score
keeps its 1-5 axis, the columns are queryable plaintext on purpose,
stress is stored literally, and a read delegate sees them by design
- the backup guard is red until the carriers land alongside
- MOOD_DIMENSIONS carries the five self-state dimensions in capture order,
each with its column, its label and anchor keys, its orientation and its
0-10 bounds
- getA1ForMood maps the five-point label onto the pleasantness scale once,
so the quick check-in stays a full-value capture path
- only stress is inverse-oriented, and a test pins that count
- the secret scan reads a dotted message key on a *Key field as a token;
allowlist the dimension keys the way the existing message keys are
A fresh advisory names nanoid below 3.3.17: a custom generator can loop
forever when asked for a zero-size id. It reaches the tree only through
next and postcss, but the audit gate rightly blocks on a known HIGH with
a fix available. Bounded override in the established pattern; the audit
drops from one high to none.
The suite outgrew its runner. The 25-minute guard was sized for 128 files
taking about 13 minutes; the sharing release took the suite to 180 files and
past the cap on CI runners, so every run died at the timeout with every test
to that point green, reported as cancelled. The guard's own rule applies:
split, don't raise.
The files run as two vitest shards, each with its own Postgres testcontainer.
A gate job carries the exact name branch protection requires and fails when
any shard failed or was cancelled, so a half-green matrix cannot satisfy it.
vitest is invoked directly because pnpm passes the -- separator through to
the script literally: the flag never applied through the script form, and
both "shards" ran the full suite. Proven by paired local runs: the script
form ran 180 files twice, the direct form splits 90/90 with 927 plus 689
tests, all green.
Measuring the seven `/settings/layout/<child>` routes was meant to pin the
back-link spacing. It found there was no back-link to pin.
`SettingsHubBackLink` and `settings.sections.layout.backToHub` have existed
since the sub-pages were split out, and the only thing that mounted them was
the shell's slug FALLBACK — the branch that runs when a page passes no
`heading`. These pages pass an explicit one, so the fallback never ran and no
child route has painted a back-link since. The component was written, the
string was written, the reverse-coverage guard saw a call site and was
satisfied, and the way back to the hub was not on the page. Two ends, no pipe.
It now comes from the page, where the heading it belongs to comes from.
The geometry spec grows the seven routes and a second family. A route with a
back-link carries an extra row above the title, so it is taller by
construction; comparing it against a plain route would report structure as
drift. Each family is checked for its own constant heading top, and the step
between the back-link and the title is asserted at 6 px — `PageHeader`'s
documented `space-y-1.5`, which is what the shell's hand-rolled `space-y-6`
would have drifted back to.
Measured, both languages: plain routes 24.0 px, back-link routes 51.0 px,
spread 0.0 px in both families, step 6.0 px on all seven.
The back-link is also painted twice, once per breakpoint, so the measurement
picks the copy with a box — `querySelector` returns document order, which on
a desktop width is the hidden mobile one, and reading it reported no
back-link at all.
Every item here is a check that could pass while the thing it checks is
broken, or a margin the sweep's codemod could not reach.
Guards:
- The access-payload matcher accepted `.level` on ANY object. A chart's zoom
level or a log record's level would have satisfied it and reported a
consumer that does not exist. It now names the bindings the entry actually
arrives under; a third binding is a deliberate edit rather than a silent
widening. Re-falsified: rename both readers, it goes red.
- The `pl-7` sweep skipped any line CONTAINING `//`, so a class string sharing
a line with a URL was waved through. Only the part after a real line-comment
marker is exempt now. Proven by putting `pl-7` on a line with an
`https://` comment: caught.
- `LEGIT_IDENTICAL` in the English-leak guard had no staleness check, unlike
both of its siblings, and carried two dead keys — one from a removal long
before this work, one from the duplicated version fields that came off
`/admin`. Both pruned, the check added, falsified with a made-up key.
- `split-description.mjs` split inside `z. B.`, `d. h.`, `u. a.` and `p. ex.`
and produced a head ending mid-abbreviation. Both halves have to be caught,
which is why the rule is a shape (single letter + stop) rather than a list,
and the merge re-tests its own tail. Exercised against eight cases including
the ones that used to break.
Margins the codemod could not see, because the child lives in a different
component from the card that spaces it: six form roots under managed record
settings (one of which also carried the banned `space-y-5` step), the
low-stock status line, the avatar card's explainer, and the backups restore
report. Each was adding a margin on top of the card's own gap.
Two facts the text diet cut too close, both restored inside the guideline:
that an excluded injection site wins over a medication that prefers it, and
that the privacy card's second button leads to a full health record.
The gate the diet shipped with matched `<SettingsCardHeader` in three
component folders. That is not where every card description lives, so five
real ones were outside its reach — including the full backup's, which the
sweep's own shortlist had named and which stayed at 351 German characters
across three sentences while the guard reported zero violations.
Two ways in were missing. A card header can sit in a folder the walk never
entered (`labs`), and a description can be FORWARDED to the header by a shell
— `ExportCardShell`, `ImportCardShell` — where the prop is on the shell's tag,
not the header's. The gate now matches both, over seven roots, and the
"finds what it is meant to check" floor moved with it.
`SubPageShell` is deliberately not included, with the reason written at the
list: its description renders as `text-foreground text-sm`, the body tier,
where the standards allow prose. Gating it would enforce a rule §3 does not
make for that tier.
The five, all six locales:
- full backup — the twelve-item enumeration was the description. It is now
the card's opening body paragraph, where a list of what is in the file
belongs; the description is one sentence.
- local OCR — the how-it-works sentence stays in the slot; "less accurate,
review every value" is safety copy and reads foreground in the body.
- CSV import, dose-history import — second sentence to the body.
- the ECG page — the "does not interpret the waveform" claim gets its own
paragraph rather than riding the page explainer.
Proven by reverting the full backup's text: the widened gate reports it as
both two sentences and over length, at the call site, in both languages. The
old gate could not see the key at all.
Both ends of the divider removal were green and the pipe between them was
never run. The `<hr>` came out of every integration card, all three unit
tests were updated to expect none, and this browser spec kept asserting nine.
It is the same shape as the failure this project keeps rediscovering: two
correct halves and nothing exercising the assembly.
The cost was not just a red spec. The Pixel-5 horizontal-overflow assertion
sits AFTER it in the same test, so `/settings/integrations` had no proven
mobile-overflow check for the whole sweep — the test never reached it.
The second assertion read a sentence's bytes and broke on the text diet while
the thing it was about — the card rendering its explainer at all — never
changed. `SettingsCardHeader` now names its description slot
(`data-slot="settings-card-description"`) and the spec targets that: the slot
is present, non-empty, and not a raw i18n key. The product name stays
assertable because a proper noun is not prose. The wording itself is pinned
where it belongs, in the unit test that reads the same bundle it renders from.
Every interactive path, every import, MCP and Telegram checked an incoming
measurement against VALUE_RANGES before storing it. The provider sync writers
did not, so a decode slip or a glitched sensor landed a row the application
itself calls impossible, and every mean, median and personal band derived from
it inherited the impossibility. Filtering the read side hid the row. It kept
landing.
The gate is one module reading the domain the app already declares, applied at
two kinds of seam. reconcileExternalMeasurement is the shared identity writer
for Polar, Oura, WHOOP, the Withings sleep leg, the Apple export importer and
the batch endpoint, so gating there means a provider added later inherits the
refusal rather than having to remember a check it never knew about; the new
rejected_range verdict carries no row, which is what makes the compiler force
every caller to handle it. The five writers that hold Prisma themselves gate at
their entry, before the existence probe decides insert versus overwrite, so an
impossible reading can no longer replace a good stored value.
An out-of-band value is dropped. Not clamped, because a clamp invents a reading
nobody took. Not stored behind a flag, because a quarantined row is still a row
and every consumer would have to learn the flag. Not fatal to the batch, because
one bad sample must not strand the run's other readings behind a held watermark.
Absence reads as absence.
A silent drop, though, is indistinguishable from a provider that sent nothing,
so every drop is tallied on the wide event under measurement.range_rejected with
the metric, the provider and the edge it broke. Counts only: the reading itself
is health data and never enters the log line, and the direction plus the
metric's own declared bound is what a diagnosis actually needs. The tally
accumulates rather than replacing, because a provider gates once per resource
leg and the reconciler once per row. One warning per provider lifts the event to
warn so a drop does not hide inside a green line.
A structural guard enumerates all twenty measurement writers with the reason
each is allowed to write, so a new one has to answer the question before it
ships. scripts/diagnose-implausible-measurements.ts reports the rows already
stored, aggregated per metric and source with no value beyond the bucket's own
extremes. It repairs nothing: whether a stored impossible row is a decode slip
or a band that is too narrow is not a call a script gets to make.
Thirty-four routes, both consoles, English and German, desktop and phone.
The claims this program makes are geometric, so they are checked with
geometry rather than by looking at screenshots.
What it found, which is the reason it exists:
- `SettingsCard as="section"` sat one spacing step tighter than its
neighbour on every desktop width. The `<Card>`-backed variant inherits
`gap-4 md:gap-6`; the landmark variant wrote a flat `gap-4`. Seventy-five
of eighty-six cards measured 24 px and eleven measured 16 px, directly
above and below each other. The expected value in the spec is a function
of the viewport for exactly this reason — a constant would have agreed
with whichever half it was written against.
- One German page subtitle wrapped to a second line where English fit on
one. Shortened in all six languages.
After both: heading top 24.0 px on every route, spread 0.0 px, in both
languages and at both widths — that is the no-jump claim, measured. Card
header-to-body distance equals the card's own gap on all 86 cards, and no
card body sits off the card's inner edge.
The header's HEIGHT is pinned only where one line is achievable. At 393 px a
70-character sentence needs about 380 px of column, so most explainers wrap
there; the spec asserts the wrap away at desktop width and only forbids a
third line on a phone. The alternative is truncating the sentence, which is
the defect this program removed.
Seeds an entry with a ticked tag, a rated factor, an encrypted note, a
non-Berlin timezone and a sync counter above zero, exports through the real
builder, restores through the real `POST /api/admin/backups/[id]/restore`,
and reads the row back out of Postgres. Values, not a payload shape: this
repository has already proved that asserting a `moodEntries` key stays green
with the restore call excised.
Two more cases beside it. A hand-written payload in the shape files were
written in before the note, zone, counter and tag keys rode along — the
compatibility floor, and the only way to know it holds is to send one. And a
ticked tag this instance cannot resolve, which has to come back named in the
skip report rather than silently absent.
Seen red three times on the way in: re-adding the tag-link filter loses the
ticked tags, dropping `tz` from the restore write loses the zone, dropping
the note ciphertext from the payload loses the text. Each reverted and the
file's hash checked back to its recorded value.
A lab runs its own method on its own device, so the window a physician
evaluates a value against is the one printed next to it on that report,
not a catalog band averaged over every lab. Reading a report pulled the
value and dropped that range: the extraction asked for it, the commit
handed it to the biomarker resolver, and the resolver only uses it when
it mints a NEW marker. For every analyte the user already had — the
normal case after a first panel — the printed range was read and then
thrown away.
Add `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText`
to `LabResult` and carry the range from the report to the reading. The
text is stored whether or not numbers can be read out of it, because
"bis 5,0" and "negativ" state a window too; a range that could not be
parsed is kept verbatim, never replaced with invented bounds.
`resolveEffectiveReferenceRange` is the one place the precedence lives:
the source window wins for that single reading, and the catalog stays
the net for readings without one. A printed string with no derivable
bound does not displace the catalog. Every surface that judges a
reading routes through it — the API verdict, the doctor report, the
insights blocks and per-marker assessment, the coach snapshot, the MCP
read and the FHIR `referenceRange` — so a value cannot read in range on
one surface and out on another.
The API keeps `referenceLow` / `referenceHigh` as the window in force,
so a client that reads only those two renders the right range and the
right verdict with no change of its own; the catalog band, the source
window, its origin and a divergence flag ride alongside for clients
that want to show both.
Read side: the chart draws the source window as a second, visually
distinct band with the reading it came from named beneath it, the
reading rows name the printed range and the catalog band where the two
disagree, and the doctor-report PDF prints the report's own string with
a footnote naming the saved range. Entry side: one optional field on
the lab form, folded away until asked for, and the printed range is
editable on the scan review screen.
The range parser covers the notations German and English reports
actually print — two-sided, one-sided, comma or dot decimals, thousands
separators, trailing units — reads units without ever converting them,
and refuses to guess on anything ambiguous.
`moodEntrySchema` gains `note`, `noteEncrypted`, `tz`, `syncedAt`,
`syncVersion`, `createdAt`, `updatedAt` and `structuredTags`; every one of
them optional or defaulted, so a backup file written before this parses
unchanged and restores exactly as it did.
The restore writes them. The note follows the measurement pair: ciphertext
verbatim when the file carries it, a portable file's plaintext re-encrypted
on the way in, legacy column null either way. `tz` absent still means the
legacy Europe/Berlin reading, which is what those rows meant. `syncVersion`
comes back at the value the file recorded — restoring at the default handed
the next round to any paired device holding a higher number, and the device
would then overwrite the recovered entry with what it had.
The link rebuild re-creates both arms: rated factors from `factors`, ticked
tags from `structuredTags` with a NULL rating. An unresolvable tag key is
reported under its own `moodTag` family, beside the rated one, because an
operator needs to know which of the two an entry lost.
The disaster-recovery payload dropped the free-text note, the row's
timezone and the last-writer-wins counter, and the tag-link read filtered
`rating: { not: null }` — which is the definition of a BINARY link, so
every present/absent tag a person picked was missing from the file too.
Both arms now mirror the measurement pair: the recovery arm carries the
note ciphertext verbatim plus `tz`, `syncedAt`, `syncVersion`, `createdAt`
and `updatedAt`; the portable arm carries the decrypted note, `tz` and no
ciphertext.
BINARY keys ride in their own `structuredTags` array rather than as a
nullable rating inside `factors`, because a restore resolves `factors`
against RATED tags and names what it cannot resolve — a rating-less entry
there would collide with both. Absent means empty, so a file written
before this parses and restores exactly as it did.
The two link arms partition the links exhaustively: a malformed link comes
back named as unresolvable instead of falling between them.
The mood read was an inline `include`, so nothing could compare it to the
model the way `measurement-backup-completeness.test.ts` compares the
measurement select. Name it — `MOOD_ENTRY_BACKUP_SELECT` and
`MOOD_ENTRY_TAG_LINK_SELECT` — and add the sibling guard that binds both
to `prisma/schema.prisma`.
The mapper's parameter is spelled `moodEntry` rather than `e` because the
guard proves a column reaches a payload by matching `: moodEntry.`, and `e`
is also the intake-event mapper's parameter one section above: a matcher on
`: e.` would match across both and could never fail.
The guard is red as it stands, and names the defect: `note`,
`noteEncrypted`, `tz`, `syncedAt`, `syncVersion`, `createdAt` and
`updatedAt` are selected and written to no payload. The fix follows.
Measured before the sweep: 317 description and hint strings under the
`settings` and `admin` namespaces, median 83 characters, 113 of them two
sentences or more. The rule the design standards now carry is one sentence,
guideline 120 characters, and this is the sweep that makes it true.
Almost nothing was retranslated. A second sentence is already written in
every language — it is just in the wrong slot. `scripts/split-description.mjs`
cuts a description at its first sentence boundary in all six bundles at once
and either drops the tail or files it under its own key; the twenty-one keys
that moved now render in the card body as foreground `text-sm`, which is
where multi-sentence prose belongs and where a reader can actually read it
(the slot is muted `text-xs`). Eight tails were dropped outright, because
"Off by default" under a switch, "Topics and frequency are adjustable" above
the topic switches, and "Only admins can create invitations" on an
admin-gated route are not information.
Twenty-eight keys are gone: the privacy page's intro, which was a table of
contents for the four cards under it; two About descriptions that listed
verbatim the labels and the buttons beneath them; a card description that
repeated its own title; the API-token sentence that said what the paragraph
below it said with the actual instruction; and the eight integration
secondary sentences. Six descriptions were rewritten shorter in all six
languages where trimming was honest rather than lossy.
On the strings that changed: 4391 characters of English down to 2290, and 32
multi-sentence descriptions down to zero.
The rule is now a gate. `card-description-length.test.ts` reads the keys that
actually reach a card header's description slot — statically, from the JSX —
and checks them in EN and DE, because a rule that only holds in English
produces a slot that wraps to two lines in half the locales. Its exception
list is empty and carries a reason requirement plus a test that removes a
stale entry. Verified by putting a second sentence back: it fails.
Most of what Scorecard looks for this repository already does: Actions
pinned by commit SHA, a security policy with a private reporting path,
Dependabot with auto-merge, keyless signing on the release image, a
branch-protected trunk. The value is not the score, it is that the checks
keep running after the person who set them up has moved on, and that
anyone evaluating the project can read the result without being given CI
access.
publish_results is on, which is what makes the score citable. It reads
repository metadata and the workflow files; nothing about the code or its
contents goes anywhere. The token is an OIDC exchange, the same mechanism
the release workflow already uses to sign the image.
No badge in the README yet. The public API has no entry for this
repository until the first run publishes one, and the badge URL currently
renders 'invalid repo path'. It goes in once there is a score behind it.
Two questions the existing scans cannot answer.
The dependency audit runs with --prod, which is the right scope for a
blocking gate but leaves a gap: a dev dependency can still ship. pdfjs-dist
is the case that made this concrete. It sits in devDependencies, and
rasterize-pdf.ts imports it at runtime, so standalone output tracing copies
it into the production image where the prod-scoped audit has never looked.
Trivy reads 627 packages out of pnpm-lock.yaml without --include-dev-deps
and 1455 with it; pdfjs-dist is only in the second set. Trivy's help text
lists the flag as npm/yarn/gradle only, but it works on the pnpm lockfile
too, which is worth knowing before someone deletes it as a no-op.
The container scan builds the image from the current main. Nobody runs
that image. What self-hosters pull is the one built from the last release
tag, and an advisory published after the tag lands against an image
nothing is about to rebuild. So the second job scans ghcr.io/mbombeck/
healthlog:latest instead of a local build.
Schedule only, no push or pull_request trigger, because the change-
triggered half is already covered and would just run twice. The lockfile
job reports into code scanning without failing: widening to dev
dependencies surfaces transitive tool advisories by the dozen, and a red
badge from day one gets muted inside a week. The image job fails on
CRITICAL, after its own report has been uploaded by a separate step.
Static analysis was the one class of check the pipeline had no answer for:
lint catches style, the type checker catches shape, the tests catch
behaviour, and nothing walked the data flow from an untrusted input to a
sink. This is GitHub's default setup written out as a workflow file, so the
query suite, the excluded paths and the schedule sit in the diff rather
than behind a settings toggle.
Pull requests and a weekly run on the default branch. No push trigger:
everything reaches main through a pull request that was already analysed,
and the weekly run is what keeps the Security tab current.
src/generated is excluded. Extracting the 9 MB Prisma client costs minutes
and can produce nothing anyone would act on, because the fix for anything
found in there is to regenerate it.
Twenty-six of twenty-eight admin files already used the Settings card and
header. What made the console read as its own system was everything on top of
that: three hand-rolled cards, a header slot that meant five different things,
and a heading the frame took but never painted.
- `SectionFrame` drops `title` and `subtitle`. Seventeen call sites passed
both into a component that has rendered neither since v1.18.6.1 — the shell
reads the same keys itself. A prop contract nothing honours is a promise the
next reader believes.
- The invite card, the four host-metric headers, and the `/admin` welcome
card stop hand-rolling `bg-card border rounded-xl` shells with their own
padding steps and their own heading weights. `text-sm` as a card title on
the first card of a page, directly above a correct one, was the first thing
the eye landed on.
- The header status slot carries status. The backups run, the invite create,
the danger-zone wipe, and the user filters come out of it; the app-log
refresh stays but reads as the secondary it is.
- Loading states paint their card and header. Encryption returned a bare
spinner row and then a card, so the section jumped when the status landed —
the fix `coach-feedback-section.tsx` already carried, applied where it was
missing. Same for the notification-status card.
- One label/value tile. `StatusItem`, the encryption section's private `Stat`,
the user-management inline panels and the backups upload box were four
fills and three paddings for one role.
- The overview stopped printing the running version, build SHA and build time
twice, thirty pixels apart. The version tile owns build identity — it is the
one that checks for a newer release; the snapshot owns what is running.
- `ai-server-key` had three paragraphs stacked in a muted `text-xs` slot;
one sentence stays there and the two that carry information move into the
body as foreground. The central-codex consent copy moves the same way, and
its disconnect is `variant="destructive"` — which `ConfirmButton` refused to
accept, so every destructive confirm in the tree had been hand-tinting an
outline instead.
- Leftovers: the `-mt-2` correcting the card's own rhythm, two alpha hovers on
text, `text-[10px]` badges, the redundant `space-y-6` wrapper, the empty
state repeating the header's CTA, and the `mt-6` a card added inside a
frame that already spaces its children.
The sweep that follows from the primitives. Nothing here is new behaviour;
every change removes a second way of saying something the surface already
said one way somewhere else.
Headers. The two collapsible security cards stop rebuilding
`SettingsCardHeader` by hand to carry a chevron — the chevron rides inside
the title button, and the description moves out of the collapsed region, so
the first paint is no longer a bare title. The import card shell, the export
and import section headings, and the four free-standing group headers stop
inventing their own type scale: a heading above a GROUP of cards is
`SectionHeading` (`text-base`), a card title is `SettingsCardHeader`
(`text-lg`), and the step between them is what says which is which. The
targets card gets a title instead of a naked icon.
Actions. Every card-level row is `SettingsCardActions`. The primary action
comes out of the header's status slot in six cards (password, web push,
pillar order, coach memory, and the two danger-zone triggers) and out of the
middle of the card in three more; the reset that hung in the dashboard-layout
header joins save as a secondary in the same row. Result and status lines
move ABOVE the row — sources' cross-link, the invite form's two status lines
— because nothing follows a card's actions. Revoking a share link is
`variant="destructive"` rather than an `outline` hand-tinted red in two
different ways. The import row had four variants for four buttons; it has
two. Export downloads are the card's primary, not an outline link.
Integrations. One description sentence with the setup-guide link inline: the
second paragraph was byte-identical across six of eight cards, which is the
tell that it said nothing about the provider it sat under. The `<hr>` under
the header — which existed only in this folder — is gone. So is the hint that
pointed at a guide 40 px above it, and the second format for "not
configured". Connect is one button everywhere: primary, `sm`, in the action
row, instead of primary-and-small in three cards and outline-and-large in
four.
Plus the standards leftovers the audit listed: two `pl-5` list gutters, one
`scroll-mt-20` anchor, a purple header icon, `text-[10px]` badges, four
hand-rolled callout shells that `SettingsInfoTile` already paints, and the
two inline buttons that never had the mobile tap floor at all.
The "looking after" block sat directly under the greeting and above this
account's own day, permanently, for anyone with a single shared record. The
dashboard is this account's day; other people's records are a destination,
and the account switcher in the top bar and the sidebar already is that
door — with the same access level and record kind on each entry.
Drop the card, its test, and the five strings only it used, across all six
locales.
The access-payload consumer guard named that one component as the reader of
`level` / `sections` / `recordKind`, so it went red on the deletion, which is
what it is for. It now names the surfaces that actually adopt those fields —
the presentation resolver and the switcher — and matches the property access
rather than one variable name, so a rename of the binding cannot make it
match nothing and pass. Verified by removing every reader: it fails.
Adds `scripts/i18n-key-tool.mjs` for dropping or rewriting a key across all
six bundles in one go.
The two consoles used the same primitives and still read as two systems,
because everything without a primitive had drifted: the header-to-body
step existed in three competing techniques at once (`mt-4` on the first
body child, `space-y-4` on the card, `mb-4` on the header), the body
started either at the card edge or 28 px in, and both shells hand-rolled
the page title instead of calling `PageHeader`.
Move the decisions into the primitives so a call site cannot re-open them:
- `SettingsCard` is gap-based like `Card` (`flex flex-col gap-4`). The
header-to-body step is the container's, not a margin somebody remembers.
A dense card still overrides the gap on the card itself.
- The body starts at the card edge. `pl-7` is gone — off the spacing scale,
and it mis-aligned action rows against their own forms wherever two
neighbouring cards disagreed about it.
- The card header's description slot loses its `space-y-1`. That was the
licence call sites used to stack explainer paragraphs into a muted
`text-xs` slot; the slot holds one sentence.
- New `SettingsCardActions`: one action row, trailing edge, last thing in
the card. `align="start"` spells the one exception, a card whose only
actions are links out.
- New `StatTile` replaces four hand-rolled label/value boxes in Admin.
- `SectionHeading` moves from `insights/` to `ui/`. It is the heading above
a group of cards, which is what Settings needs; Settings had been using a
card header with no card, which reads as a card that failed to paint.
Both shells now render their heading through `PageHeader`, which gained
`headingAs="div"` for the second copy each shell paints (mobile above the
nav strip, desktop inside the grid) so only one is a real `<h1>`. The
page explainer is muted everywhere: the primitive said foreground, the
standards and every hand-rolled copy said muted, so pages disagreed with
each other. Medications, Vorsorge, the labs / custom-metric / illness
detail pages and the clinician view drop their hand-rolled title blocks
too, including the mid-word truncation on the medications subtitle.
Tests pin the parts that can be checked exactly: the card's gap, the
description slot's missing rhythm, the action row's two alignments, the
`<h1>`-free shells, and a sweep asserting `pl-7` stays gone.
`hasEntryUnitConversion` and `canonicalValueToEntry` shipped beside the
hours-to-minutes conversion for an editing surface that does not exist and has
no ticket. Neither has a caller, so both were only two more things to keep
true, and the dead-code gate is right to say so.
The module comment claimed the inverse was there so a future surface could not
land on a different answer. It says what is actually true now: one direction,
one place. The git history keeps the rest if that surface ever arrives.
The mood sheet opens on the five-face quick check and reveals the rest of
the form, timestamp row included, once a face is picked. The dialog-overflow
spec measured the quick check instead, found no date field, and failed on
its own readiness gate rather than on the layout — a guard that cannot reach
the state it is about proves nothing either way.
It now picks a face and waits for the annotate panel, which is the state the
report describes.
Two things about the card that shows what changed since the last panel.
The comparison had no expiry. Someone whose newest panel is years old was
still reading "since your last panel" about a draw from another year — not
stale UI state but a stale sentence, and one the app kept repeating on the
overview every day.
The window is a year, applied to the data rather than to a per-user
dismissal. A year because routine panels are commonly drawn annually, so a
shorter window would hide a comparison that is genuinely the newest thing
somebody has for months out of every year, while past a year the wording
cannot be defended at all. Doing it on the data means nothing to
acknowledge, nothing to persist, no housekeeping asked of anyone, and one
answer for every client through the existing absent contract — the card
already un-mounts on it, and so does anything else reading that route.
Verified in both directions over the real endpoint.
The delta on the right was muted at the smaller size, the same typography
as the meta line under the analyte, so it optically hung off that lower
line and the row read as bottom-aligned even though it measures centred to
the pixel at every width. A delta is the number the row exists to report,
so it takes the content tier, and the cell states its own centring rather
than inheriting it.
The existing cases pinned their clock at the same time: fixed fixture days
against a relative window is a test that passes until a date in the future
and then fails for reasons unrelated to the code.
The three entries stacked two full-width buttons under the value — a
disabled grey Save above a red-outlined "Remove smoking status" — so each
one read as a slab of controls with the same weight as the field it
belonged to, and scanning them ran the two labels together.
Each entry now ends in one right-aligned action row: the quiet destructive
first, the primary Save last, both at the mobile tap-target floor. The
remove control drops the field name from its visible label, which the
Label directly above already carries, and keeps the full phrase as its
accessible name and in the dialog it opens — so three of them side by side
stay distinguishable to a screen reader. Six languages.
Follows the card action-row convention: primary bottom right, secondary
beside it, nothing after the row.
On a phone the placeholder sat noticeably low. The composer row was
bottom-aligned so the send button would stay beside the last line as the
textarea grows, but that also bottom-aligned a single-line textarea against
a 44 px touch target: 7 px of space above the text and 1 px below.
The row centres its textarea now and the controls keep their own bottom
alignment, so both properties hold. Measured empty, one line and four
lines, phone and desktop: the single-line gap goes 7/1 to 4/4 on a phone
and 2/1 to 2/2 on desktop, and the multi-line case is unchanged with the
send button still level with the last line.
The accepted formats and the size limit were a caption of their own under
the upload buttons, so the card read as an explanation, a control, and then
a second explanation. They now finish the sentence that says what the card
does, which is where someone looks before choosing a file rather than
after. Same two strings, six languages unchanged.
The intro listed all four instruments with their subject in brackets and
then explained itself, which ran to two lines on a desktop header and five
or six on a phone — a paragraph to read before reaching the four cards that
say the same thing. It keeps its opening claim and the one caveat that
matters, and loses the enumeration the cards below already carry: one line
on desktop in all six languages, measured at 1280 and 1440.
The pointer to mood history at the foot of the page is gone. The link on
the mood page pointing here stays, because that direction earns its keep:
someone logging a mood may not know the screeners exist, while someone who
came here for a screener knows where their diary is.
The card told people to add a channel "below". A position is only true
while nothing above or below it moves, and the same card already reads as
the first thing in its group, so "below" says less than the group's own
name does. It now names Delivery channels, which stays true wherever the
card is rendered.
The card's title and its per-channel description were still English in
Spanish, French, Italian and Polish while the strings around them were
translated; they are translated now too. Six languages.
The Coach button showed its unread dot over an empty conversation, and
opening the Coach neither showed anything nor cleared the dot.
Two paths fed one indicator. The nightly sweep flipped a "remind me"
reminder from active to due and stopped there — no conversation, no
message, and `surfaced` had no writer at all. The launcher lit its dot off
a second query that asked only whether anything was due. So the dot
promised content that was never written, and because opening the Coach
clears the message-based signal and not the reminder one, the only way out
was to resolve the reminder in Settings, which nothing on the badge
suggested. A reminder minted for a plan review put a user who had never
typed in the Coach in exactly that state.
The sweep now writes the reminder into the user's Coach thread as a real
assistant message and marks it surfaced in the same transaction: no
message, no status change, and nothing to point at. The badge derives from
that message like every other proactive one, so opening the Coach shows the
note and clears the dot. The launcher's second query is gone.
Reminders an older build left stuck on due are picked up by the same pass,
so an indicator that has been sitting on an empty conversation resolves
into the message it always implied.
No provider call and no push: the body is a deterministic localized
template around the user's own note text, six languages.
Proven end to end against Postgres — the row exists, the badge reads that
row, opening clears it, a second tick writes nothing, and a failed write
leaves the reminder untouched with the badge dark. All five fail against
the previous sweep.
The second indicator in the report, under the bottom bar's overflow entry,
is the active-route marker for that group and not a badge.
Refs #715
The add and edit surfaces could be scrolled sideways and the time picker
sat past the right edge: measurements, mood, the medication intake editor
and the cycle log sheet all showed it, so the cause is in what they share.
Two mechanisms, both in shared primitives.
DateField and TimeField wrap a text input. A flex item's min-width:auto
resolves to a content-based minimum, and for a text input that is its
intrinsic size attribute — 20 characters, about 180px. Neither field could
render below ~230px whatever width the parent offered, so the date + time
row demanded ~470px inside a 400px dialog body and pushed the clock button
58px past the edge. min-w-0 on the field box and on the overlay input
releases that floor; the row now fits at every width from 360px up.
The scrolling body only declares overflow-y. CSS promotes the other axis
from visible to auto, so every deliberate outward bleed in the tree turned
the form into a pannable surface — SheetSection's trigger row was 8px wider
than its own container for no benefit (a focus ring already draws outside
the box), and Switch's hit-target pseudo reaches 13px past the track. The
bodies and the dialog surface now state overflow-x explicitly, the section
trigger no longer bleeds, and the dialog's grid column is pinned so an
over-wide child cannot drag the surface out with it.
Measured before and after on the four reported surfaces at 360 / 390 / 430
/ 768 / 1280 / 1440: the date/time row spilled 83px on every width from
640px up and none of it remains.
An e2e spec asserts the two harms directly — the surface is not a
horizontal scroll container, and no focusable control lies outside its
visible box — with a canary that proves the reachability check can fail.
Entering or leaving a record is a server write followed by
window.location.assign, so the click that starts it returns long before
the document it produces exists. The switcher helper returned there, and
everything a test did next raced the app's own navigation.
Both ways of losing that race are silent. A goto issued into the gap is
cancelled when the assign commits, which Chromium reports as
net::ERR_ABORTED and which is what CI saw twice. Or the goto wins, and
the page it lands on belongs to the caller's own record, where a route
that is ungranted in somebody else's record is simply granted: /coach
opened in that gap issued all four of the protected reads the test
asserts never happen. The second is the worse outcome, because the
assertion was reading the wrong record and could as easily have passed
there.
Neither is visible in the DOM the click leaves behind, which is why
waiting on the banner cannot settle it: the document on its way out
already shows whatever the next assertion looks for, so the wait returns
at once and proves nothing. A marker on window can settle it, because a
document replacement is the one thing that destroys it. If the navigation
never happens the wait fails, which is what a switch that did not land
should do.
Both helpers use it now, so the retry that was added downstream of
leaving a record has nothing left to absorb; it stays, because the claim
there is that the record survives the round trip rather than that one
navigation attempt wins.
Reproduced before fixing, under CPU contention at CI's worker count:
three of six runs flaked, showing both faces. Six of six clean at the
same contention afterwards, then twenty consecutive runs of the file and
the whole sharing set, none flaky.
Two repo-wide guards caught the additions rather than a reviewer, which
is what they are for.
The success toasts reached for sonner directly. The success affordance
belongs to the outcome module, which binds it to the success key and
nothing else, so a surface cannot raise a green toast for a write that
wrote nothing. They go through toastWrittenOutcome now.
The sheet's date header formatted a day key in the browser's zone. A
cycle day key is a calendar day, not an instant, so it is anchored at
noon UTC and rendered in UTC, the same anchor the domain's day
arithmetic uses. Otherwise a reader east or west of the browser zone
could see the sheet name a different day than the one it writes to.
The day count was the length of the labelled run inside the calendar
grid, so the same day read as day 11 over a month and day 23 over three:
the run was cut off at the window's edge rather than at the cycle's
start. That was a latent contract question while every caller asked for
the same ninety days, and it stopped being latent when the month grid
began reading its own month, which is thirty days by construction.
The verdict now anchors on the period start the record holds and falls
back to walking the grid only when no caller supplied one, so the ring's
arcs stay grid-derived (they are about what can be drawn) while the day
is about what the person logged. The overdue ceiling then measures the
real elapsed count too.
Covers D-list edge 15, both shapes: a window that clips the run and a
window that excludes the cycle start altogether.
The constants make ovulation minus five through ovulation plus one, and
the comment above them claimed a six-day window. Wilcox's six days are
the five before ovulation plus the day itself, measured against a KNOWN
ovulation day. Ours is estimated, usually by back-calculating a luteal
length from a median cycle, so a window drawn tight to the estimate
loses its last day whenever the estimate lands early, and the ovum stays
viable for most of a day after release besides.
The direction is decided by who reads the window. It is surfaced to
people trying to conceive and to people avoiding it, and for the second
group a window one day short is a window that misses. The constants keep
the conservative reading and the comment now states it, with the note
that changing either one is a coordination matter because the iOS client
reimplements the same spec.
The width is pinned by a test rather than left to be re-read off the
numbers the code already uses.
Two defects put a record into a state that looks, from the app, like no
period was ever logged. Both are fixed going forward; neither repairs
the rows already made, and neither is visible from the surface it broke.
A closed torso is a live cycle carrying an end date with no live cycle
after it. A cycle is closed by its successor, so closed with no
successor means the successor is gone, and a record whose last cycle is
closed has no open cycle for the engine to forecast from. The signature
is exact and needs no audit trail.
A period with no cycle row is bleeding that only ever existed as a
day-log, from the flow chips, the bulk drain or an import. The engine
reads cycle rows, so the record holds periods and reports none.
The report imports the bleeding-day rule from the write path rather than
restating it, so it and the app cannot drift on what counts as a period
start. Checked against seeded records: it names the torso, names the
three flow-only starts while passing over a day two and a spotting day,
and says nothing about a healthy record.
Nothing is written.
MenstrualCycle rows were only ever written by the one-tap period
boundary. Every other way of recording bleeding wrote a day-log and
stopped there: the log sheet's flow chips, the bulk drain, an Apple
Health export full of menstrual-flow samples. The engine reads cycle
rows, so a person could log every period they had and the module still
held that none had happened. No cycle length, no forecast, an empty
chart, and an offer to log a first period. An Apple Health user who
never found the one-tap button had the whole module dead.
A first bleeding day now opens the cycle it belongs to, through one
shared seam in the day-log write helper, so the single capture, the
bulk drain and the Apple Health fold all infer a start the same way and
the one-tap boundary's own write lands on a date that already has a
cycle, which the rule declines.
The rule is conservative, because a false start corrupts every length
derived after it. Spotting does not open a cycle: it is as often the
tail or the herald of a period as its first day. Bleeding flagged as
between periods does not, since that flag exists to say so. Neither does
a bleeding day within the hard physiological minimum of an existing
start, which is what keeps day three of a period, or a forgotten day
two, from starting a cycle of its own without needing a contiguity
check. A genuinely short cycle is not inferred from flow alone; the
one-tap start still opens one on any date.
An edit is symmetric. Setting a flow opens the cycle; clearing it, or
flagging it as between periods, removes the cycle that day opened and
re-opens the one before it. A cycle that an edit can create and not undo
leaves the record holding a period nothing on the day says happened.
- the period route's start arm and the flow inference now share one
boundary helper, so a start means the same thing however it is entered
- back-dating re-anchors both neighbours, as the one-tap start does
- integration: eleven cases through the real routes, of which five are
about what must NOT open a cycle
The page reads one window anchored on today, ninety days back and half a
year forward, and the month grid navigates freely past it. A month
outside the window came back with no days: the cells still rendered
(they come from the month, not the read) and still opened the log sheet,
and the write behind it still landed, because the period route is built
for back-dating and re-anchors the neighbouring cycles for it. What did
not happen is anything visible. Saving an April flow in August closed
the sheet onto a month the read never carried, so the entry looked like
it had not been made, and entering it again did not help.
The grid now reports the month it moved to and gets its own bounded read
for it. The anchored window is untouched, because every other surface on
the page means today and the route caps a span at 400 days, so a union
with a month two years back would simply be refused.
A write also says so now. Saving, deleting and both period boundaries
raise a confirmation; the sheet's inline strip already named a failure.
Between the invisible month and the silent success there was no way to
tell a save that worked from one that never happened.
The sheet header carries the date it is writing to, and the one-tap
boundaries drop the word today when the day is not today. It always
wrote to the selected date; saying otherwise made a correction read as a
mistake.
The gear beside the log button is gone. It was a second door to the tab
directly below it and no other page carries one in that corner.
- calendar days carry isCycleStart, so the delete dialog can say that
removing this day removes the period start with it
- window arithmetic is a pure module with its own tests, including the
400-day span the anchor cannot be widened past
- integration: a day 111 back is written, is absent from the anchored
read, and comes back complete on a window over its month
- new strings in all six languages
The cold-start gate suppresses the calendar's phase band until three
cycles are observed, because a population-framed phase word off one or
two of them is not earned. The verdict read the day count off those same
labels, so the gate took the count with it: a record holding three
logged period starts resolved INSUFFICIENT_DATA and rendered an empty
ring under an offer to log a first period.
The count is not the claim the gate is about. It is the number of days
since a start the person entered themselves. The verdict now takes that
start from the cycle rows and falls back to counting from it whenever
today carries no phase, with the same overdue ceiling and no phase
claim. INSUFFICIENT_DATA is left for the case that really is one: no
period start on record at or before today.
The sheet's day header was already written for this state and could
never reach it — it renders a bare day number when no phase is
trustworthy.
- verdict round trip covers the still-learning day count and its overdue
side through the real route
- unit tests pin the anchor path, the absent-from-grid path, and that a
future-dated start is not a cycle anyone is in
Opening a cycle closes the one before it: the start write stamps the
previous cycle's endDate and lengthDays, and a back-filled start takes
its own end from its successor. Nothing undid any of that. Deleting the
start left the previous cycle as a closed torso holding an end date from
a cycle that no longer existed, and a record whose last cycle is closed
has no open cycle to forecast from — the prediction went away and there
was no way to bring it back from the app.
Both removal paths now re-derive the boundary from the cycles that
remain: a cycle ends the day before the next one begins, and with no
next one it is open again. That is a re-derivation rather than an undo
log, so it is correct for a history that came from an import or a
restore, and running it twice changes nothing the second time.
Deleting the day-log that opened a cycle now removes that cycle too.
The one-tap start writes both rows and the log sheet's delete is the
only way the web offers to take it back; leaving the cycle standing was
how the broken state survived the undo.
- soft-delete and re-anchor run in one transaction
- the audit row names the neighbour's moved boundary, the only part of
the write the tombstone cannot reconstruct
- round trip through the real routes: photograph the cycle rows, log the
start, remove it either way, compare field by field — including that
the forecast comes back
The engine ended the open cycle's phase window at the predicted next
start, so the day after a missed forecast carried no phase at all and
the verdict resolver read that as today falling outside every known
cycle window. An ordinary three-days-late period therefore reported
INSUFFICIENT_DATA: no cycle day, no phase, an empty ring and the words
insufficient data, while the reminder cron was pushing a period-confirm
prompt for the same day. OVERDUE was only reachable when the engine
itself forecast a cycle longer than the grace window.
The open cycle now runs to the predicted start or through today,
whichever is later, and stops the moment the next start is logged. The
count is bounded on the far side where it already was: past the typical
length plus its grace window the verdict degrades to OVERDUE and says
how late the period is.
The extension pins the open cycle's ovulation anchor to the predicted
length. Left to the span, an unconfirmed ovulation is back-calculated
from the window's own length, so a window that grows with the delay
would walk the estimate and every phase boundary drawn from it.
- buildPhaseCycles, phaseForDate and buildPhaseDayMap take the user's
today; the insights route and the coach snapshot pass it
- verdict round-trip covers the predicted start day, three days past it,
past the grace window, and the case where a new start closes the run
The heart-rate card hid itself whenever the server resolved no curve.
That is right for a session with no heart rate at all, but wrong for the
common case it also covered: a ride logged from a chest strap through a
provider that reports an average and a peak and nothing in between. The
stats grid shows those two numbers, the card next to them renders
nothing, and the page reads as if it has no heart-rate section rather
than as if the shape behind the numbers is not on record.
Three states now. A curve when there is one; a named absence when the
session carries heart-rate figures without a profile; nothing at all when
nothing was ever measured, since there is no absence to report there and
an empty card would be a shell.
The min-to-max envelope shipped as a stacked pair: a transparent base at
the bucket minimum plus a `max - min` delta above it. Stacked series are
measured from zero — recharts derives a stacked axis domain from the
stack offsets, whose baseline is 0 — so a chart asking for `dataMin - 10`
resolved to -10 rather than to the bottom of the band. A session spent
between 120 and 170 bpm was drawn on a -10 to 180 axis and the shape of
the effort, which is the entire point of the curve, squashed into the top
quarter of the plot. It only happened on the dense sessions the envelope
gate lets through, which are the ones with the most shape to lose.
The band is now one range area over the bucket's own `[min, max]` pair.
No stack, so the domain stays on the bpm values that are in the data.
The axis domain is computed rather than inferred, because the session's
peak has to be inside it: recharts discards an out-of-range reference
line instead of clamping it, and a reported peak is a single reading
while the curve plots bucket means, so a peak marker over a data-derived
domain would go missing on most sessions that have one.
Adds that peak marker alongside the average one, both labelled, and reads
the envelope back in the tooltip as the spread it is. The mapping moves
into a pure module so the values the axis sees can be asserted without
mounting recharts.
`durationSec` is a denormalised duration, and not every source reports
the elapsed one. Strava sends `moving_time` while the row's `endedAt` is
`startedAt + elapsed_time`, so a ride with twenty minutes of stops
carries a duration twenty minutes shorter than its own span. The curve
folded against that duration, which dropped every reading after the
moving-time mark: the tail of the session vanished with no gap and no
marker, and the detail page showed a complete-looking profile of an
incomplete session.
Fold over the span instead, with the duration as the floor for rows
whose `endedAt` is not after their `startedAt`. The bucket width tracks
the span too, so a stop-heavy ride keeps the same on-screen density as a
continuous one of the same length.
The reconstruction path also gets an integration suite over the real
route and a real Postgres. Two boundaries decide whether it is honest
and neither was provable against a mocked client: the query pads by five
minutes so a boundary reading still lands in the first or last bucket,
and a reading inside that pad must not extend the curve past the finish;
the measurement read is owner-scoped, and another account's pulse at the
same minute must not enter this curve. Both fixtures place a reading on
the far side with a bpm value no in-window reading has, so a leak shows
up in the numbers rather than only in a count. Verified by breaking each
boundary in turn and watching the matching assertion fail.
pdfjs-dist 6.1.200 and a transitive 5.6.205 both sit in the affected range
of a flaw that lets a crafted PDF run JavaScript inside the renderer. The
document vault feeds it every uploaded PDF, so pin the direct dependency to
6.2.108 and add a bounded override so the transitive copy lands there too.
The override stops short of pdf-parse's 5.4.296, which predates the flaw and
drives the local text-extraction path.
The runtime image ships every pdfjs-dist copy in the store, not just the one
that gets loaded, so an unused transitive copy still counted.
Hoisting in the image picked whichever copy the filesystem listed first, and
more than one copy reaches it, so the version named in package.json was not
reliably the one the renderer resolved. Carry the resolved version forward
from the build stage, select it by name, and fail the build when it is
missing rather than leaving another copy in its place.
The manual sleep field is labelled in hours. The column stores minutes, because
that is what every sync source delivers and because a single stage segment is
only a few of them. The form sent the typed number straight through, so a night
entered as 7.5 was filed as seven and a half minutes and the list showed eight
minutes of sleep.
Nothing rejected it. Seven and a half minutes is inside the column's
plausibility band — a stage really can be that short — so the entry looked
accepted and the loss was silent.
Both ends were already right: the field says hours, the list formats minutes as
hours and minutes. The assembly between them was where the factor went missing,
which is why the round-trip test runs the whole of it — the form's conversion,
the shipped POST, the stored row, and the formatter the list paints with.
The storage unit stays minutes. The conversion sits at the entry boundary in
one place with its inverse beside it, so a future editing surface cannot land
on a different answer, and it rounds to whole minutes rather than storing
437.99999999999994 for 7.3 hours.
The value field also lost "7,5" outright. `type="number"` sanitises a decimal
comma away on the engines that do not localise the field, so the entry arrived
as an empty string with no explanation, and `parseFloat` would have read it as
7 on the engines where it survived. The field now takes the same shape the labs
and custom-metric fields use — a numeric keypad and a submit path that reads
the comma — so every decimal metric on the form gains it, not only sleep.
`scripts/diagnose-manual-sleep-hours.ts` lists the rows already stored this
way. It reports and does not repair: an eight-minute stage and a night typed as
eight hours are the same number in the same column, and only the person who
entered it knows which it was.
The hero line said a pulse was well above its 30-day average today. Nothing
had measured a pulse for five days. The day-signals card flagged the same
reading, and the baseline-drift card said the value was above the usual range,
present tense, on a day that held no reading at all.
One class, and it sits in the data preparation rather than in any of the
sentences. Each surface was handed "the freshest stored value" with no notion
of when it was taken, so each of them assumed now. The age was reachable — the
aggregates carry `newestDaysAgo` inside their coverage object — but it read as
a statistics footnote rather than as the fact that governs the tense of every
sentence about the metric, and none of the three consulted it.
A reading is material for a claim about today when it comes from today or from
yesterday. One day rather than zero: a vital taken at 23:40 and read at 00:10
is the same reading it was half an hour earlier. Two is not, because by then a
whole day has passed with nothing in it and the sentence would be describing a
gap.
The rule now lives beside the data. The vital deviations carry the age of the
reading they were computed from, and the two deterministic consumers apply it:
the day-signals flag counts only current vitals toward its count, and the
baseline-drift card surfaces only current deviations. Stale vitals stay in the
list with their age, so the anatomy view still shows them and nothing silently
disappears; the dated level shifts were never today claims and are untouched.
For the narrated surfaces the same fact is stamped onto every metric block of
the Coach snapshot before anything reads it, so the age travels with the
numbers into the hero, the briefing, the Coach reply and the retrieval tools
instead of each of them guessing. A block narrated off a stale series also
lands in the wide event, which is where it should have been visible all along.
The baseline-drift card reported a pulse in the hundreds of millions against a
usual range in the tens of thousands. Neither figure is a heart rate, and both
came from the same place.
The application declares a plausibility domain per metric, and every
interactive, import, MCP and Telegram write validates against it. The
derivation layer did not ask. So a single stored value outside that domain — a
provider unit-decode slip, or a row from a writer that never had the gate — was
folded into the day mean, carried by the median into the band, and then held
against itself as "currently". The two numbers in the sentence were wrong by
different amounts, which is why the line read as nonsense rather than as an
exaggeration.
Both readers now refuse a value the application has already declared
impossible: the per-day means behind the band, and the latest-day mean the
deviation reads. A day left with no plausible reading contributes nothing
rather than a fabricated point, and the vital drops out of the comparison — the
coverage floor already knows how to report an absent day. The rollup branch
applies the same rule to a bucket mean, since the rollup writer averages
whatever is stored. Everything downstream of `readDayMeanSeries` inherits it,
which is every consumer of the usual range: the band strip, the drift card, the
changepoint detector, readiness, trajectory, the coach read.
The card also printed raw doubles. Each figure now renders at the metric's own
decimal precision through the shared formatter, so a mean reads as a number
rather than as a float and follows the reader's number convention.
Left open, and recorded for a separate pass: the provider sync writers do not
enforce the domain on the way in, so the impossible rows keep landing.
Marking a missed dose taken from the Verlauf ledger showed the row flip to
taken and then fall straight back to missed, with the entry nowhere on the day
it was made for.
The ledger posts the displayed slot's own anchor as `scheduledFor` and means
"record THIS dose". The route resolved the slot by window band from `takenAt`
instead, which answers a different question: which slot the recording moment
falls into. Backfilling an older slot matches no band, so the write fell
through to the standalone insert and landed as an ad-hoc row anchored at the
recording moment. The named slot stayed unserved, the optimistic row was
overwritten by the authoritative refetch, and the dose sat under today with no
sign of where the user had put it.
A named instant that is a real slot of this medication now decides the binding,
validated against the same band anchors the "diesem Slot zuordnen" pin uses, so
an arbitrary client instant still records ad-hoc. A slot that lies ahead of the
take is still refused by the existing forward guard, so a late-morning dose
cannot consume the evening slot. When the naming and the clock disagree the
binding rests on the user's choice, so the row stores it as such and the read
ledger anchors it there rather than dropping back to ad-hoc; an ordinary
on-time take resolves to the same anchor either way and is untouched.
The tests drive the shipped POST and the shipped dose-history GET, and assert
the persisted row and the ledger read back from it.
Maximizing the Coach drawer hands the conversation to /coach and carries
?doc= with it, so browser-Back can rebuild the sheet. That is the one
close-shaped transition whose whole point is to LEAVE the selection where
it is, and the close path has always returned early for it.
The dropped-traversal check added beside that close did not know. It saw
a closed sheet and a selection still in the address bar, which is exactly
the state it exists to repair, and it repaired away the return URL a
quarter of a second after the close had deliberately preserved it.
Whether it won that race was a question about how fast the machine
navigated, so it surfaced as one flaky run on the narrow viewport rather
than as a broken feature.
Both sides read one named predicate now, and the check is skipped when
the close kept the selection on purpose. Proved by driving the grace
window to zero, which is the worst case the timer can have: before, the
maximize never even reached /coach, three attempts out of three; after,
it passes there and the whole documents family passes with it. Twenty
consecutive runs of that family on the narrow viewport are clean.
The service worker writes its data-cache entry after the response has
been handed to the page, so a shell that is ready is not evidence that
the write has landed. Reading the cache once therefore sampled null on a
loaded machine, and the positive control failed saying there was no entry
about a cache that was about to have one.
Polled on the entry itself. It waits for the signal rather than for a
number of milliseconds, and it still fails if the entry never appears,
which is the whole reason the control is there.
The scope mirror is watched so a switch this browser did not begin still
holds every shell until /me reconciles it: another tab on an older
bundle, the referential action behind an account deletion, an operator.
It recognised its own by comparing the mirrored scope against the scope
it was switching to, and that is not a true test. fetchMe mirrors
whatever record the server currently reports and it runs DURING a switch,
both when postSwitchWithReconcile reconciles a lost compare-and-set and
when a second tab reloads beside the initiator. At that moment the server
still reports the record being left, so the write did not match and was
read as somebody else's switch.
What followed was silent. A foreign write publishes a new transition id,
and every terminal call opens by checking the id it was handed, so the
commit that followed was dropped without a trace and the expected scope
was left naming a record no /me would ever confirm. Nothing could release
the hold, and both tabs sat behind the hydration gate until the stored
transition aged out thirty seconds later.
That is the intermittent browser failure that has been carried through
three rounds as a canary and never explained: a peer reload racing a
switch, a gate that never lifts, and a run forty-six seconds long made of
a fifteen-second wait and a thirty-second one. On the selection that
provokes it, eight runs failed three times before and twenty failed none
after, and the runs that used to take fifty-seven seconds now take
twenty-five.
A write is foreign only when nothing of this browser's own is in flight.
Ignoring it while held costs nothing, because a held browser is already
in the state that branch would install. The rule is a named function so
it can be checked in a suite that has no DOM, and the property the guard
exists for is pinned beside it: a response that disagrees with a
committed scope is stale and still must not release.
Both failed on CI and neither failure was about the product.
The loading-state scan held /api/auth/me open with a fixed sleep in the
route handler. Three things follow from that and all three are wrong: the
hold ends 1500 ms after the first request whatever the axe scan is doing,
so the state being scanned is not the state the test names; every refetch
queues another sleeping handler, so how many are live is a property of
the machine; and a handler still sleeping when its request is cancelled
answers route.continue with "Route is already handled", which is what the
runner reported. It now holds on an explicit gate released at a point the
test chooses, counts what it held so a page that was merely slow cannot
pass for a page it held, and asserts the gate goes away on release, which
is the exit control the entry assertion never had.
The labs doorway proved that no OCR probe fires by waiting for that
request with a 500 ms timeout and expecting the rejection. An instrument
whose pass condition is its own failure has to be armed early and awaited
late, and in between it is an unhandled rejection that lands on whichever
test is finishing. Its window was wrong too: the timer started before the
switch was clicked, so on a slow runner it could expire before the labs
page existed. A request listener replaces it, covering everything from
before the click to after the record read landed, with the domain's own
read asserted in the same set so an absence cannot be a statement about a
matcher that stopped matching.
Closing a card-opened document asks the browser to go back, so the entry
the selection pushed is consumed and the reader's next Back reaches the
page before it. A traversal is a request, not a guarantee: one asked for
while another is still settling can be dropped, and nothing raises when
it is. The sheet closes, React stays consistent, and the address bar goes
on naming a document nobody has open, so a reload or a shared link
re-opens the sheet that was just closed.
Pressing Escape straight after the browser's Forward button is exactly
that shape, and it reproduces: ten repeats across both viewports failed
two of twenty with the URL still carrying the id, and none of twenty once
the close verifies itself. The check is guarded by the document id, so a
traversal that landed on a different selection is left alone, and it is
cancelled the moment the sheet re-opens.
The browser test that found it could not say so. It asserted the sheet
was closed through a locator matched on the document TITLE, and the sheet
falls back to an untitled label while its detail read is pending, so the
assertion also passed on an open sheet showing a skeleton, resolved
immediately instead of waiting, and pushed the failure into a URL poll
ten seconds later. It now asserts the dialog role, which is what the
neighbouring deep-link test already does.
The dead-code gate has been red on this branch since the sharing refusal
contracts landed. Neither name is new and neither was noticed, because
knip is not in the local gate.
consentRequiredResponse lost its last caller when every delegable route
started sharing one 403 description through recordRefusal(); the AI
consent sentence it wrapped is still used and stays, with a note at the
constant saying why there is no response object beside it any more.
mutateThenRestore was written with the sharing security fixtures and has
never been called.
No ignore entries: an export with no consumer is the thing the gate is
for.
The operator page still said every grant is read-only, one release after
the write level shipped, and the table of what a grant cannot reach
predates both the manage level and managed profiles.
Rewritten against the frozen behaviour: the eight sections a grant can be
narrowed to and what each opens, the three levels and what each adds, the
step-up that is unconditional on a managed profile and conditional on an
ordinary invitation, and what a guardian may keep for a record that has no
login of its own. The AI row is corrected rather than dropped: a manage
grant reads insights that already exist, and no level causes an outbound
provider call.
Every claim on that page was read out of the code before it was written
down. The one about backups was cut instead, because it was not.
Zod 4's .meta() is immutable: it returns a new schema carrying the
metadata and registers that one. Four request bodies in the sharing
family called it as a statement and dropped the result, so the route
table went on referencing the bare schema, the bodies were emitted
inline and anonymous, and the object-level descriptions never reached
docs/api/openapi.yaml. One of them carries the clause that scopes every
rule on the invitation endpoint to an invited grant, which is the
sentence that stops a reader carrying the step-up and the refusals
across to a guardian grant.
Bind each to a const, reference the const, and correct the level
boundary in the invitation's description while it is being published:
it still carried the blanket denial that stopped being true when the
anamnesis surface opened to a manage delegate.
The wire format does not move. Four named components appear and the four
bodies point at them; the fields, the types and the requiredness are
what they were. A new guard asserts every id this route table declares
reaches the built document, because the failing form looks exactly like
the working one.
The shared-record strip was forced by capturing /api/auth/me and serving
it back with the sharing block filled. That state is now proved across
the payload validator, the fence store and the transition machine at
once, so a single route mock cannot produce it and the two narrow
repairs both stop one step short of the shell.
So the spec stops faking it: its own login jar, a real switch into the
seeded whole-record READ grant, and a place on the list of files that
run in exactly one project, because a second project would drive the
same session row from two browsers. The viewport below md that project
used to cover is measured inside the desktop project instead, where the
shell picks its column layout from the same width-driven rules.
Seven migrations, 0296 through 0302, apply to an empty database in order and
install both triggers. The allocation had been written down as four, and every
later document that quoted it inherited a range that had already moved.
`scripts/verify-v137-adversarial.ts` is the allocation as a runnable artifact
rather than a table somebody keeps in step by hand. It names all seven, refuses
a number allocated twice, asserts each trigger is in the migration that claims
it, and counts every release inventory against a written-down number instead of
against "more than zero" — a list that lost half its entries satisfies a
non-empty check and fails this one. It also refuses to report success when it
ran no checks at all.
It found two things on its first run. Eleven handler entries looked like
duplicates because the key ignored the action, and an `[id]` route carries an
update and a delete as separate contracts; the key includes the action now. The
journey-count leg was then falsified deliberately by expecting nine, which
failed on the count and exited non-zero.
The `push_attempts` principals are declared optional in the schema and NOT NULL
in the database, and that is deliberate rather than drift. The attribution guard
is a BEFORE INSERT trigger, so a write omitting both has them filled from the
legacy mirror before the constraint is checked; requiring them in the schema
would force every established self-delivery writer to name a pair it has no
opinion about, and relaxing the columns would let a partial pair reach the
table, which is what the trigger refuses. `migrate diff` reports the difference
because it cannot see triggers, so the reason is written at the columns and
names the test that proves the accommodation still works.
Measured rather than assumed: applying trunk's chain to its own empty database
reports the same classes of diff — truncated index names, timestamp precision,
foreign keys re-stated. The release adds eight lines to that, seven of them in
classes trunk already carries and one of them the deliberate pair above.
The eight journeys were enumerated and one of them was wired. The other
seven existed as claims in a fixture file, which is the gap the enumeration
was written to close: a browser suite reports what it ran, never what it did
not, and a journey nobody wired reads exactly like a journey that passed.
All eight are wired now, and the list is checked in both directions — an
enumerated journey missing from the wiring fails, not just a wiring entry
naming a journey nobody declared. Before this, a subset was legal, so a
dropped journey and a not-yet-written one looked the same.
A journey names more than one file where more than one file carries it. J1
claims a grant runs through invite, accept, switch, expiry, revoke and a
direct URL; the browser proves the switch, what each level opens and the
direct-URL refusal, and the transitions either side are database facts
proved against real Postgres. Folding that into one file would mean a
browser spec driving expiry through the clock or an integration file
asserting what a page renders, and both are worse tests than the two that
exist.
Three journeys ride Playwright and five ride Vitest, so a green run of this
guard means all eight are claimed and here is which have executed in the
ordinary gate — never that all eight passed. The runner is derived from the
path rather than trusted, so it cannot become a label set to whichever value
made the split read better.
The extractor now reads `it(` as well as `test(`, and the parameterised
`it.each(table)("title")` form alongside the single-call one. It saw neither
before: five of the journeys are proved by Vitest files, every per-file leg
is over a matched set, and an empty match set satisfied them all. The
synthetic spec that proves the parse grew a Vitest half, and it is what
caught the parameterised form being invisible. What the matcher still cannot
see is written into its own doc comment.
The cross-midnight test read `push_attempts` for a synthetic `DEDUP`
channel. Separating the scheduler's bookkeeping from delivery diagnostics
moved the anchor to `notification_events`, and nothing has written that
channel since. The query matched no rows, `findMany` answered the empty
list, and the assertion compared an empty set it had no reason to expect —
a check that could not fail whatever the tick did.
It now reads `notification_events` by record and event type, and asserts
the single row's `dedupKey`. Removing the claim from the tick fails the
dispatch-count leg above it; narrowing the key so it no longer names the
slot fails this leg on the key's own content, which is what makes it a
check rather than a comment.
Four cases in the accountAccess integration test were written against a
contract this release rejected. They asserted an entry of seven fields and a
block of three, both of which gained one; and they asserted that a MANAGE
grant publishes `access: "manage"`.
It does not, by design. `access` is the v1.36.0 name and its union is
`read | write` — a client compiled against that release decodes it as a
closed set. MANAGE therefore publishes as `write` there and as `manage` on
`level`, and the entry schema raises a validation issue unless a manage
entry carries exactly legacy write access and whole scope. The expectations
move; the schema does not.
`recordKind` is required on an entry and on the block. Both are asserted
now, including the block-level value moving from `self` to `shared` across
a switch, so a value stuck on either reads as two cases disagreeing.
Nothing to reconcile in the tree: main's dashboard-hydration fix is already
on this branch as an identical patch, and main's delegated-dose replay guard
predates the branch point. The merge records the ancestry so the release
branch cannot be read as behind trunk.
playwright.config.ts is the one file both lines touched. Main added the second
web server on 3100 that runs the shipped DASHBOARD_SSR_PREFETCH configuration;
this branch added the service-worker project and the per-spec project
exclusions that keep every session-moving spec in exactly one project. The
merged file carries both, and the only difference against main is those 49
added lines.
The floor was described on the one route that cannot reach it and left unsaid
on the two that answer it every time: withdrawing the last Guardian grant and
renouncing it both raise `managed_profile.guardian.required`, and both
published only 'that access had already ended'. Both 409s now name their two
codes, and the annotation on the removal route enumerates all four emitters
rather than three.
A guard closes the class rather than the instance: every route module that
raises the refusal must publish it on its own operation, with the emitters
discovered from the sources.
The freeze guard gains its containment leg — a module becomes delegable by
review, not by discovery, so every file contributing a fenced verb has to be a
member of one of the surface guard's frozen allowlists.
The lifecycle suite took the invitation route's answer as an account-grant row
and reached for `id` and `expiresAt`. It answers with the roster's entry now,
so the id arrives as `grantId` and the expiry is read off the stored column,
which proves it was persisted rather than echoed.
Freezes the authority split the third level ships with. A token exercises a
MANAGE grant, and a Guardian's authority over a managed profile, exactly as a
browser does — proved at the resolver, where neither had a Bearer case before.
A token cannot offer MANAGE and cannot create a managed profile at all, because
both gates resolve through the session cookie; the second had no test until now,
and it runs against a real database with a wildcard token to make the width of
the permission irrelevant.
The claim set is held together by a map naming, per claim, the file and the test
title that proves it, so removing any one of them fails rather than quietly
narrowing what a native client may do.
The fence's client-facing half is frozen beside it: the account payload publishes
the record context on a cookie session and null on a token, and the published
contract says so under the same header names the server matches on.
Runs the legacy whole-record decoder over the entries `resolveAccountAccess`
actually produces, rather than over the fixture objects that describe them: the
existing case proves both ends and never the assembly between them, which is the
shape of test this project keeps rediscovering. Whole READ and WRITE decode,
MANAGE decodes as write and never above the level it confers, a scoped grant is
refused rather than read as the whole record, and a stored scope the server
cannot parse reads as nothing on both ends.
The canonical readers are driven over the same server-built entry, so
`level`, `sections` and `recordKind` are proved to survive the trip to the
capability resolver. The account-payload guard gains the pair for
`recordSession`: a field whose only reader is the transport type is exactly
what that guard exists to catch.
Every route that resolves a record through the sharing fence can answer 403
`sharing.access.denied`, and until now that fact was published on the selector
header and on none of the 120 operations that produce it. All 120 carry it now,
byte for byte, from one constant: the refusal is deliberately indistinguishable
across a missing account, a missing grant, a scope that does not reach the
surface and a read grant on a write, so 120 paraphrases would invite a client to
tell apart what the server refuses to.
Where a delegable route already answered 403 for its own reason — a module
gate, the cycle gate, AI consent — the two share one description rather than one
of them going unpublished, since OpenAPI allows a single response per status.
The guard recomputes the delegable set from the route sources on every run and
classifies per verb, which matters: seventeen modules have a delegable read
beside a write that refuses a switch outright.
The contract already pointed at them: the Guardian roster names
`DELETE /api/managed-profiles/{id}/guardians/{grantId}` as the route its
`grantId` is for, and that route was not in the spec. Creation, deletion,
invitation and removal now are, with the two bodies moved to
`src/lib/validations` so the wire shape has one source.
Three things are stated rather than left to be discovered:
- the two refusals on the invitation that carry no `meta.errorCode`, and the
order a client must resolve in because of them
- the last-Guardian 409 on the removal route, which nothing can elicit through
that route today, published with the reason
- that the sentences on the ordinary invitation describe an invited grant only,
so a client cannot carry its MANAGE rules across to a Guardian grant
- publish the roster's entry from the invitation and `{ grantId, revokedAt }`
from the removal, instead of the account-grant row Prisma returned
- drop the invitation's self refusal: it named a code no request can elicit,
because a Guardian grant's grantor is the profile and never the caller
- name the duplicate grant error rather than catching every grant refusal into
a sentence that would be wrong for any other one
- keep the rate limit and the unknown-identifier 404 free of an error code,
as their siblings elsewhere are, and say why at the call site
- bound profile creation at ten an hour per account, the ceiling its
guardian sibling has carried since it shipped
- distinguish a failed roster read from a slow one, with the way back
- cover the three caller states the roster read has to refuse: an
unaccepted invitation, an expired grant, and MANAGE held elsewhere
- cover what deletion does to a guardian standing inside the record
recordKindLabel chooses between two sentences that mean different things
and nothing could fail it: the journey asserted the data attribute, which
that function never reads, so inverting the arms left the suite green
while a guardian standing inside a managed profile was told it was an
ordinary shared record.
The pending-acceptance note renders unconditionally inside the invite
form, so asserting it after the invitation proved a string existed rather
than anything the journey did. The claim moves onto the pending row,
which the server has to produce. The note keeps its own leg in the unit
suite, where deleting the node makes it red.
- deleting a profile and ending a guardian's access resolve fresh MFA,
and both sit behind a confirmation, so the request that reaches the
server is minutes old and routinely outside the five-minute window
- an account with no second factor is told where to set one up instead
of being asked to confirm one it does not have
- the arm is scoped to those two acts: a 401 on accept, revoke or
renounce is a lapsed session and must not read as a factor prompt
- a refused field gets its own announced node in content contrast; the
standing hint stays muted beside it, rather than being overwritten
- the timezone refusal is the only explanation for a submit button that
will not press, so it leaves the picker's muted hint slot
- drive creation, the switcher entry, the managed banner and the return
trip in a real browser, plus the guardian roster and the floor stated
before an invitation is accepted
- seed a guardian account with a second factor and refresh its step-up
before the journey acts, since no fixture could reach a step-up-gated
route before this
- tie the journey id to its test titles, so a marker that goes missing
fails a check rather than vanishing from a run nobody counted
- give the delegated-action inventory a leg that can falsify its level
axis: the walked module set must strictly exceed the manage-only one,
anchored on a route that never asks for manage
- declare AUDIT_LOG_RETENTION_DAYS and SESSION_COOKIE_SECURE in the env
manifest, so the pre-deploy check reports the two settings operators
are told to set, and hold manifest, compose whitelist and both example
files in step
- list who looks after a profile, invite another guardian and end an
existing access from the profile's own row
- state the last-guardian floor from the real roster before the act, and
say plainly that an invitation counts only once it is accepted
- give each of the invitation refusals its own sentence, branching on
status for the two the route sends without an error code
- cover every call the card can make against the real routes
- confirm before deleting, naming what goes and that it cannot be undone
- state how many people lose the record, taken from the guardian roster
rather than assumed to be one
- say that deleting is the way out when you are the last guardian, since
this route sends no last-guardian refusal
- announce a refused deletion on the profile's own row, retryable
- add GET /api/managed-profiles/{id}/guardians, cookie-only, refusing
anybody who is not an active guardian of that profile with the same 404
an unknown id gets
- disclose the grant id, the party block already shared between grant
parties, the derived state and the two timestamps, and nothing else
- publish the route in the OpenAPI contract
- add a managed-profile card to Settings -> Shared access, between the
invitation card and the standing-state cards
- collect exactly the four fields the create route accepts, with the date
of birth genuinely optional and never derived from a year
- refresh the account payload on success, so the new record reaches the
switcher and the banner without a reload
- assert the section is reachable for an owner and refused for a Guardian
inside a profile and for an adult manager
The privacy dashboard re-implemented the audit-retention resolver with its own
default and its own floor. Two implementations of one disclosure, quoted to the
same person on two pages and by the purge job; it uses the shared resolver now.
The record-content branch in the settings gate matched a classification rather
than a page, so a second manage-writable destination would have rendered the
allergy and family-history managers under its own heading without anybody
choosing that. It resolves through a slug map, and the contract test fails if
the map and the classification disagree in either direction — a destination
cannot join the kind without somebody deciding what it renders.
The managed-profile nav filter assumed a guardian grant is always manage. It is
today, which is why the check was easy to leave out; an entry below that level
would have listed destinations the gate refuses.
Interpolation used a replacement string, and String.replace reads $&, $`, $',
$1 and $$ out of one. A display name containing $' rewrote the sentence around
it — and display names land in exactly the sentences that say who did what in
somebody's health record. A replacer function has no $ grammar at all, which
removes the class rather than escaping the members of it somebody thought of.
Two sentences promised that a manage grant reaches no settings at all. Both
were true when they were written and stopped being true when the anamnesis
destination opened, and both are in the worst places for a stale promise: one
is the access description generated into the public contract and read by the
native client, the other is the paragraph a delegate meets on a page sharing
does not cover.
Both now name the boundary the code draws — record content versus account
configuration — and say which part of the record a manage grant reaches. What
stays with the account owner is spelled out rather than implied: login, the
second factor, provider connections, tokens, notification routing, module and
threshold configuration, and who else has access.
A guard checks the copy against the classification rather than against itself,
so opening a second destination or closing this one moves the sentence with it.
The contract is regenerated.
The revoke dialog tells an owner that "who entered what" stays answerable for
N days, and the activity list prints the same number under its heading. The
query behind that list had no date filter: on an instance whose retention was
shortened it showed rows the sentence said were already gone, and the number
was a caption over an unbounded read rather than its limit. It is the filter
now, resolved once so the bound and the sentence cannot drift apart.
The list also stopped at a hundred rows and said nothing about it, which is the
more dangerous of the two silences — an owner who scrolls to the bottom reads
the oldest visible line as the beginning, and a busy household reaches the cap
in a fraction of the window. The response says which of the two limits it hit,
and the view states it only when it did: a caveat that is always on is one
people learn to skip.
The self-hosting page said an empty list means nobody opened the record. It
means nobody did so within the window. Corrected, with the row cap named
alongside it.
AUDIT_LOG_RETENTION_DAYS is not only enforced, it is published: the
shared-access panel prints the window under the activity list, and the dialog
that ends somebody else's access states how long "who entered what" stays
answerable. Both read the value the server resolved. It was missing from the
compose environment: whitelist and from both example files, so an operator who
set 90 got 365 told to every user, in a sentence about their own data.
That block is a whitelist and Compose substitutes from .env regardless, which
is what makes the variable look configured while never arriving — the same
shape as the session-cookie flag in v1.5.2. A guard now covers the three places
an operator meets such a variable, and its control leg found that the cookie
flag itself is still absent from the production example. Documented there too.
Every delegable route files a breadcrumb when a request does not parse, and it
files it through auditLog on purpose: that helper is what stamps the actor, and
without the stamp a delegate's malformed request would read as the owner's own.
The rows are meant to be attributable. None of them had a sentence, so all of
them rendered in the owner's activity view as "made a change in your record" —
including one a view-only delegate can trigger by sending a query the route
cannot parse. Nothing had changed and nothing could have.
Thirty-three actions now have honest lines. A refusal says it was an attempt
and says it was refused; it never borrows the language of something that
happened. Two of them are filed by library modules the routes import — a
background import's completion row and a medication lifecycle reconciliation —
and a walk over route files alone could not see either.
The inventory guard was asking a different question from the feed. It kept only
awaited audit calls while the feed shows fire-and-forget rows identically, and
it read route files while the feed shows whatever the request reached. Both
criteria now match the feed's, the three rows the feed structurally cannot show
are excluded by name with the reason written down, and the exclusion list is
pinned at three. A leg asserts the shape of the sentence for every refusal, so
a future arm cannot map an attempt onto an ordinary verb and pass on coverage.
The whole round trip is covered against real Postgres: a view-only delegate
sends a malformed query, the row lands attributed to them under the owner, and
the sentence the owner reads is an attempt.
POST /api/allergies and POST /api/family-history have resolved
requireRecordAuth("manage", "profile") since the level shipped, while the only
form in the product that posts to either sat behind a Settings destination
classified unavailable. The write was admitted, audited and owner-scoped, and
nobody could reach it — the one-ended change this repository keeps
rediscovering, with the permission shipped and the surface left as a follow-up.
The destination now carries a classification that means what the routes mean:
reachable by everybody they already admit, which is a guardian inside the
profile they administer and an adult delegate holding manage. The four places
that decided visibility on the guardian-only literal were each updated
deliberately, and the shell that refused every settings path in an adult share
now admits exactly the destinations classified as record content. Record
configuration — modules, thresholds, notification routing — stays with the
owner at every level.
The page inside a record is its own component holding the two managers whose
routes answer for a delegate. Mounting the personal section would have put
three more forms on screen that refuse on submit. The family-history card says
whose statement the row is, which the route comment could not.
Both routes carried a long paragraph arguing the write was deliberately
unreachable. It is gone rather than left to send the next reader back to close
what this opens.
The integration coverage moves from the undeclared-mode refusal to a manage
contract: a manage delegate and a guardian each write and the row lands under
the owner with the delegate named as actor, while read and write delegates get
the insufficient-access refusal. Nothing exercised either route at manage
before.
The shared-record accessibility suite had four tests for five states: success
and empty shared one, and neither of them ever entered an empty state. A
browser run reports what it ran and never what it did not, so a state nobody
wrote a test for produced exactly the same output as a state that passed.
The five states are enumerated in a fixture, each test names its state, and a
fast guard reads the spec back against the list — a state that loses its test,
its anchor, or one of its proofs now fails in the ordinary gate rather than at
the point somebody notices the spinner is silent.
Every state runs four proofs: an axe scan, the document reading order, either
a keyboard reach with a visible focus indicator or a live-region announcement
for the one state with nothing to press, and, where the copy is something a
person has to act on, that it renders as content rather than as muted meta and
clears the contrast floor. The retry control on the read-failure card gained a
stable slot so the keyboard proof targets the affordance rather than its
wording.
The eight release journeys are enumerated in the same fixture.
Accepting, revoking and handing back a grant had no failure rendering at all.
The request came back refused, or never left the machine, and the panel did
nothing: the confirm dialog had already closed, the list did not change, and
the only signal was the absence of one. The two refusals that most need a
reader were the two that arrived most quietly — an invitation that had lapsed
before it was opened, and the last guardian being told they may not hand back
a profile that would then have nobody looking after it.
Each refusal now renders on the row it belongs to, announced, in content
contrast, beside a control that runs the same act again. The sentences are
split by what the person does next rather than by status code, so lapsed and
withdrawn read differently and the guardian refusal names the way out. A
request that never reached the server says so instead of reporting a refusal
nobody issued.
The failed state is read off the mutation rather than copied into local state
from onError: the copy is what goes stale, and the mutation already knows
which row it was for. The invitation form keeps every field it had, and only
a successful send clears them.
Every action a manage-level route files now renders as its own sentence in
the owner's activity view, in all six locales. A write delegate can only add,
so the generic "made a change" was at least the right shape of fact; a manager
changes and removes entries the owner made themselves, and the generic line
withholds the only part that matters — whether a reading was corrected or
deleted, whether a dose history was purged.
The inventory reads the routes and calls the map rather than matching `case`
in its source, so an action that falls through to the fallback fails, and it
follows each resolved key into all six bundles. Every sentence interpolates
the actor's name and nothing else: the audit table is not encrypted and a
value in a verb line would be a health figure in a second store.
The revoke dialog's retention disclosure no longer depends on which of two
requests answers first. `retentionDays` rides the grant list, so a client
holding a row to revoke holds the window it must state, and the branch that
omitted the sentence is gone.
- document srcSet, poster, object data, form action, window.open,
sendBeacon, CSS url() and spread props as shapes this matcher cannot
see, alongside the two trees it walks
- match the imperative `.src =` form, anchored to a real occurrence so
the new leg cannot pass by matching nothing
The detail sheet loaded the original twice on open — the inline preview
and the download href each fetched and decrypted the whole blob — and
for a non-inline document the old href had cost nothing until clicked.
- one loader, lifted to the sheet, and only for a document that renders
inline; everything else fetches at click time
- make the download a real disabled control that reflects a failed
fetch: href="#" with aria-disabled announces unavailable and still
activates, and it ignored the failure entirely
Removing the Vary block left the disk-layer case green: it asserted what
is IN the cache, and the claim is what the cache will SERVE. On the
external-switch path nothing wipes those entries — the app never
initiated the switch — so Vary is the only thing between a cached
delegated response and the next request under another context.
- assert cache.match() hits under the stored context and misses under
the new one; it fails when the Vary is removed
- correct the acceptance map and the client docblock to claim exactly
what executes: no zero-entry count, no offline leg
The suite only passed on one worker: four specs shared the scope
delegate's cookie jar and moved the same session's record selector, so
at CI's two workers they moved the epoch under each other and the
failures were the harness racing itself.
- one login per switching spec, which is a separate session row and
therefore a separate selector; the login bucket is cleared between
them because the ceiling is five and setup now signs in eight times
- each such spec runs in exactly one project, since a second project
drives the same row from two browsers
- clear what the browser suite WRITES, so it can be run twice: the
write journey saves a minute-rounded reading and collided with itself
- wait for adoption after leaving a record, retry a lost switch the way
a person would, and budget the cross-tab hold for machine load
Every one of the three observed nothing and passed anyway.
- AC-04 held /api/auth/me so the record read goes first: /me is unfenced
and adoptable, so the client silently re-adopted before ever sending a
stale assertion
- AC-06 installs its intercept before the request and holds a FENCED
read; /api/auth/me is an actor surface and echoes no context at all
- AC-07 moves to its own spec and a project with the service worker
enabled, and reads the claim off the cache: with workers blocked the
data cache is never populated and its positive control could not pass
- restore the shared session after each test, and start from a known
record, so these cannot fail the specs that run beside them
data-slot rides {...rest} onto DateField's hidden ISO input, so the
invitation spec asserted visibility on an element that can never be
visible and typed into nothing. Use the primitive's data-testid, which
lands on the overlay a person edits, and assert the mirror took the
value.
The batch path had no unique-violation handling at all, so one duplicate
anywhere in the transaction answered 500 — indistinguishable from a
server fault, and what every client retries against. The single path
narrowed with instanceof, the form prisma-errors.ts exists to replace.
A shared cache is one reverse proxy away, and without Vary the first one
to appear would key a delegated record response on URL alone and hand it
to the next request under a different context. Appends rather than
replaces, so a route that already varies keeps what it had.
- the idempotency arm refuses only a wrongly-asserted context; an admin
route behind unfenced requireAdmin still serves a headerless request
- the epoch trigger does not fire on a statement that names record_epoch
without moving the selector; the source guard is what covers that
- keep the record entries until /api/auth/me has answered: clearing the
cache under a mounted observer made the held tab refetch at once
- exclude the account cell from the cancellation, and swallow a repeat
trigger, so a burst of 409s cannot kill the request that ends the hold
- read the account payload network-only; the default staleTime would
have returned the very context the browser was told is wrong
A selector pointed at a record whose grant has lapsed reports the same
disagreement on every /api/auth/me, so routing it to the hydration gate
rendered a permanent spinner with no controls. Expiry reaches this state
on its own: revocation clears the selector inside its transaction and
expiry does not.
- treat an unprovable context as a refusal, which paints the door with
the button back to one's own record
- check the refusal ahead of the hydration gate, so neither trigger can
render behind a spinner again
A browser-issued <img src>, <iframe src> or <a href download> cannot
carry the record-session assertion, so the fence refused all three on
any session that had been inside a shared record — permanently, since
none of them will ever send a header.
- add a structural guard: no client file may name a record-fenced route
in a src=, href= or window.location sink
- fetch the thumbnail, the inline preview and the download through the
app transport and hand the DOM an object URL, revoked on unmount
- make the two API probes in the managed-profiles browser spec assert
the same context the app beside them asserts
- give every acceptance case a stable id and assert the mapping from a
runnable artifact, recording which cases execute in the ordinary gate
and which await the browser stage
- add the browser spec for the cases no cooperative layer can reach:
peers reconciling to an exact context, an aborted switch response, a
closed initiator, a delayed response, and the offline disk layers
- attach the asserted context to every same-origin request inside the
three fetch entries, so no call site classifies itself
- discard a response whose echoed context contradicts the adopted one;
serve one that carries no echo, which is what keeps the public poll
and every actor surface working
- adopt from /api/auth/me and the switch response, and from nothing else
- send the epoch on every switch and retry once after reconciling
- hold and reconcile on the 409 instead of leaving the record
- evaluate the fence in the wrapper above findCached, so a stale request
neither replays a body, learns a cell exists, nor inserts a claim
- return the refusal rather than throwing: one route composes the
wrapper outside apiHandler, where a throw escapes the envelope
- read the claim and the session's own context on one session lookup
- both record resolvers check the fence after authentication and before
the carrier, off the one session row that also decides the scope
- POST /api/account/switch takes an optional expectedEpoch and writes
conditionally, so concurrent switchers resolve to one outcome
- GET /api/auth/me publishes the context a browser bootstraps from
- every response that resolved a record scope echoes it back
- regenerate the API contract
- add an import-free contract module both bundles can name, holding the
header names, the parse and the verdict
- add the server fence: header read, wide-event stamp, and the refusal
- add RecordSessionChangedError as a third sibling on the existing
sharing-error arm, so the 409 needs no new serialisation branch
- add sessions.record_epoch, owned by a BEFORE UPDATE trigger so the
ON DELETE SET NULL path and raw SQL writes cannot skip it
- normalise pre-existing rows so epoch zero means never switched
- project the epoch off the row getSession already loads
`/` runs its reads server-side and hands them to TanStack through a
HydrationBoundary, so the first HTML already carries real tiles. React threw
that away on every cold load. The app shell hydrates first and its mount
answers `/api/auth/me`; the dashboard sits below `loading.tsx`, hydrates later,
and its first client render therefore read an account the server never had.
That disagreed with the streamed HTML, React reported #418 and re-rendered the
whole page. The prefetch paid for its queries and delivered none of its paint.
The account payload is now withheld from the hydration render wherever it is
only rendered. `useAccountOnceMounted()` answers null until the component has
mounted, which is what the server rendered, and hands the payload over on the
next render. The dashboard page, the caregiver card, the getting-started
checklist, the achievements tile and the unit labels read it that way. `useAuth`
itself still reads the cache, because the document vault redirects off
`user.modules` and a first render claiming "no account" bounces it to `/`.
Two tiles disagreed without any account involved. A query reports `isLoading`
true on the client's first render and false on the server, so both now gate on
whether the cell holds an answer.
Nothing in CI ran the shipped configuration: the suite's server turns the
prefetch off so route mocks keep governing the dashboard. A second server runs
it on, and one spec asserts that no hydration error is reported and that the
tile node the server streamed is still the node in the DOM once the page
settles.
Measured over nine cold loads with the CPU throttled 4x: seven bailouts before
and none after, median LCP 869 ms before and 145 ms after. FCP and the time to
the first tile in the DOM are unchanged, because the streamed paint was never
the problem. Keeping it was.
- Wait for a validated account scope before warming record reads
- Abort scoped preloads and discard handoffs across record transitions
- Cover malformed scope reloads and fresh own-record recovery
- Time out stalled provider sends without unhandled late rejections\n- Record a transient timeout outcome for cascade fallback\n- Cover timeout fallback with unit and integration tests
- Exclude and erase egress claims with recipient-side lookup support\n- Serialize data wipes with managed-profile Guardian authorization\n- Cover last-Guardian and queued-claim wipe races
- Commit a final authorization claim under the lifecycle lock\n- Keep provider calls and retry outcomes outside database transactions\n- Retain authorization claims with notification delivery diagnostics
- keep shared navigation independent of actor module preferences\n- open scoped records at their first allowed destination\n- announce record access and kind in doorway names
- prove profile uses a read-only shared route\n- cover scoped navigation independent of actor module flags\n- require action, access, and kind in record button names
- Render a record-keyed status view for managed profiles\n- Refuse shared Settings links before actor controls mount\n- Localize intentional unavailable states
An owner can now narrow a grant to the parts of their record they mean to
share, and can offer the third level. Both decisions are made once, on the
invitation, and neither widens afterwards.
The invite schema takes `scope`: absent or null is the entire record, which
is what every stored grant means and what an owner who does not narrow still
gets. An empty array, a key outside the vocabulary and the whole-record
sentinel are each refused with a 422 naming the field; `inviteGrant` refuses
the same three underneath, and its `invalid_scope` now has an HTTP mapping
instead of falling into the generic refusal.
Offering MANAGE asks for a fresh second factor when the account has one. That
gate resolves through the session cookie and has no Bearer fall-through, so a
token caller is refused first with `sharing.invite.manage_browser_only` rather
than being told it is not authenticated by a route that had just
authenticated it. The consequence is deliberate: management of a health
record is offered from a browser.
The account payload gains `sections` and `level` beside `access` and
`canWrite`, resolved server-side and ordered as the consent screen reads.
`access` was `grant.access === "WRITE" ? "write" : "read"`, which would have
published a grant that can delete entries as read-only; it is a total map
now, so the next level cannot ship silent. The capabilities hook carries both
facts and turns neither into an affordance yet — the edit and delete controls
come back in the release where the routes behind them answer.
The consent surface asks two questions in order rather than laying out a
nineteen-cell grid. The level is three radios, narrowest preselected. What
they can open is two radios, the entire record preselected, and only the
narrow branch expands the eight sections with a sentence each. Both honest
limits are stated above the checkboxes, where the decision is being made: a
section shares everything written in it, and the cross-record surfaces do not
appear for a narrowed grant. Documents carries its own warning, cycle appears
only for an owner who tracks it, and Manage collapses the question to the one
line that answers it.
The Manage sentence says the other person can add, change and remove entries,
including ones the owner entered themselves, and says in the same breath what
it does not reach. The acceptance side gets its own responsibility line rather
than the write one wearing a stronger adjective.
Six locales.
A grant has meant one thing since it shipped: the whole record, to read or to
add to. This lays the words for the two shapes the rest of the sharing work
needs — part of a record, and management of one — while changing what no
existing grant does.
`ShareDomain` is a closed set of eight sections in `src/lib/sharing/scope.ts`,
clustered from the delegable read surfaces rather than invented, plus the
distinguished `record` for the routes that read across them. It never converts
to a `ModuleKey` and never imports one: two masks, two questions, no shared
spelling, because the day they share a name is the day somebody makes one
imply the other, and a module the owner enabled is not a section the owner
shared.
`account_grants.scope_json` is NULL on every row that exists, and NULL is the
entire record — the answer those rows were consented as, so no backfill and no
re-consent. A non-NULL value is read through one fail-closed normaliser: a
blob it cannot parse opens nothing, including a section the blob happened to
name. That is the deliberate inverse of the module preference normaliser,
which fails open on purpose; both do the safe thing with a value nobody can
read, and the safe thing differs because one answers an authorization question
and the other a presentation question.
`MANAGE` joins the level enum, ordered above WRITE so a write grant can never
satisfy it, and carries no scope at all: management of part of a record
promises a boundary that cannot survive an edit, so the invite path refuses to
write one.
`requireRecordAuth` takes the section as a second required argument. The
requirement is the lever — a delegable route without a classification does not
typecheck, so the set of classified routes cannot fall behind the set of
delegable ones. All fifty-seven modules carry theirs. The structural guard
freezes each against the production vocabulary, fails when a module's own arms
disagree, and pins a non-zero count of resolved sections so a matcher that
stopped reading the argument fails instead of agreeing with everything.
`requireGuardianAuth` is exported and reaches nothing yet. It gates on the
managed-profile marker rather than on the grant, so an adult who granted
management of their own record cannot arrive at that record's settings by any
argument about what MANAGE means: widening MANAGE would widen the record
resolver and would not touch this one. Its frozen list lands empty, with the
declaration site as the positive control that keeps "nothing reaches it" a
measurement rather than a renamed symbol.
`users.managed_profile_at` says since when a record is one somebody else runs.
A timestamp and not a boolean, for a reason the schema has to keep possible:
clearing it is the handover, and the grants and the activity trail span it
untouched.
The fix that stopped a delegate overturning a recorded dose through the second
intake route landed on the trunk with no line in the notes. A change that
alters what somebody else may do to your record and says nothing about it is
the shape this project keeps rediscovering, and a release assembled from these
notes would have shipped it silently.
v1.36.1 stopped a delegate changing a dose the owner had already recorded, and
it stopped them on one route. The sibling `POST /api/medications/[id]/intake`
reaches the same rows through the shared slot upsert, where the only existing
protection is against a pending sync echo washing out a recorded dose. An
explicit skip is not an echo: it fell through, flipped the outcome, and the
inventory the take had consumed was refunded on the way. So the refusal I added
was a door with the next one left open.
The rule compares OUTCOMES rather than refusing every write onto an actioned
slot, and the reason is the third case in the new contract. A double tap, an
offline replay and a retry after a partial batch all re-post the same decision,
and a caregiver hits all three; refusing those would make the feature
unreliable exactly when somebody is standing in a kitchen with a pill box. Same
decision applies, a different one is refused.
The check rides into `applyCanonicalSlotWrite` rather than sitting at the call
site, because that is where the existing row is already loaded: no extra query,
and nothing between the read and the update for a concurrent write to slip
through. A refused attempt files under the owner with the same action name the
sibling route uses, so it reaches the activity panel the same way.
Three integration cases, and each asserts the row rather than the status alone
— a 403 that had already written the flip would read as protection while being
the opposite. Watched fail: with the delegate flag forced off, the flip returns
201 while the replay and the owner's own correction stay green.
Seven findings from the adversarial pass before v1.36.1 was tagged. One
blocking defect shipped in that release; these did not, and they are all places
where somebody looking after another person's health record meets a wall with
no explanation, or where a guard cannot fail.
The document vault was the largest. `/documents` is marked as a shared
destination, so the shell offers it under a switch, and every read behind it
resolved the caller instead of the record: the vault painted a query-error card
and the illness view painted another beside it. That is the same failure the
release describes as fixed on the dashboard. Five GET arms move to the record
resolver now, and only the GET arms: the list, one document, its file, its
thumbnail and the vault's usage payload, which the filter bar is built from.
Uploading, retyping, deleting, linking a document to an illness and every AI
verb stay with the owner, and the two reads that spend decryption CPU key
their quota on the person calling rather than the record, the rule
`medications/compliance` already follows.
Logging a value on a metric you track yourself leaves the admitted set. It was
admitted, named on the consent screen in six languages, and unreachable: the
only form that posts one sits on a page a shared record does not open, and the
component that renders it says so in its own comment while returning null. It
is the third route the rule that withdrew allergies and family history fitted
exactly, and it was applied to two of the three. Reading those values stays.
Two guards could not fail, and both were proved by mutation before and after.
The prefetch guard checked rule three with two regexes: one was satisfied by
the doc comment every page carries, the other missed an aliased import, and
the pair stayed green with the dashboard genuinely prefetching off the
switch-blind session. It reads the AST now, resolves aliases through the
module's own import table, and pins the five prefetching pages by name so a
page that stops being detected is loud. The activity verb map decided
membership on a literal `requireRecordAuth("write")`, so putting the argument
on its own line, which is what the formatter does the moment the call grows,
dropped a route out of the scan and turned a genuinely-red run green. Both
counts are of matches now rather than of files walked.
The rest are single instances of rules applied everywhere else. "Take all due"
was withheld on a note saying it rides the bulk intake route; it does not, and
its own docblock says why not, so a helper with five morning tablets tapped
five times for nothing. Marking a dose skipped is offered wherever marking it
taken is. A form could be opened in the frame before the app knew whose record
was on screen and then stood there once the answer arrived, on the capture
sheet and on the dashboard's quick-entry sheets. Two more copies of the intake
toast were still building their own, one of them with an Undo pointing at a
route no delegate may call. The customise link on the mood page led to a page
that exists to say it is not part of what was shared. And the access-level
radios on the invitation screen are hidden by design with no focus ring on
their label, so keyboard focus moved invisibly across the one screen where
somebody grants write access to their record.
Three new structural guards, each watched fail first: the customise link sits
inside a capability branch on every surface a delegate can reach, every intake
surface confirms through the one shared decision, and the batch sweep is
offered at the level that admits it.
Found by an adversarial pass over the release candidate before it was tagged,
and it is the reason the tag waited.
`POST /api/medications/intake` is admitted for delegates because a caregiver
standing next to the patient has to be able to say the tablet went down. It is
an update, though, and the same body can carry two other things. Snoozing
writes `snoozedUntil` on the OWNER's medication row, and the reminder cron
skips that medication for as long as the stamp is in the future. The field
takes any future instant. So somebody with write access could stop another
person's medication reminders for years, and three things made it quiet: the
owner is deliberately not notified of a delegated snooze, the activity feed
renders the whole family as "marked a dose", and the only accurate trace was a
line in the admin audit log.
The second was flipping a dose the owner had already recorded — taken to
skipped and back, which rewrites their compliance history and refunds
inventory. The consent screen promises that changing what is already there
stays with the owner.
Both refuse for a delegate now and stay open to the owner, who is the only
person either was ever for. A refused attempt is filed under the owner and gets
its own sentence in the activity panel rather than borrowing the one for
marking a dose, because folding them together is what made the gap dangerous.
Two more from the same pass:
The consent notice named allergies among the things a delegate may add. That
left the admitted set before this release shipped, and it said so in six
languages on the one screen where somebody decides who may write into their
health record. It also omitted half of what IS admitted. The copy now matches
the frozen list, and the test asserts the withdrawal rather than only the
presence of the rest.
The banner told a WRITE delegate nothing at all — the mode line rendered only
in the read case, guarded by a comment stating that write grants did not exist
yet. This is the release that made them exist. It matters that the banner say
it, because the rule that a refused control is absent rather than disabled
rests on the banner naming the mode globally.
And a third instance of the substitution this release already fixed twice: the
BMI chart divided the owner's weights by the height of whoever was looking.
Inside a shared record it draws nothing now rather than a number belonging to
two different people.
My own bug, and it produced a failure that pointed at the opposite of the
cause. The helper clicked the trigger and then re-read the trigger's
`data-state` to decide whether the menu had opened. Radix marks the rest of the
page `aria-hidden` while a menu is open, and Playwright's role locators skip
anything hidden from the accessibility tree — so a trigger found through
`getByRole` stops matching the instant the click succeeds. Reading its
attributes then does not report "still closed"; it hangs until the test budget
runs out, and the log blames the trigger for not being there.
The check is on the menu now. Both journeys pass locally, and the reason is in
the file so the next person does not have to lose an evening to it.
It kept dying at thirty seconds on the quick-add trigger. The trigger was not
the problem: the dashboard route segment suspends behind its server render and
shows `loading.tsx` until that resolves, and on a shared runner with the whole
suite in flight that has overrun the default test budget. Raising the locator
timeout achieved nothing, because the test itself was the thing expiring.
So the wait is now on the skeleton going away rather than on the network going
quiet — `networkidle` says nothing about whether a suspended segment resolved —
and the budget belongs to the page rather than to the default. Every assertion
about the round trip stays exactly as tight as it was.
`.click()` carries Playwright's 30 s actionability wait. The visibility check
that replaced it ran on the default expect timeout, so the helper made the wait
shorter than the code it stood in for, and a dashboard that was slow to paint
failed on the trigger rather than on the menu.
The add-measurement journey and the two accessibility steps that open a
dropdown all did what the water journey did: wait for the network, click the
trigger, then wait for a menu item. All three clicked markup React had not
wired up yet, and the add-measurement one timed out on the trunk's own runner
for exactly that reason.
They open through the same helper now. The documents bulk-bar menu keeps its
plain click deliberately: it fires after selecting fifty rows, so hydration is
not in question there and a retry would only hide a real failure.
Address4 decoded a leading-zero octet as decimal, so `010.0.0.1` parsed as
10.0.0.1 instead of being rejected. That is precisely the alternate-notation
class `isPublicUrl` was written to catch, and this copy arrives transitively
under the MCP SDK's rate limiter, where nothing of ours screens the value
first. The production tree resolved 10.2.0 while the dev tree already had a
fixed one, so the override names the floor rather than trusting resolution.
The assertion demanded an empty console on `/` and was catching React's
hydration mismatch, intermittently, on this branch. Measured before touching
it: a production build of the trunk with the SSR prefetch on — the shipped
configuration — raises #418 on three loads out of three, on both viewports. A
control worktree on plain main behaves identically, so nothing here caused it.
With the prefetch off, which is what this suite runs against, neither tree
raises it in isolation; it appears only when the whole suite is loading the
machine enough to change the streaming order.
So the check was failing a branch for a defect that ships today, and doing it
unreliably enough that it read as a regression. The one message is exempted,
with the measurement and the mechanism at the filter so nobody has to
rediscover either, and the exemption names its own removal condition. Every
other console error still fails this test.
The defect itself is written down rather than waved through: the server streams
the route-level skeleton while the client's first pass renders the dehydrated
snapshot, and React throws the server tree away. Which means the prefetch that
exists to make this page paint fast is paying its cost and delivering none of
its benefit.
`undici` moves to 8.10.0 and the `fast-uri` override rises to 3.1.5. Both were
published as high after the last run on the trunk, so this is new information
rather than neglect, and both matter here: undici is what backs `safeFetch`,
which is the one documented egress boundary and the place the connect-time
DNS-rebinding pin lives.
The undici advisory is a cross-user information disclosure and a parse-time
crash through degenerate private cache directives. `fast-uri` arrives four ways
through transitive dependencies and the existing override was pinned one patch
short of the fix.
The safe-fetch dispatcher suite passes against the new client, which is the
part that had to be checked rather than assumed: the pin reaches into undici's
connect hook and a client bump is exactly where that could quietly stop being
enforced.
The water-capture journey clicked the dashboard quick-add trigger after
`waitForLoadState("networkidle")` and then waited thirty seconds for a menu
item that never arrived. The failure snapshot shows the page rendered, the
trigger present, and no menu anywhere: the click landed on server HTML that
React had not attached a handler to yet, and the one click the test had was
spent before hydration.
`networkidle` cannot close that window, because the trigger is in the first
HTML and exists long before the client bundle finishes. The gate has to be the
element's own state. `openMenu` clicks and confirms the trigger reports itself
open — Radix stamps `data-state`, the hand-rolled mobile capture button carries
`aria-expanded` off React state — and spends another click if it does not,
because the problem is a lost event and waiting longer for a swallowed click
achieves nothing. Out of attempts it fails naming the state the trigger was
actually in, rather than letting the caller time out on a menu item that was
never going to appear.
This is a race the spec always had; it surfaces when the machine is loaded
enough to push hydration past the click, which is why it reads as a
branch-specific regression when it is not one. The tour passthrough spec keeps
its single un-retried click on the same trigger: proving that exactly one click
reaches the button through the overlay is that test's entire point.
MedicationSideEffect was classified as backed up and was not carried: the
export wrote nothing and the restore had nothing to read, so a restored
account kept the drug and lost the reason someone may have stopped taking it.
Delegated write access sharpens that — a side effect is one of the things a
second person can contribute, and it was the contribution a restore dropped.
The rows ride inside their medication, like the schedules beside them. A
top-level array would have to carry `medicationId` and survive a restore that
mints a fresh id for a portable file, which is the remap the intake events
already work around by drug name; a nested create has no id to remap, because
Prisma binds the child to whatever id the parent row actually got.
The note follows the measurement contract for the same dual-column
arrangement: a portable export carries the decrypted note and no ciphertext, a
disaster-recovery payload carries the ciphertext verbatim, and the restore
re-encrypts a legacy plaintext row rather than writing plaintext back into the
column.
Move the model from COVERAGE_PENDING to TWO_ENDED_MODELS, and settle the claim
where a written verdict cannot: the round trip seeds a side effect, exports
through the real builder, empties the account and reads the row back out of
the real restore route with its category, entry, severity and note intact.
Both halves were excised to check the test can fail — the restore branch turns
the count red, the payload turns the field values red.
Ten routes on `/` refused with `undeclared_mode`, which made the first page
of account sharing the only page in it that does not work. Each one now
declares a mode, per route rather than as a class.
Eight are record reads: the dashboard snapshot, the Today digest, the badge
grid, the Coach nudge signal and reminder ledger, the medication low-stock
thresholds, and the two presentation blobs. The old objection to admitting an
aggregate — a summary can carry a finding from a module the grant does not
cover — does not hold while a grant is whole-record and all-or-nothing. Every
admission site names that, so the day per-module scope lands the aggregates
are findable.
The layout routes split their verbs. Reading the owner's arrangement is right
and, for the dashboard, forced: the snapshot already carries the layout and
the client seeds the same cache cell from it, so an actor answer would put two
people's layouts in one key. Writing it is not a caregiver's job, so the PUT
and DELETE stay bare and refuse. Same on the medication list, where the stored
order names the owner's medication ids and means nothing anywhere else.
Two are actor surfaces: the UI language belongs to the person reading the
screen and must not be transplanted onto the record, and the feature-flag
matrix reads the settings singleton and no user row at all.
Also fixes a second instance of the delegate-data-under-owner-banner defect,
on the same page: the dashboard's client-side band fallback derived blood-
pressure targets and the weight range from `/api/auth/me`, which answers about
the caller, and painted them over the record's charts. Under a switch it now
derives nothing and waits for the snapshot's own bands.
The integration suite drives every admitted route with both accounts seeded,
proves the two answers differ, and then demands the owner's back.
Taking `POST /api/allergies` and `POST /api/family-history` out of the
delegable set moved one end and left the other: the integration suite still
asserted that a delegate holding a WRITE grant gets a 201 from both, so ten
cases went red on a decision that was deliberate.
They are pinned the other way round now, with the reason in the file. A
delegate is refused at either grant level, nothing lands under either
account, and — the leg that matters most and that nothing else here covered —
the owner's own unswitched write still succeeds. Withdrawing a delegated
write by breaking the ordinary one would have been the worse bug.
The dose-history purge was the one destructive verb in the intake family
that removed rows outright. Both siblings, the single-event DELETE and the
bulk delete, stamp `deletedAt` and bump `syncVersion`; the purge called
`deleteMany`, so a mis-tap was final and a client offline at purge time kept
every row forever, because a row that no longer exists produces no tombstone
for the sync feed to carry. It tombstones now, scoped to live rows so a
second purge counts zero. No inventory refund: a purge wipes a history as
bookkeeping, and refunding every dose ever taken would inflate the current
container count instead.
The manual water quick-add and the lab-result un-delete both wrote health
data and reached only the wide event, which expires. Months later nothing
could answer who put a day's total there or who brought a reading back, and
with a second person able to act on a shared record that question has a
second possible answer. Both write a durable row now, named after the
neighbours they sit beside.
The refused-delegation audit comment claimed the row was written for the
owner's benefit. It is not: it is filed under the caller, it is only written
for a header refusal, and a header names an account the caller merely
asserts. Filing under that claim would let anyone write rows into any
account's activity feed by naming it. The comment says what the row is now,
and points at `recordDelegatedAccess`, which is the half the owner does see.
Two comments in the biomarker surfaces still claimed the `SetNull` foreign
key left the readings behind when a marker is deleted. That stopped being
true when delete became a "drop this and its history" action and the
confirmation copy was rewritten in six locales to say so.
The purge route gains a call-shape assertion rather than a count assertion,
because a count comes back identical from a `deleteMany`. The lab restore
route had no test at all and now has one.
The activity feed maps an audit action to a sentence. The web client posts to
/api/medications/[id]/intake, which has audited as `medication.intake` since
v1.0.0, but the map only knew `medications.intake.update` — so a delegate
recording a dose showed up in the owner's feed with no verb at all. Both
spellings are recognised now, plus `measurement.create.batch`.
A new test reads the audit actions straight out of every route that calls
requireRecordAuth("write") and fails when one has no case in the map, so the
next route to be opened for delegates cannot land a silent gap.
The delegate notification is fire and forget now, like every neighbouring
side effect. The dispatcher awaits each channel in the cascade, so one
unreachable channel added its whole timeout to the request of the person
standing next to the patient waiting for a dose to register. The integration
test polls the ledger rather than relying on the await, so nothing about the
proof weakens.
Every test in e2e/delegated-writes.spec.ts had been skipping since the
day the invitation form shipped its level control. The skip guard looked
for data-slot="grant-invite-level"; the control landed as
"grant-invite-access-option". The file said out loud what to change and
nobody changed it, so a quiet skip and a passing suite read the same in
a CI summary.
Two assertions inside it had never been executed and were both wrong:
the header button reads "Add", not "Add measurement", and the form opens
on blood pressure, which has no `value` field. Fixed against stable
attributes rather than viewport text, with data-slot="measurement-add"
added to the control the journey clicks.
Adds the deep-link leg the SSR suite cannot hold: a query parameter is
the same affordance as the button, and ?add= / ?new=1 open exactly what
the resolved level admits.
Three surfaces record a dose and all three had to reach the same two
conclusions inside somebody else's record: name the record, and drop an
Undo the server refuses. Two learned it and the dose ledger did not,
because the ternary was written out three times. It is written once now,
in the file the other two already share, with its own test.
POST /api/allergies and POST /api/family-history leave the frozen
delegable write set. The argument that admitted them still stands — an
allergy is the single most useful thing a caregiver can contribute — but
the only surface that posts to either lives in Settings, which a switch
closes, so no delegate could reach the form at any level. That is a
permission frozen ahead of the caller for it, and the list is built the
other way round on purpose.
Both delegable READ arms stay. A caregiver reading the allergy list is
what the feature is for; only the contribute step waits, and it comes
back in the same diff as the surface that offers it.
Three of the newly gated surfaces are query-backed, so the suite gains a
query client and a cache seed rather than a hook mock: the component
under test stays the real one. Each leg asserts the rail, the card and
the reminder row are still THERE — only the mutating control is gone.
Breaking each gate in turn puts every leg red.
Nine surfaces offered a delegate an action the server declines, and the
switch had never been asked about any of them.
The Today rail's dismiss and the coach check-in's keep / let-go write
through routes that resolve the caller, so both refuse under a switch;
they now ask canManage and are absent at either grant level. A priority
action with no href and no handler is no longer rendered as an inert
button at all — the type carries that rule now.
The Vorsorge dashboard card offered the mark-done that /checkups already
withholds, and the checkups list branch inlined its own ungated copy of
the button the cards branch gates, so which view a browser last chose
decided whether the action appeared. Both bind canManage.
The chart overlay cog persists through a route that resolves the caller
and stores a preference belonging to the person rather than the record.
Gated once in the control so the three chart wrappers cannot drift.
Three query parameters opened sheets past a gated button: ?add=<TYPE> on
measurements, ?new=1 on medications, and ?edit=1 on a medication detail,
the last of which opens a wizard refused at both levels. A deep link is
the same affordance as the control that produces it.
The efficacy retarget dial rewrites a setting the owner chose. The
episode documents card offered a link and an upload the vault gates on
the same endpoint throughout. And the dose ledger built its own success
toast, missing both the Undo suppression and the "saved to" receipt its
two siblings already carry — the one a delegate met on every dose.
The Coach drawer is not mounted inside a shared record, so every button
calling askCoach() opened nothing. The launch provider now publishes no
value there and each entry point's existing null check does the rest;
the documents sheet, the one that rendered without checking, now checks.
The coach positive control navigates to the dashboard to prove the launcher
renders for a delegate in their own record. It left the browser there, and the
next step clicks Accept, which lives on the access page. The click waited out
its timeout against a page that does not carry the button.
An assertion inserted mid-journey has to restore the state it borrowed.
A server-prefetching page dehydrates a health record into the HTML and
seeds it into the client's query cache under the key the client cell
reads. That copy is only right when the account the page read is the
account the client's route would answer with, and under an account
switch it never is: `getSession()` answers who is CALLING, deliberately
and permanently, so all five prefetching pages seeded the delegate's own
rows into a document opened on somebody else's record. The dashboard was
the worst of it — snapshot, digest and widget layout ride routes that
refuse the refetch, so the delegate's numbers stayed on the owner's
dashboard, while the chart row refetched to the owner and left the page
showing two people at once under a banner naming one.
Resolve the session through `getUnswitchedSession()` instead, which
answers null while the browser is acting on another record. The prefetch
steps aside and every cell fetches through its route, which is where the
grant check lives. Resolving the owner here instead was the other
candidate and is worse: the grant check has exactly one home, half of
what these pages prefetch rides routes that declared themselves not
delegable, and re-deriving it in an RSC would be a second program
deciding whose record this is.
Close the two shell-level doors beside it. The Coach launcher, its
drawer mount and the module tour hang off `<AuthShell>` rather than off a
route, so dropping Insights and Coach from the navigation under a switch
left them painting on every page in the record and on the unavailable
panel too — one tap from a drawer whose every action refuses, and a
`POST /api/insights/coach/seen` fired by opening it at all. All three
bind `useRecordCapabilities()`, the same resolved grant the banner and
the switcher read. Absent, not disabled.
The prefetch rule is structural: the guard walks the app tree, so a sixth
prefetching page is covered the moment it lands rather than the moment
somebody remembers. The pages stay on the session-surface allowlist with
their reasons rewritten, because a wrapper one directory deeper is the
easiest way for a surface to walk out of a guard.
The activity view told an owner that a delegate acted on their record. With
delegated writing it can say which act: added a reading, recorded a lab result,
marked a dose. The mapping already existed as a pure module with its own test
and a fallback for anything unmapped; this is the wire it was missing.
The component suite runs without a DOM renderer, so it proves what a page
paints and never what a button does. That is the right shape for this work,
because the property that matters most is a control being absent — but it
leaves the other half unproven: a form that renders is not a form that
submits, and a write that reaches somebody else's record is exactly the thing
worth watching happen.
The journey stands down while the invitation form has no level control, since
minting a WRITE grant behind the UI's back would prove a path no person can
walk. It names the control it is waiting for, so a run that skips while the
control exists reads as a bug here rather than an absence upstream.
Sharing lived in a settings panel and a menu three levels deep. That is where
a grant is managed and not where it is used: somebody who opens this app every
morning to check on a parent does not go through settings to do it, and the
design standards draw the same line, settings being account utilities rather
than feature destinations. So the dashboard carries one compact card listing
the records shared with this account, each row a one-tap switch through the
existing flow.
It is a doorway and not a preview. No numbers, no last reading. A slice of
somebody else's record on the delegate's own dashboard would sit outside the
one frame the design leans on, the banner naming the record you are inside.
An account nobody has shared with sees nothing at all.
The other half is the receipt: a reading, a lab result or a dose recorded
under a switch now says which record it landed in, because "Saved" alone is
the confirmation somebody acting for another person does not need. The undo
on the dose toast goes at the same time, since a delegate cannot remove what
they recorded and an undo they can see is one the server refuses.
Also here: the lines the owner's activity view owes each admitted verb, as a
translated mapping with a fallback, so a later release filing a verb it has
never heard of still renders a sentence rather than a blank row.
Marking a dose taken or skipped is the verb the delegation was written for:
somebody looking after a parent confirms the morning tablet. Adding a
medication and noting a side effect come with it. The card menu keeps only
what survives and disappears entirely when nothing does.
Four detail tabs leave the registry inside somebody else's record, because
their whole content is owner work: the schedule editor and its plan history,
the supply ledger, the external-token row, and the advanced surface holding
the wizard, the import and the destructive zone. A stale deep link into one
lands on the landing tab, the way an inapplicable tab already does.
The take-all-due sweep goes too. It rides the bulk intake route, which the
delegation does not name, and a control that half-works is worse than one
that is not offered.
Every page a switch can reach now renders the controls the grant covers and
none of the others. Readings, lab results and illness entries keep their add
path at WRITE and lose edit, delete and selection at both levels. Mood,
screeners, cycle days, checkup reminders and the document vault lose every
mutation control at both levels, because no grant admits those verbs and the
routes behind them refuse a switched session outright.
Absent, not disabled, throughout. The banner already says whose record this
is, so no chip repeats it beside each missing button; the one exception is
the measurements empty state, where somebody who may add will look for the
edit control beside the row they just entered, and one muted line says where
the limit is.
Two dead ends went with them: the custom-metric section on the measurements
page linked every row into a route the shell turns a delegate away from, and
the wrench glyphs point at settings pages sharing does not cover.
Three controls carry most of the app's mutation surface between them. The row
delete is spelled once for measurements, lab results, biomarkers, allergies,
family history, custom metrics and medication supply; the selection bar is
the only bulk delete; the capture picker is the fastest path to four entry
forms on a phone and bypasses every page header.
Deleting belongs to the owner at every grant level, so the first two are
absent inside somebody else's record rather than greyed out: a disabled bin
still claims the row is the delegate's to remove. The picker keeps the two
kinds a delegation admits and drops a mood entry and a glass of water, which
the server refuses under a switch; when nothing survives, the bar's plus
button goes with them instead of opening an empty sheet.
The surface guard grows a write leg. Membership is decided by the argument
a module passes to the record resolver, not by the identifier, because a
delegable read and a delegable write name the same symbol and mean opposite
things. The call matcher resolves aliased and namespace-qualified calls
back to the original name, and proves it can against synthetic modules.
Two assertions carry the decision not to add a provenance column to eleven
tables. Every module in the write list has to call auditLog, which is the
only writer that stamps the acting account, and none of them may reach the
audit table through the client instead. That decision rested on the trail
existing; it now rests on a test.
The prose on the read list said the write arms keep refusing under a
switch. Eleven of them no longer do, so those sentences are rewritten
rather than left to lie.
The integration suite drives the shipped POST exports through the real
handler, resolver, grant table and Prisma. Five legs per verb: the write
lands under the owner and nowhere else, a read grant is refused with
nothing written, a caller who was never granted is refused the same way,
the request after a revocation is refused with no re-login, and a caller
who never switched is indistinguishable from before. The grant is minted
through the invitation flow rather than planted, and the audit assertion
reads both ends of the row.
Account sharing shipped read-only, and everything that consulted the grant
lived in the application chrome: the nav bars, the banner, the switcher. No
feature surface asked. A delegate opened somebody else's record, saw the add
button on every page, filled the form, and was refused by a server that was
right to refuse. Nothing leaked; it taught people the product was broken.
This is the answer every mutation affordance asks for. Two booleans, because
a grant at WRITE admits a short closed list of verbs and nothing else:
`canAdd` for an entry of an admitted kind, `canManage` for changing what is
already there or adding what the list does not name. `canWrite` arrives
resolved from the server and is bound, never recomputed; what this decides is
only which class of control a resolved level covers.
The absent case is the permissive one. Failing closed would blank every add
button in the app for every account on every cold load, to spare a delegate
one frame between the reload and the account query landing.
Four medication verbs resolve the record: adding a medication with its
nested schedule, recording a side effect, and both forms of marking a dose.
Editing and deleting a medication stay refused under a switch, which is the
sharpest asymmetry in the feature — a delegate can create a medication and
can never correct the typo in its dose afterwards.
The side-effect rate bucket keys on the acting account rather than the
record. That was the condition on which the verb was admitted: on the
record key a delegate could exhaust the owner's allowance and lock them out
of their own log, and could collect a fresh one by switching to another
record. Everyone who has not switched lands in the bucket they always did.
Both intake routes tell the owner who marked their dose. Everything else
downstream stays addressed to the owner and is correct there: the
cross-device sync wakes their devices, the web-push clear closes their
pending reminder and recounts their badge, the compliance rollup recomputes
their day.
Two validation breadcrumbs move to auditLog(), the only writer that stamps
the acting account.
An allergy, a family-history entry, an illness episode and a custom-metric
value now resolve the record rather than the caller.
Two of them carry a consequence worth naming. The family-history row
describes the record's relatives, so it is the one admitted write where
third-party health information is present by design; it is stored as the
record's own, exactly as the read arm already serves it. The illness module
gate runs against the record before the write, so a delegate cannot open an
episode inside a record whose owner switched the module off, whatever they
set on their own account.
The custom-metric entry reaches its metric through the ownership hop, which
now resolves against the record: a delegate can only feed a definition the
owner made, and their own metric of the same name is unreachable from in
there. Its validation breadcrumb moves to auditLog().
POST /api/labs and POST /api/biomarkers resolve the record. The free-text
lab path mints its biomarker under the record's catalogue rather than the
caller's — a result added to somebody's record whose marker landed on the
helper's account would leave the owner with a reading they cannot chart.
Adding a marker the record already tracks stays the ordinary 409 from the
same per-account uniqueness the owner would hit themselves. The PUT and
DELETE on the sibling routes are unchanged and still refuse under a switch.
Both validation breadcrumbs move to auditLog(), the only writer that stamps
the acting account.
Both POST arms of /api/measurements resolve the record rather than the
caller. Every `userId: user.id` in the handler already meant "the record",
so the bodies are untouched: the safety-floor check, the reminder
satisfaction, the rollup recompute and the cache eviction are statements
about the record and stay addressed to it.
The two validation breadcrumbs move from a bare client create to
auditLog(). That helper is the only writer that stamps the acting account,
and a delegate's malformed payload filed without it reads as the owner's
own typing.
The sync-changes fixtures seeded rows at a literal calendar date. The route
serves only what sits inside the tombstone retention horizon, which is sixty
days plus a fifteen-day margin, so the fixture was inside the window when it
was written and fell out of it exactly seventy-five days later. Three cursor
round-trips started failing at midnight with no commit behind them, and would
have failed on every run from then on.
The rows now anchor five days before the current time, so they cannot age out
of the window they are meant to be inside. The mood entry's own date string is
derived from the same anchor rather than written out, because the two drifting
apart is the same defect one level down.
The payload type still said the level is read throughout and `canWrite` false.
Both were true of the shipped release and neither survives the invitation
gaining a choice. Also says what `canWrite` does not mean, since the word
invites a larger reading than the thing it grants.
A delegate retrying with one `Idempotency-Key` across two people's records met
itself in one cell. The cache tuple was `(caller, key, method, path)`, so the
second record's request replayed the first record's cached 201: the handler
never ran, no row was written for the second person, and the caller was told it
had been — with the other person's id in the body. Nothing errored and nothing
logged a conflict, and five of the write verbs about to become delegable are
wrapped here.
Fold the claimed record into the `key` component, centrally, once. Not into
`userId`: that column is a foreign key to `users` with a cascade behind it and
cannot hold a composite. It keeps naming the caller, which is what makes two
delegates on one record two cells for free. The separator is a byte the key
validator refuses, so a caller cannot hand-craft a key into a cell belonging to
a request they are not making.
A request with no acting account keys byte-for-byte as it did before, so a
client that never heard of sharing cannot be affected.
Accepted and pinned by test: the fold uses the CLAIM, before the grant check,
so within the 24h window a delegate whose access has ended can still replay a
key they had already completed. They receive bytes they already had, nothing
new is written, and a fresh key from them is refused because that request
reaches the handler.
Both frozen surfaces move in this diff. The claim reader gains a guard leg
freezing its consumers, since an unchecked claim is exactly the value a future
caller must not mistake for permission.
The module header still described a permission to read, and the grantee field
still said "invited to read it". Both are half the sentence once the level is
a choice.
The rule that decides what a request says it is acting as — session stamp on
the cookie transport, selector header on Bearer, a header on a cookie request
naming nothing — sat inside the resolver that acts on the answer. A second
caller needs the same question answered before any auth context exists, and
two implementations of "whose record is this" drift; the copy that drifts is
the one nobody reads.
Move the decision, the header name and the two predicates into
`auth/acting-carrier`, as a pure function of what the transport carried. The
resolver keeps what only it may do: turning a claim into a data scope once a
live grant says so. No behaviour changes — the resolver's arms are the same
arms, and its suite passes untouched.
The header-name guard follows the constant to its new file.
The invitation form gains the choice, and the option labels say what actually
ships rather than the word "write": a delegate can add readings, results,
observations, a medication and a marked dose, and can edit or delete nothing,
including what they entered a moment ago. That gap between what "write access"
sounds like and what it does is where a support case lives, so it is on the
card rather than in a reply.
The delegate reads the same thing before they accept. A pending write
invitation states its level and one sentence about what accepting means, on the
screen where they accept it, because acceptance is where the two consents meet
and it happens once.
Ending an access says what stays: the entries remain and the owner can remove
any of them, and the activity view keeps saying who entered what for as long as
the instance's audit window reaches. That number comes from the server, so an
instance running ninety days says ninety, and a browser that has not been told
says nothing rather than a made-up year.
The browser journey now invites at the wider level and reads the posted body
for it. That is the only check in the tree that can: the component convention
is server-rendered only, so no test under src/ runs the form's submit path, and
every one of them stays green with the control wired to a constant.
A grant has always had an access column and `inviteGrant` has always written
READ into it. That was the right way to ship the first version: "this release
never grants write" was a property of the file rather than a promise in a
comment. Remove it deliberately now, so somebody sharing their record can offer
reading or reading plus adding.
The level is a required argument to `inviteGrant` and is defaulted in exactly
one place, the invite schema, where an omitted field becomes READ. An older
client's payload therefore stays valid and stays narrow, and the domain module
never guesses a level nobody named.
There is no upgrade path and that is the decision, not a gap. A grant is READ
or WRITE from the moment it is offered and stays that way until somebody ends
it. Widening a live one would change what the delegate agreed to without asking
them again, and their consent is half of what makes a write grant legitimate.
The way up is a new invitation, accepted again.
The audit row and the wide event carry the level: "who was given what, and by
whom" is the question that row exists to answer.
The intake routes call notifyDelegatedIntake with both account ids and
the helper decides. Keeping the comparison here rather than at the call
sites means a route added later cannot notify somebody about a dose they
marked themselves, and a snooze is answered in one place as what it is:
moving a dose, not resolving one.
The medication name is read scoped to the owner, because that name goes
onto the owner's lock screen. Nothing addresses the delegate: the
dispatcher takes a single account id, and under record substitution that
id is the owner. The person who marked the dose learns what they did
from the response they are already awaiting.
The integration test asserts the ledger row before the sender mock, so a
dispatch nobody recorded cannot pass as a dispatch.
The matrix derives each row's label from the event name, so a new event
type gets a row whether or not anybody wrote the strings for it. That is
how SECURITY_ALERT has been rendering its own raw key since v1.23: the
forward i18n coverage test only sees literal call sites, and this key is
assembled from a runtime value.
Move the derivation out of the page so a test can compute the keys the
page computes, assert both resolve in all six bundles, and name the
missing one. Write the sign-in row's strings, and the strings for the
delegated-dose row and its push body.
The owner of a shared record gets an alert when a delegate marks one of
their doses. It is the one exception to the rule that bookkeeping stays
in the activity view: whether the tablets were taken is a fact about the
owner's own body, and they should not have to go looking for it.
Default ON, against the opt-in precedent of PERSONAL_RECORD, because the
event only exists once the owner has invited somebody and given them
write access. The per-channel row in the matrix is the valve for anyone
who finds it noisy, and both positions of that preference are covered.
The destructive-control registry carried this card because a detector read
its hardcoded `expiresAt: null` as a field being blanked, and an entry
explained at length why blanking was harmless here. The explanation was
right and the cause was not: the null was there because nobody could set a
date, not because none was wanted.
With the control wired the detector no longer fires, so the entry no longer
matches anything and the registry's own staleness check asked for it back.
The invite route has accepted expiresAt, stored it and annotated on it since
the grant model shipped. The card sent a hardcoded null, so the column existed,
the validator accepted it, the wide event reported on it, and no person could
set one. The release note said an invitation could carry a lapse date.
The form now offers the date, floored at tomorrow because a grant that lapses
today confers nothing. A chosen day maps to the END of that day in the
browser's own zone, so "until the 30th" means the 30th rather than the
midnight that starts it, which is the reading anybody picking a date has in
mind. Empty stays the default and still means no lapse date.
Both ends are pinned rather than one: the control is asserted in the rendered
form, and the mapping is exported so its contract holds without a click, which
is this repository's convention for component tests.
An unrevealed chart cell is `invisible` and `aria-hidden`, so the chart row
is not in the accessibility tree at all until the reveal gate fires. Waiting
on the tile strip left it to chance whether the row was read: locally the
cells were still hidden when the strip appeared and revealed six seconds
later, while the runner that reported the broken heading order had already
revealed them. The row is the largest surface on the page, so a scan that
sometimes skips it is not a check of the page.
Gating on a revealed cell makes the coverage the same on every run. With the
heading level put back to its old value the case now fails 16 out of 16
repeats across both themes and both viewports, where the same defect
previously surfaced twice in several full runs.
A dashboard chart card called itself an h3. Nothing contained it. The cards
beside it in the same grid — "Recent unlocks", "Recent workouts" — are h2,
and so are the target and usual-range cards on the metric subpages, so the
chart card was both the odd one out and a level skipped straight from the
page h1. The rendered dashboard ladder was h1 → h3 → h3 → h3 → h2 → h2, and
/insights/bmi was h1 → h3 → h2.
A heading level is a claim about nesting. Someone moving through the page by
headings hears a subsection announced where there is no section, and screen
reader rotors build their outline from exactly this. Promoting the title
keeps the same text, the same classes and the same pixels; only the claim
changes.
The unit guard fails in the fast run; the browser scan catches the same
regression through axe, but only in the e2e job.
A child model rides its parent through the backup, so the check looks
for the relation field name as well as the delegate. It looked for the
bare name anywhere in the declared files, and `symptomLinks` is declared
twice — on `IllnessDayLog` for the illness link, on `CycleDayLog` for
the cycle one. Deleting the illness branch from the restore route left
all twelve checks green, because the cycle write in another file was
answering a question about a different table.
A name that points at exactly one model is still evidence anywhere. A
name shared by several counts only in a file that also operates on the
declaring parent's delegate, and a check asserts the cycle file proves
the cycle link and refuses to prove the illness one.
That leaves the illness link unprovable from source: its include is a
shared constant with no query beside it, and the parent that would
disambiguate it is reached through the episode in another file.
`STRUCTURALLY_UNATTRIBUTABLE` names it with what defeats the matcher,
and naming a model there is not a free pass — it has to be seeded in
the round trip, which is checked here.
The plan says which models a backup carries and a source-text check says
each one has a reader and a restore branch written for it. Neither can
tell a restore branch that runs from one that only exists: wrap the
nutrient restore in `if (false && …)` and every check stays green, which
is the correct-green-and-inert shape this project already shipped once.
So settle it end to end. Seed one row of every two-ended model, export
through the real payload builder, delete the account so the cascade
empties what a hand-listed sweep would miss, restore through the real
admin route, and count each row back out. A model that never travels
comes back at zero and says which one it was.
The two-ended list is a literal tuple now, so the seed registry can be
keyed by it: adding a name without seeding and counting a row for it is
a compile error rather than an unproven claim.
Also names `src/lib/illness/dto.ts` as a backup writer. The illness
day-log's symptom include is a shared constant living there, not a
literal in the records writer — the same "the caller delegates" shape
that already caught the measurement pager.
Several accessibility scans ran against a page that had not finished
arriving, so axe reported violations the app does not have. The worst of
them was the medications case, which waited for a level-one heading and
then checked that a level-one heading exists. A page that really lost its
heading would have waited out the timeout under a misleading name instead
of failing the rule, which makes it a check that cannot fail rather than a
flaky one.
The filter rail was the other bad sentinel. It renders beside the loading
silhouettes rather than after them, so it was on screen while the rows were
still grey rectangles. Under an 8x CPU throttle the rail and the loading
marker resolved in the same 40ms poll and the rows a poll later.
Every case now gates on a marker the real component carries in its
resolved, non-empty branch, and on a component other than the one the scan
asserts. Both the desktop table and the mobile card list carry the same
marker so the wait holds at any viewport. No sentinel names a heading, the
app frame, a busy marker or the filter rail any more.
The two cycle entries in the frozen list claimed the gate reads `gender` off
the resolved auth context. It does not: `requireCycleEnabled` names that
parameter `_gender` and ignores it, then re-loads gender and the profile by
the resolved user id through the module gate. The answer is the same — the
owner's — but a reviewer checking the claim would have found a dead argument
and no explanation.
The profile route is the exception and now says so: it passes the auth-context
gender to `isCycleEnabled` itself to compute the flag it returns.
A frozen-list entry is only worth the reviewing it invites, so it has to
describe the mechanism that is there rather than the one the call site looks
like it has.
The strip is new this release and had not been checked against the
design standards. It carried the shape §11 names as the anti-pattern: a
centred flex-wrap row whose gap-y drops the action onto its own line
once the name is long enough. The maintainership strip is the sibling
that already had an action and already had the right shape, so the two
now read the same — sentence on min-w-0 flex-1, way out on shrink-0,
beside it rather than under it.
The exit control was a 32px tap target. Leaving somebody else's record
on a phone is the one interaction in the whole banner stack, and it now
carries the standard's floor (min-h-11 sm:min-h-9).
The three strips also spell their icon size three ways for identical
CSS; they all use size-3.5 now.
Text tiers are unchanged and were already right: the person's name is
foreground because it is content, the read-only qualifier is muted
because it is meta.
The offline, shared-record, demo and maintainership strips lived inside
the content column, so each one pushed the top bar down while the
sidebar's logo band stayed at the top of the window. Both bands read
SHELL_HEADER_BAND and are therefore the same height with the same
border, and the single line they draw across the top of the app still
came apart: measured on the French maintainership strip, the two borders
sat 66.5px apart at 768px and 61.0px apart at 1280px, and the full
three-banner stack put them 184.5px apart. After the hoist the step is
0.00px at every depth and both widths.
A banner is a statement about the whole window, not about the content
column, so it belongs above the row. The outer wrapper keeps the h-dvh
and the row below takes what the banners leave, which means the stack
comes out of the viewport budget instead of being added on top of it:
the document still does not scroll at any depth, and <main> stays the
one vertical scroller at both breakpoints.
The existing seam guard could not see this class. It asserts that both
bands carry the shared band tokens, which stayed true the whole time,
and the e2e spec beside it measures a shell with nothing above the
header. The new spec forces the banners on through the conditions the
components actually read — an unmaintained locale, a patched account
payload, a real offline context — and measures the same two borders at
four stack depths. Reverting the layout change turns it red on the first
banner with the measured offset in the message, while the older seam
spec stays green.
The maintainership strip gains a data-slot so the shell's chrome can be
enumerated from one attribute.
`GET /api/measurements` was the resolver test's example of a route refusing
under a switch. It is delegable now, so that arm was asserting the opposite
of what ships and went red on the migration — which is the arm doing its job,
not a defect in it.
Split in two. The measurements route keeps both of its real claims: unchanged
un-switched, and the owner's rows and not the delegate's once switched. The
refusal moves to `GET /api/share-links`, which is non-delegable permanently
and by design — a delegate who could mint a clinician link would hold a door
that survives the owner revoking their access. A route that can never join
the frozen list cannot break this arm the same way twice.
Ten routes: the cabinet list, the medication detail, its cadence, schedule
revisions, side effects, inventory, phase config and intake history, plus the
account-wide intake list and the batched compliance read.
`GET /api/medications/compliance` is the one delegable read that spends a
quota, and its rate-limit bucket now keys on the ACTOR while everything below
it scopes to the record. Both halves matter: a delegate hammering the route
must burn their own allowance rather than lock the owner out of their own
cabinet, and switching records must not hand the same caller a fresh one. The
test reads the `rate_limits` row rather than inferring it from a 429, because
a bucket keyed on the wrong person still answers 200.
`GET /api/medications/[id]/api-endpoint` stays refused. It returns two scalar
fields and no secret, and it tells a delegate whether the owner holds a live
ingest credential and for which medication — in the same file as the PUT that
mints one that would survive revocation.
The two intake validation breadcrumbs move onto `auditLog()` for the actor
stamp. The POST arm's breadcrumb stays as it is: that arm cannot be reached
under a switch.
Four routes: the cycle history, the day log, the profile and the custom
symptom vocabulary.
All four gate on `requireCycleEnabled(user.id, user.gender)`, and both
arguments now come off the record. That is the correct reading and the one
that had to be verified rather than assumed: whether cycle tracking applies
is a property of the record, so a delegate who tracks nothing still sees the
owner's cycle, and a delegate who tracks their own sees nothing when the
owner does not. Both directions are asserted.
Eight routes: the two sleep reads, the nutrient overview and its daily
series, the illness episode list, detail and day logs, and the workout list.
The illness day-log read is the one worth reading twice. Its queries are
episode-scoped rather than user-scoped, and what makes them safe is the
`loadOwnedEpisode(id, user.id)` hop above them. That hop now resolves against
the record, so a delegate cannot reach their own episode from inside somebody
else's account — asserted, because it is the kind of thing that would look
fine and behave wrong.
`GET /api/workouts/[id]` stays refused. It differs from the list beside it by
one conditional include that decrypts a paragraph written about the owner.
The switch is stamped on the session row, not held in the tab. Every
authenticated spec opens its context from the same storageState file, so
the sharing journey switching that jar into the owner's record switched
it for whatever spec happened to be mid-navigation on the other worker.
The failures land nowhere near the cause: /settings/ai painting "Not
part of shared access" instead of the AI panel, an axe scan reading a
banner its spec never asked for, a waitForResponse waiting on a request
the refused surface never sends. Which spec loses depends on scheduling,
which is why it moved between runs.
Log the delegate in a second time and hand that jar to the journey. Same
account, its own session row, no reach outside the file. The three login
captures collapse into one helper while the third one is added, and the
fixture's login-bucket reset already covers the extra attempt.
The guard matches the two things a switching spec cannot avoid naming,
the switcher entry it clicks and the endpoint that entry calls, and
checks both that the match set is non-empty and that the shared jar is
still what the ordinary specs read. Verified red by pointing the journey
back at the shared jar.
The credits page named CC BY-SA 4.0 alone. MaxMind stopped distributing
GeoLite2 under that licence by itself: the databases ship under the
GeoLite End User License Agreement, which incorporates CC BY-SA 4.0 by
reference and controls wherever the two disagree. Name both, in that
order, and link the EULA. The attribution string was already there and
is unchanged; the e2e page check now asserts both licences rather than
the one that no longer stands alone.
Reconcile the self-hosting guide with the same wording, point it at the
current EULA URL, and say why the `/about` page must stay reachable — it
is where an operator's own attribution lives.
Correct the project licence line while on the page: it still claimed
AGPL-3.0, which the page's own footer contradicts. The trunk has been
PolyForm Noncommercial 1.0.0 since v1.15.19.
Six routes: the mood-entry list and detail, the custom-metric list, detail
and entries, and the personal-record list. The custom-metric entries read
establishes ownership on the metric before it queries by metric id, and that
hop now resolves against the record — a delegate cannot reach their own
metric from inside somebody else's account.
`GET /api/mood/tags` stays refused. It looks like plain record vocabulary and
one select in the middle of it pulls the owner's tag layout, which is a
presentation preference and belongs to the person rather than the record.
The mood list's validation breadcrumb moves onto `auditLog()` for the actor
stamp, the same change the measurement reads took.
Every credential-shaped row is left out on purpose — API tokens, trusted
devices, step-up elevations, known devices, clinician share links, and the
grants behind shared record access. The per-model reasoning already lives in
backup-plan.ts with a structural test behind it, but an operator planning a
recovery does not read that file.
The consequence differs by target and the difference is the part worth knowing.
Restoring onto the same instance changes nothing: the restore replaces data
tables and does not touch grants, so shared access carries on as it was.
Restoring onto a fresh instance brings none of it, so both people invite and
accept again before sharing works. That is the fail-safe direction, and it is
better planned for than discovered.
Vitest is a knip plugin, so every file under `src/**/__tests__/**` is an
entry, and a module whose only importer is its own test counts as used
for as long as the test survives. Planting a module with a single test
importing it left `pnpm knip` at exit 0 — the gate reports green on code
production stopped reaching.
Knip can answer the question, just not in the pass CI runs: production
mode drops the plugin's test entries and keeps the entry and project
patterns suffixed with `!`. Mark them, which is inert in the default
pass, so `pnpm knip --production --include files` is one command away.
It stays a review tool rather than a second gate, and the config says
why: twenty-nine files today, some test-only by construction and some
unreachable from production on purpose, plus at least one false positive
where a script's `@/` import does not resolve. Baselining that set
unread would encode noise as an invariant.
Move the config to JSONC so all of the above lives next to the settings
it describes instead of in a commit message nobody will run `git log`
for.
Ten routes: lab results and their detail, the biomarker catalogue and its
detail, allergies, family history, the mental-health screener history, and
the health-profile facts. All GET arms; every write arm in the same files
keeps `requireAuth()` and refuses under a switch.
The family-history payload is the one admitted read that carries third-party
health information by design — it describes the owner's relatives — and it is
admitted because a caregiver reading a family history is what the feature is
for and the rows are the owner's own.
The mental-health module gate resolves against the record, not the caller.
That is the recorded decision: the module map says which parts of a record
exist, so a delegate who enabled the screener on their own account gains no
screener view inside somebody else's. The integration test asserts both
directions.
git, eslint and vitest each carry an exclusion for local development
worktrees, and all three name /.worktrees/ while the convention actually in
use is /.wt-*/. Every one of them was therefore walking eleven full copies of
the source tree.
The consequences differed by tool and none of them announced itself. pnpm lint
took ten minutes instead of one and reported warnings from whatever commits
those copies happened to sit on. vitest collected duplicate suites that drag in
a second React instance, which fails in ways that have nothing to do with the
working tree. git left them merely untracked, one git add -A away from
committing the repository eleven times over.
With the true warning count finally visible, teach no-unused-vars the
underscore convention this codebase already follows and drop the five
disable directives that convention made redundant. pnpm lint is clean.
A non-underscore unused argument still warns; that was checked rather than
assumed.
A consent record has to answer who consented and when, and the grant row
already does. It does not have to answer from where, and an address kept for
as long as both accounts exist is personal data held past the point it
evidences anything. Nothing read the column, which makes it exactly the
write-only field scripts/audit-column-readers.ts exists to surface.
The acceptance stays evidenced: audit_logs records it with the caller's
address under that table's own retention. The column never reached the wire,
so the grant view and the OpenAPI contract are unchanged.
Written before the feature shipped, so migration 0295 drops an empty column.
Six routes move from `requireAuth()` to `requireRecordAuth("read")`: the
measurement list, the single reading, the two series reads, and the two
reminder reads. Each is admitted in the delegable classification with its
five answers written; each enters the frozen list with the argument for why
a delegate reading through it cannot extend their own reach.
Only the GET arms move. The POST, PUT and DELETE arms in the same files keep
`requireAuth()`, which refuses outright while a switch is active, so a file
entering the list admits a read and nothing else.
The two validation breadcrumbs on the list and series reads now write through
`auditLog()` rather than a bare `prisma.auditLog.create`. That helper is the
only writer that stamps `actorUserId`, and without the stamp a delegate's
malformed query lands on the owner's record looking like the owner's own.
Integration coverage drives the shipped GET exports against real Postgres:
the owner's rows reach a delegate holding a live grant, the delegate's own
rows do not, a caller without a grant is refused `sharing.access.denied`, the
request after a revocation is refused with no re-login, and a caller who
never switched sees exactly what they saw before.
`fs.globSync` never matches a leading dot, so every guard built on
`**/*.{ts,tsx}` walked past `src/app/.well-known` — the two OAuth
discovery documents and the apple-app-site-association handler sat
outside fourteen frozen-set sweeps. An unlisted `getSession()` planted
in the authorization-server route left the session-surface guard at nine
passed; it fails now.
Move those fourteen onto one shared walker built on `readdirSync` with
`recursive`, which has no dot rule, and give it a floor every caller has
to state, so a walk that comes back too small throws instead of
reporting a clean tree.
Give the same floor to the guards that compared a discovered set against
a frozen list with nothing asserting the set had been read: gutting the
walker in the inventory-write guard left its invariant test green,
because zero offenders reads exactly like no offenders. Raise the
migration-count floor off one, and assert the locale bundles are found
before the per-locale cases are generated from them.
Document, at each walker that prunes on purpose, what is pruned and that
dot-prefixed directories are not.
An operator page for the feature, written in the register the
reproductive-health page established: say the narrow true thing rather
than the reassuring one.
What the person given access can reach, and the table of what they cannot
— credentials, sessions, tokens, connected services, notification
channels, exports, share links, the sharing itself, and the AI surfaces.
The AI row gets its own paragraph because it is the one that looks
arbitrary: server-managed processing runs under the consent the record's
owner gave for their own use.
Revocation is described honestly. It lands on the next request rather
than the next login, it puts a browser that was inside the record back
into its own account, and it cannot take back what was already seen. The
product can close the door; it cannot reach through it, and the page says
so rather than implying otherwise.
The activity view's window is described as the operator's setting, not as
365 — an instance that shortens the audit retention shortens this view,
and an empty list means nothing was recorded within the window rather
than that nothing ever happened.
The three trust facts stay where they were: the operator could always
read the database, encryption at rest is instance-keyed rather than
per-user, and AI processing stays under the owner's own consent.
The data-protection note is deliberately absent — its stance is a
maintainer decision, and the page reads complete without it.
Two browser contexts, two seeded accounts, one database: the owner invites,
the delegate accepts and opens the record, the owner takes it back. Every step
through the shipped UI, because the half of this feature that can go wrong is
the half a person touches.
The stale-paint check is a negative one and deliberately not "the new record's
data arrives" — a check that waits for the new data says nothing about what
was on screen while it arrived. An init script arms a MutationObserver before
any page script on every navigation, including the reload the switch performs,
and latches the delegate's own marker weight. The observer's positive control
runs first on a page that definitely shows it, so its later silence is not the
silence of a broken observer.
Then it was broken on purpose, and the result is written into the spec rather
than assumed. Removing the switch-time cache wipe, the persister's restore
guard and the record-scoped hash — all three — leaves the spec GREEN: the
restore already refuses to clobber a query the live cache holds, and the
dashboard's query is created during render, so the snapshot never reaches that
route's screen at all. Removing the reload turns it red on the banner rather
than on the marker. So this suite is a regression net over the journey, not a
proof of any one control; the controls are proven where they can be broken and
observed, in the unit suites, and the spec's header says so.
The nav assertion turned up a real gap while it was failing: the avatar block
still linked into the account settings from inside a shared record. Those
routes refuse under a switch, so the link led only to an explanation of why it
did not work. It renders as a plain identity block now, and the mobile user
menu drops the same utilities.
Setup gains a second account, a marker weight in each record, a grant reset,
and a clear of the login bucket — this setup signs in twice now, and the
ceiling is five attempts a quarter-hour, so three local runs used to end with a
429 from the fixture rather than a failure from the product.
Three additions to the contract, all of them things a native client cannot
work out on its own.
`GET /api/auth/me` enters the spec for the first time, and enters it loose:
`accountAccess` is specified in full, the remaining preference fields are
declared open rather than half-enumerated. The endpoint has never been in this
document and its payload carries thirty-odd keys no contract has ever named;
specifying a subset and closing the object would publish something the server
does not keep.
`X-HealthLog-Account` is published as a reusable parameter with no route
referencing it, which is the honest state of the contract — the routes that
read another account's record arrive with the surface migration and will
reference it as they land. The header is live in the resolver today, and a
client needs the description before it can be built against a tagged spec: the
value comes from `accountAccess.accounts`, it is a selector and never a
permission, and it must never ride the sync engine, device registration, or an
auth call.
`GET /api/account/activity` carries `retentionDays` in the response and says
why: the window is operator-configurable, so a client that assumed 365 would
tell somebody their record was never opened when the honest answer is "further
back than this reaches".
The parameter is defined in the sharing route module rather than the shared
components block, so the boundary guard keeps holding the header's name to two
files — the resolver that reads it, and the one place that publishes it.
Each behaviour here was verified by removing the code that provides it and
confirming the test went red, then putting it back. Two of them did not, and
both were rewritten rather than kept:
The restore-side persister check asserted that the delegate's own dashboard
could not be read back inside a shared record. Deleting the guard left it
green, because the record-scoped hash already makes a snapshot written in one
record unreadable in another. It now counts what landed in the cache, so the
guard it names is the thing it measures — and the weaker line is kept beside
it with a note saying why.
The switcher's account rows were asserted from server-rendered markup. Radix
portals do not render server-side at all, so an earlier draft was measuring
the renderer. Those rows moved to the browser suite; what stays here is the
gate, which fails sharply — with the `canSwitch` bail removed the two cases do
not return different markup, they throw.
Two more shapes worth naming. The cache dimension is exercised through a real
QueryClient built from the app's own defaults, because "the hash string
changed" and "one record's data cannot be read while inside another" are
different claims. And the invalidation is a plain function over a client with
three live entries seeded, with a test that asserts they start valid — without
it, a client that failed to seed and one where everything was invalidated look
the same from every other assertion.
Settings gains one section for one concept: who else can open this health
record, whose this account may open, and when they were opened. Split across
two pages it would be two half-answers.
An invitation confers nothing until the other person accepts it, and the card
says so above the field rather than after the submit — that is the part
somebody needs to know before they type a name. Refusals are named from the
stable errorCode, because "no account with that name" and "that person already
has access" are different situations with different next steps.
Ended grants stay in the list. The row is the consent record, and "who had
access, from when to when, and who ended it" is the question the panel exists
to answer; a list that dropped them could not. Revoking asks once and then does
it: reducing access must never be harder than granting it was.
`GET /api/account/activity` answers the owner's other question. One row per
person per day with a count, not one per request — a hundred identical lines
is a way of answering nothing. The window is published with the rows rather
than assumed: audit history is purged on a schedule the operator sets, so the
view prints the number the server resolved from the same setting the purge job
reads. An instance running ninety days says ninety.
Six locales ship with it.
Switching is three acts in a fixed order, and the order is the safety
argument. Ask the server, so a refused switch leaves the browser where it was.
Empty the caches a reload cannot reach, awaited rather than fired, because a
wipe racing the navigation is a wipe that sometimes does not happen. Then
reload the document, which is what discards the in-memory cache, the rendered
React trees, and every request already in flight against the old record.
The banner is the feature, not decoration. Someone who forgets they are
switched will log their own blood pressure into another person's record, and
there is no undo for a reading that was never theirs. So it states whose
record is open, that it is read-only, and carries the way out — in the one
visual register reserved for "something about this screen is not normal", at
the top of the shell above every other strip. A subtle tint is what people
stop seeing on the third switch.
The switcher lives in one component mounted in both user menus. A way in on
the laptop and no way out on the phone is how the two menus have drifted
before.
Nav entries that sharing does not cover drop out, and a page reached by
bookmark says so instead of painting a row of refusals. Both are paint: the
server refuses those routes on its own, from a frozen list this code cannot
reach. Settings, connected services, notifications and the AI surfaces are the
account around a record rather than the record itself.
A grant can end while a browser is inside the record. Revocation clears the
delegate's sessions in the same transaction; expiry does not, and leaves a
session stamped with an account it may no longer act on — correct refusals
under a screen that lies. One cache subscriber notices the stable errorCode
and leaves, rather than asking two hundred call sites to remember.
Every cached read in this app is about one health record, and nothing in the
key factory said which one — there was only ever the signed-in account's. Once
a browser can act on somebody else's, `["dashboard","snapshot"]` names two
different answers and the cache cannot tell them apart. That is this repo's
known cache-poison class, on health data, across two people.
One line at the root rather than a dimension threaded through twenty-three key
modules: a `queryKeyHashFn` that folds the record id into the cache identity of
every entry. Key arrays are untouched, which is the part that matters — prefix
invalidation and the import-time dependent-key bundles both match on the array
and keep working. Prefixing the arrays would have broken every bundle silently.
The hash is defence in depth. Switching is a full page reload, so the
in-memory cache is empty on the other side of it; the layers that survive a
reload are the IndexedDB snapshot and the service worker's data cache, and
those are handled by refusing to write either while a switch is on, and by
skipping the restore. Nothing of the owner's record ever reaches the disk of a
delegate's device, so a stale scope marker can cost an empty restore and never
another person's data.
The persister's per-account binding read the account payload's id, which under
a switch is the delegate in both states — it could not tell the two apart. It
now asks the record scope, which can.
`GET /api/auth/me` grows an `accountAccess` block: the accounts this caller
may act on, the one the browser is inside right now, and the booleans a UI
binds directly — `canSwitch`, and `canWrite` per account. Every value is
resolved server-side. A client that worked out what it may do from grant rows
would be a second program deciding one person's access to a health record, and
the two answer differently the first time an expiry or a revocation appears.
`canWrite` is false for every grant in v1 because invitations write READ only.
It is resolved through the grant state machine rather than hardcoded, so the
day a WRITE grant exists the answer changes without this file being edited.
The route becomes an actor surface. It has to be: the switcher, the banner
naming whose record is open, and the way back out all read this payload, and
under a bare `requireAuth()` the whole app shell would refuse the moment a
switch was on. Every field it returns is still the caller's own.
The active record is published as a full entry, not an id to look up. A banner
that had to join two fields could render unnamed the moment they disagreed;
here the active entry is one of the listed ones, selected by the pass that
built them. A stamp left behind by a grant that lapsed reads as not switched —
the same answer the resolver gives the next delegated request.
Not published: the other account's avatar. Those bytes are owner-scoped, so a
URL here would resolve to a refusal and paint a broken image. Clients keep the
initials fallback.
The first entry in either frozen list. It is the way back out of a
switch: every other route refuses while one is active, so a browser that
could not reach this one would be stuck inside a record it entered. The
entry names why it may say what it says — one session row, the caller's
own, nothing of the owner's rendered and nothing granted.
The entry count moves with it, so an addition has to be made twice
rather than once.
The lifecycle surface under /api/account. An owner offers read access to
somebody already on the instance, the delegate accepts, either side ends
it, and a browser session points itself at a record it has been granted.
Ending a grant clears the acting-account carrier on every session of the
delegate pointing at that record, in the same transaction that stamps the
row. Split apart, a revocation would leave a browser inside a record it
can no longer read: every request refused, the banner still naming the
owner, nothing on screen able to explain it.
Revocation has no step-up and no confirmation step. Reducing access must
never be harder than granting it was.
Grant management is non-delegable by construction rather than by a check:
every route here resolves through bare requireAuth(), which refuses while
the caller is acting on another account, so no code path through them
runs as somebody else. The switch endpoint is the one actor surface, and
it is the way back out. A delegate already inside a record renounces
after switching out, one request, rather than the fail-closed default
growing an exception that later gets widened.
The state machine is not re-implemented anywhere: routes call the grant
module's predicate and its transitions, and the session carrier has one
writer.
Adds AuditLog.actorUserId (migration 0294) and threads it centrally, out
of the wide-event context the acting-account resolver already stamps. No
call site passes an actor, so no route can forget one.
NULL keeps meaning "acted for themselves" — the reading every existing
row carries. The threading fills the column only when a request resolved
an acting account AND the row is being filed under that account, so a
self-action, a refusal, and a delegate's row about themselves all stay
NULL rather than acquiring an id that would rewrite what the history says.
Delegated reads coalesce to one row per owner, delegate and day, through
a partial unique index and an insert that expects to lose the race. The
alternative is a row per request on the busiest table in the schema and
an activity view made of identical lines.
The column carries no foreign key, against the grain of user_id beside
it: SET NULL there erases an account from its own history, while here it
would turn "the delegate opened this record" into "the owner opened this
record". An id that no longer resolves reads as a deleted account; an id
that silently became NULL reads as a lie.
Both resolvers land with empty allowlists. That is the correct start and
also the shape that hides a broken matcher: a scan that finds nothing
agrees with a list that holds nothing, and the two look identical from
outside. So every leg pins a count that does not start at zero. The scan
asserts it found route modules, the two frozen legs assert the matcher
still finds the declaration site, the co-occurrence leg asserts each
cookie-only helper is present in the tree it reads, and the two
out-of-band Bearer surfaces assert they still resolve a token of their
own before asserting they reach no resolver.
Membership is decided from the parsed module rather than the source text,
so a route may explain in a comment why it is not delegable without being
enrolled by saying so, and an aliased import cannot hide one. The
synthetic modules at the top prove both halves of that.
Enumerates with a recursive readdir rather than a glob: globSync skips
dot directories, so it drops the three route handlers under
src/app/.well-known and leaves the sweep looking healthy at 444 modules
instead of 447. One of the three is pinned by name.
Scans 447 route modules and 2338 source files, and finds nothing.
The unit file walks the arms with the grant module deliberately unmocked,
so the resolver consuming the one "is this grant active" predicate is a
property under test rather than a claim in a comment. Its Prisma fake
honours its own `where` clause for the same reason: a fake that answers
any query with any row turns every scoping assertion into a check that
cannot fail.
The integration file proves the two things a unit suite cannot, because
both are about which rows come back. Substitution: the owner's
measurements are returned and the delegate's own, seeded alongside, are
not, so a resolver that quietly served the caller their own record shows
up as the wrong values rather than as an empty list. Never cached: a
revocation lands between two requests inside one test and the second one
403s.
The do-no-harm arm runs against a real shipped route, and the cookie-only
helpers are asserted against a real session row carrying a real switch,
because the one edit that would break them is `getSession()` itself
substituting the owner and no mocked session can catch that.
The structural guard freezes the set of files allowed to touch the
carrier column and the selector header, and asserts a non-zero match
count before comparing against the allowlist. A matcher that finds
nothing agrees with an emptied list and reports success; this repository
has shipped several guards that did exactly that.
One resolver, both transports, on every request. The cookie path reads
the carrier off the session row it has already loaded; the Bearer path
reads the `X-HealthLog-Account` selector header, because stamping switch
state onto a long-lived token would make the credential itself ambient
authority. Two carriers converge on one function for the same reason
`resolveBearerToken` lives in one file for two wires.
Three declared modes. `requireRecordAuth("read" | "write")` honours a
valid switch and substitutes the owner as the data scope while keeping
the delegate as the actor. `requireActorAuth()` always serves the
caller's own rows and keeps working under a switch, which is what the
switcher and the banner need.
Bare `requireAuth()` refuses to run under a switch at all, with a stable
`sharing.not_permitted` code. It does not fall back to the caller's own
account, and that arm is the point of the whole design: a delegate who
believes they are reading the owner's record and is handed their own is
the same data mixing pointed the other way. A route nobody has reviewed
is inaccessible while switched, never wrongly accessible.
The grant is loaded from Postgres on the request that uses it. Nothing
memoises the verdict, so a revocation lands on the delegate's next
request rather than their next login.
Refusals do not enumerate. The denial takes no constructor arguments and
carries a fixed message, and neither branch looks the named account up:
an account that does not exist and one that granted nothing produce the
same empty row from the same query, so the two answers are identical in
bytes and in timing rather than by careful wording. The reason reaches
the audit row and the wide event and nothing else.
`getSession()` still answers who is calling. That is what leaves
`requireAdmin`, `requireCookieAuth` and `requireFreshMfa` structurally
out of a switch's reach; the row's carrier rides out beside the session
id as a value to be checked, never as a decision already made.
The selector header is a Bearer carrier only. Sent over the cookie
transport, or to an actor surface, it is refused rather than ignored. A
client that names an account on a route which never reads one has a bug,
and a correct response to a wrong expectation hides that bug until it
arrives as a support case about data that looks wrong.
Five test files gain the session shape their fixtures always claimed to
have; they were building an auth context without one.
Migration 0293 adds `sessions.acting_as_user_id`. NULL, which is what
every session has carried until now, means the browser is acting as the
account it signed in as.
It is a selector and not an authorization. The resolver joins it against
a live grant on every request, so a value stranded here by a grant that
has since been revoked or expired confers nothing. Nothing sweeps the
column for correctness; the sweeps that exist are housekeeping, so a
delegate's browser lands back in its own account instead of on a wall of
403s.
ON DELETE SET NULL, not CASCADE. The two ends mean opposite things:
deleting the session's owner should take the session with it, which is
the existing `user_id` cascade, while deleting the account being acted on
must leave the delegate's browser session alive. A cascade on this side
would sign a delegate out of their own account because somebody else
deleted theirs.
A revoked grant with no revoker, or a revoker with no revocation, are
both rows that cannot answer the question the table exists to answer.
The migration already enforces the pair; this asserts it against real
Postgres rather than trusting that the domain module is the only writer.
The delete-all-data guard counted an account's surviving rows with a
literal `WHERE user_id = $1`, which is the same assumption the wipe loop
itself used to make. Account grants relate two accounts and carry no such
column, so the guard raised `column "user_id" does not exist` and stopped
before it could assert anything.
The predicate now comes from the wipe plan's own owner-column
declaration, so the guard asks the question the route answers instead of
its own version of it.
An account grant is one account's standing permission to read another
account's record. The row is also the consent record for the act that
created it: there is no second receipt table, because two records of one
act drift and then nobody can say which is true.
That shapes the table. Revocation stamps revoked_at and revoked_by
instead of deleting, so the record can still answer who had access, from
when to when, and who ended it. The uniqueness constraint is therefore
partial — one live grant per (owner, delegate), with history accumulating
underneath — otherwise a re-invitation would either be refused or would
overwrite the first grant's acceptance. Both foreign keys cascade: a
grant naming a user who no longer exists is an authorization nobody can
resolve and nobody can revoke.
The state machine lives in src/lib/sharing/grants.ts and only there. The
acting-account resolver and the grant surfaces both read these rows, and
if each carried its own idea of "active" the drift would be silent in
exactly one direction: the resolver admitting a grant the panel shows as
ended. Pending confers nothing, expiry is checked against the clock of
the request that reads the row, and every transition is a conditional
update so two concurrent accepts cannot both win.
The access column carries READ or WRITE and only READ is ever written.
It exists from day one so that delegated writes become enforcement work
rather than migration work.
Three lifecycle paths, each proven against the real route:
- Delete All Data removes grants from either side. The wipe loop asks
every model for "this account's rows", and a table relating two
accounts has no user_id to answer with, so wipe-plan.ts now declares
the owner columns and translates. Wiping only the grantor side would
leave the account still holding access to other people's records.
- Account deletion removes them in both directions.
- An admin restore leaves them exactly as it found them. Grants are
classified NOT_IN_BACKUP: rolling data back is a decision an admin
makes, but rolling an authorization back is a decision nobody makes,
and a grant revoked on Tuesday must not come back alive out of
Monday's file. After a restore, sharing is off and both people
re-consent.
The backup classification guard was reading a user relation by field
name, so a model naming its account relations grantor and grantee read
as instance-scoped and was excused from having a verdict at all. It now
reads the type. That surfaced InviteToken, classified here too.
The server names every unresolved catalogue key in its response and in the
audit row, and the integration suite holds that end. This is the other one:
a report nobody sees is the same silence the naming replaced.
The panel moves into its own exported component so it can be rendered
against a report directly. The SSR smoke tests in this directory cannot
drive a state transition, and a render proven only by reading it is not
proven. Pinned: every key is printed rather than summed away, each key
carries its own count and catalogue, an empty report renders nothing at
all, and the copy resolves in a second locale.
Three of the restore's lookups resolve a key against a seeded catalogue:
cycle symptoms, illness symptoms, mood factors. Each threw on a key it
could not resolve, inside the one transaction that wipes and rebuilds the
account. So a single renamed symptom rolled everything back and answered
500. No measurements, no doses, no notes, nothing. A year-old backup read
by a newer instance is the ordinary case for a backup, and the catalogue
drifts between those two dates by design.
The unresolvable link is dropped and named now. What it costs is one
association between a row and a symptom; the day-log, the mood entry, the
flow, the temperature and the encrypted note all come back. The count and
the exact keys ride the response, the audit row and the wide event, and
the admin surface shows them in a panel that stays put instead of a toast
that leaves. A quiet drop would be the older failure of this same seam
wearing a success message.
A dangling `cycleId` or a dose recorded against a medication the file
never carried still throws. Those name rows the file was supposed to hold,
so a missing one means the file contradicts itself. A catalogue key names
a row the instance owns, which is why it gets the other answer.
`IllnessSymptom` carries no owner column at all, so a key that will not
resolve there can only ever be drift; the file was never able to supply it.
The restore does not mint the missing catalogue row back. That would
preserve the link at the price of reference data colliding with whatever
the next migration seeds under that key, and a label nothing can translate.
The top bar's bottom border and the sidebar logo band's bottom border draw
one horizontal line across the top of the app, and they sat a pixel apart.
Both declared h-16, so the number was never the problem. Every box in the
app is border-box: the top bar carried `h-16 border-b` on one element, which
puts the line inside the 4rem at 63 to 64 px, while the sidebar put `border-b`
on the wrapper and `h-16` on the link inside it, which puts the line after the
4rem at 64 to 65 px. Same number, different line.
The height and the border now travel together as SHELL_HEADER_BAND in
shell-metrics.ts, next to the sub-shell scroll floor that exists for the same
reason. The element carrying that class is the band; neither call site
restates a height, and the sidebar link stretches with h-full. Border colour
stays per-surface, because the two chrome surfaces are tinted apart by design
and only the geometry has to match.
Two guards. chrome-header-seam.test.tsx asserts the structural property, that
each band renders every token of the shared constant and declares no height of
its own; a second height class on either element replaces the shared one
through twMerge, so both halves fire on the same edit. The e2e spec measures
the painted result at 768, 900, 1024 and 1280 px in both the expanded sidebar
and the collapsed rail, and checks each band actually paints a border at the
same weight first, so two missing lines cannot pass as two aligned ones.
`settleBeforeMeasure` ends with a `waitForLoadState("networkidle")` whose
rejection is swallowed and a `page.evaluate` awaiting
`document.fonts.ready`. Both are written to end on their own terms, and
neither had a bound: the config leaves `actionTimeout` and
`navigationTimeout` unset, which Playwright reads as no limit, so a
single sub-resource that never completes consumes the whole test budget
rather than the wait.
The way it surfaces is the worst part. The swallowed timeout hides the
stall, the run reports whichever call was still pending when the fixture
closed the page, and the message reads
Test timeout of 30000ms exceeded.
Error: page.evaluate: Target page, context or browser has been closed
which names neither the surface nor the resource. Raising the budget
changes nothing, because no budget bounds an unbounded wait.
Route one font request to a handler that never answers and the current
code reproduces that message exactly; with the bound in place the same
run reaches the spec's own assertion in twelve seconds.
Six seconds is an order of magnitude above the slowest settle measured
across the suite (1.1 s, /insights at a mobile viewport).
The `/insights/workouts` case gated its axe scan on `#main-content
[role="status"]`. That role is not a content marker: the busy skeletons
carry it too, and the one the streamed route shell paints first is a
`chart-skeleton` with `aria-busy="true"`. The sentinel therefore went
green on a document the page client had not touched yet. A frame trace
under a 20x CPU throttle puts that skeleton on screen at 408 ms and the
page's own `<h1>` at 4119 ms, so for most of four seconds the scan could
land on a tree with no level-one heading and report
`page-has-heading-one` against `html`. That is the failure a loaded
runner has been showing, and the page it accused has the heading.
Gate on the two states the page itself renders instead, the list or the
empty state. The overview case above already excludes `aria-busy`
skeletons the same way.
`/coach` had a smaller version of the same gap: `coach-page` is the
sizing wrapper around a `<Suspense fallback={null}>`, on screen 241 ms
before the conversation under it exists. Gate on the conversation.
Neither sentinel waits for a heading. Removing the `<h1>` from
`SubPageShell`, `CoachHero` and the conversation's screen-reader title
still turns the matrix red with four `page-has-heading-one` violations
per theme.
The nine-rung verdict ladder was written for the opt-in dashboard hero.
That hero was retired when the digest-driven Today rail became the single
promoted hero, and its i18n copy was dropped across every locale in the
same change, but the resolver stayed behind. Since then it has had no
caller outside its own tests: nothing renders it and no string exists for
any of its variants.
Every rung it computed already has a live owner. Dose states ride the
digest's dose_window item, an elevated-at-rest window rides tension_window,
the score delta rides the digest score block the Today hero paints its chip
from, weight drift and sleep deficit and a silent stretch are the coach
nudge cron's own triggers, and a crisis blood-pressure reading is the
safety-floor engine's. Wiring a renderer back on would rebuild the second
hero the retirement removed.
- Delete the resolver, its ladder suite, and the client-safety import walk
that pinned its bundle graph.
- Fold the two shared nudge thresholds back into the cron. The leaf module
existed only so a client component could read them without dragging the
server graph along; there is no such component now.
- Repoint the score-config guard off the verdict onto the digest fields the
Today hero actually reads, so it still fails on the assembly it was
written for.
- Repoint the clinical-floors notes off the retired surface.
Three integration cases pinned the old behaviour: the sync handshake stamping
the checkpoint, and the calendar read persisting the prediction. Both are the
faults this release removes, so the cases were asserting a bug.
Each file now asserts the read writes nothing AND that the other end does. The
second half is not decoration: without it, "nothing was written" would pass
just as well against a column nothing writes at all.
The cycle case seeds its own history and its own profile row rather than
reaching a route to get one, because reaching the route is exactly what the
prediction must no longer depend on.
The assertion in the module-gate suite could not fail: that fixture's mood
badges carry no completion date, so the result it drove had no pending
unlock and "nothing was written" was true either way. Restoring the INSERT
left it green.
The case moves to its own file with the builder faked so the result always
carries a pending unlock. Undoing the fix now turns it red. The
disabled-module invariant the old case reached for lives with the write, in
the sweep's suite.
A GET must not have side effects — api-handler.ts says so next to
READ_HTTP_METHODS, where an MCP-audience token is admitted on the safe
methods precisely because they are assumed to write nothing. Three routes
broke that: the sync handshake bumped User.lastSyncedAt, the achievements
read inserted unlock rows, and the cycle calendar fired a discarded promise
at the forecast cache so a failed write left no trace anywhere.
Each write moves to the place that causes it.
The sync checkpoint is now stamped by the batch ingest endpoints for
measurements, mood and intakes. The handshake reports it. That also fixes
the watermark: the checkpoint used to advance past a window the client had
not drained, and a client trusting it would skip that window. It can only
trail a real sync now.
The unlock rows move to a nightly achievement-unlock-sweep. The badge grid
never needed them — unlocked comes from the live metrics and the completion
date is derived — so the payload is unchanged; the rows pin that date and
carry it into the backup, and the sweep reaches accounts that never open
the page.
The cycle forecast moves to a nightly cycle-prediction-refresh. The
CyclePrediction row is not a cache of a read: it is the index the reminder
cron scans, so which reminders an account received depended on whether
somebody had opened the calendar. persistPredictionCache no longer
swallows its errors either — the job reports them.
The two calendar-day cases built their due instant with setHours on the
process clock, which vitest pins to UTC, while the component floors the due
date in the profile zone. Those two disagree about the date for the two hours
before UTC midnight, so both cases went red every night in that window and
green again by morning.
Deriving the date parts from the display zone removes the disagreement rather
than hoping the suite never runs at night. Noon is chosen deliberately: at any
offset the zone uses, 12:00 UTC lands on the same calendar date there, so the
instant cannot slide either way.
Verified inside the failing window: red before, green after, and putting
setHours back turns it red again.
Eight test and fixture files hardcoded scoreVersion: 2, a value the
constant has not held since v1.35.0. Now they import SCORE_VERSION and
use it, so a future bump cannot leave a fixture pinned to a stale
number.
The one genuinely historical case (record.test.ts, proving the input
fingerprint moves when the algorithm version moves) now derives both
its "previous" and "current" markers from SCORE_VERSION instead of two
unrelated literals, so the pair keeps its meaning after the next bump.
The Bearer guard demanded the literal `apiToken.findUnique(`.
`lib/idempotency.ts` writes the same call fluent-style, with a newline
between `prisma.apiToken` and `.findUnique({`, so the matcher never saw
it. It has been resolving a raw Bearer value to a user id since the
idempotency work landed, outside the frozen set, while the guard stayed
green on five files.
Tolerate whitespace around the member access and name the file with what
it does: it decides which user's idempotency cell a replay lands in, and
authorises nothing, because the handler still runs its own requireAuth.
Add the non-zero count assertion the other guards now carry. Narrowing
the regex back to its old form turns this test red, which is the proof
the broadened matcher is what finds the file. The mint-site matcher in
T3 was checked for the same defect and has no hidden members.
The Bearer guard freezes one mechanism: the files that turn an
Authorization header into a user. It reads like coverage of "who can
resolve a caller" and it is not. Two other families do the same job by
other means and no guard watches either.
Fifteen files under src/app read the session cookie directly instead of
going through requireAuth: eight API routes and seven pages. Nine more
authenticate on a token carried in the URL, including the clinician
share tree, which serves one person's whole health record to an
anonymous caller holding a path segment. A sweep that greps for
getSession sees none of the second family.
Freeze both as literal lists with a reason per entry, matched by five
independent legs: the session-deriving helpers, a direct read of the
session cookie name, a dynamic route segment carrying a credential,
share-token resolution, and a query-string credential. Legs overlap, so
each asserts its own membership and its own non-zero count. A leg whose
matcher stops matching then fails instead of letting the union carry it.
`<main id="main-content">` scrolls the authenticated shell, but it was
`position: static`, so an absolutely-positioned descendant with no nearer
positioned ancestor resolved against the initial containing block. That
block sits outside the scroll container: `overflow-y-auto` never clipped
those boxes, and their static position extended the DOCUMENT's scrollable
overflow instead. The result is a second vertical scroll surface beside
`<main>`, which UI-STANDARDS §9 forbids.
The escapees are library markup, so no per-component wrapper would have
caught them. A Radix `<Switch>` outside a `<form>` renders a hidden
`position:absolute` bubble input on its first pass and drops it only once
the button ref resolves, and the root `loading.tsx` chart skeleton carries
an `sr-only` announcement that lives until the real content streams in.
Both are gone within a few hundred milliseconds of hydration, so the page
carried a full-height document scrollbar for the whole pre-hydration
window and looked clean immediately after. On /settings/modules at a
390x844 viewport the document measured 1952 px against an 844 px viewport
while the switches were still bubble-inputs.
Anchoring the scroll container closes the escape route for every such box
at once. The over-scroll guard grows the matching structural assertion:
`<main>` must not compute to `position: static`. The height read it
already had can only see an escapee that happens to be mounted at measure
time, which is why it went red on a loaded CI runner and green on the same
tree twenty minutes earlier; the computed style is timing-free and fails
on every route and both viewports the moment the anchor is removed.
The MCP due-list answered "is this checkup overdue" by comparing the two
instants, so a checkup booked for nine in the morning read overdue from ten
past. The screens read the same reminder as "today" until midnight. One
reminder, two verdicts, and the model reading the tool result repeated the
one nobody could see.
Same counting as the screens and the briefing payload now, on the account's
own clock rather than the server's. A checkup stays due for the whole of the
date it belongs to, and is overdue from the date after.
The briefing payload states how far off a checkup is as a bare number the
model narrates back: "due in N days", "overdue by N days". It got that
number by dividing the hour gap by twenty-four, which answers a different
question than the one the sentence asks. A checkup booked for nine this
morning came out "overdue by 0 days" by the evening. One due Sunday at
01:00, read late on Friday, came out one day off — Saturday, on a calendar
where it is two. A clock-change day is twenty-three or twenty-five hours
long and rounded whichever way its length pushed it.
The checkup screens had exactly this and it was fixed there, so the counting
already exists. Lift it out as `calendarDaysUntil` and have both the phrase
and the number come off it, rather than leaving the payload with a second
body that can drift again. It counts by differencing two integer day indices
read on the person's profile clock, so the answer is exact rather than
rounded, and `extractFeatures` resolves that zone and hands it down — the
number now agrees with the screen the person is looking at.
Today's checkup moves off the overdue list, where the hour gap had put it as
soon as its appointment time passed. The hour it was booked for may be
behind, but the day it belongs to is not.
The horizon that bounds the read stays an hour cutoff on purpose. It is a
query bound, not a statement to anyone about which day something falls on.
Apple Watch ECG recordings reach the record straight from the app instead of
only through an export archive. One recording per request, gated by the same
switches that already govern reading them.
A recording is now identified by what it is rather than by what a source calls
it, so the same strip arriving through both doors converges on one row. The
migration that adds the constraint resolves the duplicates already stored: it
keeps the row holding the waveform over one holding only a verdict, and moves a
removed row's event link onto the row that is kept. The losing rows are deleted.
It asserted the stored ciphertext does not contain each sample rendered
as a string. Base64 is full of digits, so a short sample value like 3
matches by accident: the check failed on a mutation that had nothing to
do with encryption, and would equally have passed while leaking. It now
asserts the column holds the keyId.base64 envelope and nothing else,
which a serialised sample array cannot satisfy.
The mobile project read the day as final on a table with no sleep in it.
The rows were gone; what was left was the dashboard snapshot the desktop
run built from them. It carries the sleep-freshness the digest reads, it
lives in the analytics SWR bucket at sixty seconds fresh and an hour of
stale-serve, and a DELETE straight into Postgres does not touch it. So
the second project asked whether last night's sleep was in, and a cached
answer from the first project said yes.
The purge now goes through the bulk-delete route. That tombstones the
rows the way the management list does, recomputes the rollup buckets the
snapshot reads through, and hard-evicts the bucket, so the next read
rebuilds from the table rather than from the run before it. The
delete-by-external-id route names the rows the way this spec does, but it
invalidates without evicting, and a marked-stale cell is served while it
revalidates - the next read would still have been the stale one. The ids
come from SQL because the measurements list collapses overlapping sources
to one row per day and can hide a row that still counts.
The purge stays where it is rather than moving into the cleanup block:
the eviction only happens when the delete removes something, so tidying
up at the end of each run would leave the next one with nothing to delete
and the same stale snapshot.
The starting state is asserted, not assumed, and it stays that way.
A round trip through the real handlers: post a recording, read it back
through the list and the detail routes, and check what a client actually
receives rather than a fixture rebuilt at each end. Post it twice and
the second call says updated, not inserted, and the table does not grow.
Feed the same strip to the export-archive importer afterwards and it
says skipped; feed it to the importer first and the route says
duplicate. Both directions leave one row.
The migration test drops the index, manufactures the collisions the
resolution step exists for, and runs the migration file through the same
executor the deploy uses. It checks which row survived each tiebreak,
that the salvaged event link followed the keeper, that rows differing
only by user, source or sampling rate were left alone, and that the
constraint rejects a new twin afterwards. Applying the migration to the
empty testcontainer proves none of that.
The Withings ECG tests now hand the transaction the same delegate the
shared writer uses, so they still watch the real write instead of a stub
standing in for it.
POST /api/insights/ecg takes one recording per request: the stable
recording id the client already has, the on-device timestamp, the
sampling rate, the waveform as integer micro-volts, the lead, the
average heart rate, and the device's own verdict. Same gating as the
reads (auth, the insights module, the insightStatus surface), userId
from the session, APPLE_HEALTH the only source a client may claim.
sampleCount and durationSeconds are derived here, and nothing about the
waveform is ever interpreted.
The write body now lives in one place, src/lib/ecg/persist-recording.ts,
shared by the Withings sync, the export-archive importer and the new
route. Three writers with three persistence bodies is how the two ends
of one rule drift apart.
Migration 0291 adds the second unique index on
(user_id, source, recorded_at, sampling_frequency). The archive importer
identifies a strip by a hash of its content and a live client by the
device's own sample uuid, so the same physical recording arriving
through both doors carried two different ids and would have landed
twice. One device does not record two different strips at the same
instant at the same sampling rate, so the constraint is sound and the
convergence stops being a convention.
The table already holds rows on deployed instances and two writers have
been filling it since v1.19.0, so the index cannot just be created: a
pre-existing collision would abort the migration. The migration resolves
duplicates first, keeping the row with the most samples (a stored strip
beats a verdict-only row), then the one linked to its event
measurement, then the most recently written, then the lowest id so the
choice is total. The losers' event link is salvaged onto the keeper
first, so resolving a duplicate never costs the recording its place in
the event timeline.
If that resolution is wrong on some instance, the failure is visible at
deploy time and not later: db:migrate:deploy runs on boot, so the
container crash-loops with the migration named in the logs and the
release never serves traffic. A deploy that finishes in under thirty
seconds did not run it.
The GET list and detail routes and the export-archive path are
unchanged; this is a second door, not a replacement.
The changelog is meant to read as my own notes, but a stack of entries
narrated me in the third person ("the maintainer's complaints", "per
maintainer directive", "26 of the 27 maintainer findings landed") and
carried internal review choreography no reader can use: numbered review
rounds, finding counts, item codes, QA severity labels, a local path to
a design handoff.
Every one of those now states the change instead of who asked for it.
Where the only content was the attribution, the line is shorter or gone.
No entry's technical claim changes. "maintainer" stays wherever it means
the operator role of a self-hosted instance.
The flip from an authored recipe back to the defaults was written with the
retired pillar as the thing left out. With that pillar gone, both halves of the
case sent the same full list, so the first read false where it expected true and
the flip was never exercised at all.
It now takes out a pillar that still exists, and says at the assertion why the
two halves have to differ.
The spec watched the day flip from provisional to final on a hero that
only its own subject was keeping on screen: the arrival marker was the
last thing in the calm-degrade guard holding the hero up for the bare
fixture account, so removing the arrival chip took the hero with it and
left the spec nothing to observe.
Putting the arrival back into the guard would bring back a worse
problem. An arrival-only digest lands in the compact all-clear
composition, which would tell someone with no readings at all that
everything is clear - a claim about their health made from nothing, and
made only on the days something happened to sync. The guard stays as it
is; the fixture was what was wrong.
So the spec now seeds its own precondition: one integration needing a
reconnect, which is an ordinary Today for anyone whose readings come
from a device. It is read fresh on every request rather than through the
dashboard snapshot cache, so unlike a seeded score or briefing it cannot
lose a race with a cached snapshot, and the rail item admits no module
gate and no dismiss path. The row is removed again afterwards.
While the spec was open: it now pins the state it starts from. It only
ever asserted that the pending-sleep note was absent at the end, which a
note that never rendered also satisfies. It asserts the note is up and
the day reads provisional first, so the two halves are one assertion
with the number flipped.
The component side is pinned in unit tests, since the spec needs a built
app and a database to run.
The cycle ring stops working out its own verdict and reads the server's. The
constant that made that possible is deleted rather than deprecated, because a
constant left in client code is a constant that gets copied again, which is how
the other client's ring froze in the first place.
The score settings page takes the shape the other settings cards have, loses a
pillar that could never score, and turns green where the app's other two scores
already did. A saved selection now comes back as it was sent.
Five gaps in the published specification are closed, including a route that
shipped this morning and was never described at all.
Two branches met here. One retired the fitness pillar and renamed the test's
pillar list accordingly; the other, written against the eight-pillar world,
referred to it by its old name and its old count. The merge kept the new
declaration and the old references, so nothing compiled.
The name carried the count, which is why the collision was visible at all.
There are seven now.
/api/auth/me/health-score-config shipped in v1.35.0 with a settings page
in front of it and no entry in the contract at all — no path, no schemas,
nothing. A client had the surface and no way to generate against it. This
is publication only; the route does what it already did.
Both verbs are registered off what the route actually returns rather than
off a neighbour's shape. The asymmetry matters and is now written down: the
request speaks the positive selection, the pillars that should count, while
the response carries both sides resolved plus the recipe version. The column
stores the complement, which is storage and no client's business.
The response's updatedAt is documented as optional because it genuinely is
absent until a selection exists — an account that never chose has nothing to
guard a write against, and a decoder that required the field would break on
that read.
The PATCH 422 is written out rather than reusing the shared base-token
response, because this route has four of them and a client branching on
meta.errorCode needs all four: the breadth refusal with the sentence to show
the person, the validation failure, the unparseable base token, and the
non-JSON body. Naming only the token would have been a contract that omits
the refusal people actually hit.
The route test now drives the real route to produce each of those responses
and asserts the published contract names every code that comes back, so a
response the route can emit but the spec does not describe fails the suite.
That closes the base-token ledger: nine routes emit invalid_base_updated_at
and all nine now document it.
GET /api/auth/passkeys was absent from the contract entirely — no path
entry, no schema — while the client teams had been pointed at a published
schema for it since v1.32.8. It is now registered with the shape the route
actually serves: id, name, credentialDeviceType, credentialBackedUp,
createdAt, and the nullable lastUsedAt that started this. The route itself
is unchanged; only the record of it was missing. PasskeyInfo is deliberately
separate from WebauthnMfaCredentialInfo — one lists sign-in credentials, the
other second-factor security keys, and merging them would blur two surfaces
that behave differently. The route test now compares the projection the
route asks Prisma for against the published property set, so neither end can
move without the other.
`invalid_base_updated_at` existed only in route tests. It is now on all
eight published endpoints that accept a `baseUpdatedAt` body token, and the
field description says the thing implementers keep getting wrong: an
unparseable token, `null` included, is rejected with 422 rather than falling
back to the unconditional write. Omitting the key is the only way to get
that write.
/api/auth/me/health-score-config also returns this 422 but is not in the
spec at all; publishing that route is a separate piece of work.
The `type` query parameter on GET /api/insights/derived carried the eleven
VITALS_BASELINE vitals and described itself as being for that metric only.
SAME_TIME_BASELINE takes a `type` too, and its four cumulative day metrics
were absent from the enum, so a client generated against the spec could not
send them at all. The parameter now publishes the union of both registry
sets and the description says which values belong to which metric. A guard
reads the enum back out of the assembled document and pins it to the
registry, so a type added to either engine reaches the contract with it.
The idempotency wrapper's two 409 arms sent `error` as `{ message }`. That
was the only object-shaped error the application emitted; every other route
sends the documented `{ data, error: <string> }` envelope, which is also
what the spec has always promised. The code was wrong, not the spec. Both
arms now build their response through one helper that sends the string, so
every route under withIdempotency serves the envelope its clients decode.
The hero score-ring comment still described MED_COMPLIANCE as pooled 7-day
adherence. It has been today's dose progress since v1.27.8.
iOS green-lit removal on 2026-07-30 (their decoder treats both as
optional, confirmed at ShareLinkDTO.swift:105-106) and confirmed no
shipped build breaks. Drops RETIRED_FHIR_SCOPE_FIELDS and its spread
from the create and list responses, removes the two properties and
their required-list entries from ShareLinkCreated/ShareLinkList in
the OpenAPI source, and regenerates the spec. The request side was
already closed off by .strict() in an earlier pass; this finishes it
on the response side.
Route tests now assert the fields are absent from both responses
instead of asserting the constants they used to carry.
The app carries three scores and meant two things by a green dot.
Readiness and the sleep score both call 70 green; the health score alone
wanted 75. One app cannot do that, so the outlier moved. The yellow floor
stays at 50: raising the bar for a reward and lowering the bar for a
warning are different decisions, and only the first one was on the table.
The band is still not a function of the number. The composite takes the
worse of the mean's band and the worst counted pillar's, so a composite
carrying a red pillar stays red however high the mean sits - the stored
band on a recorded day exists for exactly that reason. Only the mean-side
thresholds moved.
The method version moves with it. A day already recorded keeps the band
it was actually shown under, and the notice key carries the version, so
each account is told once that the rules moved. The week-over-week
comparison is left alone on purpose: the number itself does not change,
only the colour it is drawn in, so a subtraction across the change still
compares two numbers worked out the same way.
Under a number, 'Just in' did not say what had arrived - the score? a
reading? - and the clock face beside it changed nothing a person would
then do. The line is gone, and with it the only wall clock this surface
rendered.
What it was there for survives: the day still flips in place when last
night's sleep lands, and the pending-night note still says the number is
reading an incomplete day. An arrival on its own is no longer a reason
to paint a hero at all, so an account whose only news was the arrival
now gets nothing rather than a bordered empty card.
The digest still carries the arrival, which the morning line and the
wide event both read.
Save moves out of the header and into its own row at the bottom right,
where every other settings card that saves a form puts it. The body
starts on the title's reading edge instead of the card edge, so the text
lines up with the cards above and below it.
The rules between the rows are gone; the gap between groups does the
grouping. Every row said 'Being recorded - Shown in the app' under the
pillar name, which is the modules screen's business and was the same two
words on every line - the sentence at the top of the card already says
it once. The paragraph explaining that heart, glucose and cholesterol
are three pillars in one area is gone too.
The area headings read in the content colour now rather than the muted
one, so they stop looking switched off beside the rows they head.
It could never score. The schema records VO2max but cannot tell a
measured test apart from a device estimate, so every fitness row reached
the scorer unproven and was refused - the pillar has never produced a
value on any account. Listing it on the score settings page as a thing
you can choose, with a line admitting it does nothing, was chrome over a
switch that changed no number.
So it leaves the catalogue: no pillar id, no domain, no scorer, no row,
no wire field. A stored selection that still names it reads back fine -
the resolver has always filtered stored ids against the catalogue and
drops what it no longer knows, so nobody's saved recipe throws or is
discarded over it.
That also fixes the page claiming unsaved changes forever. The save
stripped the fitness pillar out of the selection on its way to the
server, so the answer that came back could never match the form that
sent it, and 'All pillars' left the save button lit no matter how often
you pressed it.
The spec hand-builds a calendar response, and the wheel now reads the
resolved verdict from it. Without the field the ring paints empty, so the
fixture derives the verdict from the same phase plan it derives the day
grid from and the two cannot drift apart.
Three checks. The engine tests pin the overdue boundary from both sides:
day 42 of a stated 28-day cycle still counts, day 43 stops counting and
reports fifteen days later than usual.
The round-trip test calls the real GET handler, reads the response body as
text and parses it, so a field that both ends handle but the wire never
carries fails here. It runs the same instant twice for two accounts in
different timezones and gets two different answers, which is what the
account timezone is for. It also checks the ring's cycle day against the
phase engine the coach read uses, so the two cannot tell someone two
different days.
The guard reads the syntax tree of every file under components, hooks and
app, and fails on a grace-period constant, on an overdue verdict computed
rather than read, and on a cycle-day count compared against a sum. It
follows local bindings, so lifting the threshold into a variable above the
comparison does not hide it. A companion suite tries nine disguises against
the same detector and pins five innocent shapes it must ignore, including
an overdue medication dose.
The cycle ring's day count, its phase arcs, and the judgement that a period
is late all used to be worked out in web client code, including the
fourteen-day grace window that decides when the count stops being an
observed fact. Anything else that wanted the same answer had to reproduce
that rule, and a second copy of it is how a ring ends up showing someone a
cycle day their record does not hold.
GET /api/cycle/calendar now carries a verdict: the state, the cycle day,
the ring's arcs, the cycle start, how many days until the next period, and
when the period is late, how late. It is resolved against the user's own
timezone day, which a browser in another zone cannot do. The web wheel,
the wellness-strip tile, the insights teaser and the temperature chart all
render it, the grace constant is gone from the components directory, and
the spec says the field is required.
The overdue caption now names the number of days instead of only saying
later than usual.
A medication on a rolling interval had its expected dose anchored on the moment
the person actually injected, and was then checked off against the moment it was
planned for, with six hours of tolerance. Those two are allowed to sit days
apart, because a dose taken late still counts, so the dose someone's own
injection created could never be marked done. It stayed open, went overdue, and
the app asked for it again.
No editing was needed to reach it. Taking a weekly dose a few days late was
enough, on any account. Push reminders read the plan rather than the entries and
were never affected, so this was on screen, not on a phone.
The shell renders the heading pair twice under distinct ids, one column
hidden below the medium breakpoint and one hidden above it, so exactly one is
ever on screen. The spec asserted the desktop id alone: green on the wide
project, red on the narrow one against a heading that is hidden by design.
It now follows whichever of the two the viewport reveals, and asserts that
exactly one is visible, so a real duplicate would still fail.
CI died at the 4 GB default heap while checking types. The build was given its
own ceiling earlier this month so a local run, CI and the published image all
invoke it the same way; the type check was left on the default and has now grown
past it. Same treatment, same reason.
Also raises the aggregate bundle cap from 3260 to 3310 KB, and only that one.
Every per-route ceiling is untouched and every measured route sits within a
kilobyte of its previous value, so nothing a person downloads grew. The rise is
the new score-settings route, a mutually exclusive chunk that the whole-build sum
counts even though no single visit pays for it, plus its strings across six
locales. The reasoning is recorded in the budget file beside the number.
The new page wrote its own success toast. The outcome module owns that
affordance so a saved thing looks the same everywhere, and a structural check
catches anyone reaching around it. That check offers an allowlist entry as a
way out; taking it would have left two places deciding what a success looks
like, which is the thing the module exists to prevent.
Two branches met here. One made `configured` a required field so a caller
cannot stay silent and publish "not authored" for an account that chose its
own composition. The other wrote these cases before that existed, so they
compiled against the old shape and stopped compiling against the new one.
Filling the field in to satisfy the compiler would have thrown away the point
of making it required, so each case now states what it actually is: the two
with a hand-picked selection are authored, and the one whose own comment says
the account counts all eight is the default.
The Health Score stops being a by-product of switches meant for something else.
It gets its own page, its own recorded history with the recipe that made each
day, and a comparison that refuses to draw a line across two different
definitions.
An account that upgrades keeps the composition it already had, so the number
does not move on its own. What changes is that the composition is now visible
and yours.
The composite's coverage counts distinct areas of health, not pillars.
The learning line called them pillars, so an account recording blood
pressure, glycaemia and cholesterol read that it had one eligible pillar
while three sat in front of it. The count was right and stays; the noun
was wrong.
The meter beneath the score carried the same confusion further. Its
fraction is the three-area minimum the score requires, and a score only
exists once that floor is met, so it rendered as full coverage for every
account that has one. The arithmetic is untouched. Below the floor the
fraction is a real number and is shown as one; at or above it, the floor
is stated. The dots and the percentage were always honest, they track how
much history is behind the number, and they stay.
Both are held down by an AST guard rather than a text search, with a
counter-test for the neighbouring lines that are legitimately about
pillars and legitimately about a real fraction.
The flag was proven end to end over the real snapshot, digest and derived
wires, which is the right place to show that it arrives and the wrong place
to be the only proof of what it means. That suite needs a container runtime,
so on a machine without one the definition stood unguarded: replacing the
default side of the comparison with the unnarrowed catalogue, the exact
mistake the docblock warns about, left the whole unit suite green.
The definition now has its own fast test, and that break takes two of its
cases red.
A dedicated settings page lists every scorable pillar grouped by the area
of health it speaks to, so the fact that blood pressure, glycaemia and
lipids are three pillars of one area is visible rather than buried. Each
row shows the three things that were never separable before: whether the
pillar is being recorded, whether it is shown, and whether it counts
toward the score. Only the last is writable here, and the page says so.
A pillar whose tracking is switched off is not listed at all, because it
is not waiting for anything. A pillar this build cannot score yet is
listed, disabled, and says why. Wellbeing under safety signposting is
named as safety guidance and never as a mistake in the selection. The
refusal for a selection too narrow to produce a score comes from the
server and is shown in the reader's own language.
The score's foot disclosure gains one link into the page.
Two sentences about the Health Score stopped being true the moment
someone could choose which pillars count toward it.
The one people read said the score averaged "every pillar that has
enough recent data". For an account that took a pillar out, that
describes ground the number does not cover, and it reads as a
reassurance rather than a description. It now says the average is over
the pillars that count, that a pillar without recent data is still left
out rather than zeroed, and that which pillars count is the reader's own
choice, so the combined number is not itself a clinical standard. All
six locales.
The one nobody reads named the WHO HEARTS technical package flat, as
though a standards body had signed off on whatever selection the account
holds. HEARTS names the risk factors the pillars are drawn from; it
prescribes no average over them. The entry now says what it is cited
for. While checking where that citation surfaces it turned out it
surfaces nowhere: ProvenanceExplainer takes the prop and renders only
the method caption, which v1.22 did on purpose when the info popover
went away. That is written down in the file now, because the map's own
header implies otherwise and the next person would trust it.
The registry entry for HEALTH_SCORE lists all eight pillars with a
minimum of three, which reads like the recipe and like a threshold. It
is neither. The published coverage and confidence are built in
computeComposite over the resolved composition, so a person counting
four pillars is not measured against a denominator of eight, and the
real floor is the breadth rule, which counts distinct domains rather
than inputs. Both facts are now stated at the entry instead of being
inferable only by following the call chain.
Tests: the published denominator is never the eight-pillar catalogue; a
pillar somebody took out of their score is never listed back as missing
coverage; a pillar they kept and have no data for still is; the registry
list stays equal to the score catalogue; the choice sentence is present
in all six locales.
Five accounts, each writing its selection through the real PATCH route
and reading the flag back off the dashboard snapshot, the daily digest
and the derived-metric payload. Nothing is hand-built between the ends:
a builder that forgets to carry the field returns undefined and the
assertions fail.
The four negatives are the ways an account reaches the default
composition without having authored it, including one that kept every
pillar (hasSelection is true, the flag is not) and one that took out a
pillar its modules had already withdrawn. A sixth case walks the same
account through a save and an undo, so a value cached under a key that
does not include the recipe cannot pass.
One line under the method version, shown only when the server says the
composition is the person's own. It states the fact and stops there:
which pillars they took out belongs to the settings surface, and
working it out here from a configuration blob would put a second
decider behind one sentence.
The dashboard snapshot copies it off the composite, the daily digest
copies it off the snapshot, and the derived-metric payload already
carries it inside the composite value. Optional on both DTOs like the
score identity fields beside it, so older cached bodies stay valid.
The digest's score object is additionalProperties:false and had never
declared deltaReason, scoreVersion or composition, all of which it has
been sending. A generated strict decoder would have rejected the real
payload, so they are declared here too rather than left standing next
to a field that documents itself correctly.
One boolean on the composite: true when the account's resolved
composition differs from the composition its defaults would resolve to
today, both narrowed by the same modules. Decided once in the score
reader, from the same config and module map the composition came from,
so no client interprets a config blob to answer it.
This is not hasSelection. An account that opened the surface and kept
every pillar has written a selection and is not configured; an account
whose modules alone narrow the set has authored nothing and is not
configured either.
The promise is that nobody's number moves the day they upgrade, and the
way it holds is easy to lose: there is no backfill and no migration
step, because a null column reads as version zero, version zero counts
every pillar, and every pillar narrowed to what the account's modules
record is exactly the set the module switches were producing before.
The inheritance is the resolver's behaviour, so the next person to touch
the resolver needs to know they are holding it.
Also records what the first save sets in motion, once each: the delta
suppressed while the comparison window still straddles the change, the
next stored day marked as a break so the line is drawn in two segments,
and the notice raised under a key carrying the recipe version.
The recipe boundary opened with an early return for a missing date and
another for a date it could not read. Deleting either one changed no
answer any test or any caller could observe: both cases parse to NaN,
and every comparison against NaN is already false, so the refusal was
falling out of the arithmetic underneath them. An if that cannot change
an outcome is a comment wearing a keyword, and it invites the next
reader to trust a lock that is not attached to anything.
The behaviour is unchanged and now has one statement instead of three,
with the reason written where the next person will look for it.
The seam had the same shape in miniature: a day naming no recipe was
already broken away from a day that named one, by plain inequality, so
the arm only ever mattered when two consecutive days both named none.
That case is now covered, and removing the arm makes it fail.
The recipe boundary also required a version of at least one before it
would suppress. That reads like a second lock and is not one: the
resolver returns a version below one only on the same path that returns
no change date, so nothing could ever reach the condition and no test
could make it fail. Removing it leaves the guard asking the one question
that has an honest answer, which is whether the recipe moved inside the
window.
The invariant that makes the removal safe is now pinned instead: a
resolved configuration never carries a version without the date beside
it. A resolver path that produced one would wave that account's recipe
change straight past the guard.
Also pins the order of the two rules that can fire together. A change
that moved the composition trips both, and the person is told they
changed what counts rather than that the included pillars changed, which
would describe the consequence and leave out the act.
The named failure now has a test that drives the whole chain a request
takes: fixture rows through the real reader, the real score record
write, the real dashboard snapshot, the real verdict rung, and the real
digest that gives the morning push its sentence. Only the IO around that
chain is faked. A version built on a hand-written snapshot literal would
have stayed green through a release where the assembly between the two
ends dropped the field, which has happened here before.
Every case runs twice on the same data. Once with the recipe changed
three days ago, once with it changed a month ago. The second run is what
proves the fixture really does produce an eleven-point fall, so the first
run's silence is the guard working rather than an account with nothing
to say. The decline is gradual on purpose: a cliff would raise each
pillar's own noise floor and the delta would be suppressed for a reason
that has nothing to do with the recipe.
Beside it, the boundary's edges, the notice key's two dimensions and the
seam's shape, each with a case proving the guard stays quiet on innocent
data. Against real Postgres, a row now has to come back carrying the
recipe version and change date the account's stored configuration
supplied.
Changing which pillars count is an act the person performed, not
something that happened to their health. Until now the week-over-week
comparison could not tell the difference: both windows are computed in
one request under whatever recipe is in force right now, so the pillar
sets agree, the subtraction looks legitimate, and the dashboard could
announce "your score dropped 14 points" to someone who had just come
back from the settings page.
The recipe now carries its own dated identity, and the delta guard
compares against it the same way it already compares against the global
method boundary: a recipe that changed between the two windows
suppresses the delta with a new reason, config_changed, and the surfaces
that gate on an unexplained delta stay quiet. The score record stores
the recipe version and its change date on every write, resolved from the
same stored blob the composition came from, so a row can say which
recipe produced it. A new series module reads those versions back, marks
the first day after a change as a break, refuses to join the composite
line across it, and keeps the per-pillar rows running through it: a
pillar is graded against its own reference band and stays comparable
when the average of the pillars does not.
The notice key folds the person's recipe version in beside the method
version, so a change in either raises the existing dismissible notice
once and dismissing one never dismisses the other. An account that never
chose keeps its old key untouched.
The module-to-pillar mapping had no test of its own. It lived in
ternaries, so the only way to notice it had changed was for someone's
score to change.
The new file drives the real reader against a fake database and pins
three things. The composition for every module combination with no
stored recipe, compared against the old expression written out verbatim,
which is what makes it a before-and-after rather than a restatement. The
difference between a pillar that is selected and has no data yet, which
keeps its row, and a pillar whose module is off, which has no row at
all. And the breadth refusal at read time, whose reason has to be the
shared rule's verdict on the set the reader resolved, so a second copy
of that rule would show up as a disagreement.
Until now the set of pillars behind the Health Score was assembled from
the module switches alone, in a stack of ternaries inside the reader. A
person who turned the sleep module off lost the sleep pillar from their
number as a side effect of a setting about recording data, and there was
nowhere else to say what should count.
The reader now reads the stored recipe and narrows it to the pillars a
module is actually recording. The mapping from module to pillar moves
out of the ternaries into a named map beside the reader, where it can be
read and tested. Glycaemia stays fed by either glucose or labs, which
was the one entry a one-module-per-pillar map would have lost.
Nobody's number moves on upgrade. An account that never opened the new
surface resolves to every pillar, and every pillar narrowed by module
availability is exactly the set the ternaries built, so the inheritance
holds by construction with no backfill.
A pillar the recipe selects whose module is off produces no row at all,
rather than a "waiting for data" line. Turning a module off is a
deliberate act, and asking someone for data they chose to stop recording
would be nagging them in the voice of their own settings.
The audit script prints the annotation verbatim, so a note wrapped across
three schema comment lines came out with the comment markers folded into
the middle of the sentence.
The delete-all-data guard seeds every table straight from information_schema
so a table added next year is asserted without anyone remembering. A CHECK
constraint defeats that: the generic seeder has no way to guess a value the
constraint accepts, and the whole suite failed on the new table. A Postgres
enum carries the same closed set, the seeder reads it from pg_enum, and
Prisma now types the column instead of trusting a string.
Dropped the day-key format and non-empty-composition checks with it. The
writer builds the key through the timezone helper and refuses an empty
composition before inserting, and the restore validates both against the
wire schema before the transaction opens, so a third copy in the database
bought nothing and cost a hand-written line in a fixture whose point is not
having one. The composite range check stays: free to check, and a number
outside nought to a hundred renders as a score nobody can read.
The scorer and the stored composition both had to answer what a valid
pillar list looks like, and both had their own copy of the answer. One
function next to the catalogue now, used by both.
Four suites hand-roll a Prisma stand-in for the backup builder, so a new
section reaches them as an undefined delegate rather than as an empty
result. Each gains the delegate, and the admin restore round trip gains an
assertion that the day comes back with its composition and per-pillar
scores, not just its headline: a row restored without those looks restored
and answers nothing.
A new user-scoped table is outside all three by default, and the two
structural guards said so the moment the model landed: the wipe plan and
the backup verdict list both named it as unclassified. It now joins the
wipe list, rides the backup payload as its own spread section, and comes
back through the restore route.
The classification guards only prove a helper exists in a declared file, so
they would pass an unreferenced restore helper. The integration round trip
goes through the real admin restore route instead, with the table emptied
first so a restore that carries nothing cannot pass by leaving what was
already there.
PATCH /api/auth/me/health-score-config takes the pillars a person
wants counted and stores the rest as the deselection list. A selection
that cannot produce a score is refused with a sentence naming what is
missing, using the same rule the scorer applies at read time. The
write evicts the score caches, and so does the modules route, which
changes the same composition and never did.
The fingerprint carried the composition twice: once as its own key and once
as the ordered pillar ids inside the per-pillar triples. No input could make
those two disagree, so the term could not be broken and the test that
claimed to cover it passed with the term removed. Dropped the duplicate and
replaced the test with one that pins what the ids are for: a different set
of pillars producing the same numbers in the same order is a different
standing, and now reads as one.
Reads the stored deselection list into the pillar set the scorer and
the settings surface both work from. An absent list means the person
never chose and keeps counting everything; a present list is an
authored recipe and is never widened. Ids outside the catalogue are
dropped on read rather than failing the whole blob, so one unknown
string cannot discard a recipe someone wrote.
Every surface that shows a score now goes through one entry point that
computes it and writes the day down, rather than three surfaces each
remembering to. The write is an insert that yields on conflict, so a second
computation of the same day leaves the first row alone, and a failure is
annotated rather than thrown: a score that renders must not disappear
because a bookkeeping insert did.
The fingerprint covers exactly what the stored composite and band depend on
- the algorithm version, the composition, and each counted pillar's score
with its scoring-mode identity. A pillar that was graded but not counted
stays out of both the fingerprint and the row, because it did not
contribute to the number being stored.
The composite needs three distinct domains and one physiological
pillar. Until now only the scorer could narrow the pillar set, so the
rule lived inside it. A settings write can narrow it too, and a second
copy of the rule would drift, so both layers now call the same
function over the same pillar-to-domain map.
The score is computed live from today's rows, so once the readings behind
a past day change the number the person actually saw that day is gone.
Nothing wrote it down. This adds the table that does: one row per account
per local day, holding the composite, its band, the algorithm version, the
composition that produced it, every counted pillar's own score, and a
fingerprint over exactly what those depend on.
Append-only by construction. A unique index on account plus local day gives
the writer something to conflict against, and there is no updated_at,
because a later computation of the same day must leave the earlier row
exactly as it stood.
A nullable JSONB column on users carrying which pillars the person
chose to count toward the composite score. Stored as a deselection
list so a pillar shipped later counts by default and cannot be
confused with one the person removed. NULL keeps its own meaning:
never chose, which is not the same as counting nothing.
The over-scroll guard measured a settings page while two of its cards were
still showing their loading state, and read a height that no longer existed a
moment later. The page was blamed for a second scroll surface it did not have.
The gate waited on the layout shell, which is present long before the data
under it, and its own documentation says a sentinel has to be something the
measurement depends on. It now also waits for the loading placeholders the app
marks in the markup, and gives up quietly rather than failing if one legitimately
stays, since a timeout would be a worse failure than the one being prevented.
The check compiles the app's stylesheet the way the app does, and reaches
postcss through the Tailwind plugin's own resolution rather than taking a
dependency on it. The dead-code gate reads the module name and asks for a
declaration; the borrow is deliberate, so the name is listed as one the gate
should leave alone.
The panel picked up a green +N chip beside its label when the score moved
back into the band, and the repo-wide affordance guard counts that colour
wherever it appears outside the outcome module. The chip reports a week that
went up rather than an action that succeeded, so it is recorded here at
exactly one use rather than routed through the outcome table.
The sideways check could not fail for the case it was written for. A panel
wider than the phone kept it green: the hero band wears overflow-hidden for
its glow, so an over-wide panel is clipped rather than scrolled and the
document never widens by a pixel. The person loses the right-hand end of
every row and nothing reports it.
It now compares the panel with the room the band actually offers, which is
the comparison the clipping makes meaningful.
The reserve and the row's label column were carried as comments with pixel
figures nobody could reproduce: the tool that produced them was never
committed. This adds one that is, and the figures change.
The reserve did not hold anything. Its declared minimum was 30rem while its
own blocks added up to more than that, so the height was whatever the blocks
happened to be and the constant was decoration. The blocks are back under the
floor and the floor is 28rem, which sits between the shortest and the tallest
panel a real report produces: three scored rows come to 410 pixels at the
352 pixel column, four 438, five 466, six 494, and the reserve is 448. A short
report is no longer pushed down by 74 pixels when the score lands.
The label column goes back to the 7rem the design specifies. It had been
widened to 8rem on the grounds that 7rem clipped the longest label in five of
six languages and 8rem cleared them; measured, the longest label overruns both
widths in five of six, and the only difference is English, by a margin smaller
than the error introduced by measuring in a substitute typeface. The extra
width came off the fill bar for nothing.
The new check renders the real band with the compiled stylesheet and reads the
boxes back in a browser: the reserve against the panel it stands in for, every
row in all six languages on one line with an ellipsis rather than a push, and
no sideways scroll at the 352 pixel column or at 360 pixels stacked. It states
plainly that it cannot know the shipped typeface, since the font is fetched at
build time and no font file is committed, so every assertion in it is one that
holds in any typeface.
Four things a person notices if they break: a pillar never shows up twice
anywhere in the panel, a failed read never lands in the not-scored list, the
panel does not push the page sideways at 360 pixels, and the whole thing is
there with the briefing switched off. Every one of them was broken on purpose
first to check the test actually goes red.
The label column is 8rem, not 7. Rendered in all six languages, 7rem clipped
the longest label in five of them, two of those by two pixels, which reads as
a bug rather than a truncation. The reserve is 480 pixels, measured against
the built panel rather than guessed: three rows come to 440 and six to 524.
The overview fixture now carries a health score, so the phone-width guard
measures a band with the panel in it instead of an empty column.
The stand-in for the discovery engine took an argument it never read, so
the lint run flagged it. Give the mock a function type instead: the
argument stays inspectable, which is the whole point of the probe, and
nothing is declared and then dropped.
The grader returns the score and its basis together and the pillar builder
copies both across, and each of those ends already had its own test. Neither
sees what happens between them: the envelope hand-off, the reader's narrowing,
the pillar list, the JSON that finally goes out. A field can be produced
correctly, consumed correctly, and never reach anyone, which is exactly how a
payload field shipped earlier this month and carried nothing on the wire.
These tests read the assembled response and assert the basis that arrives, so a
future hand-picked key list on that path fails here rather than in someone's
browser.
Three lines now sit above the reference band: which axis set the number
and how far it was from its ceiling, what the scale means at the ceiling
and at 100, and that the pair shown is a recency-weighted average rather
than a reading anyone took. In all six languages.
The guard read a parameter as required whenever it carried no question
mark and no default, so `Locale | undefined`, `Locale | null`, a locale
widened to `any`, and a default sitting on the destructuring pattern all
walked straight past it. Each of those lets a caller say nothing and the
sentence come back in English, which is the bug the guard exists to stop.
A counter-case pins the other direction: a plainly required locale still
reads clean, so the guard stays quiet on the shape the chain is meant to
have. The correlation read now also asserts the language it was asked in;
nothing else in that file would have noticed it going back to English.
The score popover now opens with three sentences before the reference
line: which axis set the number and how far it sat from its boundary,
what the scale means at that boundary, and that the pair behind it is a
recency-weighted average rather than a single reading someone took.
The lines read the basis the grader already publishes, so nothing here
recomputes anything. A pillar that arrives without a basis contributes no
lines at all instead of an empty one.
The score is the worse of the systolic and diastolic sub-scores, and
until now nothing said which one it was or how far over its ceiling the
reading sat. The grader now returns that basis together with the score,
as one value, and it is carried as one value all the way to the pillar
so an explanation can never belong to a different grading run than the
number it explains.
The score returns to the right-hand column of the hero band, where it sat
for eleven weeks: the number at the top, then one thin line per pillar that
actually counts toward it. The card that was pinned below the greeting is
gone, so the score has one place on the page again.
Each contributor line now takes its own colour. A pillar scoring 92 used to
be painted red just because the overall score was red.
Anything not contributing is one quiet line instead of a block: a failed
read is named once for all the pillars it took down and carries the retry,
safety guidance is named in full, and everything simply not recorded yet is
counted, with the names a tap away under the score.
Three things are held down here. The route hands the reader's language to
the strip builder, for a cookie and for a stored preference. The pattern
sentence comes back in German for a German reader, asserted word for
word, through the real chain rather than the engine on its own — the
engine could always write German, it was simply never asked, so an
engine-only test would have stayed green the whole time it was broken.
And a cached strip stays with the language it was written in.
The guard reads the declarations rather than grepping them, so renaming
the argument or moving the fallback into a variable does not slip past
it. Its counterpart runs the same detector against those disguises and
insists each one is caught, because a check that cannot fail reads like
proof and is not.
On a German metric page the strip printed its own summary in German and
the pattern line under it in English. The pattern line is a finished
sentence the server writes, and the route never told the server who was
reading, so it wrote English.
The route now resolves the reader's language the same way the
correlations and analytics routes do and passes it down. The language is
a required argument the whole way through — it used to be optional with
an English fallback, which is why a caller could stay silent and still
get a sentence back. The cycle page's lagged rows had the same gap and
are fixed with it.
The strip's browser cache is keyed by language too, so switching
language no longer re-uses the sentence written for the previous one.
Every fix in this release is the same shape underneath. Two places in the code
were each deciding something they both had to agree on, and nothing made them
agree: two copies of the due-day rule, two clocks on one card, two server caches
behind one page, two ideas of where a nap belongs.
The version anchors, the changelog and the published specification move together
as usual.
The nap fill and a wake bout measured 1.52:1 in the dark theme and 1.16:1
in the light one, so on any night with a wake bout the two bands read as a
single block. That defeats the point of pulling the nap out in the first
place.
No fill colour can fix this. To clear the 3:1 asked between neighbouring
shapes, a nap colour would have to beat all five stage colours and the card
behind them, and in both themes those two demands point in opposite
directions with nothing left in between. The band now carries a card-
coloured outline instead, which is the usual answer for stacked bars and
leaves the stage colours exactly where they were. The tightest pair it
leaves is 4.81:1.
The legend and the tooltip already name the nap, so the distinction never
rests on colour alone.
The reading behind the pin was taken under `next start`. The container runs
`node .next/standalone/server.js`, and a fix that is green everywhere and
inert in production is the failure this project has paid for before, so the
same probe was re-run under the standalone entrypoint with static assets and
public copied the way the Dockerfile copies them.
No difference: three module instantiations either way, one shared registry
with the pin, separate registries without it, and a measurement write made
through a route handler reaches the page render's cache only when the pin is
there. The note says which entrypoint the claim rests on so the next person
does not have to wonder.
Three things are worth not regressing: a big afternoon nap must not land in
the night's stage buckets, a day without a nap must come out exactly as the
old per-wake-day path produced it, and the two surfaces must name the same
session as the night for the same rows. The last one drives the real
/api/sleep/night handler rather than re-deriving its answer.
The chart's row assembly moved into a plain function so the nap band can be
tested at all; Recharts draws nothing under server rendering.
The stage-composition chart totalled each wake day, and a nap ends on the
same wake day as that morning's night. An afternoon nap was added straight
into the night's stage buckets, so the column read longer and shallower
than the night actually was.
The composition now asks the same question the night view already asks:
which session was the night. The night's stages are the night's, and the
nap sits on its own band above them, in its own colour, with its own line
in the tooltip. Days without a nap look exactly as they did.
`dashboardSnapshotCacheKey` documents itself as the single source of truth
for the key, and the invalidators sweep by it, but the read spelled the same
string out by hand. The two matched, so nothing was broken today — edit one
side and the other stops matching silently, and the only symptom is a
dashboard that keeps painting the body from before the write.
The read now calls the function. The guard warms the cell through the real
read and then asserts the entry sits at the composed key exactly, so a
suffix change on either side is caught rather than forgiven by the prefix
sweep; two more cases cover a snapshot invalidation and a recorded dose.
The header on the shared read claimed both entry points ride the same cell.
That claim now holds, but only because the cache registry is pinned to the
global, so the header says so.
The admin panel offered a toggle for the Health-Score explainer and
described it as the ? next to the delta line. That ? had already been
taken off the insights surface a release earlier, and the caption that
replaced it went away entirely when the health score was replaced by
the reference composite. Since then an operator could turn the switch
off, get a saved confirmation, and change absolutely nothing.
The toggle, its wording in all six languages, its place in the flag
matrix the app and the iOS client both read, and its column on
app_settings are all gone. Nothing is left to promise a control that
does not exist.
A guard goes in with it: every remaining assistant sub-flag has to be
read by some surface, and the panel and the plumbing that carry all of
them by construction do not count as proof.
A production build instantiates a module imported from both a Server
Component and a route handler once per bundler layer. The cache registry
sat in plain module state, so the home page's server render held its own
set of Maps and every API route held another. Recording a dose evicted
the route handlers' copy; the home page then rendered from a copy nothing
had touched and painted the dose as still due until the entry aged out on
its own, two or three minutes later. Because that render is dehydrated
into the client query cache, it also overwrote the fresh value the browser
had already fetched.
Measured on `next build && next start`, single process: the page render
and the snapshot route reported different cache instances, and after an
interactive write the route handlers' copy dropped to zero entries while
the page's copy stayed full.
The registry now lives on globalThis under a Symbol.for slot, which is the
only scope both layers share. db.ts guards its own global assignment with
a non-production check because it is only defending against dev reloads;
this one has to hold in production, so it is unconditional.
Moves the host zone under the function to prove the answer does not follow it, since the suite otherwise runs pinned to UTC and would not have noticed. Adds the two days a year that are not twenty-four hours long, where counting hours rounds the evening into tomorrow. The structural check that the shared body never reads the device clock was looking for the wrong shape and let a deliberate break through; it now looks for the word itself.
The baseline step submitted what you filled in and ignored the answer.
The profile write is field-independent: it stores everything that
validated and reports back what it declined. Nobody read that report
here, so a value the server refused vanished between the input and the
next screen and you moved on believing it was saved.
It now says which field did not make it, using the name that sits next
to the input you typed into, and keeps going with the rest that did
save. When nothing could be saved at all, the step stays put and names
the field that blocked it instead of a shrug. The wording and the
field-naming come from the same place the account form in Settings
uses, so the same refusal reads the same wherever you hit it.
The phrase read the device clock while the date printed under it read the profile clock. Set a laptop to another zone and the card could say a checkup was tomorrow above a date that was today. The zone is now something a caller has to state, and both screens state the one their dates are printed in.
Two screens once carried their own copy of the same date arithmetic and quietly disagreed for a year. This fails the moment a second one is written, whatever it is named, and says where the real one lives.
The dashboard starts its snapshot request the moment the router commits to
"/", and parks the response in a slot outside the query cache until the
mounted cell asks for it. The cell only asks on its first fetch, so on every
return to the dashboard — where it already holds data and does not refetch on
mount — the parked response simply sat there unclaimed.
The next thing to call the query function was then whatever came along: the
two-minute poll, a focus refetch, or the invalidation that follows recording
something. All three exist to fetch data newer than what is on screen, and all
three were handed a response that had been requested before the entry was
written. So a value recorded within a minute of arriving on the dashboard
could keep reading as the old one until the following poll replaced it.
Only preload when no snapshot cell has produced data yet. That is the cold
first load the preload was built for, and it is the only moment the mounted
cell will claim the response promptly.
The cases that used to read wrong: an evening look at this morning's checkup, a Sunday early-hours checkup seen late on Friday, a Saturday late-night one seen just after Friday midnight, and one from yesterday that only slipped past a few minutes ago. Each is a case where counting hours and counting dates give different answers.
The dashboard summary card and the checkups page each worked out "today",
"tomorrow", "in N days" and "overdue by N days" from the same due date, and
each did it with its own copy of the arithmetic. The page compares calendar
days. The card subtracted one instant from the other and divided, so a checkup
due Sunday at 01:00 read "tomorrow" when you looked on Friday evening, one due
Saturday at 23:59 read "in 2 days" when it was in fact tomorrow, and one due at
09:00 quietly stopped saying "today" as the afternoon wore on.
Both now read from one place, so they cannot drift apart again.
A cycle symptom can be recorded with a 1-4 intensity. The log-day sheet
writes it, the day-log reads it back, and the backup never carried it:
the file held the symptom keys and nothing else. Export a day recorded
as "cramps, and they were a 4", restore it, and you get "cramps". The
symptom is there, the number is gone, and nothing fails, which is why
nobody noticed.
The file now carries `symptomSeverities` alongside `symptomKeys` — one
entry per symptom that was actually rated. `symptomKeys` still decides
which symptoms a day had, so a release that only knows that field reads
every symptom out of a new file unchanged, and the two lists can never
disagree about presence. A link nobody rated is simply not listed: no
entry means never rated, which is not the same as rated zero.
A file written before this restores exactly as it did, with every
intensity absent rather than invented. An intensity outside 1-4, from a
hand-edited file or some later scale, costs that one number and leaves
the symptom and the rest of the restore alone.
The overnight reminder tests handed the check every intake row the
fixture held, whatever date range it had actually asked for. That left
the suppression assertion unable to fail. Narrow the read back to the
day the check happens to run in and the 23:45 dose logged at 23:50 drops
straight out of it, yet the test stayed green. The fake now honours the
scheduledFor range it is given, so the assertion answers the question a
person really asks: does it stop pestering me about a dose I already
took.
Two cases were missing at the other end of the same boundary. A dose
genuinely missed at 23:45 still has to escalate in the small hours, and
the missed-dose row it leaves behind has to name the evening it belongs
to rather than the morning the check runs in. Both are pinned now, next
to the case where the dose was taken and no missed row may be written at
all.
The last-attempt lookup runs on the zero-rows path, which is exactly the path a
fresh account takes. When it threw, the whole nutrients read returned 500 — so an
account with nothing to show got an error instead of an empty page, and the
feature that exists to explain an empty surface was the thing preventing it from
rendering.
Wrapped so a failed ledger read degrades to the plain empty state and is recorded
for the operator rather than shown to the person. The route's shape assertion now
carries `lastAttempt: null` explicitly, so a field cannot join this response
without someone deciding it should.
Thirty-four changes, most of them one shape: something the server or the screen
already knew and did not say. A closed schema that omitted three fields it sends.
A fence around sensitive data that the contract never described, so the native
client dropped an affordance rather than risk crossing it. An ingest whose only
durable trace fired on success, leaving "did the phone even post" unanswerable. A
profile save that discarded valid fields to punish an invalid neighbour. A card
titled "that day" that received the day and threw it away.
The correlation-discovery engine always sets shrunkR (the
sample-size-shrunk effect) and tier (the phrasing-confidence class)
on every surfaced pair, but the closed DiscoveredCorrelation schema
never declared either — same defect class as the dashboard snapshot:
additionalProperties: false rejecting a field the runtime actually
sends. Both are required, matching src/lib/insights/correlation-discovery.ts.
GET /api/dashboard/snapshot is additionalProperties: false in the spec
but the runtime emits three blocks the schema never declared: the
band/target math (targetBands, always present), the score-card
narrative (healthScore.tension / healthScore.returnToBand, additive
optional fields), and the briefing recall/forward-look
(briefingMemory). A strictly generated client rejects the real
response as a result.
Adds all four to dashboardSnapshotResponse with the exact runtime
type, optionality, and nullability, matching DashboardSnapshot in
src/lib/dashboard/snapshot.ts and the DTOs in
src/lib/dashboard/score-narrative.ts. No route behaviour changes.
Five assertions slept a fixed 100 ms and then read the audit ledger. The write is
fire-and-forget, so on a busy runner the read lands first and the assertion fails
with "expected [] to include 'consumed'" — which reads as a broken refusal when
the refusal worked and only its breadcrumb was still in flight. CI failed on
exactly that while nothing in the branch touches step-up or MFA.
Replaced with a poll that returns the moment the reason appears and, on timeout,
asserts against the real list so a genuine regression still says what WAS
written rather than reporting a bare timeout.
The existing test encoded the behaviour this release deliberately changed: the
breadcrumb used to fire only when a row landed, which made the one durable trace
of an ingest conditional on its success. It failed for the right reason.
Rewritten to assert the new intent with the reasoning kept, and it goes red again
if the condition returns.
GET /api/nutrients now looks up the most recent nutrient.batch.ingest
audit row when the window is genuinely empty, and surfaces it as
lastAttempt only when that call landed nothing (inserted === 0 &&
updated === 0). A real success elsewhere is left alone, so an empty
window for an unrelated reason never gets reframed as a failure.
The lookup runs on the zero-rows path only, never on the happy path,
and stays counts-and-reason-codes only - no user-typed content, no
per-entry health values. The reason code never reaches the page as
text: each of the four closed skip reasons maps to its own human
sentence, and the two that have no useful action for the account
holder (unit_mismatch, upsert_failed) say plainly that the entries
couldn't be stored rather than inventing advice. day_invalid is the
one case worth naming a real cause for - a clock or time zone mismatch
on the sending side - without claiming it was a phone, since any
token holder can call this route.
This depends on AuditLog retention: cleanupOldAuditLogs prunes rows
past 365 days by default, so a dormant account's last failed attempt
can age out and the explanation silently reverts to the plain empty
state. Documented at the lookup for whoever next touches that job.
The Settings > Account save handler read json.error straight off the
wire and rendered it as-is - the exact path a raw validator message
would have reached a person's screen through, and the only place the
report's symptom actually reproduces (baseline-form already goes
through the errorCode-aware localizedApiError helper).
It now resolves meta.errorCode into a localized sentence, the same
convention used everywhere else in the app, and never touches the
top-level string. A partial save (some fields landed, one didn't)
gets its own message naming the rejected field via the same label
already shown next to that input on this form, and reseeds the
form from what the server actually persisted so a field that was
silently skipped can't read as saved.
describeRejectedProfileField lives in account-section-utils so both
the warning and the failure path share one field-name-to-label
mapping instead of duplicating it.
The 409 email-conflict branch had a safe, specific message already,
but no errorCode - the account-section fallback would have had nothing
to translate it through once it stopped echoing json.error verbatim.
Give it profile.update.emailInUse so it renders in the active locale
like every other profile-save outcome, and propagate errorCode through
both route handlers even on the (non-issues) conflict path.
Also updates the /api/user/profile route test for the reported
scenario directly: a valid timeFormat now lands even when gender in
the same PATCH is unsupported, instead of the whole request failing.
apiErrors.profile.update.{nothingSaved,invalidBody} let the web client
resolve applyProfileUpdate's new errorCode into a localized sentence.
settings.profilePartiallySaved / profileNothingSaved carry a {field}
placeholder so the account settings screen can name the specific field
that blocked or was skipped, in the active locale, instead of a raw
validator string. All six locales.
A profile PUT/PATCH validated as one safeParse and wrote as one
transaction, so a single rejected field took every sibling change down
with it - editing height and gender together lost both, and onboarding
lost a whole baseline step to one field. The schema has no cross-field
dependency, so applyProfileUpdate now re-validates each present field
against its own schema and writes whatever validates; only a body where
nothing validates is refused outright, naming the field that blocked it.
The top-level error string also stopped being a raw Zod message
(issues[0].message) that could read "Invalid option: expected one of
"MALE"|"FEMALE"|"OTHER"" on a person's screen. It's now a person-safe
sentence plus a stable errorCode for the web client to localize; the
full per-field detail is relocated (not deleted) into details.issues,
matching the multi-issue envelope the iOS contract already expects from
other write routes.
SubPageShell insets its own heading to the card body edge rather than the
container edge, so a section heading or a meta line rendered flush left sat
16 px to the left of everything above it. The day-context heading, the sport
chips and the count line now take the same responsive inset.
The list hook already returned the canonical total and the page threw it
away, so the surface never said how much history it was showing. Render it
as a meta line above the rows.
The route and the hook already accepted a sport, with nothing to reach it.
Add the chip row. Its vocabulary comes from the unfiltered read, which is
the same query the list itself runs when no sport is chosen, so the row
costs no extra request and choosing a sport can never empty the row it was
chosen from. A filtered view falls outside the server prefetch and pays one
client fetch; a filter that matches nothing says so instead of borrowing the
first-run empty state.
The bpm range under each effort zone sat at an arbitrary text-[10px]. It is
a micro-label on a card, not a chart axis, so it takes the text-2xs token
the standards mint for exactly this.
"How does this compare to last time?" is the most likely next question
after reading a workout, and answering it meant going back to the list and
scanning for the one before.
Resolve the previous session server-side, through the same canonical picker
the list uses, so the link can never land on a duplicate row recorded by a
second watch, and render it beside the own-history average line. Null when
nothing older exists, and then no link is shown.
The stats card's hand-rolled heading moves to the icon-less TileHeader
while the file is open.
The "That day" card took the workout, dropped it, and offered three chips
to Pulse, Sleep and Mood. None of those pages accepts a date, so every chip
landed on an all-time trend surface with the day nowhere in sight.
Replace it with the day itself: the intraday pulse chart pinned to the
session's day, the night the person woke from into it, and that day's mood
entries. All three reads are addressed by the server-resolved dayKey, so
nothing here decides which day a workout belongs to.
The two outbound links that remain are undated and say what they lead to,
Sleep trends and Mood history. Sleep and the intraday chart mount only when
their module is on, so a switched-off module cannot paint as a failed read,
and a query that has not answered yet shows a skeleton rather than claiming
the night was empty.
The detail page also drops the description it borrowed from the list.
The detail payload carried the session's start timestamp but nothing that
said which local day it belongs to. Anything that wants the day around a
workout had to slice the ISO string, which is the browser's day, not the
person's: a 20:00 UTC session reads as the 15th in London and the 16th in
Auckland.
Resolve the day server-side from the user's timezone and ship it as
dayKey. Additive, so a native client that does not read it is untouched.
The score had two mounts: a compact face in the hero band and a
nine-block stack further down the overview. The stack also rode inside
the daily-briefing branch, so an instance with the briefing switched off
lost the whole breakdown.
Fold both into one progressive-disclosure card pinned between the
greeting and the customizable region. Collapsed it reads as the score
face: the ring, the band sentence, the weekly delta or the honest reason
there is none, Rest Mode, the tension and return-to-baseline lines, and
the coverage meter. Opening it adds a three-across pillar grid, the
named list of pillars that are not scored yet, the personal weight goal,
and the method footer with its cited standard.
The card is deliberately not a section in the insights layout registry:
an id a saved layout has never seen merges in as default-invisible,
which would have deleted the score for every account that has ever
customised the page.
A pillar the server gated stays visible by name with its reason instead
of being drawn as a zero, safety signposting leads that list, and a
pillar whose read failed gets an error row with a retry rather than
silence. The hero band goes back to the full-width greeting, the shape
it already had for an account without a score.
nutrient.batch.ingest only reached the durable AuditLog table when
inserted + updated > 0, so a batch where every entry was rejected
(unit mismatch, out-of-range value, invalid day, a write failure) left
the exact same empty trail as a phone that never called the endpoint
at all. That made an empty nutrient_intake_days table unanswerable
from the durable record - the question this surface exists to answer.
Record the call unconditionally, the same shape measurement.batch.ingest
and workout.batch.ingest already use (counts only, no per-entry health
values, no user-typed content), plus an aggregate skip-reason breakdown
so an operator can tell, from the row alone, that the phone posted and
why nothing landed.
HTTP status and the per-entry response shape are unchanged - a batch
endpoint returning 200 with per-entry outcomes on a partial failure is
the correct, documented contract and native clients already decode it.
The page's overview query shares queryKeys.nutrientIntake(30) with
MicronutrientsCard, which already renders its own QueryErrorCard on a
failed read. But the page's empty-state check ran first and treated
undefined data (the failed-read state) the same as a genuinely empty
window, returning "no nutrient data yet" and short-circuiting before
any of the three cards - including the already-correct one - ever
mounted. A self-hoster with HealthKit permissions granted and a read
that actually failed saw the same blank invitation as someone who
never logged anything.
Add the isError branch above the empty-state check so a failed read
surfaces its own QueryErrorCard with a retry, and cover it with a test
that renders the actual page (not just the card in isolation) so a
regression here can't hide behind a passing unit test again.
GET /api/medications/intake/dose-history-import/{jobId}/status reads
through the same readMedicationIntakeImportJob projection as the
per-medication route, so both 200 responses now point at one shared
MedicationIntakeImportJobStatusEnvelope object instead of two
independently generated copies.
Audited every other route description for a schema named by id that
never resolves to a components.schemas entry. Found one more:
CoachPrefs is named in the coach-prefs PUT description, but every
actual consumer is a .extend() of it, so the base never carried
forward to a $ref. Force-registered it the same way as the
dose-history fatal-reason enum. The remaining unresolved meta ids are
request/query schemas zod-openapi inlines rather than hoists — none of
them are named by id in another schema's prose, so nothing points a
reader at a component that silently isn't there.
Documents POST /api/medications/intake/dose-history-import and
GET /api/medications/{id}/intake/import/{jobId}/status, including the
full 16-value MedicationImportSkipReason set the v1.33.0 shape widened
to, read straight from MEDICATION_IMPORT_SKIP_REASONS so the two never
drift again.
Also force-registers MedicationDoseHistoryImportFatalReason into
components.schemas: the 422 description names it, but nothing else
$refs it, and zod-openapi drops unreferenced schemas silently.
Documents POST /api/oura/sync, /api/polar/sync, /api/strava/sync,
/api/nightscout/sync (all four share the resolveSyncOutcome shape) and
GET /api/google-health/sync/status, matching the routes' own
sanitising/clamping logic field for field.
Documents the saved doctor-report scope endpoint, reusing the real
savedReportProfileSchema so the spec's leaf cap and version literal
stay tied to the source of truth.
The field has been served on both routes since v1.28.13 but was
missing from ShareLinkCreated and ShareLinkList, which are
additionalProperties: false.
GET /api/meta/capabilities dropped group membership at the wire: it
shipped a flat group-id list and a flat leaf list with no boundary
between them, so a native client could not tell which leaves live
under which group, or which group is the fenced sensitive tier. That
made it unsafe for the client to offer a "select all" affordance for
any group, since it could not mirror the server's fence against
bundling more than one sensitive leaf behind a single control.
Add SHARE_GROUPS to the report-selection catalogue, derived from the
existing REPORT_GROUPS/MEASUREMENT_LEAF_GROUP/STRUCTURED_LEAF_GROUP
maps: each group now carries its member leaf ids, and the group whose
id equals SENSITIVE_GROUP_ID carries sensitive: true. share.groups in
the capabilities response now ships this shape instead of bare ids;
share.leaves is unchanged. Update the OpenAPI schema and regenerate
docs/api/openapi.yaml.
Add an invariant test asserting every leaf in the flat leaves set
appears in exactly one group and no group carries a leaf outside that
set, plus a check that exactly one group is flagged sensitive.
`next build` needs more than Node's 4 GB default: the TypeScript pass after
compilation runs the whole project through the checker and dies with
"Ineffective mark-compacts near heap limit" at around 4.07 GB.
CI and the production image already knew that. `e2e.yml` and the Dockerfile both
invoke the build with `--max-old-space-size=8192`. The bare `pnpm build` did not,
so the one place that ran with the default was a local gate — which then failed
for a condition that exists in neither environment that matters, and would have
kept failing every time somebody ran the documented gate.
Three places declared how the build is invoked and one of them disagreed. The
script now carries it, so a local run, CI and the image all get the same ceiling.
The existing prefixes in the workflow and the Dockerfile stay: they are harmless
now, and removing them would put the knowledge back in more than one place.
GET /api/insights/rhythm-events was never in the spec, so a client had
nothing to generate a type from but the three-value ECG classification
next door — reusing it would drop VERY_LOW / FIRED. Register the route
with its own RhythmEventClassification carrying all six
RhythmClassification values; the ECG enum stays untouched at three.
On a phone the composer stacked its controls under the textarea, so an EMPTY
input was two rows tall: a text line, a 44 px tap-target row, and padding. That
is roughly a tenth of a phone screen spent before anyone has typed anything, and
it is the first thing on the screen when the Coach opens.
The auto-grow was never the problem. The textarea already starts at one line and
grows to six; it was the layout around it that reserved the second row up front.
The stacking existed to protect keyboard focus order. That argument does not
hold: focus follows DOM order, and the DOM order is textarea, actions, Send in
both layouts. Nothing jumps. `items-end` keeps the controls beside the last line
as the textarea grows, so the send button stays where the thumb left it.
One component serves both surfaces — the conversation view and the hero, which
re-parents the same live composer — so this is one fix rather than two.
The test that asserted the stacked layout now asserts the row, with the reason
written down, and it fails when the stack comes back.
The header fix needs two declarations to hold together: the second grid row is
conditional on a description, and CardAction spans the full row set. The second
is the trap. `row-span-2` against a single explicit row mints an implicit second
row and brings the phantom gap straight back for every header that carries an
action but no description.
Reverting it broke nothing. Nothing in the suite asserts a `row-span` class, so
the regression is DOM geometry the unit tests cannot see, and it would have come
back silently at the next tidy-up. This is a literal-class assertion in the shape
`assessment-spine-order.test.ts` already uses, because the question has an exact
answer.
Also finishes the compact-card tier while it is open: three cards on the metric
surfaces still carried `gap-1.5` where the assessment cards carry `gap-2`. One
tier, one value, no `gap-1.5 py-3` left in the tree.
grid-rows-[auto_auto] was unconditional, so a header with a title and
no CardDescription still reserved an empty second row track and drew
the gap-2 for it — 32px instead of the intended 24px between heading
and body on desktop. Make the second row conditional on a description
being present, and widen CardAction to row-span-full so it still
tracks the header's row count when an action lands in a
description-less card.
Also correct two assessment cards that had drifted onto gap-1.5
instead of the sanctioned gap-2 compact tier.
The navigation-geometry spec failed on a merge commit whose tree was
byte-identical to the branch head that had passed minutes earlier, so nothing had
regressed. The spec resolved the element it measures with
`main.firstElementChild`, independently in each of its two `page.evaluate` calls.
Any node mounting ahead of the wrapper between the two reads made it measure two
different elements, which is why the failure was 64 against 131.92 rather than a
two-pixel drift. A timing problem gives a small difference or a null; this was a
substitution.
The shell's content wrapper now carries a stable `data-slot`, both reads select
it by that, and the wait gates on the element rather than on scroll state alone.
The same positional pattern in the overscroll spec is fixed with it, so the class
is gone from the measurement paths rather than from one spec.
Re-running the job made it green, which is a workaround. This is the fix.
The `app` service declares 62 environment variables. The commented-out
`app-worker` example declared 14, missing every SMTP, VAPID and APNs value plus
the AI-host allowlist and the offline geo directory. The worker is the process
that sends notifications and runs the AI jobs, so anyone following the documented
split got a worker that could not email, push or send APNs, and nothing failed
loudly. It just did nothing.
Hoists the shared block to a YAML anchor both services merge, so copy-paste
cannot drift again. The resolved configuration is unchanged: 62 variables on
`app` and 3 on `db` before and after.
Also:
- The commented GeoLite2 mount pointed at a directory the default GEOLITE2_DIR
does not read. Uncommenting the example as written did nothing. The two now
agree.
- Bounded log rotation on both services. The default json-file driver grows
without limit and fills the disk on exactly the small hosts this file targets.
- `cap_drop: [ALL]` on the app, and a written reason why the database does not
get it: the Postgres entrypoint starts as root and needs CHOWN/SETUID/SETGID
to hand off to its own user. Dropping capabilities there breaks startup, which
was confirmed rather than assumed.
- A memory limit on the app, and a written reason why the database has none. A
bounded app container restarts clean when the kernel takes it; a database
killed mid-write does not.
`read_only: true` stays off for now: the app writes a runtime cache and that
needs testing before it can be turned on.
The digest-pinning section explained how to set HEALTHLOG_IMAGE_REF and never
said what happens if it is left behind. It stays until someone changes it, and
every deploy in between reports success while the running instance holds the
previous release.
Adds the warning, the two post-deploy checks that would have caught it (the
version endpoint AND the running container's image, because the first alone can
be served from a cache), the note that a suspiciously fast deploy is a signal,
and the duplicate-key trap: the platform accepts two rows with the same key
without warning and does not define which wins.
The card carried a four-sentence non-medical notice at the top of the cycle
on-ramp. It goes, at the maintainer's decision.
The goal-specific caveat on the prediction surface is untouched: a profile set to
avoid pregnancy still gets the stronger "not a contraceptive method" copy, which
is chosen server-side by goal and shared with the native client, so nothing about
that path changes here.
Removes the key from all six locales and the now-unused icon import.
2026-07-30 14:30:18 +02:00
1247 changed files with 125936 additions and 12436 deletions
@@ -64,7 +64,7 @@ For more details, see the [security documentation](https://docs.healthlog.dev/se
HealthLog Docker images are built and published from this repository's CI:
- **Source**: [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) — runs on every push to `main` and every `v*` tag
- **Source**: [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) — publishes on every `v*` tag; pull requests build the image without publishing it
- **Architectures**: `linux/amd64` and `linux/arm64`
- **Provenance attestation**: each image carries a [SLSA build provenance](https://slsa.dev/spec/v1.0/provenance) statement linking it back to the GitHub Actions run, the commit SHA, and the workflow definition
"$comment":"Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.34.1: / 431, /insights 420, /measurements 417, /insights/mood 438, total 3238, baseline 130, lazy catalogs 792. The 108 KB aggregate rise from v1.34.0's 3130 is not one user load: the explicit assessment error/timeout/poll-exhaustion contract adds 2.7-4.4 KB to each of roughly forty mutually exclusive metric route chunks, which the whole-build sum counts independently. Catalogs account for only about 2 KB of the delta, no catalog is statically route-referenced, and Recharts remains exactly one fingerprinted chunk. A measured single next/dynamic assessment boundary was reverted because it raised the artifact further to 3243 KB while saving only about 1 KB per route. The aggregate cap therefore moves narrowly from 3140 to 3260 (22 KB headroom); per-route ceilings, the static-catalog guard, and the one-Recharts-chunk guard remain unchanged.",
"$comment":"Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.35.0: / 431, /insights 421, /measurements 417, /insights/mood 439, total 3286, baseline 130, lazy catalogs 804. Every per-route ceiling is unchanged and every measured route is within a kilobyte of its v1.34.1 value, so nothing a person actually downloads grew. The 48 KB aggregate rise from 3238 is the score-configuration settings route, which is a new mutually exclusive chunk the whole-build sum counts independently, plus about 12 KB of its strings across the six locale catalogs. No catalog is statically route-referenced and Recharts remains exactly one fingerprinted chunk. The aggregate cap therefore moves from 3260 to 3310 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged.",
| Read | Read what the grant opens, and change nothing. |
| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, and marking a dose taken or skipped. Editing, deleting and restoring stay with you. |
| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. |
Offering **manage** asks the owner for a fresh second factor if they have
one enrolled, which makes it a browser-only act either way: a Bearer
caller is refused before anything else happens and told to use a browser.
Accepting it has no such restriction. Manage never carries a section
scope, because "they can do anything, but only to part of you" promises a
boundary an edit cannot keep.
Everything a manager does is written to the trail under their own name,
with a verb naming the act rather than a generic "made a change".
**Nothing about the account around the record.** This is the line the
whole feature is built on, and it is drawn on the server:
| Password, passkeys, second factor, recovery codes | Silent takeover, surviving any revocation |
| Sessions, trusted devices, sign-out-everywhere | Ending or enumerating the owner's logins |
| API tokens, the MCP connector | A durable credential that outlives the access |
| Connected services (Withings, Fitbit, WHOOP, Google Health, Polar, Oura, Strava, Nightscout) | Re-pointing a sync, or feeding somebody else's wearable into this record |
| Notification channels and push devices | The owner's health alerts redirected to another phone or chat |
| Exports, the health-record archive, clinician share links | A one-click copy of everything, or a new door that outlives the access |
| Asking an AI provider anything | AI processing of the owner's data, under the owner's own consent |
| Modules, thresholds, units, the record's language and timezone | Configuration of the account, not content of the record |
| Sharing itself | Inviting somebody else, widening the access, or passing it on |
| Locale, theme, dashboard layout | Presentation belongs to the person, not to the record |
This table is about a grant between two accounts. A managed profile is
the one place where some of it is deliberately different, because a record
with no login has nobody else to keep its own configuration: see "Records
with no login" below for exactly which part of it a guardian may reach.
The last two rows are worth a sentence each. Sharing is not
transferable: somebody given access cannot invite a third person, cannot
raise their own level, and cannot hand it on. And display preferences
stay with the person at the keyboard, so reading somebody else's record
does not change your own theme, language or dashboard.
The AI row is the one that looks arbitrary and is not. Server-managed AI
features send the record's data to a provider under a consent the record's
OWNER gave, for their own use. Somebody else triggering that would create
a consent-shaped act the owner never made. So a manage grant can READ
insights that were already generated, and nothing a delegate does at any
level causes an outbound provider call or queues a generation job. The
owner's own path is unchanged and is the control that proves the
difference.
These are not checks that a page forgot to render. Every route in the
product refuses by default while a browser is acting on another account;
a route becomes reachable only by declaring it, and the set of declared
routes is frozen by a test.
## Revocation, and what it cannot undo
Ending access takes effect on the other person's **next request**, not at
their next login. The grant is checked against the database on every
single request, so there is no cached verdict to wait out. If their
browser is sitting inside the record when the owner ends it, the same
database transaction puts that browser back into its own account — they
land at home rather than on a wall of refusals.
What revocation cannot do is take back what was already seen. A person
who had read access could have read, remembered, screenshotted or written
down anything in the record. The product can close the door; it cannot
reach through it. Nothing in the interface implies otherwise, and neither
does this page.
Ending access never deletes the record of it. The row stays with the
dates and with who ended it, because "who had access, from when to when,
and who ended it" is a question a deleted row cannot answer. Re-inviting
the same person creates a new row beside the old one.
## Records with no login
A **managed profile** is a health record for somebody who does not sign
in: a child, or an adult who is cared for. It is a real account row with
no password, no passkey and no e-mail address, and it is never presented
as something to log in as.
- It is created from **Settings → Shared access**, under a fresh second
factor. An account with no second factor enrolled is refused rather than
waved through, because the act creates a permanent management
relationship over a person who cannot object to it.
- Creation takes a display name, optionally a real date of birth, and the
language and timezone the record's own days and reminders are measured
in. A year on its own is never turned into the first of January. The
language belongs to the record, not to the guardian reading it, so the
guardian's own screens stay in their own language.
- The creator becomes the first **guardian** in the same transaction, over
an ordinary accepted MANAGE grant with the profile as grantor. If either
half fails, neither happens: there is no window in which the profile
exists with nobody looking after it.
- A second guardian is invited and accepts exactly like any other
invitation. An accepted guardian grant does not expire; the invitation
token still can.
- **A managed profile can never be left guardian-less.** Revoking,
handing back, deleting a guardian's own account and deleting the
profile all take the same lock on the profile and re-check the count
inside it, so two people cannot both leave at once. The last guardian is
refused, with a message naming the two ways out: add another guardian,
or delete the profile under a fresh second factor. Deleting the profile
deletes everything recorded for it.
- A guardian may keep that record's settings: modules, units, thresholds,
its language and timezone, and its notification preferences. Connected
services are visible as a status only. Connect, callback, sync,
credential, disconnect, resume and test are not guardian surfaces in
this release, and their deep links land on the shared-record unavailable
page rather than on a raw refusal.
- **Its reminders go to its guardians.** A record with no login has no
channels of its own, so medication reminders, measurement reminders,
safety-floor alerts and low-stock alerts fan out to every active
guardian, in that guardian's own language and over the channels that
guardian already configured. Nothing else fans out; every other event
stays on the path it was already on. The payloads carry no take or skip
action, on any channel, so a notification about somebody else's record
cannot be acted on from a lock screen. One guardian suppressing
server-side reminders on their own device does not suppress anybody
else's.
- Delivery attempts record both the record the message is about and the
person it was sent to, so the admin notification diagnostic can answer
"who was this for" and "whose record was it about" separately.
There is no emancipation flow: no route, no control and no copy suggests
a managed profile can be turned into an ordinary account. The data model
leaves room for it (the credentials are nullable and the managed marker
can be cleared) and clearing that marker stops the notification fan-out,
but nothing in v1.37.0 exercises any of that.
## Seeing what happened
**Settings → Shared access** answers three questions for the owner, at
any time: who can open this record, since when, and when they last did.
Below that, a list of the days somebody else opened it and how many times
— one line per person per day, rather than a line per request.
That list is bounded by the instance's audit retention, and the page says
so with the actual number rather than a hardcoded one. The default is 365
days (`AUDIT_LOG_RETENTION_DAYS`); an operator who shortens it shortens
this view too, and the sentence under the heading changes to match. The
query is bounded to that window, so the sentence is the real limit of the
list rather than a caption over an unbounded one.
Two things the list does not claim, both worth reading before treating it
as an answer:
- **An empty list is not proof that nobody opened the record.** It means
nobody did so within the window, and that any earlier row has been
purged.
- **The list is capped.** It returns at most the hundred most recent rows
inside the window; when it hits that ceiling it says so under the
heading. A busy household can reach the cap in far less time than the
retention window, and the oldest line on screen is then the oldest line
shown, not the oldest that exists.
## What this does not change about the instance
Stated plainly, because a sharing feature is exactly the moment somebody
re-reads the trust model:
- **The operator could always read the database.** Anyone with server or
database access, and anyone with an admin account on this instance, can
reach health data directly. That was true before sharing existed and is
unchanged by it. On a household instance the operator is usually one of
the people using it; on an instance hosting other people, it is worth
saying out loud.
- **Encryption at rest is instance-keyed, not per-user.** The encrypted
columns are protected by the key in `ENCRYPTION_KEYS`, which belongs to
the instance. Sharing does not weaken that, and it never did protect one
account's data from the operator of the same instance.
- **AI processing stays under the record owner's consent**, because the AI
surfaces are not part of what sharing covers.
## For operators
Nothing to configure. Sharing is available on every instance, needs no
environment variable, and does nothing until one account invites another.
The same is true of managed profiles.
Four operational notes:
- **Invitations disclose whether a username exists.** An invitation to a
name nobody uses is refused with "no account with that name". That is
deliberate: the alternative is a silent pending row that will never be
accepted because the owner mistyped. The caller is already
authenticated and the endpoint allows ten invitations an hour per
account, which bounds how fast the surface could be walked.
- **Deleting an account** removes the grants it is party to. The audit
rows naming that account as the one who opened somebody's record stay,
and render as "a deleted account" — nulling them would turn "your
daughter opened your record" into "you opened your record", which is
worse than an unresolvable id. Deleting the last guardian's account is
refused while the managed profile would survive it, so an account
deletion cannot strand a record.
- **A managed profile is a user row and appears as one.** It is listed in
the admin user list like any other account, with no e-mail address
beside it and an internal username nobody is ever asked to type. No
login path will accept it: there is no password hash to verify against
and no address to look it up by.
- **The second-factor requirement is not the same on both features, and
the difference is deliberate.** Creating a managed profile, deleting
one and inviting a second guardian require a second factor
unconditionally: an account with none enrolled is refused and told to
set one up, because those acts create or end a permanent relationship
over somebody who cannot object. Offering manage access on an ordinary
invitation asks for a fresh factor only from an account that has one, so
an instance where nobody has enrolled a factor can still use every level
of ordinary sharing. All of them are browser-only either way.
"purpose":"Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY."
}
]
},
{
"name":"Operational windows and cookie flags",
"description":"Values the product states back to a user or that decide whether a session cookie is accepted. Optional: each has a documented default. They are listed so `pnpm check-env` reports them, and so the compose whitelist and the example files can be held in step with this manifest.",
"required":false,
"variables":[
{
"name":"AUDIT_LOG_RETENTION_DAYS",
"purpose":"How long audit rows survive, in days (365 by default). Published, not merely enforced: the shared-access activity feed and the dialog that ends somebody's access both state the number back to the user.",
"example":"365"
},
{
"name":"SESSION_COOKIE_SECURE",
"purpose":"Forces the Secure flag on every cookie the app issues. Unset, it tracks NODE_ENV. Set false for a LAN / VPN-only self-host served over plain HTTP, where the default makes login look broken.",
"That list is how it drifted to nine tables: it was correct the day "+
"it was written and nothing noticed when the schema moved past it. "+
"Delete through ADMIN_WIPE_MODELS.",
).toEqual([]);
});
});
Loaded 100 of 1247 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.