A read-failure primitive decided from the status alone, so a 401 from a
step-up or re-proof gate would have read as an ended session and sent
the reader to sign-in. The code now decides first and the status is the
fallback for codes it does not know.
The two invitation routes answer an unknown name or e-mail with
sharing.invite.no_account. Both have a second 404 with a different next
step, and since every error now carries a code the clients match on it
instead of on the status. The status defaults move into a module the
client can import, and the contract text no longer says these arms
carry no code.
The day view gains a scores section below the values: the health score as
recorded that day, the readiness the nightly job persisted for the night
that ended that morning, a device's own recovery, the sleep score of that
night and strain (computed, or the device's day strain on its 0 to 21
scale). Each tile carries the person's usual range over the 30 days before
and opens the score's own page; a score the day does not hold has no tile,
and a day without any has no section.
Every value comes from where its page reads its history: the stored health
score record, the persisted RECOVERY_SCORE and STRAIN_SCORE rows read on
the day they describe, the canonical recovery ladder, and the sleep score
computed over one bounded read of the 30 nights up to the day. The stored
score rows leave the values list of the day they are stamped on, so a
readiness no longer appears a day early among the readings.
Scores follow their own module (recovery, sleep; the health score is core)
and map to the whole record for sharing: a scoped grant sees them named as
not shared, a whole-record grant sees them. get_day sends them to the
Coach and the MCP endpoint by name and number, and the Coach exclusions
take out every score built on excluded data.
A running cycle now states its cycle day and phase ("Cycle day 12,
follicular phase"), computed the way the cycle calendar does. Only logged
cycles count, the next logged start closes a cycle, and an open cycle
stops counting past its typical length plus the grace window instead of
reading a day count nobody recorded.
Land every file the day view, the timeline and life events share before
any of them is built, so the server, the day view and the timeline can be
built side by side without editing the same file (#613).
Schema and migration 0387: LifeEvent with a short category list, calendar
start and end dates, a day/month/year precision, encrypted title and note,
a soft-delete tombstone and an index for the per-user live list. Every new
column is annotated for the column-reader sweep. Life events travel in both
backup purposes with their two ends in one file, are wiped with the
account, and are registered for key rotation.
src/lib/day/contract.ts carries the constants and wire types for
GET /api/day/{date}, /api/day/index, /api/timeline, /api/timeline/readiness
and the life-event routes, free of Zod so the navigation chrome can import
it; the schemas beside it feed the routes and the published contract. The
Coach and MCP tool name get_day is reserved there, and life events are
listed as the one section no model ever sees.
The opt-in timeline module, its navigation entry, the day-section and
timeline-lane surfaces, the day and timeline query keys (evicted by every
record data bundle), and stub routes that answer 501 behind their
admissions. The four cross-section reads declare the whole record for now;
the life-event list is a profile read, and life events join the profile
sharing domain and its consent copy in all seven locales. The day,
timeline and lifeEvents message namespaces carry their base copy as
release-pending keys.
The aggregate bundle cap moves to 4400: the branch already measured 4365
against 4335, and the new copy adds 10 KB.
- Load each settings section on its own: the section route imported all
twenty-one statically. A preload starts the chunk beside the record
gate's read, and the loading state paints the settings card shell.
/settings/[section] goes from 611 to 403 KB gz.
- Take Zod out of the shell every route shares: the account-access parse
run on each boot is plain TypeScript now, held to the published schema
by an equivalence test over good and broken blocks. The canonical unit
table moves to its own module, so the display transform no longer
pulls Zod in either. Layout entry 199 to 136 KB gz; /insights 369 to
304, /medications 428 to 365, /labs 400 to 337.
- Open a saved Coach conversation at its last answer: an instant jump
instead of the smooth scroll that lost the end, then follow late
layout (charts, tables) for a few seconds until the reader scrolls.
- Show the document archive as a month grid of preview tiles, two
across on a phone and three or four on a desktop, with the month
headings kept.
- Add the admin card for the measurement table maintenance: a status
read on the maintenance route, one calm confirmation, and the run's
state while it is queued, running, finished or stopped.
- Re-base the bundle budget: /insights at 304, /settings/[section]
watched at 403, aggregate cap 4335 with the reason written down.
A guardian with a fresh second factor mints a one-time handover link
(hlp_ token, HMAC hash stored only, 1/7/14 days, at most one open per
profile, withdrawable) with a proposed access level per guardian: end,
view only or manage, view only by default. The link is shown once with a
copy button and a QR code; nothing is mailed. The panel sits at the end
of each managed-profile row and hints when the person is under 16.
The person opens /claim/<token>, sees a minimal preview (name, expiry,
who keeps which access, no health data) and chooses a username, a
required email and a password under the registration policy. The claim
is one transaction under the managed-profile lock: the token is
consumed, the credentials are set, the marker is cleared, every pending
invitation the record offered is withdrawn, each guardian's access is
set to its proposal, the disclaimer, onboarding and AI consent are
reset, and every step is audited on the record and on each guardian's
own account. A failure anywhere leaves the profile managed and the link
unused. Each guardian is notified on their own channels.
The new owner then sees each guardian's access on the first screen after
signing in and decides finally; until then the proposal holds, and the
decision waits at the top of Shared access. A guardian whose access
moved since the claim is left alone. The same screen points the owner
at their own notification channels, since reminders no longer reach the
guardians.
Both anonymous routes refuse a single-sign-on-only instance, refuse a
live session before spending the per-address rate limit, check the link
before probing names, and answer one 404 for every unusable token. The
token travels in request bodies only; hlp_ joins the redaction and
secret-shape rules and /claim/ the path-secret registry. Usernames
starting with managed- are reserved. The session-surface and acting-
account guards list the new surfaces with their reasons.
It named three of them and the booster mint made four the same day. A comment
that enumerates its own callers goes stale on the next one, so it states the
shape instead and keeps the note about the two verbatim copies it replaced.
`writableDomains` answers "is there at least one delegated write route in this
section this grant satisfies". Its docblock promised a per-section answer the
table cannot give: within a section the split runs by verb, and three of the
four sections marked writable carry MANAGE-only creates beside their WRITE ones
— an allergy and a family-history entry under `profile`, a water entry and a
checkup reminder under `measurements`, a snooze and a re-recorded dose under
`medications`.
No shipped control is wrong because of it: the allergy and family-history forms
already ask the manage question, and so does the water entry. But the name and
the sentence beside it invite a control to read "my section is writable,
therefore my create is", and that reading is wrong in three sections out of
four.
Documentation only, in the three places the claim is made — the published
field, the function that derives it, and the guard that holds it against the
routes, whose green run now says what it does not prove. A per-verb table would
be a second place the route declarations live, and the declarations are what
decides; the control keeps asking the hook for its own verb class.
`POST /api/mental-health/assessments` is fenced on `mind` and writes one row
into `measurements`: the instrument's total, minted `COMPUTED` and keyed on the
administration it came from, so the trend reads through the chart and rollup
tier without the encrypted item answers going anywhere. A grant scoped to mood
and mind therefore adds a point to the owner's measurement series, and nothing
said so.
Left in place rather than fenced. Withholding the projection the way the
booster mint is now fenced would file a screener whose own total is missing
from the record — an inconsistency inside the section the grant does open, to
avoid a derived point in one it does not. So it is documented instead, in the
three places it is decided or agreed to: the declaration, the section's own
definition, and the consent copy in all seven locales, which is where a
widening belongs. The published operation says it too.
`canWrite` is the grant's LEVEL and carries no scope term. Its docblock said
"May this caller ADD to that record", and the client hook republished it as
`canAdd`, so fourteen controls across measurements, medications, labs, illness
and the dashboard gated a create on a boolean that answers `true` for a WRITE
grant scoped to the document vault — a grant whose writable-section list is
empty, because the vault takes no delegated write at any level.
Nothing shipped was visibly wrong before the capture picker, because the nav
drops a section a grant does not open. That is a property of one surface, not
of the answer, and the answer is the thing many controls read.
So the coarse answer is gone from the hook, and every consumer asks
`canWriteDomain` for its own section instead: `measurements` on the two
measurement surfaces and the dashboard's empty state, `medications` on the
medication list, table, ledger, card menu and side effects, `labs` on the lab
list and biomarker detail, `illness` on the episode list. Each of those is the
section its own route declares. `canWrite` keeps its name and its wire shape —
native clients read it — and its docblock now says what it is: whether the
grant is a reading one, with the per-section lists beside it for the question a
control actually has.
The hook table gained the vault-scoped WRITE grant as its own leg: level true,
every section false. The take-all-due fixture described a grant the server
never mints — `canWrite: true` over an empty writable list — and now derives
its list from the same table the server derives it from.
`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.
`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`.
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.
Every accountAccess entry on GET /api/auth/me carries writableDomains
and manageableDomains: the sections the grant opens, intersected with
the sections whose routes accept a delegated write at that level. A
READ grant gets two empty lists, a WRITE grant gets writable sections
only, a MANAGE grant gets both, and the vault appears in neither.
The client parser checks the structural invariants (no repeats, within
the grant's sections, manageable within writable, nothing below the
level) and requires the two published views of the active record to
agree on the lists. Regenerate the OpenAPI document and extend the
payload consumer guard with the active-record readers.
Add the one table of which record sections have a route declaring
requireRecordAuth("write") or ("manage"), derived from the route files,
and a structural guard that re-derives it from src/app/api on every run
and fails on a disagreement in either direction. The matcher tolerates
the line break a formatter puts after the opening parenthesis and
asserts a non-zero match count.
delegatedDomains() turns a grant's level and scope into the sections it
can add to and the sections it can change, keeping write within manage.
api-handler imports the record-session fence and the fence threw
RecordSessionChangedError / SharingAccessDeniedError imported back from
api-handler — the one real import cycle in the tree, working only by
evaluation-order luck. HttpError, the SharingAuthError family and
StepUpRequiredError now live in api-errors.ts (importing nothing but
the fence contract), api-handler re-exports the identical class objects
so no importer moves, and the fence imports the leaf. A guard pins both
the identity and the leaf structure.
The pre-create sweep in inviteGrant writes an audit row when it closes a
lapsed grant, so the transaction slice it accepts now covers auditLog
alongside accountGrant. Point the module and route test fakes at the
same shape: they stub updateMany to close nothing, so the audit branch
stays unreached in the unit fixtures.
The live-pair uniqueness is a partial index (WHERE revoked_at IS NULL),
which cannot read the clock: an expired but never-revoked grant kept
occupying the slot, so re-inviting the same person after expiry always
failed as a duplicate though they had no access. Close the lapsed row at
the moment of re-invite — conditionally, the expiry state in the WHERE —
so revocation still never deletes and the fresh invitation mints its row.
The revoker is stamped GRANTOR because the paired revoked-by check
requires both columns set and the enum has no expiry member. An audit row
records the close. Proven against the real index: the guard was watched
fail with a duplicate before the fix landed.
The visit stage's second half landed while this was in flight and both
touched the same three places. Resolved as one set: the link facade
carries the reverse document-to-visit direction and the new dose pair
side by side, the sharing literals hold both sets of admissions with the
count re-added on top, and the language bundles take the shorter module
description the trunk settled on.
The switcher and the banner named a shared record's owner by their greeting
name — the value they typed at onboarding for "how should HealthLog address
you". When an owner has set a full name, that is what a delegate should see:
entering somebody's record, you read whose it is by who they are.
- the account-access resolver publishes the owner's `fullName` alongside the
greeting name and the username
- `accountLabel` prefers fullName, then displayName, then username. Optional
on the argument so the grant-view and actor shapes that never carried it
keep the old two-field behaviour untouched
- the closed presentation schema carries `fullName` and its parity matcher
compares it, so the two published views of the active record cannot name
the same owner differently
- the OpenAPI contract regenerates with the additive field
This deliberately discloses the owner's full name to every delegate, read-only
included — it was patient-identity data behind a MANAGE-gated export until now.
That is the intended behaviour, documented at `accountLabel` so it is not
mistaken for a leak and tightened back. The resolver, the schema parity, the
label fallback and a read-only delegate's banner are each pinned by a test.
- a query-key module and a dependent bundle that also evicts the
preventive-care root, because logging a dose settles a booster and the
checkups list goes stale in the same tick
- the OpenAPI module publishes the resolved series with the contract
written into the description: render text from these numbers, never
recompute them
- the health-background consent sentence now names the immunization
history in all six languages, so the widening is stated where it is
agreed to rather than inferred from a code comment
- no server-side cache helper: nothing cached reads a dose, and a sweep
over caches nobody reads would look like coverage without being it
Query keys land in the central factory before any surface reads them,
because the lint rule that bans a bare array means the first component
listing visits cannot compile without a slot. There is deliberately no
server-side cache invalidator beside them: nothing cached reads a visit
yet, and a sweep over caches nobody reads would look like coverage
without being any. The module says so at the place a reader would go
looking.
The OpenAPI table publishes seven paths with everything resolved — the
practitioner as an object rather than an id, each link with its label
and date, the appointment's next due instant computed. What it does not
publish is the reminder origin enum, which still reads with two members:
the third value exists in storage and reaches no response, because every
preventive-care read excludes it. Keeping that contract closed is why
the exclusion is worth a guard.
Classifying visits under the existing health-background domain widens
what that domain grants. Somebody who shared it so a relative could see
an allergy list now also shares every appointment and the address book.
That is stated where it is agreed to rather than left in a code comment:
the consent screen's sentence names visits and practices, in all six
locales, and the activity feed gains a verb line for each of the eight
new delegated actions so an owner reads what was done rather than
'made a change'.
Removing the Vary block left the disk-layer case green: it asserted what
is IN the cache, and the claim is what the cache will SERVE. On the
external-switch path nothing wipes those entries — the app never
initiated the switch — so Vary is the only thing between a cached
delegated response and the next request under another context.
- assert cache.match() hits under the stored context and misses under
the new one; it fails when the Vary is removed
- correct the acceptance map and the client docblock to claim exactly
what executes: no zero-entry count, no offline leg
A shared cache is one reverse proxy away, and without Vary the first one
to appear would key a delegated record response on URL alone and hand it
to the next request under a different context. Appends rather than
replaces, so a route that already varies keeps what it had.
- the idempotency arm refuses only a wrongly-asserted context; an admin
route behind unfenced requireAdmin still serves a headerless request
- the epoch trigger does not fire on a statement that names record_epoch
without moving the selector; the source guard is what covers that
- evaluate the fence in the wrapper above findCached, so a stale request
neither replays a body, learns a cell exists, nor inserts a claim
- return the refusal rather than throwing: one route composes the
wrapper outside apiHandler, where a throw escapes the envelope
- read the claim and the session's own context on one session lookup
- both record resolvers check the fence after authentication and before
the carrier, off the one session row that also decides the scope
- POST /api/account/switch takes an optional expectedEpoch and writes
conditionally, so concurrent switchers resolve to one outcome
- GET /api/auth/me publishes the context a browser bootstraps from
- every response that resolved a record scope echoes it back
- regenerate the API contract
- add an import-free contract module both bundles can name, holding the
header names, the parse and the verdict
- add the server fence: header read, wide-event stamp, and the refusal
- add RecordSessionChangedError as a third sibling on the existing
sharing-error arm, so the 409 needs no new serialisation branch
An owner can now narrow a grant to the parts of their record they mean to
share, and can offer the third level. Both decisions are made once, on the
invitation, and neither widens afterwards.
The invite schema takes `scope`: absent or null is the entire record, which
is what every stored grant means and what an owner who does not narrow still
gets. An empty array, a key outside the vocabulary and the whole-record
sentinel are each refused with a 422 naming the field; `inviteGrant` refuses
the same three underneath, and its `invalid_scope` now has an HTTP mapping
instead of falling into the generic refusal.
Offering MANAGE asks for a fresh second factor when the account has one. That
gate resolves through the session cookie and has no Bearer fall-through, so a
token caller is refused first with `sharing.invite.manage_browser_only` rather
than being told it is not authenticated by a route that had just
authenticated it. The consequence is deliberate: management of a health
record is offered from a browser.
The account payload gains `sections` and `level` beside `access` and
`canWrite`, resolved server-side and ordered as the consent screen reads.
`access` was `grant.access === "WRITE" ? "write" : "read"`, which would have
published a grant that can delete entries as read-only; it is a total map
now, so the next level cannot ship silent. The capabilities hook carries both
facts and turns neither into an affordance yet — the edit and delete controls
come back in the release where the routes behind them answer.
The consent surface asks two questions in order rather than laying out a
nineteen-cell grid. The level is three radios, narrowest preselected. What
they can open is two radios, the entire record preselected, and only the
narrow branch expands the eight sections with a sentence each. Both honest
limits are stated above the checkboxes, where the decision is being made: a
section shares everything written in it, and the cross-record surfaces do not
appear for a narrowed grant. Documents carries its own warning, cycle appears
only for an owner who tracks it, and Manage collapses the question to the one
line that answers it.
The Manage sentence says the other person can add, change and remove entries,
including ones the owner entered themselves, and says in the same breath what
it does not reach. The acceptance side gets its own responsibility line rather
than the write one wearing a stronger adjective.
Six locales.
A grant has meant one thing since it shipped: the whole record, to read or to
add to. This lays the words for the two shapes the rest of the sharing work
needs — part of a record, and management of one — while changing what no
existing grant does.
`ShareDomain` is a closed set of eight sections in `src/lib/sharing/scope.ts`,
clustered from the delegable read surfaces rather than invented, plus the
distinguished `record` for the routes that read across them. It never converts
to a `ModuleKey` and never imports one: two masks, two questions, no shared
spelling, because the day they share a name is the day somebody makes one
imply the other, and a module the owner enabled is not a section the owner
shared.
`account_grants.scope_json` is NULL on every row that exists, and NULL is the
entire record — the answer those rows were consented as, so no backfill and no
re-consent. A non-NULL value is read through one fail-closed normaliser: a
blob it cannot parse opens nothing, including a section the blob happened to
name. That is the deliberate inverse of the module preference normaliser,
which fails open on purpose; both do the safe thing with a value nobody can
read, and the safe thing differs because one answers an authorization question
and the other a presentation question.
`MANAGE` joins the level enum, ordered above WRITE so a write grant can never
satisfy it, and carries no scope at all: management of part of a record
promises a boundary that cannot survive an edit, so the invite path refuses to
write one.
`requireRecordAuth` takes the section as a second required argument. The
requirement is the lever — a delegable route without a classification does not
typecheck, so the set of classified routes cannot fall behind the set of
delegable ones. All fifty-seven modules carry theirs. The structural guard
freezes each against the production vocabulary, fails when a module's own arms
disagree, and pins a non-zero count of resolved sections so a matcher that
stopped reading the argument fails instead of agreeing with everything.
`requireGuardianAuth` is exported and reaches nothing yet. It gates on the
managed-profile marker rather than on the grant, so an adult who granted
management of their own record cannot arrive at that record's settings by any
argument about what MANAGE means: widening MANAGE would widen the record
resolver and would not touch this one. Its frozen list lands empty, with the
declaration site as the positive control that keeps "nothing reaches it" a
measurement rather than a renamed symbol.
`users.managed_profile_at` says since when a record is one somebody else runs.
A timestamp and not a boolean, for a reason the schema has to keep possible:
clearing it is the handover, and the grants and the activity trail span it
untouched.
The payload type still said the level is read throughout and `canWrite` false.
Both were true of the shipped release and neither survives the invitation
gaining a choice. Also says what `canWrite` does not mean, since the word
invites a larger reading than the thing it grants.
The module header still described a permission to read, and the grantee field
still said "invited to read it". Both are half the sentence once the level is
a choice.
A grant has always had an access column and `inviteGrant` has always written
READ into it. That was the right way to ship the first version: "this release
never grants write" was a property of the file rather than a promise in a
comment. Remove it deliberately now, so somebody sharing their record can offer
reading or reading plus adding.
The level is a required argument to `inviteGrant` and is defaulted in exactly
one place, the invite schema, where an omitted field becomes READ. An older
client's payload therefore stays valid and stays narrow, and the domain module
never guesses a level nobody named.
There is no upgrade path and that is the decision, not a gap. A grant is READ
or WRITE from the moment it is offered and stays that way until somebody ends
it. Widening a live one would change what the delegate agreed to without asking
them again, and their consent is half of what makes a write grant legitimate.
The way up is a new invitation, accepted again.
The audit row and the wide event carry the level: "who was given what, and by
whom" is the question that row exists to answer.
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.
`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.