"Finish without the profile" completed the flow with no record id, and
the route then derived the child's answers onto the guardian's own
record. The server now decides: answers given for somebody else with no
record to apply them to complete and latch the flow without deriving or
seeding anything, whatever the client sent. The checklist carries the
profile instead and reads none of those answers as the guardian's own.
A record id the caller does not guard answers 404 like every
id-addressed managed-profile route, one answer for missing, not managed
and not theirs alike; the contract documents the field, the 404 and the
422 text. Pinned by two integration cases and a second child journey.
"Someone I look after" derived the child's answers onto the guardian's
own record. The completion now takes the managed record the profile
step just created, checks the same access rule the guardian's modules
route applies, and lands the same derivation and dashboard seed on that
record through the same record-keyed write; the guardian's own flow is
stamped complete and latched without touching its map. Three
integration cases pin it, and the child journey reads the child's map
through a real switch — it asserted the guardian's map before, which
pinned the wrong record.
Also from the review: the checklist's visit row read a meta the visits
list never publishes and now counts its body; the first-result tile
read the measurements list as an array and now shows the reading; the
demo allowlist admits the disclaimer and tour writes, with a structural
test over every onboarding route file; restart clears the old first
result, and the screen trusts the ledger before painting a result as
done; the headroom sweep's raw-key check matches the flow's own key
shapes; the demo migration script stops copying the dropped column; the
questions-to-pass rule and the shell's onboarding-path rule are pure and
pinned; the baseline form composes the shared action row; the schema
diff loses the unrelated formatting churn.
Run against the standalone build, the four journeys and the axe sweep
surfaced five things the unit tier could not see. The completion route
derives only once every question is settled, and Q6 stays pending for
somebody with no unit-bearing area — the confirm screen now passes the
questions the flow never showed before it completes. The dashboard seed
matched a JSON null where the unset column is a SQL NULL, so it never
landed; the retired step route carried the same filter. The auth shell's
onboarding branch matched only the front door, so every question screen
rendered inside the full app chrome with the sidebar. Inline links
relied on colour alone, the step list sat outside any landmark, and the
baseline form brought a second h1 onto the confirm screen. The two
sweeps run serially, since each drives one account through the API.
The integration file gains the seed case that would have caught the
null filter.
The guardian's modules branch no longer invalidates the health score in the
route; the record-keyed write it shares with the setup flow does. The guard
pinned the old call site and went red for a behaviour that had not changed.
It now asserts the route goes through the shared write with the record id
and that the helper is still the place that invalidates.
The needs-based flow is the one setup flow now. POST /api/onboarding/step
and the six goal slugs go, and with them the two account columns the
wizard kept — its checkpoint and its goal list — through a migration
that drops them; onboarding_completed_at stays as the first-run
redirect's gate. The one durable effect the goals had, the dashboard
tile order, moves to the completion route: the answers now carry both
halves of Q2, which modules are on and what sits on top, and the seed
keeps the wizard's one contract of writing only while the layout column
is still unset.
The OpenAPI table loses the step path and the completion's description
says what the route does today; the demo-allowlist test names a
mutation that still exists.
The write-level row listed "a vaccination dose and the booster reminder it
suggests" three paragraphs under a section table that puts the vaccination
record in Health background. Read together, that tells a self-hoster a
health-background WRITE grant can plan a booster — which the route beside it
now refuses, because the reminder it mints lives under Readings. The row lists
the dose alone, and the exceptions paragraph, which is where a reader looks for
exactly this, carries the two-section case and says the dose logs either way.
With it, the small things the same pass turned up:
The cycle-insights fixture asserted a property it could not show. Its docblock
claims the cycle-native answer survives the fence, and it seeded no symptom at
all, so `symptomPatterns` was empty in every leg and a regression that emptied
it along with the foreign columns would have passed green. One catalogue
symptom on the luteal days, asserted on the owner leg and on the cycle-only
one.
The immunization rows matched on a bare `role="button"`, which passes vacuously
the day the card becomes a real button and fails for the wrong reason if
anything else in the subtree grows one. They carry the same stable marker the
visit card took.
`/api/cycle/calendar` reads the same wrist-temperature channel the crosstab
does and is deliberately not fenced, and it was the only one of the pair with
nothing saying why: no value from it reaches the response.
The encrypted export's new 403 documented the switch refusal and not the
narrow-scope one it answers first, unlike the two settings operations beside
it. Both sheets say at the top that their Save and Delete are gated at the
caller, so a third mount reads the constraint before it violates it. And one
docblock line ran past the wrap.
Two cases in the integration file answered one or two questions and posted
the confirm, which the gate now refuses; they answer or pass all six. The
audit assertion reads the details column as the JSON string it is.
Four of them. The confirm re-stamped its completion instant on every call, so
a replay quietly moved the moment the setup finished at while the derivation
beside it was carefully latched. The two list answers were stored exactly as
sent, duplicates included, so the column could hold something the reader would
never return — the reader de-duplicates on the way out and now so does the
write. The wide-event name for saving an answer used a different noun from its
two siblings, which makes a dashboard filter awkward for one feature. And the
contract said the restart body was required while the route accepts none.
The account payload resolved the setup state for the actor while its module
map, its cycle flag and every count the checklist reads beside it answer for
the active record. A guardian looking at a child's dashboard therefore got the
child's rows ordered by their own answers, and the child's reading count
tested against their own flow state. The two halves of that component have to
agree about whose record they describe, so the field is record-scoped now,
like the module map next to it.
Under a grant narrowed to one section the answers come back empty, for the
reason the module map is masked: they name the domains the record tracks, and
a grant that opens readings does not open that. The shape of the flow still
comes through.
The three write routes stay actor-only and say why: a delegable declaration is
a reviewed entry in the sharing surface guard, and a read that is wider than
its write is the honest shape while that entry is still to come.
The setup flow writes a record's module map, its glucose unit and its unit
preference. All three have a dedicated settings route that records an audit
row, and the flow recorded none, so "why did my modules change" had no answer
in the activity panel. Restarting the flow did write a row, which made the gap
look like an oversight rather than a decision, because it was one.
Same event names as the dedicated routes, since it is the same column and the
panel should group them. The module row names the keys that actually moved and
the domains kept for their content.
The derivation wrote an explicit off for every module the answers did not
name, which is right for a record being set up and wrong for one with years of
rows behind it. An established self-hoster has, for most modules, no stored
preference at all — they were default-on and nobody ever touched the toggle —
so the first confirm took away the navigation entry, the dashboard widget and
the settings entry for domains full of content. The rows survived; the
surfaces did not.
A derived off now asks first, and only for the modules it would actually
retract. Ownership is not re-answered: the measurement domains come from the
same map the MCP reads and the coach snapshot gates off, and each remaining
module names the one table its content lives in, exhaustively, so a module
added later fails to compile until somebody says where its content is. A
module whose domain holds rows keeps whatever the record already had, because
absence is what "on" looks like in the stored map.
The only gate on the derivation was Q1 being set, so one answer was enough to
turn the whole questionnaire into a module map. Two ways that landed wrong and
they compound. Somebody answers who the record is for, closes the tab, and
later finishes the legacy four-step wizard, which posts to this same endpoint
and knows nothing about these questions: the five unanswered ones each read as
their conservative default, eleven surfaces are written off, and the
once-only marker makes it unrepeatable. Or any client patches one answer and
posts the confirm straight back.
The gate now asks whether every question has an answer or a deliberate pass.
A half-answered flow derives nothing, is not stamped complete, and keeps its
confirm step pending, so finishing the questions later still does the work.
The legacy arm is untouched: a caller with no setup row still answers the
fixed acknowledgement it always did.
The spec's sixth question is skipped when the account already carries the
preferences, and the flow had no way to know: `User.unitPreference` is
nullable with null meaning "default metric", and the account payload coerces
that away, so nothing downstream could tell a chosen metric from an unset
column. The two columns are read raw on the server now, into the same
vocabulary the wire publishes, and a record whose account holds both reads its
`units` step as done — the question answers itself and is never put again.
Read-side only: the stored ledger keeps saying pending, because the person
really has not answered it, and an answer given later still lands.
The two writes the units answer makes — the display columns and the record of
the answer — go in one transaction, so a failure between them can no longer
leave the columns changed with no record of what changed them.
Four claims the unit suite cannot reach, because each is about what is in the
database afterwards: the answers persist and a replay writes nothing new, the
module derivation happens once and leaves a later Settings change alone, a
restart resets the steps and not the modules, and a delegate with a live
accepted grant and a switched session is refused on all three writes — with
neither record acquiring a row, which is the quiet failure a fall-back to the
caller's own record would produce.
The demo guard gains the three paths and, beside them, the case that matters
more: admitting one verb on a path must not open a sibling.
`POST /api/vaccinations/{id}/booster` is declared `("write", "profile")`, and
that is right for the act — filing the Impfpass and arming the booster it
suggests are one thing to the person doing them. The row it writes is not: a
`MeasurementReminder` belongs to the measurements section, every direct route
over the model declares it there, and creating one needs MANAGE. So a WRITE
grant scoped to the health background alone produced a reminder on the owner's
checkup list that the delegate could neither read nor change through the
section that owns it.
The seam is fenced the way the visits service fences a checkup closure, with
one difference stated at the call site: this refuses rather than skips. There
the closure is one effect of a larger save and reporting it as not done keeps
the save honest; here the reminder is the whole request, and a 201 over a row
that was never written would tell the person their booster is planned while
nothing rings. The prompt that raises the call asks the same question client-
side, so a delegate outside the section is not offered it in the first place;
the dose itself still logs either way.
`vaccination-booster-scoped-delegate.test.ts` drives the owner, a delegate
holding both sections, a profile-only delegate and a whole-record one. The
scoped-refusal leg fails against the previous handler, which answered 201 and
left the row.
Nothing recomputes an answer. The module map they produced survives on the
account row, but the map says what is switched on, not that the person told the
flow they take medication daily and have a visit next month — so a restore
without the row opens a blank questionnaire for somebody who already answered
it, and leaves the checklist ordering itself from nothing.
Both ends live in one file, the arrangement the visits and the score already
use, because a reader who greps only the restore route gets a false negative
when the route delegates.
`modulesDerivedAt` is the field that makes this more than history and it is
asserted rather than defaulted: it is the once-only latch on the module
derivation, so a restore that let it default to null would hand the next confirm
permission to re-apply the questionnaire over every module decision taken since,
silently. The round trip seeds it non-null and reads it back.
The file's schema validates the three answer objects loosely and the restore
narrows them through the same fail-soft readers the account payload uses. Being
stricter there would refuse a whole account's restore over one retired chip.
The wipe takes the row for the reason the reset already takes the wizard's own
columns: the record the answers were about is gone, so the answers describe
nothing.
The three routes that act on the whole of an account — `DELETE
/api/settings/data`, `DELETE /api/settings/account`, `POST
/api/export/encrypted` — name no record. They resolve the caller and act on
whatever the caller owns, so the question a switched session puts to them is
what happens when the browser believes it is somewhere else.
They already refuse: all three resolve through `requireFreshMfaIfEnrolled` or
its erasure sibling, and both open with `requireAuth()`, which reads the acting
carrier and throws `sharing.not_permitted` for anything but `none`. What was
missing is that the contract never said so. `/api/settings/{data,account}`
published a 403 about narrow Bearer scope alone, and the encrypted export
published no 403 at all, so a client that switches records had no way to learn
which of its calls stop working short of trying one.
One sentence, spliced onto the three, byte-identical for the same reason the
delegable refusal is one string. `switched-session-destructive-refusal.test.ts`
drives each route with a live whole-record MANAGE switch and asserts both
halves: the status, the code and the wording, and that neither record moved —
a refusal raised after the transaction would satisfy the first alone. The
export leg additionally asserts no archive rode along with the 403.
`GET /api/cycle/insights` is declared `("read", "cycle")`, which is right for
the phase labels and wrong for what it compares them against. The crosstab rows
carry `lutealAvg`, `follicularAvg` and `delta` in display units for resting
heart rate, HRV, sleep duration, steps, weight, body/wrist/skin temperature and
blood glucose, plus the mood score read straight off `MoodEntry` — measurement
and mind data, published in full to a grant scoped to the cycle alone, with the
lagged discovery's narrated interpretation on top.
The visits service already had the right shape for this: a predicate over the
grant's sections, asked before the foreign rows are read. Both sections are now
fenced at the query rather than in the response, so a caller outside them never
loads the values in the first place and the rows read exactly as they do for a
record with nothing logged. The wrist-temperature read that sharpens the
prediction stays: it never reaches the response.
The predicate had two verbatim copies, one in the visits service and one in the
vaccination service, with a comment on the second saying a second spelling would
be a second thing to keep in step. It is one module now, `sharing/acting-domains`,
and the third consumer imports it rather than a feature service.
`cycle-insights-scoped-delegate.test.ts` drives four grants against a fixture
whose separation is large enough that the FDR gate cannot be the reason a row is
missing: the owner and an entire-record delegate get both figures, a cycle-only
delegate gets neither and no lagged row, and a cycle+mind delegate gets the mood
column and not the weight. Both scoped legs fail against the previous handler.
On the release that adds the ledger the table is empty, so on a host that has
been uploading nightly for months every account read Never, under a sentence
saying no copy had ever reached the bucket. That sentence is untrue on exactly
the morning an operator opens the new card for the first time, and it is a
claim the host cannot make: what is empty is the record, not the bucket.
The run now records every account it walks, not only the ones an object came
of. A walk that produced nothing moves the attempt instant and leaves the
success columns alone, so the two cases separate: no row at all is no record
yet, a row without a success is never. Never now means what the word says —
a run looked at this account and has never put anything there.
The card names the last check on the never row, so the state is a fact with a
timestamp rather than an assertion. Runbook and all seven bundles carry the
same two sentences. The integration file proves both arms against real
Postgres, including that a failed run leaves the previous good copy on the
row.
The threshold was the schedule period with no budget on top of it. Two things
spend that budget on a host where nothing is wrong. The cron runs 02:30
Europe/Berlin, so on the DST fall-back Sunday the gap between two runs is 25
hours and every account on every host would cross one period on the same
morning. And the ledger instant is when that account's object landed, by
design, so on a cohort walked one account at a time an account reached an hour
later tonight than last night drifts past 24 with two clean runs behind it.
The schedule period and the verdict threshold are now separate numbers. The
period is still what the card quotes; the threshold is the period plus six
hours, applied to both steps, so due still means one run produced nothing and
stale still means two. The fall-back night is pinned in the unit file by its
real dates rather than by an arithmetic 25, and a genuinely missed night at 31
hours still reads due. The runbook says why the slack is there.
The column was INTEGER. A single object may occupy 8 MiB across 10 000
multipart parts, which is the 80 GB the uploader explicitly permits, and
Postgres INTEGER stops at 2.1 GB. An account between the two uploads fine and
then cannot be recorded, so the biggest account on the host is the one the
console would have to report on and could not.
BIGINT in the table, BigInt in the model, Number() where the route builds the
wire row so the payload type stays a plain number. The migration is edited in
place rather than followed by a second one: the table has not shipped, and a
column widened before its first release is one column, not a history.
The comparison with the in-database backup does not carry over, for the
record: that size is octet_length over a bytea Postgres caps at 1 GB, while
this object never touches the database at all.
The unit tests either side of this mock Prisma, so neither proves the
migration applies or that the writer and the reader agree on a shape. The
integration file runs the real nightly job against real Postgres with an
in-memory bucket, then reads the row back and asks the console's own
classifier what it says: fresh after a run, one row across two runs, stale
once it ages past two nightly periods, gone when the account is.
The component test pins the case an operator most needs stated: a host with
no bucket configured says so, rather than painting an empty list that reads
as "no problems". The accessibility scan's admin-backups response gains one
row in each state, because a card that paints only its header proves nothing
about the rows anybody reads.
Two small corrections ride along, both from writing the tests. The freshness
badge composes its classes through `cn` like every other surface, and the
error-reporting line waits for the settings to arrive instead of asserting
"nothing is sent" while the answer is still loading.
Two more creates the client replays out of its offline outbox ran the
handler again on the replay. A titration step carries no natural key at
all, so a lost success response left the medication with two dose
changes on the same day and the runway estimate reading off the wrong
one. A custom cycle symptom is unique on its label, so the replay did
not duplicate — it answered the 409 that uniqueness raises, reporting a
conflict the client cannot act on for a row it has no record of
creating.
Both wrap in `withIdempotency`, the composition their siblings already
ship, and both operations publish the header and the in-flight 409 so a
client generated against the contract can tell a retry is safe.
The integration file that drives the medication creates twice against
real Postgres gains the titration case: one dose-change row after the
replay, and a replayed 201 carrying `X-Idempotent-Replay: true`.
`POST /api/mcp/tokens` is deliberately not on this list. Its response
carries a freshly minted `hlk_` credential, and the wrapper refuses to
persist a secret-shaped body — publishing the header there would
promise a replay the mechanism will never perform.
A disaster-recovery snapshot carries the singleton `app_settings` row —
registration, the MFA requirement, the default locale and timezone, module
availability, the notification and AI configuration, the document size cap and
quota — and the restore wrote it back as a side effect of restoring one
account. An operator putting a single record back on Wednesday reconfigured the
whole host from Monday's file, silently, with nothing on screen saying so.
Rebuilding a host from a snapshot is a real case, so the write stays: it is now
`restoreInstanceSettings: true` on the request and a checkbox beside the typed
gate, off unless the operator ticks it. Restoring an account and reconfiguring
the installation are two decisions, so they are two answers.
The 422 confirmation sentence and the dialog copy drop the instance-wide claim;
the opt-in carries it instead, in all seven catalogues. Integration coverage
runs both arms: without the flag the settings row keeps its own values, with it
the file's values land.
Three consent-surface tests mocked the shared AI-consent bucket at 60 where it
is 20, and the analytics-read and MCP mocks named 60 against real caps of 120.
Nothing asserts the number today, so nothing was wrong on the wire — but the
mocked verdict now carries `limit` all the way to `X-RateLimit-Limit`, and the
first test that reads that header off a mock would pin the fiction.
The response-shape file's header pointed at a document that is not in the
repo; it says what it means directly instead. It also now records the one
thing the gate cannot see: `meta` is a loose object on purpose, so undeclared
keys inside it survive the parse and the strip comparison does not reach them.
The request-scoped capture kept the last verdict of any kind and fell back
to it when nothing had refused. That fallback fires in exactly one state —
no limiter refused this request — which is the definition of "this 429 came
from somewhere else". So a daily AI budget that is spent until midnight, an
hourly generation quota, and a provider-side 429 relayed onward all went out
carrying our own bucket's numbers: a remainder that says there is room left
and a Retry-After measured against an unrelated window. On the AI paths the
captured window can also expire while the provider call runs, so the dressed
delay could land at zero and tell the client to retry immediately into the
ceiling that had just refused it.
The cell now keeps refusals only. A request every bucket let through reports
nothing, and a 429 from another cause leaves undressed, which is correct:
there is no bucket to describe. The two-limiter rule is unchanged — the last
refusal wins, and a refusal still outranks a pass.
The unit case that pinned the fallback is turned round, and both directions
now run through the real wrapper: a limiter refusal gets the headers, a 429
raised without one gets none. The integration file gains the two shapes it
was missing — a route that consults two buckets must report the one that
refused, and a route whose 429 comes from the daily budget must carry no
rate-limit headers at all.
`GET /api/auth/me` publishes a boolean per module, and three different
situations collapse into the same `false`: the record has the module switched
off, the active grant's sections do not open it, or the operator switched it
off for the whole instance. A client holding the boolean has to guess between
them, so the empty state either says nothing or says something that may be
untrue -- offering a delegate a switch that is not theirs to reach, or telling
an account holder to turn on a module the operator removed.
`moduleAccess` carries the reason beside the booleans, which keep their exact
meaning: `enabled`, `disabled`, `not_granted`, `unavailable`, resolved
outside-in so the reason published is the one nothing further in can change.
`modules[key]` stays exactly `moduleAccess[key] === "enabled"` for every key,
and the two halves are built independently -- the boolean still runs through
`maskModulesToSections` -- so the test of that pairing is an assertion rather
than a restatement of a derivation. It is checked on the pure builder across a
matrix of grants, records and operator blobs, and again on the real route for a
scoped delegate, an unscoped one, and an operator-disabled module.
Both ends in one change, because a payload field with no reader is the failure
this repository keeps rediscovering. The reader is `ModuleDisabledNotice`, the
shared module-off empty state the empty-states audit asks for: it names the
reason in the reader's own language and offers the enable control only when the
record's own switch is what is off, since neither of the other two is fixable
from that page. The nutrients page adopts it and drops its bespoke block, which
had been handing every delegate a button that would refuse them.
Registered in the OpenAPI registry as `ModuleAccessMap` and carried on the
account payload; the native contract gains a field and loses nothing, since a
Bearer request carries no switch and never reads `not_granted`.
Response drift was structurally undetectable. `openapi:check` proves the
YAML matches the registry and the route-coverage guard proves the verb is
listed; both pass when the registered schema disagrees with what
`apiSuccess()` sends, and the coverage guard says so in its own doc
comment. That is how `/api/sync/changes` shipped five domains against a
three-domain contract and how the error envelope went years without
declaring the field the multi-issue 422 was built for.
Two bodies, the two that were broken: a full `/api/sync/changes` page
with live and soft-deleted rows in all five domains, its `cursorExpired`
short-circuit (a second shape under the same schema), and a 422 carrying
more than one issue.
`safeParse` alone would not have caught either finding, because Zod
strips undeclared keys instead of refusing them — a schema short of the
wire parses cleanly. So the parsed value is compared back to the raw
body; a strip means the contract is missing something the server sends.
Removing `cycleDays` / `cycles` from the sync schema, or `details` from
the envelope, fails all three cases.
`/api/measurements` and `/api/labs` sort on one non-unique column and
page with `skip`/`take`. When the sort key ties, Postgres does not
promise the same order for the query that fetches page N and the one
that fetches page N+1, so a client paging its own history can receive a
row twice and never receive another — silently, on both sides.
Ties are not hypothetical here. The partial unique index forbids two
live measurements of the same type at one instant, but the tie that
occurs in production is the cross-type one: a daily `stats:` rollup
posts steps, distance, flights and active energy at the identical
timestamp, and an unfiltered history list pages straight through the
block. A lab panel drawn in one sitting shares `takenAt` across every
analyte in it.
Appending `{ id: <same direction> }` to the `orderBy` makes the order
total. Same fix on the five siblings that page the same way — medication
intake history, illness day-logs, custom-metric entries, reminder
history, mental-health assessments — because the defect is the shape,
not the two routes it was found on. No wire change; `/api/sync/changes`
already ordered this way.
The fixtures seed ids in an order unrelated to insertion order, so the
assertion on order inside the tied block can only hold if the secondary
key is doing the work: with the tiebreaker removed the new file fails on
all three cases.
A refused client had nothing to back off against. `rateLimitHeaders()`
emitted `X-RateLimit-Remaining` and an ISO `X-RateLimit-Reset`, no
`Retry-After` and no cap, and only about 70 of the 199 route files that
consult a limiter attached even those two. The rest answered with a bare
prose envelope, including every endpoint an offline replay queue hits:
the four batch ingests, `/api/sync/changes` and `/api/auth/refresh`. A
first-run Apple Health import trips the 60-per-minute batch ceiling
reliably, and the only strategy left to the client was a blind backoff.
Threading headers through 129 handlers is 129 edits the next new route
forgets, so the verdict is carried instead: `checkRateLimit` records it
in a request-scoped cell and `apiHandler` dresses any 429 that leaves
without headers. A route that already built its own is left alone —
`Retry-After` is the presence test, and it comes from the same builder.
`Retry-After` is whole seconds rounding UP (RFC 9110 §10.2.3), floored
at zero. `X-RateLimit-Limit` is new and needs the cap, so the limiter
result carries it. `X-RateLimit-Reset` keeps its ISO form: it is the
wrong convention for the name, but it is on the wire today and changing
it is a decision of its own.
The cell lives in its own leaf module because 177 test files replace
`@/lib/rate-limit` wholesale with a mock factory, and `apiHandler` must
not depend on anything those factories stand in for.
Publishing the active record's module map and cycle flag on the account
payload was right for a whole-record grant and too generous for a narrowed
one. `accountAccess.active` is set for any live grant, level and scope
included, so a delegate scoped to `measurements` read whether the owner
tracks their cycle, their screeners, their illness episodes and their
supplement intake, and the cycle flag derives from the owner's recorded sex.
Both fields are now masked to the sections the active grant names: a closed
section answers `false`, which is the answer a module that is off already
gives and the answer the browser's route inventory has always acted on. An
unscoped grant names the whole record and is unaffected, so the own-record
and whole-record payloads are byte-identical to what they were.
The masking table lives beside the sharing vocabulary rather than inside it,
and runs one way only: no value in it can turn a module on, admit a read or
widen a grant.
`auditLog` serialises `details` with `JSON.stringify` into a text column,
so the assertion was reading a field off a string and finding nothing.
Refs #939
The 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 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.
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.
Turning a module off for a profile you look after wrote a column nothing
on screen was reading. GET /api/auth/me published the ACTOR's module map
while switched, and the navigation, the dashboard tiles and the insights
sections all gate on it — so a guardian who turned Cycle off for the child
kept seeing a Cycle destination inside the child's record, and a guardian
who tracks it saw one there whether or not the child did (#939).
The map and the resolved cycle flag now answer for the record the session
is inside. They are not display preferences: every surface they gate shows
the record's data, so the record is what decides. Locale, theme and layout
stay the actor's. The record is taken from the grant-validated active
entry rather than the session's own selector, so a revoked switch cannot
publish somebody's configuration, and with no switch the payload is what
it was.
Cycle also gets a row on the guardian's Modules surface, which it never
had. It is a delegated module, so its state is the record's own cycle
profile and modulePreferences.cycle is refused — which left a guardian no
way to turn it off for a record whose sex says it should be on. The family
names the real column instead of a blob entry the gate ignores.
Refs #939
A managed profile could be created and deleted and never changed. A name
typed as a placeholder was permanent, a household that moved kept the
timezone the record was created in, and there was no way to correct a date
of birth somebody finally had (#939).
PATCH /api/managed-profiles/{id} takes the fields creation takes, every
one optional and at least one required — an empty body is a refusal rather
than a save that audits a change nobody made. It carries the gate the rest
of the family carries: a cookie session with a fresh second factor, an
active Guardian grant on the record, and the same byte-identical 404 for
"no such record" and "not yours". A GET beside it answers the record as it
stands, because the account payload names a managed record and nothing
else, and a form filled from that would have had blank fields to send.
The new field is gender. Leaving it out of v1.37.0 meant every managed
record started with the column NULL, which is what the cycle module
derives its default from: a guardian creating a record for a child could
neither record the answer nor turn the module off from there. It reuses
the column and the three values every other surface writes, so there is
one word for one column rather than two.
Refs #939
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 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.