An unrevealed chart cell is `invisible` and `aria-hidden`, so the chart row
is not in the accessibility tree at all until the reveal gate fires. Waiting
on the tile strip left it to chance whether the row was read: locally the
cells were still hidden when the strip appeared and revealed six seconds
later, while the runner that reported the broken heading order had already
revealed them. The row is the largest surface on the page, so a scan that
sometimes skips it is not a check of the page.
Gating on a revealed cell makes the coverage the same on every run. With the
heading level put back to its old value the case now fails 16 out of 16
repeats across both themes and both viewports, where the same defect
previously surfaced twice in several full runs.
A dashboard chart card called itself an h3. Nothing contained it. The cards
beside it in the same grid — "Recent unlocks", "Recent workouts" — are h2,
and so are the target and usual-range cards on the metric subpages, so the
chart card was both the odd one out and a level skipped straight from the
page h1. The rendered dashboard ladder was h1 → h3 → h3 → h3 → h2 → h2, and
/insights/bmi was h1 → h3 → h2.
A heading level is a claim about nesting. Someone moving through the page by
headings hears a subsection announced where there is no section, and screen
reader rotors build their outline from exactly this. Promoting the title
keeps the same text, the same classes and the same pixels; only the claim
changes.
The unit guard fails in the fast run; the browser scan catches the same
regression through axe, but only in the e2e job.
A child model rides its parent through the backup, so the check looks
for the relation field name as well as the delegate. It looked for the
bare name anywhere in the declared files, and `symptomLinks` is declared
twice — on `IllnessDayLog` for the illness link, on `CycleDayLog` for
the cycle one. Deleting the illness branch from the restore route left
all twelve checks green, because the cycle write in another file was
answering a question about a different table.
A name that points at exactly one model is still evidence anywhere. A
name shared by several counts only in a file that also operates on the
declaring parent's delegate, and a check asserts the cycle file proves
the cycle link and refuses to prove the illness one.
That leaves the illness link unprovable from source: its include is a
shared constant with no query beside it, and the parent that would
disambiguate it is reached through the episode in another file.
`STRUCTURALLY_UNATTRIBUTABLE` names it with what defeats the matcher,
and naming a model there is not a free pass — it has to be seeded in
the round trip, which is checked here.
The plan says which models a backup carries and a source-text check says
each one has a reader and a restore branch written for it. Neither can
tell a restore branch that runs from one that only exists: wrap the
nutrient restore in `if (false && …)` and every check stays green, which
is the correct-green-and-inert shape this project already shipped once.
So settle it end to end. Seed one row of every two-ended model, export
through the real payload builder, delete the account so the cascade
empties what a hand-listed sweep would miss, restore through the real
admin route, and count each row back out. A model that never travels
comes back at zero and says which one it was.
The two-ended list is a literal tuple now, so the seed registry can be
keyed by it: adding a name without seeding and counting a row for it is
a compile error rather than an unproven claim.
Also names `src/lib/illness/dto.ts` as a backup writer. The illness
day-log's symptom include is a shared constant living there, not a
literal in the records writer — the same "the caller delegates" shape
that already caught the measurement pager.
Several accessibility scans ran against a page that had not finished
arriving, so axe reported violations the app does not have. The worst of
them was the medications case, which waited for a level-one heading and
then checked that a level-one heading exists. A page that really lost its
heading would have waited out the timeout under a misleading name instead
of failing the rule, which makes it a check that cannot fail rather than a
flaky one.
The filter rail was the other bad sentinel. It renders beside the loading
silhouettes rather than after them, so it was on screen while the rows were
still grey rectangles. Under an 8x CPU throttle the rail and the loading
marker resolved in the same 40ms poll and the rows a poll later.
Every case now gates on a marker the real component carries in its
resolved, non-empty branch, and on a component other than the one the scan
asserts. Both the desktop table and the mobile card list carry the same
marker so the wait holds at any viewport. No sentinel names a heading, the
app frame, a busy marker or the filter rail any more.
The two cycle entries in the frozen list claimed the gate reads `gender` off
the resolved auth context. It does not: `requireCycleEnabled` names that
parameter `_gender` and ignores it, then re-loads gender and the profile by
the resolved user id through the module gate. The answer is the same — the
owner's — but a reviewer checking the claim would have found a dead argument
and no explanation.
The profile route is the exception and now says so: it passes the auth-context
gender to `isCycleEnabled` itself to compute the flag it returns.
A frozen-list entry is only worth the reviewing it invites, so it has to
describe the mechanism that is there rather than the one the call site looks
like it has.
The strip is new this release and had not been checked against the
design standards. It carried the shape §11 names as the anti-pattern: a
centred flex-wrap row whose gap-y drops the action onto its own line
once the name is long enough. The maintainership strip is the sibling
that already had an action and already had the right shape, so the two
now read the same — sentence on min-w-0 flex-1, way out on shrink-0,
beside it rather than under it.
The exit control was a 32px tap target. Leaving somebody else's record
on a phone is the one interaction in the whole banner stack, and it now
carries the standard's floor (min-h-11 sm:min-h-9).
The three strips also spell their icon size three ways for identical
CSS; they all use size-3.5 now.
Text tiers are unchanged and were already right: the person's name is
foreground because it is content, the read-only qualifier is muted
because it is meta.
The offline, shared-record, demo and maintainership strips lived inside
the content column, so each one pushed the top bar down while the
sidebar's logo band stayed at the top of the window. Both bands read
SHELL_HEADER_BAND and are therefore the same height with the same
border, and the single line they draw across the top of the app still
came apart: measured on the French maintainership strip, the two borders
sat 66.5px apart at 768px and 61.0px apart at 1280px, and the full
three-banner stack put them 184.5px apart. After the hoist the step is
0.00px at every depth and both widths.
A banner is a statement about the whole window, not about the content
column, so it belongs above the row. The outer wrapper keeps the h-dvh
and the row below takes what the banners leave, which means the stack
comes out of the viewport budget instead of being added on top of it:
the document still does not scroll at any depth, and <main> stays the
one vertical scroller at both breakpoints.
The existing seam guard could not see this class. It asserts that both
bands carry the shared band tokens, which stayed true the whole time,
and the e2e spec beside it measures a shell with nothing above the
header. The new spec forces the banners on through the conditions the
components actually read — an unmaintained locale, a patched account
payload, a real offline context — and measures the same two borders at
four stack depths. Reverting the layout change turns it red on the first
banner with the measured offset in the message, while the older seam
spec stays green.
The maintainership strip gains a data-slot so the shell's chrome can be
enumerated from one attribute.
`GET /api/measurements` was the resolver test's example of a route refusing
under a switch. It is delegable now, so that arm was asserting the opposite
of what ships and went red on the migration — which is the arm doing its job,
not a defect in it.
Split in two. The measurements route keeps both of its real claims: unchanged
un-switched, and the owner's rows and not the delegate's once switched. The
refusal moves to `GET /api/share-links`, which is non-delegable permanently
and by design — a delegate who could mint a clinician link would hold a door
that survives the owner revoking their access. A route that can never join
the frozen list cannot break this arm the same way twice.
Ten routes: the cabinet list, the medication detail, its cadence, schedule
revisions, side effects, inventory, phase config and intake history, plus the
account-wide intake list and the batched compliance read.
`GET /api/medications/compliance` is the one delegable read that spends a
quota, and its rate-limit bucket now keys on the ACTOR while everything below
it scopes to the record. Both halves matter: a delegate hammering the route
must burn their own allowance rather than lock the owner out of their own
cabinet, and switching records must not hand the same caller a fresh one. The
test reads the `rate_limits` row rather than inferring it from a 429, because
a bucket keyed on the wrong person still answers 200.
`GET /api/medications/[id]/api-endpoint` stays refused. It returns two scalar
fields and no secret, and it tells a delegate whether the owner holds a live
ingest credential and for which medication — in the same file as the PUT that
mints one that would survive revocation.
The two intake validation breadcrumbs move onto `auditLog()` for the actor
stamp. The POST arm's breadcrumb stays as it is: that arm cannot be reached
under a switch.
Four routes: the cycle history, the day log, the profile and the custom
symptom vocabulary.
All four gate on `requireCycleEnabled(user.id, user.gender)`, and both
arguments now come off the record. That is the correct reading and the one
that had to be verified rather than assumed: whether cycle tracking applies
is a property of the record, so a delegate who tracks nothing still sees the
owner's cycle, and a delegate who tracks their own sees nothing when the
owner does not. Both directions are asserted.
Eight routes: the two sleep reads, the nutrient overview and its daily
series, the illness episode list, detail and day logs, and the workout list.
The illness day-log read is the one worth reading twice. Its queries are
episode-scoped rather than user-scoped, and what makes them safe is the
`loadOwnedEpisode(id, user.id)` hop above them. That hop now resolves against
the record, so a delegate cannot reach their own episode from inside somebody
else's account — asserted, because it is the kind of thing that would look
fine and behave wrong.
`GET /api/workouts/[id]` stays refused. It differs from the list beside it by
one conditional include that decrypts a paragraph written about the owner.
The switch is stamped on the session row, not held in the tab. Every
authenticated spec opens its context from the same storageState file, so
the sharing journey switching that jar into the owner's record switched
it for whatever spec happened to be mid-navigation on the other worker.
The failures land nowhere near the cause: /settings/ai painting "Not
part of shared access" instead of the AI panel, an axe scan reading a
banner its spec never asked for, a waitForResponse waiting on a request
the refused surface never sends. Which spec loses depends on scheduling,
which is why it moved between runs.
Log the delegate in a second time and hand that jar to the journey. Same
account, its own session row, no reach outside the file. The three login
captures collapse into one helper while the third one is added, and the
fixture's login-bucket reset already covers the extra attempt.
The guard matches the two things a switching spec cannot avoid naming,
the switcher entry it clicks and the endpoint that entry calls, and
checks both that the match set is non-empty and that the shared jar is
still what the ordinary specs read. Verified red by pointing the journey
back at the shared jar.
The credits page named CC BY-SA 4.0 alone. MaxMind stopped distributing
GeoLite2 under that licence by itself: the databases ship under the
GeoLite End User License Agreement, which incorporates CC BY-SA 4.0 by
reference and controls wherever the two disagree. Name both, in that
order, and link the EULA. The attribution string was already there and
is unchanged; the e2e page check now asserts both licences rather than
the one that no longer stands alone.
Reconcile the self-hosting guide with the same wording, point it at the
current EULA URL, and say why the `/about` page must stay reachable — it
is where an operator's own attribution lives.
Correct the project licence line while on the page: it still claimed
AGPL-3.0, which the page's own footer contradicts. The trunk has been
PolyForm Noncommercial 1.0.0 since v1.15.19.
Six routes: the mood-entry list and detail, the custom-metric list, detail
and entries, and the personal-record list. The custom-metric entries read
establishes ownership on the metric before it queries by metric id, and that
hop now resolves against the record — a delegate cannot reach their own
metric from inside somebody else's account.
`GET /api/mood/tags` stays refused. It looks like plain record vocabulary and
one select in the middle of it pulls the owner's tag layout, which is a
presentation preference and belongs to the person rather than the record.
The mood list's validation breadcrumb moves onto `auditLog()` for the actor
stamp, the same change the measurement reads took.
Every credential-shaped row is left out on purpose — API tokens, trusted
devices, step-up elevations, known devices, clinician share links, and the
grants behind shared record access. The per-model reasoning already lives in
backup-plan.ts with a structural test behind it, but an operator planning a
recovery does not read that file.
The consequence differs by target and the difference is the part worth knowing.
Restoring onto the same instance changes nothing: the restore replaces data
tables and does not touch grants, so shared access carries on as it was.
Restoring onto a fresh instance brings none of it, so both people invite and
accept again before sharing works. That is the fail-safe direction, and it is
better planned for than discovered.
Vitest is a knip plugin, so every file under `src/**/__tests__/**` is an
entry, and a module whose only importer is its own test counts as used
for as long as the test survives. Planting a module with a single test
importing it left `pnpm knip` at exit 0 — the gate reports green on code
production stopped reaching.
Knip can answer the question, just not in the pass CI runs: production
mode drops the plugin's test entries and keeps the entry and project
patterns suffixed with `!`. Mark them, which is inert in the default
pass, so `pnpm knip --production --include files` is one command away.
It stays a review tool rather than a second gate, and the config says
why: twenty-nine files today, some test-only by construction and some
unreachable from production on purpose, plus at least one false positive
where a script's `@/` import does not resolve. Baselining that set
unread would encode noise as an invariant.
Move the config to JSONC so all of the above lives next to the settings
it describes instead of in a commit message nobody will run `git log`
for.
Ten routes: lab results and their detail, the biomarker catalogue and its
detail, allergies, family history, the mental-health screener history, and
the health-profile facts. All GET arms; every write arm in the same files
keeps `requireAuth()` and refuses under a switch.
The family-history payload is the one admitted read that carries third-party
health information by design — it describes the owner's relatives — and it is
admitted because a caregiver reading a family history is what the feature is
for and the rows are the owner's own.
The mental-health module gate resolves against the record, not the caller.
That is the recorded decision: the module map says which parts of a record
exist, so a delegate who enabled the screener on their own account gains no
screener view inside somebody else's. The integration test asserts both
directions.
git, eslint and vitest each carry an exclusion for local development
worktrees, and all three name /.worktrees/ while the convention actually in
use is /.wt-*/. Every one of them was therefore walking eleven full copies of
the source tree.
The consequences differed by tool and none of them announced itself. pnpm lint
took ten minutes instead of one and reported warnings from whatever commits
those copies happened to sit on. vitest collected duplicate suites that drag in
a second React instance, which fails in ways that have nothing to do with the
working tree. git left them merely untracked, one git add -A away from
committing the repository eleven times over.
With the true warning count finally visible, teach no-unused-vars the
underscore convention this codebase already follows and drop the five
disable directives that convention made redundant. pnpm lint is clean.
A non-underscore unused argument still warns; that was checked rather than
assumed.
A consent record has to answer who consented and when, and the grant row
already does. It does not have to answer from where, and an address kept for
as long as both accounts exist is personal data held past the point it
evidences anything. Nothing read the column, which makes it exactly the
write-only field scripts/audit-column-readers.ts exists to surface.
The acceptance stays evidenced: audit_logs records it with the caller's
address under that table's own retention. The column never reached the wire,
so the grant view and the OpenAPI contract are unchanged.
Written before the feature shipped, so migration 0295 drops an empty column.
Six routes move from `requireAuth()` to `requireRecordAuth("read")`: the
measurement list, the single reading, the two series reads, and the two
reminder reads. Each is admitted in the delegable classification with its
five answers written; each enters the frozen list with the argument for why
a delegate reading through it cannot extend their own reach.
Only the GET arms move. The POST, PUT and DELETE arms in the same files keep
`requireAuth()`, which refuses outright while a switch is active, so a file
entering the list admits a read and nothing else.
The two validation breadcrumbs on the list and series reads now write through
`auditLog()` rather than a bare `prisma.auditLog.create`. That helper is the
only writer that stamps `actorUserId`, and without the stamp a delegate's
malformed query lands on the owner's record looking like the owner's own.
Integration coverage drives the shipped GET exports against real Postgres:
the owner's rows reach a delegate holding a live grant, the delegate's own
rows do not, a caller without a grant is refused `sharing.access.denied`, the
request after a revocation is refused with no re-login, and a caller who
never switched sees exactly what they saw before.
`fs.globSync` never matches a leading dot, so every guard built on
`**/*.{ts,tsx}` walked past `src/app/.well-known` — the two OAuth
discovery documents and the apple-app-site-association handler sat
outside fourteen frozen-set sweeps. An unlisted `getSession()` planted
in the authorization-server route left the session-surface guard at nine
passed; it fails now.
Move those fourteen onto one shared walker built on `readdirSync` with
`recursive`, which has no dot rule, and give it a floor every caller has
to state, so a walk that comes back too small throws instead of
reporting a clean tree.
Give the same floor to the guards that compared a discovered set against
a frozen list with nothing asserting the set had been read: gutting the
walker in the inventory-write guard left its invariant test green,
because zero offenders reads exactly like no offenders. Raise the
migration-count floor off one, and assert the locale bundles are found
before the per-locale cases are generated from them.
Document, at each walker that prunes on purpose, what is pruned and that
dot-prefixed directories are not.
An operator page for the feature, written in the register the
reproductive-health page established: say the narrow true thing rather
than the reassuring one.
What the person given access can reach, and the table of what they cannot
— credentials, sessions, tokens, connected services, notification
channels, exports, share links, the sharing itself, and the AI surfaces.
The AI row gets its own paragraph because it is the one that looks
arbitrary: server-managed processing runs under the consent the record's
owner gave for their own use.
Revocation is described honestly. It lands on the next request rather
than the next login, it puts a browser that was inside the record back
into its own account, and it cannot take back what was already seen. The
product can close the door; it cannot reach through it, and the page says
so rather than implying otherwise.
The activity view's window is described as the operator's setting, not as
365 — an instance that shortens the audit retention shortens this view,
and an empty list means nothing was recorded within the window rather
than that nothing ever happened.
The three trust facts stay where they were: the operator could always
read the database, encryption at rest is instance-keyed rather than
per-user, and AI processing stays under the owner's own consent.
The data-protection note is deliberately absent — its stance is a
maintainer decision, and the page reads complete without it.
Two browser contexts, two seeded accounts, one database: the owner invites,
the delegate accepts and opens the record, the owner takes it back. Every step
through the shipped UI, because the half of this feature that can go wrong is
the half a person touches.
The stale-paint check is a negative one and deliberately not "the new record's
data arrives" — a check that waits for the new data says nothing about what
was on screen while it arrived. An init script arms a MutationObserver before
any page script on every navigation, including the reload the switch performs,
and latches the delegate's own marker weight. The observer's positive control
runs first on a page that definitely shows it, so its later silence is not the
silence of a broken observer.
Then it was broken on purpose, and the result is written into the spec rather
than assumed. Removing the switch-time cache wipe, the persister's restore
guard and the record-scoped hash — all three — leaves the spec GREEN: the
restore already refuses to clobber a query the live cache holds, and the
dashboard's query is created during render, so the snapshot never reaches that
route's screen at all. Removing the reload turns it red on the banner rather
than on the marker. So this suite is a regression net over the journey, not a
proof of any one control; the controls are proven where they can be broken and
observed, in the unit suites, and the spec's header says so.
The nav assertion turned up a real gap while it was failing: the avatar block
still linked into the account settings from inside a shared record. Those
routes refuse under a switch, so the link led only to an explanation of why it
did not work. It renders as a plain identity block now, and the mobile user
menu drops the same utilities.
Setup gains a second account, a marker weight in each record, a grant reset,
and a clear of the login bucket — this setup signs in twice now, and the
ceiling is five attempts a quarter-hour, so three local runs used to end with a
429 from the fixture rather than a failure from the product.
Three additions to the contract, all of them things a native client cannot
work out on its own.
`GET /api/auth/me` enters the spec for the first time, and enters it loose:
`accountAccess` is specified in full, the remaining preference fields are
declared open rather than half-enumerated. The endpoint has never been in this
document and its payload carries thirty-odd keys no contract has ever named;
specifying a subset and closing the object would publish something the server
does not keep.
`X-HealthLog-Account` is published as a reusable parameter with no route
referencing it, which is the honest state of the contract — the routes that
read another account's record arrive with the surface migration and will
reference it as they land. The header is live in the resolver today, and a
client needs the description before it can be built against a tagged spec: the
value comes from `accountAccess.accounts`, it is a selector and never a
permission, and it must never ride the sync engine, device registration, or an
auth call.
`GET /api/account/activity` carries `retentionDays` in the response and says
why: the window is operator-configurable, so a client that assumed 365 would
tell somebody their record was never opened when the honest answer is "further
back than this reaches".
The parameter is defined in the sharing route module rather than the shared
components block, so the boundary guard keeps holding the header's name to two
files — the resolver that reads it, and the one place that publishes it.
Each behaviour here was verified by removing the code that provides it and
confirming the test went red, then putting it back. Two of them did not, and
both were rewritten rather than kept:
The restore-side persister check asserted that the delegate's own dashboard
could not be read back inside a shared record. Deleting the guard left it
green, because the record-scoped hash already makes a snapshot written in one
record unreadable in another. It now counts what landed in the cache, so the
guard it names is the thing it measures — and the weaker line is kept beside
it with a note saying why.
The switcher's account rows were asserted from server-rendered markup. Radix
portals do not render server-side at all, so an earlier draft was measuring
the renderer. Those rows moved to the browser suite; what stays here is the
gate, which fails sharply — with the `canSwitch` bail removed the two cases do
not return different markup, they throw.
Two more shapes worth naming. The cache dimension is exercised through a real
QueryClient built from the app's own defaults, because "the hash string
changed" and "one record's data cannot be read while inside another" are
different claims. And the invalidation is a plain function over a client with
three live entries seeded, with a test that asserts they start valid — without
it, a client that failed to seed and one where everything was invalidated look
the same from every other assertion.
Settings gains one section for one concept: who else can open this health
record, whose this account may open, and when they were opened. Split across
two pages it would be two half-answers.
An invitation confers nothing until the other person accepts it, and the card
says so above the field rather than after the submit — that is the part
somebody needs to know before they type a name. Refusals are named from the
stable errorCode, because "no account with that name" and "that person already
has access" are different situations with different next steps.
Ended grants stay in the list. The row is the consent record, and "who had
access, from when to when, and who ended it" is the question the panel exists
to answer; a list that dropped them could not. Revoking asks once and then does
it: reducing access must never be harder than granting it was.
`GET /api/account/activity` answers the owner's other question. One row per
person per day with a count, not one per request — a hundred identical lines
is a way of answering nothing. The window is published with the rows rather
than assumed: audit history is purged on a schedule the operator sets, so the
view prints the number the server resolved from the same setting the purge job
reads. An instance running ninety days says ninety.
Six locales ship with it.
Switching is three acts in a fixed order, and the order is the safety
argument. Ask the server, so a refused switch leaves the browser where it was.
Empty the caches a reload cannot reach, awaited rather than fired, because a
wipe racing the navigation is a wipe that sometimes does not happen. Then
reload the document, which is what discards the in-memory cache, the rendered
React trees, and every request already in flight against the old record.
The banner is the feature, not decoration. Someone who forgets they are
switched will log their own blood pressure into another person's record, and
there is no undo for a reading that was never theirs. So it states whose
record is open, that it is read-only, and carries the way out — in the one
visual register reserved for "something about this screen is not normal", at
the top of the shell above every other strip. A subtle tint is what people
stop seeing on the third switch.
The switcher lives in one component mounted in both user menus. A way in on
the laptop and no way out on the phone is how the two menus have drifted
before.
Nav entries that sharing does not cover drop out, and a page reached by
bookmark says so instead of painting a row of refusals. Both are paint: the
server refuses those routes on its own, from a frozen list this code cannot
reach. Settings, connected services, notifications and the AI surfaces are the
account around a record rather than the record itself.
A grant can end while a browser is inside the record. Revocation clears the
delegate's sessions in the same transaction; expiry does not, and leaves a
session stamped with an account it may no longer act on — correct refusals
under a screen that lies. One cache subscriber notices the stable errorCode
and leaves, rather than asking two hundred call sites to remember.
Every cached read in this app is about one health record, and nothing in the
key factory said which one — there was only ever the signed-in account's. Once
a browser can act on somebody else's, `["dashboard","snapshot"]` names two
different answers and the cache cannot tell them apart. That is this repo's
known cache-poison class, on health data, across two people.
One line at the root rather than a dimension threaded through twenty-three key
modules: a `queryKeyHashFn` that folds the record id into the cache identity of
every entry. Key arrays are untouched, which is the part that matters — prefix
invalidation and the import-time dependent-key bundles both match on the array
and keep working. Prefixing the arrays would have broken every bundle silently.
The hash is defence in depth. Switching is a full page reload, so the
in-memory cache is empty on the other side of it; the layers that survive a
reload are the IndexedDB snapshot and the service worker's data cache, and
those are handled by refusing to write either while a switch is on, and by
skipping the restore. Nothing of the owner's record ever reaches the disk of a
delegate's device, so a stale scope marker can cost an empty restore and never
another person's data.
The persister's per-account binding read the account payload's id, which under
a switch is the delegate in both states — it could not tell the two apart. It
now asks the record scope, which can.
`GET /api/auth/me` grows an `accountAccess` block: the accounts this caller
may act on, the one the browser is inside right now, and the booleans a UI
binds directly — `canSwitch`, and `canWrite` per account. Every value is
resolved server-side. A client that worked out what it may do from grant rows
would be a second program deciding one person's access to a health record, and
the two answer differently the first time an expiry or a revocation appears.
`canWrite` is false for every grant in v1 because invitations write READ only.
It is resolved through the grant state machine rather than hardcoded, so the
day a WRITE grant exists the answer changes without this file being edited.
The route becomes an actor surface. It has to be: the switcher, the banner
naming whose record is open, and the way back out all read this payload, and
under a bare `requireAuth()` the whole app shell would refuse the moment a
switch was on. Every field it returns is still the caller's own.
The active record is published as a full entry, not an id to look up. A banner
that had to join two fields could render unnamed the moment they disagreed;
here the active entry is one of the listed ones, selected by the pass that
built them. A stamp left behind by a grant that lapsed reads as not switched —
the same answer the resolver gives the next delegated request.
Not published: the other account's avatar. Those bytes are owner-scoped, so a
URL here would resolve to a refusal and paint a broken image. Clients keep the
initials fallback.
The first entry in either frozen list. It is the way back out of a
switch: every other route refuses while one is active, so a browser that
could not reach this one would be stuck inside a record it entered. The
entry names why it may say what it says — one session row, the caller's
own, nothing of the owner's rendered and nothing granted.
The entry count moves with it, so an addition has to be made twice
rather than once.
The lifecycle surface under /api/account. An owner offers read access to
somebody already on the instance, the delegate accepts, either side ends
it, and a browser session points itself at a record it has been granted.
Ending a grant clears the acting-account carrier on every session of the
delegate pointing at that record, in the same transaction that stamps the
row. Split apart, a revocation would leave a browser inside a record it
can no longer read: every request refused, the banner still naming the
owner, nothing on screen able to explain it.
Revocation has no step-up and no confirmation step. Reducing access must
never be harder than granting it was.
Grant management is non-delegable by construction rather than by a check:
every route here resolves through bare requireAuth(), which refuses while
the caller is acting on another account, so no code path through them
runs as somebody else. The switch endpoint is the one actor surface, and
it is the way back out. A delegate already inside a record renounces
after switching out, one request, rather than the fail-closed default
growing an exception that later gets widened.
The state machine is not re-implemented anywhere: routes call the grant
module's predicate and its transitions, and the session carrier has one
writer.
Adds AuditLog.actorUserId (migration 0294) and threads it centrally, out
of the wide-event context the acting-account resolver already stamps. No
call site passes an actor, so no route can forget one.
NULL keeps meaning "acted for themselves" — the reading every existing
row carries. The threading fills the column only when a request resolved
an acting account AND the row is being filed under that account, so a
self-action, a refusal, and a delegate's row about themselves all stay
NULL rather than acquiring an id that would rewrite what the history says.
Delegated reads coalesce to one row per owner, delegate and day, through
a partial unique index and an insert that expects to lose the race. The
alternative is a row per request on the busiest table in the schema and
an activity view made of identical lines.
The column carries no foreign key, against the grain of user_id beside
it: SET NULL there erases an account from its own history, while here it
would turn "the delegate opened this record" into "the owner opened this
record". An id that no longer resolves reads as a deleted account; an id
that silently became NULL reads as a lie.
Both resolvers land with empty allowlists. That is the correct start and
also the shape that hides a broken matcher: a scan that finds nothing
agrees with a list that holds nothing, and the two look identical from
outside. So every leg pins a count that does not start at zero. The scan
asserts it found route modules, the two frozen legs assert the matcher
still finds the declaration site, the co-occurrence leg asserts each
cookie-only helper is present in the tree it reads, and the two
out-of-band Bearer surfaces assert they still resolve a token of their
own before asserting they reach no resolver.
Membership is decided from the parsed module rather than the source text,
so a route may explain in a comment why it is not delegable without being
enrolled by saying so, and an aliased import cannot hide one. The
synthetic modules at the top prove both halves of that.
Enumerates with a recursive readdir rather than a glob: globSync skips
dot directories, so it drops the three route handlers under
src/app/.well-known and leaves the sweep looking healthy at 444 modules
instead of 447. One of the three is pinned by name.
Scans 447 route modules and 2338 source files, and finds nothing.
The unit file walks the arms with the grant module deliberately unmocked,
so the resolver consuming the one "is this grant active" predicate is a
property under test rather than a claim in a comment. Its Prisma fake
honours its own `where` clause for the same reason: a fake that answers
any query with any row turns every scoping assertion into a check that
cannot fail.
The integration file proves the two things a unit suite cannot, because
both are about which rows come back. Substitution: the owner's
measurements are returned and the delegate's own, seeded alongside, are
not, so a resolver that quietly served the caller their own record shows
up as the wrong values rather than as an empty list. Never cached: a
revocation lands between two requests inside one test and the second one
403s.
The do-no-harm arm runs against a real shipped route, and the cookie-only
helpers are asserted against a real session row carrying a real switch,
because the one edit that would break them is `getSession()` itself
substituting the owner and no mocked session can catch that.
The structural guard freezes the set of files allowed to touch the
carrier column and the selector header, and asserts a non-zero match
count before comparing against the allowlist. A matcher that finds
nothing agrees with an emptied list and reports success; this repository
has shipped several guards that did exactly that.
One resolver, both transports, on every request. The cookie path reads
the carrier off the session row it has already loaded; the Bearer path
reads the `X-HealthLog-Account` selector header, because stamping switch
state onto a long-lived token would make the credential itself ambient
authority. Two carriers converge on one function for the same reason
`resolveBearerToken` lives in one file for two wires.
Three declared modes. `requireRecordAuth("read" | "write")` honours a
valid switch and substitutes the owner as the data scope while keeping
the delegate as the actor. `requireActorAuth()` always serves the
caller's own rows and keeps working under a switch, which is what the
switcher and the banner need.
Bare `requireAuth()` refuses to run under a switch at all, with a stable
`sharing.not_permitted` code. It does not fall back to the caller's own
account, and that arm is the point of the whole design: a delegate who
believes they are reading the owner's record and is handed their own is
the same data mixing pointed the other way. A route nobody has reviewed
is inaccessible while switched, never wrongly accessible.
The grant is loaded from Postgres on the request that uses it. Nothing
memoises the verdict, so a revocation lands on the delegate's next
request rather than their next login.
Refusals do not enumerate. The denial takes no constructor arguments and
carries a fixed message, and neither branch looks the named account up:
an account that does not exist and one that granted nothing produce the
same empty row from the same query, so the two answers are identical in
bytes and in timing rather than by careful wording. The reason reaches
the audit row and the wide event and nothing else.
`getSession()` still answers who is calling. That is what leaves
`requireAdmin`, `requireCookieAuth` and `requireFreshMfa` structurally
out of a switch's reach; the row's carrier rides out beside the session
id as a value to be checked, never as a decision already made.
The selector header is a Bearer carrier only. Sent over the cookie
transport, or to an actor surface, it is refused rather than ignored. A
client that names an account on a route which never reads one has a bug,
and a correct response to a wrong expectation hides that bug until it
arrives as a support case about data that looks wrong.
Five test files gain the session shape their fixtures always claimed to
have; they were building an auth context without one.