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.
v1.36.1 stopped a delegate changing a dose the owner had already recorded, and
it stopped them on one route. The sibling `POST /api/medications/[id]/intake`
reaches the same rows through the shared slot upsert, where the only existing
protection is against a pending sync echo washing out a recorded dose. An
explicit skip is not an echo: it fell through, flipped the outcome, and the
inventory the take had consumed was refunded on the way. So the refusal I added
was a door with the next one left open.
The rule compares OUTCOMES rather than refusing every write onto an actioned
slot, and the reason is the third case in the new contract. A double tap, an
offline replay and a retry after a partial batch all re-post the same decision,
and a caregiver hits all three; refusing those would make the feature
unreliable exactly when somebody is standing in a kitchen with a pill box. Same
decision applies, a different one is refused.
The check rides into `applyCanonicalSlotWrite` rather than sitting at the call
site, because that is where the existing row is already loaded: no extra query,
and nothing between the read and the update for a concurrent write to slip
through. A refused attempt files under the owner with the same action name the
sibling route uses, so it reaches the activity panel the same way.
Three integration cases, and each asserts the row rather than the status alone
— a 403 that had already written the flip would read as protection while being
the opposite. Watched fail: with the delegate flag forced off, the flip returns
201 while the replay and the owner's own correction stay green.
Seven findings from the adversarial pass before v1.36.1 was tagged. One
blocking defect shipped in that release; these did not, and they are all places
where somebody looking after another person's health record meets a wall with
no explanation, or where a guard cannot fail.
The document vault was the largest. `/documents` is marked as a shared
destination, so the shell offers it under a switch, and every read behind it
resolved the caller instead of the record: the vault painted a query-error card
and the illness view painted another beside it. That is the same failure the
release describes as fixed on the dashboard. Five GET arms move to the record
resolver now, and only the GET arms: the list, one document, its file, its
thumbnail and the vault's usage payload, which the filter bar is built from.
Uploading, retyping, deleting, linking a document to an illness and every AI
verb stay with the owner, and the two reads that spend decryption CPU key
their quota on the person calling rather than the record, the rule
`medications/compliance` already follows.
Logging a value on a metric you track yourself leaves the admitted set. It was
admitted, named on the consent screen in six languages, and unreachable: the
only form that posts one sits on a page a shared record does not open, and the
component that renders it says so in its own comment while returning null. It
is the third route the rule that withdrew allergies and family history fitted
exactly, and it was applied to two of the three. Reading those values stays.
Two guards could not fail, and both were proved by mutation before and after.
The prefetch guard checked rule three with two regexes: one was satisfied by
the doc comment every page carries, the other missed an aliased import, and
the pair stayed green with the dashboard genuinely prefetching off the
switch-blind session. It reads the AST now, resolves aliases through the
module's own import table, and pins the five prefetching pages by name so a
page that stops being detected is loud. The activity verb map decided
membership on a literal `requireRecordAuth("write")`, so putting the argument
on its own line, which is what the formatter does the moment the call grows,
dropped a route out of the scan and turned a genuinely-red run green. Both
counts are of matches now rather than of files walked.
The rest are single instances of rules applied everywhere else. "Take all due"
was withheld on a note saying it rides the bulk intake route; it does not, and
its own docblock says why not, so a helper with five morning tablets tapped
five times for nothing. Marking a dose skipped is offered wherever marking it
taken is. A form could be opened in the frame before the app knew whose record
was on screen and then stood there once the answer arrived, on the capture
sheet and on the dashboard's quick-entry sheets. Two more copies of the intake
toast were still building their own, one of them with an Undo pointing at a
route no delegate may call. The customise link on the mood page led to a page
that exists to say it is not part of what was shared. And the access-level
radios on the invitation screen are hidden by design with no focus ring on
their label, so keyboard focus moved invisibly across the one screen where
somebody grants write access to their record.
Three new structural guards, each watched fail first: the customise link sits
inside a capability branch on every surface a delegate can reach, every intake
surface confirms through the one shared decision, and the batch sweep is
offered at the level that admits it.
Found by an adversarial pass over the release candidate before it was tagged,
and it is the reason the tag waited.
`POST /api/medications/intake` is admitted for delegates because a caregiver
standing next to the patient has to be able to say the tablet went down. It is
an update, though, and the same body can carry two other things. Snoozing
writes `snoozedUntil` on the OWNER's medication row, and the reminder cron
skips that medication for as long as the stamp is in the future. The field
takes any future instant. So somebody with write access could stop another
person's medication reminders for years, and three things made it quiet: the
owner is deliberately not notified of a delegated snooze, the activity feed
renders the whole family as "marked a dose", and the only accurate trace was a
line in the admin audit log.
The second was flipping a dose the owner had already recorded — taken to
skipped and back, which rewrites their compliance history and refunds
inventory. The consent screen promises that changing what is already there
stays with the owner.
Both refuse for a delegate now and stay open to the owner, who is the only
person either was ever for. A refused attempt is filed under the owner and gets
its own sentence in the activity panel rather than borrowing the one for
marking a dose, because folding them together is what made the gap dangerous.
Two more from the same pass:
The consent notice named allergies among the things a delegate may add. That
left the admitted set before this release shipped, and it said so in six
languages on the one screen where somebody decides who may write into their
health record. It also omitted half of what IS admitted. The copy now matches
the frozen list, and the test asserts the withdrawal rather than only the
presence of the rest.
The banner told a WRITE delegate nothing at all — the mode line rendered only
in the read case, guarded by a comment stating that write grants did not exist
yet. This is the release that made them exist. It matters that the banner say
it, because the rule that a refused control is absent rather than disabled
rests on the banner naming the mode globally.
And a third instance of the substitution this release already fixed twice: the
BMI chart divided the owner's weights by the height of whoever was looking.
Inside a shared record it draws nothing now rather than a number belonging to
two different people.
MedicationSideEffect was classified as backed up and was not carried: the
export wrote nothing and the restore had nothing to read, so a restored
account kept the drug and lost the reason someone may have stopped taking it.
Delegated write access sharpens that — a side effect is one of the things a
second person can contribute, and it was the contribution a restore dropped.
The rows ride inside their medication, like the schedules beside them. A
top-level array would have to carry `medicationId` and survive a restore that
mints a fresh id for a portable file, which is the remap the intake events
already work around by drug name; a nested create has no id to remap, because
Prisma binds the child to whatever id the parent row actually got.
The note follows the measurement contract for the same dual-column
arrangement: a portable export carries the decrypted note and no ciphertext, a
disaster-recovery payload carries the ciphertext verbatim, and the restore
re-encrypts a legacy plaintext row rather than writing plaintext back into the
column.
Move the model from COVERAGE_PENDING to TWO_ENDED_MODELS, and settle the claim
where a written verdict cannot: the round trip seeds a side effect, exports
through the real builder, empties the account and reads the row back out of
the real restore route with its category, entry, severity and note intact.
Both halves were excised to check the test can fail — the restore branch turns
the count red, the payload turns the field values red.