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 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.
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.
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.
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.