Files
HealthLog/prisma/migrations/0297_managed_profile/migration.sql
T
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

30 lines
1.6 KiB
SQL

-- v1.37.0 — a record somebody else runs.
--
-- A managed profile is an account for a person who cannot run their own
-- record: a child, a parent under care. It has a display name, no e-mail and
-- no password, never signs in, and is administered by whoever holds an active
-- MANAGE grant on it.
--
-- One column, and the whole of the feature's identity fence hangs off it. An
-- invited adult who granted management of their OWN record must never reach
-- the settings, integrations, notification routing, grant management, AI
-- configuration, export or deletion of that record — those are the surfaces
-- that would let a manager outlive their own revocation. On a managed profile
-- there is no self to reserve them for, so the guardian holds them. The gate
-- is this column and not the grant level, which is what makes the two cases
-- structurally different rather than differently argued.
--
-- A timestamp rather than a boolean, deliberately. It says since when, which
-- the activity trail needs to be readable across the transition, and clearing
-- it is the emancipation act: the day the person takes the record over,
-- credentials attach (`email` and `password_hash` are already nullable, so
-- nothing here has to change for that) and this goes back to NULL. The grants
-- survive untouched — the handover changes who holds the revoke button, not
-- which rows exist.
--
-- No index. The column is read by primary key, on a row the request already
-- loaded, and the one enumeration that scans for marked rows (the reminder
-- fan-out) resolves them from the grant side.
ALTER TABLE "users" ADD COLUMN "managed_profile_at" TIMESTAMPTZ(3);