Migration 0293 adds `sessions.acting_as_user_id`. NULL, which is what
every session has carried until now, means the browser is acting as the
account it signed in as.
It is a selector and not an authorization. The resolver joins it against
a live grant on every request, so a value stranded here by a grant that
has since been revoked or expired confers nothing. Nothing sweeps the
column for correctness; the sweeps that exist are housekeeping, so a
delegate's browser lands back in its own account instead of on a wall of
403s.
ON DELETE SET NULL, not CASCADE. The two ends mean opposite things:
deleting the session's owner should take the session with it, which is
the existing `user_id` cascade, while deleting the account being acted on
must leave the delegate's browser session alive. A cascade on this side
would sign a delegate out of their own account because somebody else
deleted theirs.
A revoked grant with no revoker, or a revoker with no revocation, are
both rows that cannot answer the question the table exists to answer.
The migration already enforces the pair; this asserts it against real
Postgres rather than trusting that the domain module is the only writer.
The delete-all-data guard counted an account's surviving rows with a
literal `WHERE user_id = $1`, which is the same assumption the wipe loop
itself used to make. Account grants relate two accounts and carry no such
column, so the guard raised `column "user_id" does not exist` and stopped
before it could assert anything.
The predicate now comes from the wipe plan's own owner-column
declaration, so the guard asks the question the route answers instead of
its own version of it.
An account grant is one account's standing permission to read another
account's record. The row is also the consent record for the act that
created it: there is no second receipt table, because two records of one
act drift and then nobody can say which is true.
That shapes the table. Revocation stamps revoked_at and revoked_by
instead of deleting, so the record can still answer who had access, from
when to when, and who ended it. The uniqueness constraint is therefore
partial — one live grant per (owner, delegate), with history accumulating
underneath — otherwise a re-invitation would either be refused or would
overwrite the first grant's acceptance. Both foreign keys cascade: a
grant naming a user who no longer exists is an authorization nobody can
resolve and nobody can revoke.
The state machine lives in src/lib/sharing/grants.ts and only there. The
acting-account resolver and the grant surfaces both read these rows, and
if each carried its own idea of "active" the drift would be silent in
exactly one direction: the resolver admitting a grant the panel shows as
ended. Pending confers nothing, expiry is checked against the clock of
the request that reads the row, and every transition is a conditional
update so two concurrent accepts cannot both win.
The access column carries READ or WRITE and only READ is ever written.
It exists from day one so that delegated writes become enforcement work
rather than migration work.
Three lifecycle paths, each proven against the real route:
- Delete All Data removes grants from either side. The wipe loop asks
every model for "this account's rows", and a table relating two
accounts has no user_id to answer with, so wipe-plan.ts now declares
the owner columns and translates. Wiping only the grantor side would
leave the account still holding access to other people's records.
- Account deletion removes them in both directions.
- An admin restore leaves them exactly as it found them. Grants are
classified NOT_IN_BACKUP: rolling data back is a decision an admin
makes, but rolling an authorization back is a decision nobody makes,
and a grant revoked on Tuesday must not come back alive out of
Monday's file. After a restore, sharing is off and both people
re-consent.
The backup classification guard was reading a user relation by field
name, so a model naming its account relations grantor and grantee read
as instance-scoped and was excused from having a verdict at all. It now
reads the type. That surfaced InviteToken, classified here too.
The server names every unresolved catalogue key in its response and in the
audit row, and the integration suite holds that end. This is the other one:
a report nobody sees is the same silence the naming replaced.
The panel moves into its own exported component so it can be rendered
against a report directly. The SSR smoke tests in this directory cannot
drive a state transition, and a render proven only by reading it is not
proven. Pinned: every key is printed rather than summed away, each key
carries its own count and catalogue, an empty report renders nothing at
all, and the copy resolves in a second locale.
Three of the restore's lookups resolve a key against a seeded catalogue:
cycle symptoms, illness symptoms, mood factors. Each threw on a key it
could not resolve, inside the one transaction that wipes and rebuilds the
account. So a single renamed symptom rolled everything back and answered
500. No measurements, no doses, no notes, nothing. A year-old backup read
by a newer instance is the ordinary case for a backup, and the catalogue
drifts between those two dates by design.
The unresolvable link is dropped and named now. What it costs is one
association between a row and a symptom; the day-log, the mood entry, the
flow, the temperature and the encrypted note all come back. The count and
the exact keys ride the response, the audit row and the wide event, and
the admin surface shows them in a panel that stays put instead of a toast
that leaves. A quiet drop would be the older failure of this same seam
wearing a success message.
A dangling `cycleId` or a dose recorded against a medication the file
never carried still throws. Those name rows the file was supposed to hold,
so a missing one means the file contradicts itself. A catalogue key names
a row the instance owns, which is why it gets the other answer.
`IllnessSymptom` carries no owner column at all, so a key that will not
resolve there can only ever be drift; the file was never able to supply it.
The restore does not mint the missing catalogue row back. That would
preserve the link at the price of reference data colliding with whatever
the next migration seeds under that key, and a label nothing can translate.
The top bar's bottom border and the sidebar logo band's bottom border draw
one horizontal line across the top of the app, and they sat a pixel apart.
Both declared h-16, so the number was never the problem. Every box in the
app is border-box: the top bar carried `h-16 border-b` on one element, which
puts the line inside the 4rem at 63 to 64 px, while the sidebar put `border-b`
on the wrapper and `h-16` on the link inside it, which puts the line after the
4rem at 64 to 65 px. Same number, different line.
The height and the border now travel together as SHELL_HEADER_BAND in
shell-metrics.ts, next to the sub-shell scroll floor that exists for the same
reason. The element carrying that class is the band; neither call site
restates a height, and the sidebar link stretches with h-full. Border colour
stays per-surface, because the two chrome surfaces are tinted apart by design
and only the geometry has to match.
Two guards. chrome-header-seam.test.tsx asserts the structural property, that
each band renders every token of the shared constant and declares no height of
its own; a second height class on either element replaces the shared one
through twMerge, so both halves fire on the same edit. The e2e spec measures
the painted result at 768, 900, 1024 and 1280 px in both the expanded sidebar
and the collapsed rail, and checks each band actually paints a border at the
same weight first, so two missing lines cannot pass as two aligned ones.
`settleBeforeMeasure` ends with a `waitForLoadState("networkidle")` whose
rejection is swallowed and a `page.evaluate` awaiting
`document.fonts.ready`. Both are written to end on their own terms, and
neither had a bound: the config leaves `actionTimeout` and
`navigationTimeout` unset, which Playwright reads as no limit, so a
single sub-resource that never completes consumes the whole test budget
rather than the wait.
The way it surfaces is the worst part. The swallowed timeout hides the
stall, the run reports whichever call was still pending when the fixture
closed the page, and the message reads
Test timeout of 30000ms exceeded.
Error: page.evaluate: Target page, context or browser has been closed
which names neither the surface nor the resource. Raising the budget
changes nothing, because no budget bounds an unbounded wait.
Route one font request to a handler that never answers and the current
code reproduces that message exactly; with the bound in place the same
run reaches the spec's own assertion in twelve seconds.
Six seconds is an order of magnitude above the slowest settle measured
across the suite (1.1 s, /insights at a mobile viewport).
The `/insights/workouts` case gated its axe scan on `#main-content
[role="status"]`. That role is not a content marker: the busy skeletons
carry it too, and the one the streamed route shell paints first is a
`chart-skeleton` with `aria-busy="true"`. The sentinel therefore went
green on a document the page client had not touched yet. A frame trace
under a 20x CPU throttle puts that skeleton on screen at 408 ms and the
page's own `<h1>` at 4119 ms, so for most of four seconds the scan could
land on a tree with no level-one heading and report
`page-has-heading-one` against `html`. That is the failure a loaded
runner has been showing, and the page it accused has the heading.
Gate on the two states the page itself renders instead, the list or the
empty state. The overview case above already excludes `aria-busy`
skeletons the same way.
`/coach` had a smaller version of the same gap: `coach-page` is the
sizing wrapper around a `<Suspense fallback={null}>`, on screen 241 ms
before the conversation under it exists. Gate on the conversation.
Neither sentinel waits for a heading. Removing the `<h1>` from
`SubPageShell`, `CoachHero` and the conversation's screen-reader title
still turns the matrix red with four `page-has-heading-one` violations
per theme.
The nine-rung verdict ladder was written for the opt-in dashboard hero.
That hero was retired when the digest-driven Today rail became the single
promoted hero, and its i18n copy was dropped across every locale in the
same change, but the resolver stayed behind. Since then it has had no
caller outside its own tests: nothing renders it and no string exists for
any of its variants.
Every rung it computed already has a live owner. Dose states ride the
digest's dose_window item, an elevated-at-rest window rides tension_window,
the score delta rides the digest score block the Today hero paints its chip
from, weight drift and sleep deficit and a silent stretch are the coach
nudge cron's own triggers, and a crisis blood-pressure reading is the
safety-floor engine's. Wiring a renderer back on would rebuild the second
hero the retirement removed.
- Delete the resolver, its ladder suite, and the client-safety import walk
that pinned its bundle graph.
- Fold the two shared nudge thresholds back into the cron. The leaf module
existed only so a client component could read them without dragging the
server graph along; there is no such component now.
- Repoint the score-config guard off the verdict onto the digest fields the
Today hero actually reads, so it still fails on the assembly it was
written for.
- Repoint the clinical-floors notes off the retired surface.
Three integration cases pinned the old behaviour: the sync handshake stamping
the checkpoint, and the calendar read persisting the prediction. Both are the
faults this release removes, so the cases were asserting a bug.
Each file now asserts the read writes nothing AND that the other end does. The
second half is not decoration: without it, "nothing was written" would pass
just as well against a column nothing writes at all.
The cycle case seeds its own history and its own profile row rather than
reaching a route to get one, because reaching the route is exactly what the
prediction must no longer depend on.
The assertion in the module-gate suite could not fail: that fixture's mood
badges carry no completion date, so the result it drove had no pending
unlock and "nothing was written" was true either way. Restoring the INSERT
left it green.
The case moves to its own file with the builder faked so the result always
carries a pending unlock. Undoing the fix now turns it red. The
disabled-module invariant the old case reached for lives with the write, in
the sweep's suite.
A GET must not have side effects — api-handler.ts says so next to
READ_HTTP_METHODS, where an MCP-audience token is admitted on the safe
methods precisely because they are assumed to write nothing. Three routes
broke that: the sync handshake bumped User.lastSyncedAt, the achievements
read inserted unlock rows, and the cycle calendar fired a discarded promise
at the forecast cache so a failed write left no trace anywhere.
Each write moves to the place that causes it.
The sync checkpoint is now stamped by the batch ingest endpoints for
measurements, mood and intakes. The handshake reports it. That also fixes
the watermark: the checkpoint used to advance past a window the client had
not drained, and a client trusting it would skip that window. It can only
trail a real sync now.
The unlock rows move to a nightly achievement-unlock-sweep. The badge grid
never needed them — unlocked comes from the live metrics and the completion
date is derived — so the payload is unchanged; the rows pin that date and
carry it into the backup, and the sweep reaches accounts that never open
the page.
The cycle forecast moves to a nightly cycle-prediction-refresh. The
CyclePrediction row is not a cache of a read: it is the index the reminder
cron scans, so which reminders an account received depended on whether
somebody had opened the calendar. persistPredictionCache no longer
swallows its errors either — the job reports them.
The two calendar-day cases built their due instant with setHours on the
process clock, which vitest pins to UTC, while the component floors the due
date in the profile zone. Those two disagree about the date for the two hours
before UTC midnight, so both cases went red every night in that window and
green again by morning.
Deriving the date parts from the display zone removes the disagreement rather
than hoping the suite never runs at night. Noon is chosen deliberately: at any
offset the zone uses, 12:00 UTC lands on the same calendar date there, so the
instant cannot slide either way.
Verified inside the failing window: red before, green after, and putting
setHours back turns it red again.
Eight test and fixture files hardcoded scoreVersion: 2, a value the
constant has not held since v1.35.0. Now they import SCORE_VERSION and
use it, so a future bump cannot leave a fixture pinned to a stale
number.
The one genuinely historical case (record.test.ts, proving the input
fingerprint moves when the algorithm version moves) now derives both
its "previous" and "current" markers from SCORE_VERSION instead of two
unrelated literals, so the pair keeps its meaning after the next bump.
The Bearer guard demanded the literal `apiToken.findUnique(`.
`lib/idempotency.ts` writes the same call fluent-style, with a newline
between `prisma.apiToken` and `.findUnique({`, so the matcher never saw
it. It has been resolving a raw Bearer value to a user id since the
idempotency work landed, outside the frozen set, while the guard stayed
green on five files.
Tolerate whitespace around the member access and name the file with what
it does: it decides which user's idempotency cell a replay lands in, and
authorises nothing, because the handler still runs its own requireAuth.
Add the non-zero count assertion the other guards now carry. Narrowing
the regex back to its old form turns this test red, which is the proof
the broadened matcher is what finds the file. The mint-site matcher in
T3 was checked for the same defect and has no hidden members.
The Bearer guard freezes one mechanism: the files that turn an
Authorization header into a user. It reads like coverage of "who can
resolve a caller" and it is not. Two other families do the same job by
other means and no guard watches either.
Fifteen files under src/app read the session cookie directly instead of
going through requireAuth: eight API routes and seven pages. Nine more
authenticate on a token carried in the URL, including the clinician
share tree, which serves one person's whole health record to an
anonymous caller holding a path segment. A sweep that greps for
getSession sees none of the second family.
Freeze both as literal lists with a reason per entry, matched by five
independent legs: the session-deriving helpers, a direct read of the
session cookie name, a dynamic route segment carrying a credential,
share-token resolution, and a query-string credential. Legs overlap, so
each asserts its own membership and its own non-zero count. A leg whose
matcher stops matching then fails instead of letting the union carry it.
`<main id="main-content">` scrolls the authenticated shell, but it was
`position: static`, so an absolutely-positioned descendant with no nearer
positioned ancestor resolved against the initial containing block. That
block sits outside the scroll container: `overflow-y-auto` never clipped
those boxes, and their static position extended the DOCUMENT's scrollable
overflow instead. The result is a second vertical scroll surface beside
`<main>`, which UI-STANDARDS §9 forbids.
The escapees are library markup, so no per-component wrapper would have
caught them. A Radix `<Switch>` outside a `<form>` renders a hidden
`position:absolute` bubble input on its first pass and drops it only once
the button ref resolves, and the root `loading.tsx` chart skeleton carries
an `sr-only` announcement that lives until the real content streams in.
Both are gone within a few hundred milliseconds of hydration, so the page
carried a full-height document scrollbar for the whole pre-hydration
window and looked clean immediately after. On /settings/modules at a
390x844 viewport the document measured 1952 px against an 844 px viewport
while the switches were still bubble-inputs.
Anchoring the scroll container closes the escape route for every such box
at once. The over-scroll guard grows the matching structural assertion:
`<main>` must not compute to `position: static`. The height read it
already had can only see an escapee that happens to be mounted at measure
time, which is why it went red on a loaded CI runner and green on the same
tree twenty minutes earlier; the computed style is timing-free and fails
on every route and both viewports the moment the anchor is removed.
The MCP due-list answered "is this checkup overdue" by comparing the two
instants, so a checkup booked for nine in the morning read overdue from ten
past. The screens read the same reminder as "today" until midnight. One
reminder, two verdicts, and the model reading the tool result repeated the
one nobody could see.
Same counting as the screens and the briefing payload now, on the account's
own clock rather than the server's. A checkup stays due for the whole of the
date it belongs to, and is overdue from the date after.
The briefing payload states how far off a checkup is as a bare number the
model narrates back: "due in N days", "overdue by N days". It got that
number by dividing the hour gap by twenty-four, which answers a different
question than the one the sentence asks. A checkup booked for nine this
morning came out "overdue by 0 days" by the evening. One due Sunday at
01:00, read late on Friday, came out one day off — Saturday, on a calendar
where it is two. A clock-change day is twenty-three or twenty-five hours
long and rounded whichever way its length pushed it.
The checkup screens had exactly this and it was fixed there, so the counting
already exists. Lift it out as `calendarDaysUntil` and have both the phrase
and the number come off it, rather than leaving the payload with a second
body that can drift again. It counts by differencing two integer day indices
read on the person's profile clock, so the answer is exact rather than
rounded, and `extractFeatures` resolves that zone and hands it down — the
number now agrees with the screen the person is looking at.
Today's checkup moves off the overdue list, where the hour gap had put it as
soon as its appointment time passed. The hour it was booked for may be
behind, but the day it belongs to is not.
The horizon that bounds the read stays an hour cutoff on purpose. It is a
query bound, not a statement to anyone about which day something falls on.
Apple Watch ECG recordings reach the record straight from the app instead of
only through an export archive. One recording per request, gated by the same
switches that already govern reading them.
A recording is now identified by what it is rather than by what a source calls
it, so the same strip arriving through both doors converges on one row. The
migration that adds the constraint resolves the duplicates already stored: it
keeps the row holding the waveform over one holding only a verdict, and moves a
removed row's event link onto the row that is kept. The losing rows are deleted.
It asserted the stored ciphertext does not contain each sample rendered
as a string. Base64 is full of digits, so a short sample value like 3
matches by accident: the check failed on a mutation that had nothing to
do with encryption, and would equally have passed while leaking. It now
asserts the column holds the keyId.base64 envelope and nothing else,
which a serialised sample array cannot satisfy.
The mobile project read the day as final on a table with no sleep in it.
The rows were gone; what was left was the dashboard snapshot the desktop
run built from them. It carries the sleep-freshness the digest reads, it
lives in the analytics SWR bucket at sixty seconds fresh and an hour of
stale-serve, and a DELETE straight into Postgres does not touch it. So
the second project asked whether last night's sleep was in, and a cached
answer from the first project said yes.
The purge now goes through the bulk-delete route. That tombstones the
rows the way the management list does, recomputes the rollup buckets the
snapshot reads through, and hard-evicts the bucket, so the next read
rebuilds from the table rather than from the run before it. The
delete-by-external-id route names the rows the way this spec does, but it
invalidates without evicting, and a marked-stale cell is served while it
revalidates - the next read would still have been the stale one. The ids
come from SQL because the measurements list collapses overlapping sources
to one row per day and can hide a row that still counts.
The purge stays where it is rather than moving into the cleanup block:
the eviction only happens when the delete removes something, so tidying
up at the end of each run would leave the next one with nothing to delete
and the same stale snapshot.
The starting state is asserted, not assumed, and it stays that way.
A round trip through the real handlers: post a recording, read it back
through the list and the detail routes, and check what a client actually
receives rather than a fixture rebuilt at each end. Post it twice and
the second call says updated, not inserted, and the table does not grow.
Feed the same strip to the export-archive importer afterwards and it
says skipped; feed it to the importer first and the route says
duplicate. Both directions leave one row.
The migration test drops the index, manufactures the collisions the
resolution step exists for, and runs the migration file through the same
executor the deploy uses. It checks which row survived each tiebreak,
that the salvaged event link followed the keeper, that rows differing
only by user, source or sampling rate were left alone, and that the
constraint rejects a new twin afterwards. Applying the migration to the
empty testcontainer proves none of that.
The Withings ECG tests now hand the transaction the same delegate the
shared writer uses, so they still watch the real write instead of a stub
standing in for it.
POST /api/insights/ecg takes one recording per request: the stable
recording id the client already has, the on-device timestamp, the
sampling rate, the waveform as integer micro-volts, the lead, the
average heart rate, and the device's own verdict. Same gating as the
reads (auth, the insights module, the insightStatus surface), userId
from the session, APPLE_HEALTH the only source a client may claim.
sampleCount and durationSeconds are derived here, and nothing about the
waveform is ever interpreted.
The write body now lives in one place, src/lib/ecg/persist-recording.ts,
shared by the Withings sync, the export-archive importer and the new
route. Three writers with three persistence bodies is how the two ends
of one rule drift apart.
Migration 0291 adds the second unique index on
(user_id, source, recorded_at, sampling_frequency). The archive importer
identifies a strip by a hash of its content and a live client by the
device's own sample uuid, so the same physical recording arriving
through both doors carried two different ids and would have landed
twice. One device does not record two different strips at the same
instant at the same sampling rate, so the constraint is sound and the
convergence stops being a convention.
The table already holds rows on deployed instances and two writers have
been filling it since v1.19.0, so the index cannot just be created: a
pre-existing collision would abort the migration. The migration resolves
duplicates first, keeping the row with the most samples (a stored strip
beats a verdict-only row), then the one linked to its event
measurement, then the most recently written, then the lowest id so the
choice is total. The losers' event link is salvaged onto the keeper
first, so resolving a duplicate never costs the recording its place in
the event timeline.
If that resolution is wrong on some instance, the failure is visible at
deploy time and not later: db:migrate:deploy runs on boot, so the
container crash-loops with the migration named in the logs and the
release never serves traffic. A deploy that finishes in under thirty
seconds did not run it.
The GET list and detail routes and the export-archive path are
unchanged; this is a second door, not a replacement.
The changelog is meant to read as my own notes, but a stack of entries
narrated me in the third person ("the maintainer's complaints", "per
maintainer directive", "26 of the 27 maintainer findings landed") and
carried internal review choreography no reader can use: numbered review
rounds, finding counts, item codes, QA severity labels, a local path to
a design handoff.
Every one of those now states the change instead of who asked for it.
Where the only content was the attribution, the line is shorter or gone.
No entry's technical claim changes. "maintainer" stays wherever it means
the operator role of a self-hosted instance.
The flip from an authored recipe back to the defaults was written with the
retired pillar as the thing left out. With that pillar gone, both halves of the
case sent the same full list, so the first read false where it expected true and
the flip was never exercised at all.
It now takes out a pillar that still exists, and says at the assertion why the
two halves have to differ.
The spec watched the day flip from provisional to final on a hero that
only its own subject was keeping on screen: the arrival marker was the
last thing in the calm-degrade guard holding the hero up for the bare
fixture account, so removing the arrival chip took the hero with it and
left the spec nothing to observe.
Putting the arrival back into the guard would bring back a worse
problem. An arrival-only digest lands in the compact all-clear
composition, which would tell someone with no readings at all that
everything is clear - a claim about their health made from nothing, and
made only on the days something happened to sync. The guard stays as it
is; the fixture was what was wrong.
So the spec now seeds its own precondition: one integration needing a
reconnect, which is an ordinary Today for anyone whose readings come
from a device. It is read fresh on every request rather than through the
dashboard snapshot cache, so unlike a seeded score or briefing it cannot
lose a race with a cached snapshot, and the rail item admits no module
gate and no dismiss path. The row is removed again afterwards.
While the spec was open: it now pins the state it starts from. It only
ever asserted that the pending-sleep note was absent at the end, which a
note that never rendered also satisfies. It asserts the note is up and
the day reads provisional first, so the two halves are one assertion
with the number flipped.
The component side is pinned in unit tests, since the spec needs a built
app and a database to run.