The digest line is the body of the morning push and lands on a locked
phone. It took the briefing's first sentence, the top signal's headline
or the deterministic signal sentence verbatim, so a resting heart rate or
a blood pressure average could sit on the lock screen, and its score
floor stated the score.
A candidate with a figure in it is now passed over for the next, and the
floor says the score is ready instead of what it is. The hero's lead in
the app keeps its numbers. Tests that pinned the numeric line are turned
around, and the push builder is checked on a digest whose lead carries a
reading.
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC
midnight are the idioms behind every day-boundary fix this year: the
evening west of UTC and the small hours east of it land on the
neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in
src/ outside src/lib/tz and tests, and refuses a disable directive for
itself that is not next-line or gives no reason.
The time zone module now names every legitimate form: userDayKey for a
person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for
date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey /
isCalendarDateKey / statedDateKey for key arithmetic and parsing.
Of the 133 sites the rule found, 84 are gone. Most moved onto a named
helper with no change in behaviour; these changed behaviour:
- cycle calendar, insights, Coach snapshot and prediction refresh start
their lookbacks at the first instant of the user's day
- a model-transcribed date like "June 10, 2026" no longer lands a day
early in a process east of UTC (documents and lab scans)
- a custom report range covers exactly the picked days in the profile
zone instead of starting the evening before west of UTC
- date-of-birth pickers cap at the user's today, not the UTC one
- an allergy onset date is stored at noon UTC like other date-only values
- the report's cycle summary, the Coach's usual BP range and the intake
projector's rollup recompute use the user's day
- vaccination dates render as dates instead of the previous evening
49 remain, each with its reason on the line above: 39 UTC by design (15
file name stamps, rollup and object keys, the UTC-day score engines) and
10 marked baseline. A guard test requires a classified reason on every
directive and holds the baseline to a ceiling that only goes down.
The fasting-glucose pillar served its observed and reference labels as
"Fasting median 99 mg/dL" and "70-99 mg/dL fasting" whatever the
account's unit, and clients print those labels as they come. They are
now written in the record's glucose unit. The scored value, its unit
and the band edges stay canonical, so the score does not move and the
web, which converts from the value, cannot convert twice. The schema
now says which fields are canonical and which are display text.
Refs #1067
The activity pillar averaged the 28 days ending today, so any score computed
during the day counted a step total that was still growing and pulled the
average down. The window is now the 28 completed local days ending
yesterday, cut on day keys.
The start page's short list left out things a reminder had just told
the person about. A check-up counted only once its due hour had
passed, a booked visit was read one at a time and dropped out at its
start time, which is when its reminder fires, and both sat below doses,
sync problems and the Coach check-in under a three-item cap, so a busy
morning pushed them off.
The check-up item now covers every check-up due today or overdue and
names up to three of them. Visits are read for the whole local day,
also once started, and for the next 48 hours: everything booked today
shares one item, the next day with a visit gets a second, and the
today / tomorrow / day-after wording follows the calendar rather than
an hour count. A due check-up and today's visits always keep a place
under the cap; the displayed order is unchanged. A Coach cadence label
is translated here as it is on the checkups page.
The digest's wire shape is unchanged: visits reach it only as
`upcoming_visit` items, which may now appear twice.
Eight files mocked `@/lib/feature-flags` with `getAssistantFlags` alone.
The capability loader reads `loadAssistantSwitches`, so wherever it ran
it threw and every capability resolved to `check_failed`. A shared
factory now points both readers at the function the test drives.
Only the config-change chain reached the loader. Its fixture means AI
switched off by the operator; it now supplies the loader's other inputs
and asserts `operator_disabled` on the digest, so a starved loader turns
it red instead of passing on the failure path.
The operator switches become master, Coach, Daily Briefing, Status
notes and Reading documents. Correlations goes: it gated statistics,
not anything a model writes. Reading documents covers the document
vault, lab scans and medication extraction.
The Coach module's operator availability now follows the Coach
switch, so there is one place to turn the Coach off server-wide. The
migration folds a stored Coach availability of false into the switch,
and gives everyone who hid the Coach the AI analysis opt-out, since
hiding the Coach no longer stops nightly briefing and status notes.
A switch read that fails now fails closed. The insights module is
described as AI analysis in every locale, and the switch guard now
keeps both retired switches out and freezes the callers of the old
operator-only gate.
The comprehensive-briefing cache (User.insightsCachedText) is one slot
per account with no record of the language it was written in. Every
reader served whatever was there, so a German-language account whose
nightly warm had written English kept seeing English on the dashboard
lead and the Today briefing. The request path only replaced it once the
row was a day old and the provider answered, which with a provider at
its usage limit was never.
Add User.insightsCachedLocale (migration 0335). Both writers store the
locale they generated in; the timestamp-only refresh on an unchanged
snapshot tags the row too, since the fingerprint covers the generation
locale. Every reset that nulls the text nulls the tag.
The snapshot briefing lift and GET /api/insights/generate treat a tag
that differs from the reader's locale as an empty cache: no prose, no
timestamp, the preparing / no-provider state, and the out-of-band warm
enqueued in the reader's locale through the existing path. The
generator's own short-circuit and the POST short-circuit refuse to call
a foreign-language row fresh. A NULL tag (rows written before this
change) is served as before until the next write tags it. The Today
digest derives from the snapshot and follows.
Covered by unit tests on the lift, the route and the generator, and an
integration test that writes the row through Prisma and reads the
snapshot for a reader in another language.
The constant was left over from an earlier shape of the guard, which
located the fixture by path before it imported it directly. Nothing reads
it now, and the dead-code gate says so.
The score once graded weight against 22 x height squared, widened by 2 kg
either side: a band nobody set, no surface named, and the only producer
of that pillar's verdict. v1.34 removed it and the composite rebuild left
a pillar set where every scored band cites somebody. Nothing but review
has kept it that way since, and review is what let the original in.
So the rule is written down. PillarReference.kind becomes a runtime list
carrying the reason there is no fourth kind, reference.source is
documented as a citation a reader could go and look up, and a guard
beside the pillars checks all seven modules three ways.
The arm worth arguing about is the second. An invented target is a
continuous function of a profile field; a guideline band is a step
function into a published table. So the guard sweeps every pillar across
a matrix of heights and ages and requires the scored bands it emits to
land inside a small enumerated set transcribed from the guideline that
pillar cites. No finite enumeration can hold a per-person curve, which is
what makes the arm able to fail rather than merely able to pass. The
other two arms cover the citation string and freeze which pillar may read
which profile field, with the reason written beside it and stale entries
failing as loudly as missing ones.
The proof is in the file: the pre-v1.34 weight target is restored as a
fixture in today's shape and each arm is shown refusing it.
A pillar can leave the set behind the number without anybody choosing it:
a window rolls past its floor, a module goes off, a source goes quiet. The
delta already refuses to narrate that as a health event, which stops a
false "you dropped six points" and puts nothing in its place. The level
moves, the panel shows a different number, and the cause is nowhere on the
surface. Under the adaptive minimum this matters more rather than less,
because compositions are smaller now and sit closer to their floors.
The report carries `compositionNotice` when today's set differs from the
last local day the account has a stored score for. Optional and nullable,
the additive shape the score's wire has used six times, so a payload
cached before it existed stays valid. It names what left and what joined
as pillar ids; the surfaces already own the labels, and the reason a
departed pillar stopped counting comes from that pillar's own row in the
same report rather than from a second vocabulary.
Two changes deliberately do not raise it. A method move and a recipe
change each already have their own note, and telling someone twice about
one event teaches them to ignore both.
The key is `health-score:v<version>:composition:<hash of the sorted set>`
and rides the existing dismissed-item ledger, so a dismissal holds until
the set moves again rather than until tomorrow. The read excludes today on
purpose: the row for today is written by the same request that computed
the report, so comparing against the newest row would find today's own
composition on the second load of the same page and the note would vanish
between two views of it.
This gives `HealthScoreRecord` its first production reader. Since it
shipped it has been written on every scored read and consumed by nothing
but backup and restore, which is the write-only shape the contributor
notes call a liability and which nothing in the gate can notice, because
every check proves one end. The round trip is proved against real
Postgres: raise the note, wipe the table, restore it through the admin
route, get the same note back, and hold the dismissal across it.
Rendering is one muted line on the score card and a card of its own on the
settings page, beside the recipe note rather than inside it. Deliberately
not a push.
Four fake Prisma clients gain the new read. They are doubles of a client
the reader really does call now; leaving them short and guarding the call
site instead would have been a shim for a caller that does not exist.
Six suites pinned the old rule as the rule. Each inversion, and why it is
one:
`breadth.test.ts` — the case table asserted a refusal reason per set; it
asserts a tier and a `physiological` flag now. Every row in it except the
empty set used to be a refusal, and each is a number somebody gets to see.
Adds the tier boundaries (one pillar, two areas, physiological-absent), a
case proving the same pillars produce the same number at `partial` and at
`full`, and a block whose only job is the counter-case: the empty verdict
still carries a reason and no tier, and a composite whose selected pillars
all came back short still refuses with `no_usable_data`. A rule that has
stopped refusing anything is a deleted rule, not a graded one.
`composite.test.ts` — "requires three domains and one measured
physiological domain" asserted the refusal that no longer happens; it now
asserts the score and the basis block on the same fixture. The coverage
case keeps its fixture and its arithmetic and flips its outcome: two of
three used to be a refusal and is a scored account with a real coverage
fraction. Adds the one-area case and the zero-pillar counter-case, and the
`SCORE_VERSION` floor moves 2 -> 3 because widening who gets a score is
exactly the kind of change that must not ship without a version bump.
`record.test.ts` — "records nothing when the composite did not resolve"
used a two-pillar fixture to prove a below-the-floor day stored nothing.
That day has a number now and the table has to keep it, or an account that
starts narrow and widens later has a hole where its early days were. The
assertion is now that a one-pillar day builds a draft AND reaches the
database; the absence case survives on the fixture that genuinely produces
no score.
`composition-source.test.ts` — three recipes asserted as refused at read
time. Two of them score. What the block proves is unchanged — the reader
publishes the shared rule's verdict on the reader's own resolved set, so a
second copy of the rule would still surface here — but the agreement is
over a tier rather than over a refusal.
`route.test.ts` (score config) — the two too-narrow refusals become
acceptances, because a write that refuses what the read is willing to serve
is the two layers disagreeing. The empty selection keeps its 422 and gains
the assertions the old cases carried: errorCode, reason, and the sentence.
The errorCode driver loses the two selections that no longer 422.
`score-refusal-consumer-guard.test.ts` — its non-zero control anchored on
`three_domains_required`; it anchors on `no_pillars_selected`.
New: `health-score-basis-additive-contract.test.ts` pins that `scoreBasis`
is published, is optional on all three carriers, and points at the one
shared component — a required field on a cached surface would invalidate
every cached payload with nothing failing anywhere. It also pins that the
walk does not confuse the composite's basis with the older per-pillar
`scoreBasis` that blood pressure uses for its axis, since they share a key.
Fixtures: the notice-key assertions derive the method half from
`SCORE_VERSION` rather than restating 3. Three fixtures that named
`three_domains_required` now name `no_usable_data`, a reason the server can
still emit. The settings e2e drove its refusal through a two-pillar
selection that now succeeds; it deselects every pillar instead.
Three distinct areas of health stops being the price of admission and
becomes what it always described: a recommendation. `evaluateScoreBreadth`
returns a tier instead of a pass/fail — `full` at three areas or more,
`partial` at two, `minimal` at one — and refuses only when not one pillar
came back usable, with the reason `no_usable_data`.
The arithmetic does not change with the tier. Same mean, same worst-pillar
band drag, same noise floor. A one-area score of 82 is an honest 82 that
rests on one area, and discounting it for the pillars somebody does not
track would have been a new invented target, which is the thing this score
spent two releases removing.
The physiological requirement becomes a labelling fact rather than a veto.
An account whose only readable pillar is activity or wellbeing gets its
number with `physiological: false` on the basis block; the old refusal told
that person nothing they could not already see.
`SCORE_MIN_ELIGIBLE_DOMAINS` is now `SCORE_RECOMMENDED_DOMAINS` and still
feeds `deriveCoverage`, so the coverage fraction and the confidence blend
keep telling the truth with no new machinery: a one-area score lands at low
confidence through the path that was already there. The fraction used to be
a floor every scored account had cleared; it is a real moving number now.
`SCORE_VERSION` 3 -> 4 and `SCORE_ALGORITHM_CHANGED_AT` moves to the
release date. Both are method-identity duties: deltas suppress as
`algorithm_changed` across the straddle window, the notice key rotates so
each account is told once, and stored days stamp 4. No v3 row is rewritten
and no v3 number changes meaning.
The settings write follows the same rule rather than a stricter one. It
refused two-area and non-physiological selections on the ground that they
could not produce a score; they can, so refusing the write would be the two
layers disagreeing about the one question they share. The empty selection
is still refused, under a single reason `no_pillars_selected`, and the seven
message catalogues trade the two old refusal sentences for one.
Also fixes the stale `./series.ts` reference in the config header — that
module was deleted as dead in v1.35; the columns the sentence describes are
still written by `./record.ts`.
Same shape as the derived and MCP barrels: the line in the barrel had no
importer because the consumer imports the concrete module. Affected:
cycle (15), coach tools (9), record-settings (8), links (4), data-list,
workout-detail and analytics/score (1 each).
analytics/score also carried CompositeValue on an explicit export line
above an export * of the same module, so the name stays exported either
way.
Definitions are untouched.
The knip blind spot (ignoreExportsUsedInFile) left eight symbols carrying
an export nothing outside their file consumes — the modifier goes, the
symbols stay. The score series module goes whole: its only importer was
its own guard test, whose composite- and config-guarding suites survive
in place.
DocumentContentIndex.tokenizerVersion gains the consumer that was the
point of storing it: the content-index job now re-tokenises rows whose
version trails CONTENT_TOKENIZER_VERSION, from the decrypted index text
along the same path the rotation script uses, keyset-paginated and
fault-isolated per row — a future tokenizer bump migrates the index
instead of silently splitting it. ImportJob.uploadSha256 turned out to
already have its dedupe consumer (both kick-off routes short-circuit on
same-bytes non-failed jobs); its comment now names that instead of
pretending to be write-only. The remaining write-only columns carry
their honest annotations in the shape the audit sweep parses.
The score reader's labs query selected referenceLow/referenceHigh on the
joined Biomarker, but the catalog's range columns are lowerBound and
upperBound. Prisma rejects a select against names a model does not have
at query-validation time, before any row is read, so every labs read
threw and both the Glycaemia and Lipids pillars reported a failed read
permanently, Retry included, even for accounts with valid lab results.
Select the real columns, map the catalog bounds onto the reader's range
fallback, and pin two contracts: a real-Postgres round trip that scores
both pillars through the biomarker join (including a lipid row whose
range lives only on the catalog), and the honest empty state, where a
labs account with no rows reads as not tracked rather than read_failed,
which stays reserved for a read that genuinely failed.
Refs #780
The VISITS leaf ships in the release that made a visit possible, because a
selectable checkbox that produces nothing is worse than no checkbox. Leaf,
collector and PDF renderer land together, and the leaf carries no LEAF_MODULE
entry — like ALLERGIES beside it — because a visit is core and the whole report
is already gated by doctorReport. The absence is commented at the map so a
later reader does not read it as an oversight.
The rows print the date, the practice with its specialty, the kind, the reason,
the outcome and the conditions the visit was filed against. The two encrypted
columns decrypt at render, fail-soft, and never enter the stored selection. A
condition the person has since deleted is not named: the link row survives a
soft delete, and printing a label they withdrew would put it in front of a
doctor.
No FHIR resource type is added. That deferral is already written at the
Encounter model, and the bundle builder now says so at the point a reader would
look for the missing section: FHIR splits the future from the past across
Appointment and Encounter, which is a serialisation question this release does
not answer.
Two properties are free and worth stating: a stored selection never grows
(unknown ids drop) and a clinician share link's sections are frozen write-once,
so no existing report and no already-shared link gains a section.
The panel also offers a booked visit as the report's reason — one query, one
prefilled window end, one dismissible offer that names its own source and never
applies itself. A window control that moves on its own is what makes an export
panel untrustworthy.
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 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.
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.
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.
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.
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 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.
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 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.
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 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.