Commit Graph
567 Commits
Author SHA1 Message Date
Marc-André Bombeck b231a7892c test(02-12): retire sharing inventory anchors
- remove obsolete inventory-only journey declarations\n- select persistent shared-record journeys by Playwright project config
2026-08-06 05:16:32 +02:00
Marc-André Bombeck 5e8b90bcc1 Merge notification delivery identity 2026-08-06 05:12:42 +02:00
Marc-André Bombeck 9ba37342d7 fix: claim notification events atomically
- serialize rolling event claims before delivery\n- keep provider egress outside the transaction
2026-08-06 04:57:50 +02:00
Marc-André Bombeck ba7bf49fae test: cover atomic notification event claims
- race record event claims through PostgreSQL\n- require safety producers to honor the claim
2026-08-06 04:53:50 +02:00
Marc-André Bombeck 10a58a7e6a test: cover recipient-scoped Telegram persistence
- retain independent recipient reminder slots\n- verify chat updates and recipient cascade cleanup
2026-08-06 04:44:08 +02:00
Marc-André Bombeck 9380ea0f18 feat: separate notification event anchors
- Store record-scoped scheduler events outside delivery attempts\n- Keep delivery diagnostics free of bookkeeping rows
2026-08-06 04:32:52 +02:00
Marc-André Bombeck 1ba73a4d51 fix: enforce notification delivery authority
- Require active Guardian authority before cross-record delivery\n- Serialize managed delivery checks with lifecycle changes
2026-08-06 04:29:18 +02:00
Marc-André Bombeck b78d64cb98 test: cover notification delivery authority
- Require active Guardian authority for managed delivery\n- Cover inactive and mismatched grants
2026-08-06 04:27:27 +02:00
Marc-André Bombeck 201a24e404 Merge managed record settings 2026-08-06 04:05:16 +02:00
Marc-André Bombeck 906118f86e fix: resolve notification delivery identity 2026-08-06 03:57:23 +02:00
Marc-André Bombeck 6a1d1dd6e8 fix: preserve managed target defaults
- Return effective threshold ranges after managed updates\n- Localize closed Coach and Insights display inventories
2026-08-06 03:52:49 +02:00
Marc-André Bombeck 3bb9349171 test: cover notification attribution constraints 2026-08-06 03:49:42 +02:00
Marc-André Bombeck 6108a71d80 feat: separate notification subjects and recipients 2026-08-06 03:46:57 +02:00
Marc-André Bombeck 6771b9060f test: cover notification attribution persistence 2026-08-06 03:43:35 +02:00
Marc-André Bombeck 247ac8494c fix: complete managed record settings
- Expose canonical defaults and reject invalid settings families\n- Localize Guardian controls and refresh dependent caches
2026-08-06 03:40:13 +02:00
Marc-André Bombeck e788675b82 feat: configure managed record settings
- Scope writable settings to the selected managed record\n- Render typed Guardian controls with separated audit attribution
2026-08-06 03:21:02 +02:00
Marc-André Bombeck 389f2a042b fix: complete managed Guardian invitation lifecycle 2026-08-06 03:19:34 +02:00
Marc-André Bombeck 493437207e test: cover managed Guardian invitation lifecycle 2026-08-06 03:14:47 +02:00
Marc-André Bombeck 32380b1935 fix: clear revoked Guardian sessions
Return a removed Guardian's browsers to their own record.
2026-08-06 03:08:51 +02:00
Marc-André Bombeck 55291ad32d test: cover managed Guardian lifecycle races
Exercise cookie routes, transaction rollback, and record-serialized races.
2026-08-06 03:01:12 +02:00
Marc-André Bombeck 523ab3220b fix: serialize managed profile Guardians
Protect every Guardian-reducing lifecycle transition with a record lock.\n\nReturn a recoverable refusal when the final Guardian remains.
2026-08-06 02:50:30 +02:00
Marc-André Bombeck 5059bc9b69 test: cover managed profile Guardian races
- Exercise concurrent final-Guardian reductions against Postgres\n- Cover recoverable account and profile deletion paths
2026-08-06 02:36:08 +02:00
Marc-André Bombeck 665f223dc4 test: cover managed profile Guardian consent
- Pin ordinary second-Guardian acceptance\n- Pin non-expiring accepted Guardian grants
2026-08-06 02:29:55 +02:00
Marc-André Bombeck 58434a2b44 feat: create managed profiles atomically
- Require a fresh cookie MFA proof before creation\n- Create the credential-less profile and initial Guardian grant together
2026-08-06 02:28:56 +02:00
Marc-André Bombeck d95c48f48d feat: classify managed record settings
- Inventory every Settings destination as an explicit access class\n- Fail closed for unknown deep-link destinations
2026-08-06 02:28:29 +02:00
Marc-André Bombeck 1eb0d22285 test: add record settings contract
- Cover record-keyed DTO and cache behavior\n- Pin actor identity separately from the active record
2026-08-06 02:25:53 +02:00
Marc-André Bombeck 51cbbae17f test: cover managed profile creation
- Prove truthful profile attributes and the initial Guardian grant\n- Prove grant-write failure rolls the profile transaction back
2026-08-06 02:25:19 +02:00
Marc-André Bombeck f5ca810643 chore: format sharing authority controls
Apply repository formatting to provider and import coverage.
2026-08-06 02:05:24 +02:00
Marc-André Bombeck 620817b10b fix: retain import actor attribution
Persist record and actor identities through job completion.\nWrite one bounded transactional completion audit per job.
2026-08-06 02:00:35 +02:00
Marc-André Bombeck adb932052c fix: keep managed system work operator-scoped
Resolve managed record credentials through the operator policy for system work.
2026-08-06 01:53:04 +02:00
Marc-André Bombeck 2b2080f48c test: pin import actor attribution
Cover durable record and actor attribution across completion retries.
2026-08-06 01:52:43 +02:00
Marc-André Bombeck e209db24f4 fix: block delegated provider egress
Carry bounded work authority through queues and revalidate it at dispatch.\nResolve shared records without personal credentials.
2026-08-06 01:49:33 +02:00
Marc-André Bombeck f7a06df2bc test: pin provider origin controls 2026-08-06 01:38:35 +02:00
Marc-André Bombeck 60b842f614 fix: reauthorize delegated replay
- Recheck the live grant before returning cached delegated responses

- Refuse cached replay when the acting record cannot be resolved safely
2026-08-06 01:29:07 +02:00
Marc-André Bombeck f8b05d541e test: pin delegated replay revocation
- Cover active, revoked, and own-record cache replay against Postgres
2026-08-06 01:22:33 +02:00
Marc-André Bombeck 09ff1fb23d feat: enforce managed sharing boundaries 2026-08-06 01:16:18 +02:00
Marc-André Bombeck d86358f3e4 fix: recover managed sharing boundaries 2026-08-06 00:19:35 +02:00
Marc-André Bombeck 8669df4cda feat: add scoped record invitations 2026-08-05 23:58:30 +02:00
Marc-André Bombeck ab14fdb6f3 Let an invitation name its sections and offer management
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.
2026-08-05 23:37:00 +02:00
Marc-André Bombeck feb9d25e87 test: anchor sharing release journeys 2026-08-05 23:29:55 +02:00
Marc-André Bombeck bd6220bbba test: add sharing security principals 2026-08-05 23:26:42 +02:00
Marc-André Bombeck d9e68e7ee3 test: freeze sharing authorization inventories 2026-08-05 23:24:54 +02:00
Marc-André Bombeck d42d2bb784 Merge branch 'main' into release/v1.37.0 2026-08-05 23:04:36 +02:00
Marc-André Bombeck 1a70044af9 fix: preserve delegated dose facts on replay 2026-08-05 22:48:31 +02:00
Marc-André Bombeck da3cabb55f Give a grant a section set and a third level
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.
2026-08-04 17:11:40 +02:00
Marc-André Bombeck 24922b2787 Close the way round the delegated-intake refusal
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.
2026-08-04 15:32:40 +02:00
Marc-André Bombeck 25e86b36b3 Close the delegated-access gaps the release audit left open
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.
2026-08-04 14:55:32 +02:00
Marc-André Bombeck 69343c8820 Stop a delegate deferring somebody else's medication reminders
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.
2026-08-04 12:30:01 +02:00
Marc-André Bombeck 7eed26eb01 Merge remote-tracking branch 'origin/work/side-effect-backup' into work/sharing-write-integration 2026-08-03 21:18:13 +02:00
Marc-André Bombeck 75644bc3d2 Carry recorded side effects through the backup and back
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.
2026-08-03 21:15:45 +02:00