Skip to content

v1.38.14 — the reported bugs at their class, and a gate that stops crying wolf - #945

Merged
MBombeck merged 64 commits into
mainfrom
release/v1.38.14
Sep 10, 2026
Merged

v1.38.14 — the reported bugs at their class, and a gate that stops crying wolf#945
MBombeck merged 64 commits into
mainfrom
release/v1.38.14

Conversation

@MBombeck

Copy link
Copy Markdown
Owner

Two bugs reported by @mbreitkreuz this week, each fixed at its class, and a round of work on the things that made the release gate cry wolf. Refs #943, #944.

#944, walking distance from an export.zip import stored a thousand times too small. The import never read the record's own unit. HealthKit units now convert through one shared table (src/lib/measurements/hk-units.ts), applied on the export path; the batch route keeps its documented "captured, not validated" contract. Every mapping entry with a fixed unit carries a written reason, enforced by a structural test. scripts/repair-apple-health-distance.ts repairs stored rows: dry-run by default, only rows the export path provably wrote, refuses any account with a row that would leave the accepted range, once per account, with a runbook.

#943, blood glucose without a meal-time tag never lit the dashboard tile. Untagged readings are an UNSPECIFIED bucket across tile eligibility, value and trend, targets, doctor report and FHIR; a structural test refuses a tile gated on an enum without a null arm.

Gate hygiene. Four e2e specs de-flaked at their causes (a shared library class, a drawer race, Playwright's keep-alive socket reuse against the server's idle timeout); the score tests' teardown race was a fake database bypassed by a tail call to the real client; bundle budgets with a measured baseline and 20 KB headroom; rate-limit test timing; geometry checks fail instead of skipping without a browser; the dependency audit advisory on pull requests, blocking on main and daily.

Also in this release. The first-run profile step shows the server's rejected fields instead of dropping them; withIdempotency on the three medication create routes the native outbox replays; a synthetic post-deploy journey script and workflow (host allowlist, cleanup in finally); two more end-to-end journeys (glucose in both units, Apple Health export import); POST /api/nightscout/connect documented; off-host backup variables documented and the runbook corrected (the worker never deletes a backup object).

Two adversarial reviews ran on this branch (.planning/2026-09-10-v13814-review.md, -2.md); every High and Medium finding is fixed here. Gates on the final tree: typecheck, lint (three baseline warnings), format, openapi:check, unit suite, build, and the full integration suite run serially; this PR's CI runs both e2e shards and the image builds.

…etres

The Apple export writes DistanceWalkingRunning in the account's own
length unit; the record mapping assumes metres and never reads the
attribute, so a 2.484 km day lands as 2.484 m.

Refs #944
Apple stamps every quantity `<Record>` of an export archive with the
account's own display unit, not the unit the mapping table assumes: a
metric archive reports walking distance in km, an imperial one in mi,
body mass in lb, energy in kJ, temperature in degF. The mapper accepted
`input.unit` and never read it, so a 2.484 km day was folded into the
day's total as 2.484 m and a decade of imported history read as a couple
of metres walked.

`hk-units.ts` is now the one place a HealthKit unit is converted, shared
by the record path and the workout path (which carried its own km/mi
branch — the duplicate is what let the record path stay wrong). It
converts only inside a family and only for units Apple can emit, and
returns the reading untouched for anything it cannot place, so an
archive that already speaks the table's unit is unchanged.

Every mapping-table entry whose unit cannot be converted — a
dimensionless count, a pinned event, a logarithmic dB level, a compound
rate, the percent units whose value rides as a 0..1 fraction — now
carries a written reason, and a structural guard fails on an entry that
offers neither a convertible unit nor a reason.

Refs #944
The three score engines' unit tests hand `persist*Score` a fake Prisma, but
`upsertScoreRow` routes the written row through `afterMeasurementMutation`,
which reads the `@/lib/db` singleton instead of the injected client. Both legs
of that tail therefore opened a socket to a Postgres the unit job does not run:
on CI the attempt failed with ECONNREFUSED a few hundred milliseconds later and
logged on the way out, after the test that started it had ended. A console write
that lands while the worker is closing its RPC channel fails the whole run with
`EnvironmentTeardownError: Closing rpc while "onUserConsoleLog" was pending` —
four of nine unit-suite failures in thirty days, every one of them with all
tests green, blamed on whichever score file the worker happened to be tearing
down. Run 31961548185 carries the full stack: recomputeBucketsForMeasurement <-
afterMeasurementMutation <- upsertScoreRow <- persistStressScore <-
stress-score.test.ts:105.

It stayed invisible on a maintainer's machine because a local Postgres answers
on 5432: the same call succeeded quietly, and the suite was writing rollup rows
for its fixture users into a real database.

The four files that drive `upsertScoreRow` stub the tail. That removes the
doomed I/O rather than the log line, and the call they were making by accident
is now asserted in score-row.test.ts instead: the v1.37.19 wiring is pinned by
an expectation rather than by a stack trace. Verified by mutation — dropping the
`afterMeasurementMutation` call from score-row.ts fails the new assertion.

The settle guard's sweep is widened while here. Its eight-line handler window
and five-line registration preamble were both measured against the sites that
existed when it was written and both cut through real code: the console write in
mood-rollups.ts sits nine lines into its handler, so the one genuinely
unregistered detached promise in the tree was never seen, and the registration in
fire-and-forget.ts sits below its handler, so a correct site read as a violation.
Registration is now looked for wherever it wraps the statement, by paren depth
rather than by line count, and mood-rollups.ts registers its WEEK/MONTH/YEAR
enqueue. Verified by mutation: unwrapping that call fails the guard.

Gate: the four files, twenty consecutive runs, 20/20 green (53 tests each).
Test time across the three engine files falls from 708 ms to 20 ms — the
round-trips that are no longer made.
`pnpm audit --prod` answers a question about the published advisory database,
not about the diff. The same lockfile passes on Monday and fails on Tuesday
because someone else published, and the branch that turns red changed nothing:
seventeen of the forty-one Security & Quality failures in the last thirty days
were advisory publications on branches that touched no dependency
(GHSA-jqff-g426-hqxp, GHSA-fph4-wmhf-6fwf, GHSA-f65p-4m7j-42xc and
GHSA-5jgf-p345-68v8 in one run alone). A gate that fails for a reason the author
cannot act on is a gate people re-run instead of read.

It keeps running on every push to `main` and once a day on a new schedule
trigger, which is the cadence the question actually has. The other three jobs
sit out the scheduled run. Nothing about what ships is loosened: the container
scan still builds and scans the image on every push to `main`, and the daily
Trivy workflow still scans the lockfile with dev dependencies included and the
published image.

Gate — the trigger set, read back from the parsed workflow:

  triggers: push, pull_request, schedule
  quality          | if: github.event_name != 'schedule'
  dependency-audit | if: github.event_name != 'pull_request'
  secret-scan      | if: github.event_name != 'schedule'
  docker-security  | if: github.event_name == 'push' && github.ref == 'refs/heads/main'
The unit fix only helps the next import. An account that imported an
archive before it carries a decade of days understated 1000x, and the
authoritative repair — re-importing the archive on the fixed build —
needs an archive the account may no longer have.

`scripts/repair-apple-health-distance.ts` is dry-run by default and
selects rows only by the stamp the archive fold itself writes: the
EXPORT_XML_SOURCE_MAX provenance plus the `stats:` external id minted
for that fold. Never by "the value looks small", so a row a native sync
has since overwritten is left alone. `--apply` multiplies one account
per transaction and writes the audit row in the same transaction, which
is what makes a second run a no-op. The archive's own unit is named by
the operator (`--unit=mi`) because the discarded attribute is the defect
itself, and a row whose repaired value would leave the plausible range
is reported rather than written.

Refs #944
The field carried no description while the server ignored it. It no
longer ignores it, so the contract has to say so.

Refs #944
`totalEnergyBurned` rides the same account-display-unit contract as the
distance beside it: a Health app set to kilojoules writes `kJ`, and the
column is kilocalories. Convert it through the shared table.

Refs #944
The hero-geometry check stopped skipping under CI in v1.38 and started failing
instead, which was the point: it had caught its own launch failure and passed
for months, measuring nothing. The install step went into the quality job of
`security.yml` and nowhere else, and a second job runs the same suite — the
auto-merge job for dependency bumps. Every Dependabot pull request has failed
since on `browserType.launch: Executable doesn't exist`, four of them on
2026-09-06 alone (run 34043840314), on an error that says nothing about the
bump. One workflow was fixed; the class was not.

That job installs the same headless shell now, off the same cache key. The
error the check throws names both jobs rather than sending a reader to the
workflow that was already correct, and a guard sweeps the workflow directory so
a third job cannot run the suite with no browser anywhere in it. Its limit is
written down: the sweep is file-scoped, so a workflow with two jobs where only
one installs still satisfies it.

Gates:

  CI=1, browser path hidden      exit=1, "This check is the gate in CI and it
                                 has no browser to measure with"
  CI=1, browser present          exit=0, 4 tests passed (it measures)
  install step deleted from the  guard fails: ["dependabot-auto-merge.yml"]
  auto-merge workflow
Three of the four watched routes sat within a kilobyte of their ceiling —
444 of 445, 459 of 460, 459 of 460 — because the numbers had been re-stated
for four releases and never re-based. A budget a route is already touching
does not measure the route; it measures whichever dependency bump landed that
week. e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the
445 KB budget` with nothing in the diff that touched the bundle, and it was one
of at least four e2e failures that were the budget gate rather than a test.

The file states the measured value per route now, plus one drift allowance over
it, and a route fails on the delta. Measured on a local build at d48bbff:
/ 447, /insights 434, /measurements 433, /insights/mood 447, allowance 20 KB, so
the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The
allowance covers the runner delta and ordinary week-to-week drift; the step
change the gate exists for, a statically imported catalog or a duplicated chart
library, is several times larger and still fails. Every run prints the delta, so
a route that has crept most of the way through its allowance is legible in the
log long before it is red. The reason and the re-base instructions are written
into the file's running note, as with every raise before it.

A budget file with no baselines used to gate nothing quietly, since the loop
had nothing to walk; it now refuses.

Gates, against the current build:

  real baselines                 passed; /page +0.3, /insights +0.3,
                                 /measurements +0.2, /insights/mood +0.3 KB
  /page baseline 425 (cap 445),  FAILED: "/page: 447 KB gz is 21.6 KB over the
  /insights 413 (cap 433)        425 KB baseline, past the 20 KB drift
                                 allowance (cap 445 KB)" and the same for
                                 /insights/page, exit 1
  routeBaselineKbGz removed      exit 2, "states no routeBaselineKbGz entries,
                                 so no route is gated"
The window-reset test burned its budget in a 50 ms window: four sequential
round-trips to a containerised Postgres had to finish inside a twentieth of a
second or the window closed under them, the counter reset, and the fourth call
came back allowed. Three failures in thirty days, two of them on `main` — run
33614804032 shows `expected true to be false` at rate-limit.test.ts:56 in a file
that took 1278 ms to run.

The short window bought nothing: the expiry this test is about is forced by hand
a few lines further down, by writing `reset_at` into the past. The burn now runs
in a 60 s window, so no round-trip can outlast it, and the test asserts the row's
count is 4 after the denial — a window that closed mid-burn would also have
produced three allowed calls, and the old assertion could not tell the two
apart.

Gates:

  pnpm exec vitest run --config vitest.integration.config.mts \
    tests/integration/rate-limit.test.ts

  three consecutive runs, 2 passed / 2 passed / 2 passed.

  Mutation: with the burn window back at 1 ms the run fails exactly as CI did —
  "AssertionError: expected true to be false" on the denial assertion.
The two metric sub-page cases waited for `.recharts-wrapper`, a class the
charting library writes and every chart on the page carries, so the gate was
satisfied by whichever chart rendered first rather than by the one the route
exists to show. On `/insights/mood` that is exactly what happened: the mock
carried a single mood entry on a fixed date, the chart withholds its line below
three distinct days, and the sparse placeholder painted while some other chart
answered the gate. The mood line was never waited for and may never have been
scanned.

Both charts now carry `data-slot="chart-plot"` on the wrapper their data branch
renders — an empty window still paints the empty state and the scan still waits
— and the mood fixture is anchored on now with ten daily scores, the same way
the measurement rows above it are.
The maximize case clicked the drawer's maximize control as soon as the drawer
was on screen. Those are not the same event: the vault records `?doc=` as the
way back only once the detail sheet has actually closed with the drawer owning
the same document, and maximizing before that sends the close down the other
branch, which calls `history.back()` and races the push to `/coach`. The URL
then stays on `/documents` and nothing is raised. The sheet's own close control
leaves the tree with the sheet, so its absence is that hand-off, observed.

The delete case is addressed to the toast's attributes rather than its copy.
The literal there has already been wrong once — a trailing full stop moved and
the assertion spent its whole timeout hunting a sentence that no longer
existed, on every attempt, on both projects.
The scan's fixture was written with a Playwright API context. Every API context
in a runner shares one process-wide keep-alive agent, so a request can be handed
a socket the server has already closed on its five-second idle timeout, and a
POST — unlike a GET — is never replayed on a fresh one. That is what ended this
spec twice, on this line, on all three attempts each time: `socket hang up` and
`read ECONNRESET`.

The dose is written from the page instead. The browser opens its own connection,
the service worker is blocked in this project, and the row still goes through the
real route, so nothing about the fixture changes except which socket carries it.
FENCE-AC-07 drove its external switch through the browser context's API
request context, which shares one process-wide keep-alive agent with every
other API context in the runner. A POST there can be handed a connection the
server has already closed on its five-second idle timeout, and a POST is never
replayed on a fresh one — twice this line ended the spec with
`read ECONNRESET`, on all three attempts.

The switch is sent with the page's own `fetch`. It stays external in the sense
the case needs: the switch UI is what runs the cache wipe and a bare `fetch` is
not it, and the service worker returns without touching any non-GET, so the
request reaches the network unaltered. The positive control below still proves
the cached entry survived.
`glucose_context` is nullable, but every surface that fanned out over the
four enum members treated the named list as exhaustive. A meter synced
through Apple Health writes no HealthKit meal-time metadata at all, so an
account can hold nothing but rows the list cannot match.

Add one shared notion of the untagged bucket — the name the Coach's glucose
block already used — with a resolver that files NULL, blank and unrecognised
values under it, and a grouper that walks the named contexts first and the
untagged bucket last. The untagged bucket borrows the RANDOM threshold: an
untagged reading is a spot reading, and a fifth threshold would give the user
a second editor row judging the same readings by a different number.

Labels in all seven locales.

Refs #943
Tile eligibility filtered the four named contexts against the per-context
summaries and asked whether any of them held readings. An account whose
source records no meal time matched none of them, so the tile was omitted
with the module on, the layout toggle on, and a reading from today. Nothing
warned; the strip was simply short one card.

Move the gate out of the page into `resolveGlucoseTiles`, which walks the
shared bucket list — untagged included — and hand the caller the label key
with the summary. Both summary producers group through the shared resolver,
so `UNSPECIFIED` reaches the wire with its own count, latest value and
trend, and a tagged account still gets its per-context breakdown.

Refs #943
`buildGlucoseTargets` had the same gap as the dashboard tile: it iterated
the four named contexts, so an account whose readings carry no meal time
got no glucose target at all. Route the loop through the shared grouper.

The untagged card is judged against the RANDOM band and is not editable —
one threshold, one editor row — so the cog renders disabled the way it does
for a derived card. The insights reference panel lists the new target type,
otherwise such an account sees no band on the blood-glucose sub-page.

Refs #943
Both report paths dropped them: the raw path iterated the named contexts,
and the dense path skipped any row without a context outright, so a report
built for a meter that records no meal time carried an empty glucose panel
while the readings sat in the database. The FHIR export dropped them for
the third time, at the LOINC lookup — its own comment already said an
unspecified reading codes as 2339-0, but the table had no entry to hit.

The insights context breakdown names the bucket, and the Coach's glucose
block now resolves through the shared helper rather than its own copy.

Refs #943
The defect was invisible from the outside because every behavioural test in
the suite seeded a tagged reading. Two checks close that:

A structural guard that reads the dashboard surfaces and fails when a tile
derives presence from a list of enum buckets with no null / unknown arm and
no written reason, following one hop through a named constant. It carries
its own positive and negative controls, so a refactor that silently stops
matching anything fails here rather than going quiet.

An integration test that seeds twenty readings with a genuinely NULL context
against a real Postgres and reads the tile back through the same resolver
the page renders with: eligible, latest value intact, and a tagged reading
still filed under its own context.

Refs #943
The step's profile fieldset moves to `baseline-fields.tsx` unchanged.
The markup can now be rendered against a server answer on its own,
which is where the next commit puts a test.
The onboarding baseline step flashed a toast naming one refused field
and walked on to the done screen, stamping the account as set up over
a value the server never stored. The value the person typed was gone
with the step.

A refused field now writes its reason into that field's own error
slot, marks the control invalid, and holds the step until the person
corrects it. Every refused field is named, not only the first, and the
reason comes from the validator code rather than the validator's own
prose, in all seven shipped languages.
It shares the route and had the same gap: the banner named the first
refused field and nothing marked the input that held the value. Each
of the seven submitted fields now carries the reason in its own slot,
through the same component the onboarding step uses.
A partial save is a whole-class trap: any 200 read as a clean save
throws the person's correction away. The guard enumerates every
surface writing to either profile route and requires each to call the
per-field describer, or to prove its body carries a single field, for
which the partial arm is unreachable.
The onboarding step keeps the preference and the adapter where the
save happens and renders the control from its inputs file, so the
three conditions belong to the surface, not to one file. The control
check is word-bounded now: a wrapper named after it passed a substring
check while rendering something else.
…ng them

The audit had moved off the pull_request trigger entirely, which left a window in which a tag could be cut from a merge the audit had not seen. It runs on pull requests again as an advisory job (continue-on-error), stays blocking on the push run for main and on the daily schedule, and a tag is only ever cut from a green push run.
The record-unit conversion is now opt-in per caller and only the
`export.xml` archive path opts in. There the attribute is authoritative:
Apple stamps every quantity `<Record>` with the account's own display
unit, so `km` means kilometres and the reading is meaningless without it.

`POST /api/measurements/batch` keeps its documented contract — `unit` is
captured for audit and never read. Nothing in the tree pins the native
client's unit strings for the seventeen convertible identifiers, and a
client that reads a sample in metres while stamping the person's display
unit into the field would have had every walking distance multiplied by a
thousand on ingest. Reading the field there is a wire change and needs
the client's strings pinned first.

The mapping-table audit and the shared conversion module stay. The batch
path's identity behaviour is written down where it is decided and pinned
twice: every convertible identifier round-trips a sibling unit unchanged
by default, and a walking-distance entry sent as `km` is stored as it
arrived. The OpenAPI description says what the server actually does.

Review finding H2.
The provenance criterion proves where a row came from, not which build
imported it. An account re-imported on the fixed build carries the same
`EXPORT_XML_SOURCE_MAX` stamp and the same `stats:` external id with the
right numbers and no audit row, so it is selected exactly like a broken
one — and its rest days, under 200 m because the phone stayed at home,
survive the plausibility check after another thousandfold multiply.

So one candidate row that would leave the range now refuses the whole
account: nothing written, no audit row, the offending rows and a printed
reason instead. The idempotency guard only ever held against the
script's own prior run; this holds against the case it could not see.

The runbook says the same in words — a re-imported account must not be
repaired, the re-import already did it — and says what the dry run is
for: it is where the decision is made, not a formality in front of one
already taken.

Integration coverage for the re-imported account: correct rows, an
`--apply` run, nothing changes and no audit row is left behind.

Review finding H1.
An in-place multiplier with no reverse operation was documented with only
a dry run in front of it. Point at the backup runbook where the write
happens.

Review finding M1.
The route was exempt from the published contract as a browser handoff. It
is not one: Nightscout has no OAuth step and no authorise page, and the
route is an authenticated JSON POST that Zod-parses a URL and an optional
token, probes the instance and encrypts the pair onto the row. It was the
only POST among eight `connect` exemptions, and its three siblings —
status, test, disconnect — have been published all along, so a client
could read every part of the Nightscout flow except the one that starts
it.

Register it from `nightscoutConnectSchema` so the request shape stays
single-source, document the 400 / 413 / 422 / 429 arms the handler
actually answers with, and drop the exemption. Removing the registration
now fails the coverage guard by name.
The rollups and the cached status insights are recomputed after the
transaction, so a throw there left the rows repaired and every chart on
the pre-repair numbers — and no later run would notice, because the
audit row makes the account skip from then on.

The tail is wrapped now. The audit row is written with
`rollupsRefreshed: false` and only flipped once the recompute returned,
so a re-run can tell "repaired, rollups pending" from "done" and says so.
A run that could not finish the tail names the account, prints the
backfill to re-run — `scripts/backfill-rollups.ts --user <userId>` — and
exits non-zero.

Review finding M2.
…ates

The native client replays its offline outbox with the same
Idempotency-Key on every attempt, and `POST /api/medications`, the
side-effect log and the inventory register ran the handler again on the
replay: after a lost success response the account was left with a
duplicate medication, a duplicate symptom entry or a second pen in the
supply count. The dedupe on `(externalSource, externalId)` only ever
covered a MIRRORED create; a manually entered medication carries
neither field and had nothing to collapse a retry onto.

All three wrap in `withIdempotency`, the composition the batch ingest
already ships, and the three operations publish the header so a client
generated against the contract can tell that a retry is safe. Zod
parsing and `requireRecordAuth` are untouched.

An integration test drives each route twice against real Postgres and
asserts what a status check cannot: one row in the table, and a replayed
201 carrying `X-Idempotent-Replay: true`.
The client's notification service extension replaces the medication name
on the lock screen, and iOS routes a payload through an extension only
when the alert carries `mutable-content`. The dispatcher has set the flag
on every alert it builds since the category work for the v0.5.4 client,
but the only assertion on it sat inside a category test, where dropping
it would have failed nothing and the name would have quietly reappeared
on the lock screen of a device that asked for it to be hidden.

Two cases now say it out loud: the medication reminder carries the flag,
and so does another event type, because that is the shipped behaviour and
narrowing it would take a capability away from the client rather than
tidy something up.
S1. The journey signs in with a real account and POSTs that account's
password to whatever BASE_URL names, and the workflow took BASE_URL as
free text. Anyone with repo write, or a leaked token, could dispatch the
run at a collector of their own and keep a working credential; secret
masking guards the log, never the egress.

The host now has to be on a short const allowlist in the script, checked
before anything is sent, with exit 2 and a named refusal otherwise. The
workflow input is a choice between the two hosted instances, so the form
cannot offer a third; the script's own check still stands behind it for a
dispatch that goes round the form.

--self-test covers the refusal, the prefix lookalike a startsWith check
would have waved through included.
S2. The legs ran in one loop that returned on the first red one, so a
failed read back never reached the delete and the probe account kept the
row. Worse, the id was assigned only after the write's await returned: a
POST that timed out once the server had already committed left a row the
script could not even name.

Legs 3 to 5 now sit in a try/finally. The finally sweeps whatever the
delete leg did not, and when there is no id it asks the instance for the
row by a marker the write puts in `notes`, unique per run and known before
the request goes out. The sweep never throws — the red leg is the verdict,
and it says out loud what it could not remove.

--self-test asserts the mock holds no rows after every case that got as
far as the write, so a red read that skipped the delete is caught.
Low, review 2. The delete leg asserted HTTP 200 and printed "account left
clean". An instance that answers 200 and keeps the row would have printed
the same line, which is the failure mode the journey exists to catch.

The leg now reads the id back and requires a 404. The self-test carries a
mock that answers 200 without deleting; with the read-back removed that
case goes green, which is the proof the check can fail.
Low, review 2. The self-test walked every leg but never once asked whether
the redactor actually holds. The excerpt path is the only thing between a
token in a failure body and the run log, so it gets its own case: a body
carrying this project's own key prefix must come back with the token
replaced.
U1. The new `unit` description on the batch entry said `value` is read in
the unit HealthLog stores the identifier in. Sixteen mappings disagree:
`convertToDbUnit` runs on every entry, and a waist circumference arrives
in metres to be stored in centimetres, an oxygen saturation and a body fat
arrive as a fraction to be stored as a percent. A client generated against
that sentence sends 98 and stores 9800.

The description now says what happens: `value` is read in Apple's own
HKUnit for the identifier, HealthLog applies its storage scaling
afterwards, and `unit` is recorded for audit rather than validated
against. Two worked examples, because the percent-shaped ones are the pair
that bites. YAML regenerated.
Low, review 2. An account with one out-of-range candidate is refused
whole, rows and audit row included, but the dry run counted its rows into
"N row(s) would be repaired" — so the number an operator read before
--apply was larger than the number --apply could ever produce. The refused
accounts are now counted and reported separately, and the per-account line
says its rows are not in the total.

The refusal itself told the operator to check the Health app "before
forcing anything". There is no --force and never was. It now names the
only route that exists: re-run with the matching --unit if the archive
really was in another one.
E1. Two halves of one correction. `.env.example` still said backup objects
older than BACKUP_RETENTION_DAYS are pruned, which no code does, so a
self-hoster reading that file alone still expected the worker to keep the
bucket tidy. It now carries the same sentence as the production example.

And that sentence overreached in the other direction: "the worker never
deletes an object" is not true of `_healthcheck/<ts>.bin`, the 1-byte key
the Test-connection button writes and then deletes. The delete swallows
its own failure, so an operator who granted exactly the three documented
actions leaves one orphan per press and is never told. Both files now say
"a BACKUP object", and name the one key and what happens without
DeleteObject on that prefix.
E2. `loadOffhostConfig` parsed BACKUP_RETENTION_DAYS, clamped it to a
minimum of one day and put it on the config object, where no caller read
it. Neither annotation fits: it is not deliberately internal, because
there is no internal use, and no consumer is pending. The design forbids
one outright — the worker's grant excludes DeleteObject on purpose, so
nothing here can act on a retention window without giving a compromised
worker the ability to wipe the history. A clamp that guards nothing only
read as an enforcer the worker is not.

So the field and the clamp go, and the reason sits where the parse was.
The variable keeps its place in the two example files and on the compose
whitelist: it is the number the operator sets the bucket's own lifecycle
rule to. The docs table said "defaults to 30", which implied the app had
an opinion; it now says the app does not read it.
…te that stops crying wolf

Version anchors, the OpenAPI document and the changelog entry. Refs #943, #944.
Comment thread src/lib/measurements/hk-units.ts Fixed
The catch-all angle-bracket pattern read as an HTML sanitiser to static analysis. The annotation Apple writes is a number, so the pattern now matches exactly that, and a unit with anything else between the brackets stays unknown instead of being silently trimmed into a lookup.
@MBombeck
MBombeck merged commit 2973953 into main Sep 10, 2026
24 checks passed
@MBombeck
MBombeck deleted the release/v1.38.14 branch September 10, 2026 02:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants