Land every file the v1.42 work shares before any of it is built, so the
parts built side by side never edit the same file.
Schema and migrations 0379-0386: the HEALTH_CONNECT measurement source,
ImportJob.kind, the air-quality columns and the per-user switch on the
environment tables, sealed environment locations with nullable plaintext,
managed-profile handover links, the per-type HealthKit arrival ledger, lab
unit-conversion provenance, and user-defined vaccines. Every new column is
annotated for the column-reader sweep.
HEALTH_CONNECT reaches every exhaustive list: the source labels, the
read enum (not the write allowlist), the source-priority ladder cap, now
the number of sources rather than a fixed eight, and the write-path smoke
test. RMSSD gets its own priority class and a 300 ms ceiling. The batch
syncTrigger gains `manual`, with its label on the Apple Health card.
Queues for the Health Connect import, the compaction-tombstone purge and
the measurement-table maintenance are registered with stub handlers;
host-metric sampling moves to every five minutes. Environment and work_mem
variables reach the compose whitelist, the production example and the
manifest. The proxy admits the handover link and redirects it at the edge,
and the Health Connect upload bypasses the proxy body limit.
The new routes answer 501 behind their final authentication and are
published in the contract; backup, wipe and key rotation classify the new
models and columns; the seven message bundles carry the base copy. Guards
that list release-pending keys and columns fail once an entry is wired, so
those lists empty themselves before the tag.
Also re-anchor a login-throttle fixture to the wall clock, where its
expiry is compared, and re-base the aggregate bundle cap on a measured
12 KB of new locale strings.
A document whose preview render fails for good (a PDF whose first page
renders blank) was queued by the boot-time backfill, refused, and queued
again at the next boot, without end.
The thumbnail job now keeps the failure with the document
(document_thumbnail_failures, migration 0374): the reason, the attempts
since the last success and when the newest failed. The backfill, both the
boot discovery and the per-account pass, leaves a document out after one
deterministic refusal (raster-failed, empty-render, pixel-cap) or three of
any other kind, until the document changes. A direct enqueue still renders
it, and a preview that is made clears the record.
A weekly or nightly backup job whose process died (a restart, a
container out of memory) stayed active until its two- or four-hour
expiry, and its retry walked the accounts in the same order: the account
that killed it has the oldest copy, so it led again, and every account
after it went without a copy for as long as that record stayed too
large.
Both passes now send with a five-minute pg-boss heartbeat, so a dead run
fails within minutes and its retry picks it up. Each pass records when it
started and finished every account (backup_pass_attempts, migration
0373); an account whose last attempt never finished goes behind all the
others. The backups page shows when the run in progress started and names
an account a run died under, for the weekly and the off-host card, and a
heartbeat timeout reads as what it means.
A medication taken now and then (#1024) keeps one row and gains course
rows: calendar spans, both ends inclusive, an optional encrypted note, no
schedule or dose of their own. Courses never overlap and every course but
the latest has ended, so the latest course is always the one the
medication's startsOn/endsOn project. src/lib/medications/courses.ts is
the only writer of those two columns for a medication with courses; it
writes under a per-medication advisory lock and re-projects in the same
transaction, so reminders, the next-due line and intakeActionable keep
reading the columns and stay right.
- Migration 0368 adds medication_courses and backfills one course per
medication window (an end-only window starts on the creation day, or
on its end day when that lies earlier); a medication without a window
stays course-less. Idempotent, data-preserving.
- GET/POST /api/medications/{id}/courses and PATCH/DELETE
.../courses/{courseId} (owner-only); create seeds the first course
from a window; the medication PUT edits the latest course and refuses
a window that would break the rules (medications.course.*).
- List, detail, create and update carry courses, courseCount,
previousCourseEndedOn and canStartCourse, resolved on the server.
- Compliance expands over the span of all courses and drops the slots
between them; every compliance read selects the courses. A rolling
cadence is not anchored by an intake from a previous course.
- The card names the last course's end and the running course's number;
the detail Lifecycle group lists the courses with start, edit and
delete. The Coach compliance block, the MCP resource, the doctor
report window and table, the clinician view and FHIR (one
MedicationStatement per course with effectivePeriod and status) read
the courses.
- Backup carries courses per medication and the restore writes them and
re-projects; an older file derives the one course its window
describes. Wipe plan, encrypted columns, rotation and OpenAPI follow.
A medication can be filed under a category the person named, next to the
built-in ones (#1041). The labels live in medication_category_labels,
encrypted at rest, capped at 20 per account; a medication filed under one
carries its custom:<uuid> key in the category column.
- GET/POST /api/medications/categories (POST idempotent, 30/min per actor,
admitted at MANAGE like the cycle symptom vocabulary) and owner-only
PATCH/DELETE /api/medications/categories/{key}. DELETE moves every
medication filed under the category to OTHER in the same transaction
and answers movedCount.
- category is widened to a built-in value or custom:<uuid> on the wire,
the create and update bodies refuse a key that is not the caller's
(422 medications.category.unknown), and list, detail, the insights
medication tiles and the MCP medications resource carry categoryLabel.
A key with no owned label reads as OTHER and is counted on the event.
- The wizard's class step lists the person's categories under the
built-in rows with an inline add row; the list page gains a category
filter (session only) and a manage sheet for rename, hide and delete.
- Backup carries customMedicationCategories (decrypted labels) and the
restore writes them before the medications; wipe plan, encrypted
column registry, rotation script, sharing guards and OpenAPI follow.
A symptom of one's own ("aura", "headache") is a definition with an
encrypted name and an icon from the cycle custom-symptom allowlist; each
occurrence carries a time, a 0-10 intensity (the pain score's scale), an
optional encrypted note and an optional link to one of the record's open
illness episodes. Both ride the illness module: every route answers
illness.disabled when it is off.
- Migration 0369 (idempotent): symptom_definitions and symptom_events,
intensity CHECK 0-10, episode link ON DELETE SET NULL.
- /api/symptoms/definitions{,/[id]} and /api/symptoms/events{,/[id]}:
eight active definitions per record (a hidden one frees its slot,
showing it again re-checks), hide or purge, events create/list/edit/
delete, a new link must name an open episode of the same record
(422 symptoms.episode.notOpen). Error group symptoms.*.
- Sharing: reads at READ, logging an occurrence at WRITE, defining and
editing at MANAGE; C1/C3/C4 audit details; frozen lists, the handler
matrix and the activity verbs updated.
- Both encrypted columns registered and rotated; both models in the wipe
plan; both carried at both backup ends with occurrences nested in their
definition, plaintext in a portable file and ciphertext in a recovery
file, and a link to an episode the file lacks dropped and named
(symptomEpisodeReference).
- OpenAPI regenerated.
"Read with AI" and the lab scan used to run their model calls inside the
request. At the default response time a read of three calls outlasted the
100 s a proxy in front of the server commonly allows, and the person saw a
cut connection for a read the server was still doing.
Both now run as a background run (`DocumentAiRun`, migration 0366):
- `POST /api/documents/inbound/{id}/index` with `Prefer: respond-async`
answers 202 with a run id once every quick refusal has passed. Without
the header it answers exactly as before, which the iPhone app relies on.
A second request while a read of the same document is queued or running
attaches to that run.
- `POST /api/labs/ocr/extract` always answers 202. Only the web client
calls it.
- `GET /api/ai-runs/{id}` serves the run: the same body the synchronous
route answers with, or the failure code and status it would have given.
Scoped to the caller; another account's run is a 404.
The worker (`document-ai-run`, a long queue with a one-hour expiry, one
job per run under `lockedPass`) claims the run, asks the capability again
for the record and re-checks the wire for the provider it picks, sets the
run's deadline from the person's AI response time and refuses a read that
cannot fit the job's expiry. Sync route and worker run one body per kind,
so the two paths cannot drift. Budget is reserved at enqueue and settled
once by whoever ends the run.
Input and result are sealed with labelled AES-256-GCM, registered for key
rotation; the input is dropped when the run ends. A five-minute reaper
fails a run no worker took within fifteen minutes (and hands its
reservation back), fails a run past its deadline, and deletes a finished
run after an hour. Runs are outside backups and inside the data wipe.
The web client polls through the query-key factory and says, calmly, that
the read continues in the background, and when no worker has picked it up.
Every configured key id now has a canary in encryption_key_canaries,
checked at boot. A key that cannot open its canary, or existing data
written before the canary existed, puts the process in a refusal state:
every API route answers 503 encryption.key_mismatch, /api/health reports
reason encryption_key_mismatch, the pages render one explanation, and
neither the queue producer nor the worker starts. The process stays up
so the reason is visible instead of hiding in a crash loop.
The admin Encryption page gains a "Back up your encryption key" step:
key id and fingerprint (never the key), where the key lives per
platform, a check of a pasted copy that is compared and discarded, and
a confirmation bound to the active key id and fingerprint. The overview
gets a tile and admins get a dashboard banner while it is due.
HEALTHLOG_PLATFORM picks the platform the step opens on. Adds the
TrueNAS self-hosting page and key backup sections in the docs.
A restore writes rows back with their original updatedAt, behind every
paired client's watermarks, and removes rows without leaving tombstones.
A client that kept its cursor would miss both on its next incremental
pull. The same holds after the person deletes all their data.
`users.sync_reset_at` (migration 0363) records when the record was last
replaced: the restore stamps it as its last write and again right after
the commit, the per-account and the admin wipe stamp it inside their
transactions. Cursors now carry the time they were issued, and
/api/sync/changes answers its existing `cursorExpired` to a cursor issued
before the reset, or one without an issue time on an account that has
one. The comparison is on the issue time, because after a restore a
freshly drained cursor still carries old watermarks.
The admin wipe of every account is the operator clearing their own host,
and the bucket's copies are the documented way back from a wipe pressed
by mistake, so it writes no off-host deletion request. Deleting an
account, deleting a managed profile and "Delete all data" still do.
The backup runbook no longer says the worker never deletes an object: it
lists the grant the worker needs (DeleteObject included), when objects
are deleted, the version-4 envelope, the lifecycle check and how to
rotate the off-host key. The rotation runbook no longer says backups are
covered: rotation reaches a backup's envelope, not the content a restore
writes back, and a retired key stays needed until no backup lists it.
The privacy notice beside account deletion says the same about off-host
copies.
A failed import records its failure in a plain try block, and the
extraction test that inflates past the 64 MiB floor gets the time it
needs on a loaded runner.
Deleting an account, deleting a managed profile, "Delete all data" and the
admin wipe now write an off-host deletion request in the same transaction
as the deletion. A new job (offhost-backup-purge, kicked straight away and
run nightly as a backstop) lists the bucket once, deletes every
<date>/user-<id>.json.enc object of each pending account and removes the
request only when nothing of the account is left. A refused delete keeps
the request with the bucket's answer and is retried; the off-host card
shows how many deletions are outstanding and why. A copy the nightly run
was writing while the account was deleted is taken back after the upload.
The off-host card also reads the bucket's lifecycle rule and says plainly
when there is none (every nightly copy kept forever) or when the
credential may not read it.
Every stored backup now records the key ids its content was written
under, and every off-host upload records the same per key. The admin
encryption view lists, per key, the stored copies that need it with the
oldest date, and when the last off-host copy needing it expires. The
"safe to drop" badge now requires that no backup still needs a retired
key, instead of only that every row was rotated.
Migration 0358 adds data_backups.inner_key_ids, the key-use table and the
deletion requests.
A person stores one connection per system (base address, Papra
organization id, API token encrypted at rest) and can search it by
document name with tag and date filters, then import picked documents
one per request through the same ingest path as an upload: source-key
answer before any download, content dedup, deleted stays deleted, quota,
provenance, and the person's own AI reading setting. An import can link
the document to the visit, condition or vaccination it was picked for.
The operator lists the instances in DOCUMENT_SOURCE_ORIGINS; unset, the
feature is off. Only listed origins are contacted, through safeFetch with
the origin pinned, redirects refused, timeouts and byte caps. The routes
are cookie-only and owner-only. The token is registered for rotation,
removed by Delete all data, and not carried in backups.
A keyed upload that matched a stored document by its bytes never recorded
its key, so deleting that document let the next import run store it again.
Such keys are now kept as aliases of the document, the source-key check
reads them, and the purge carries them into the ledger with the row's own.
A source key may ride the query string and is then answered before the
body is read or the hourly allowance charged; an upload answered as a
duplicate hands its slot back. GET /api/documents/inbound/source answers
whether a key is held, under the same documents:write scope, so an
importer can skip a download.
aiRead=defer is stored on the row. The summary catch-up, the summary job
and lab auto-staging skip such documents until an explicit Read with AI
or Generate summary clears the marker; backup and restore carry it.
A documents:write caller no longer sees the quota figures on a full vault.
Refs #1038
A narrow `documents:write` token, minted cookie-only at
`POST /api/tokens/documents` and accepted by the vault upload route alone.
A scoped caller draws on its own hourly bucket per token
(`DOCUMENT_UPLOAD_LIMIT_PER_HOUR`, default 120) and gets a receipt
`{ id, duplicate }` instead of the stored row.
Uploads take optional `sourceSystem` / `sourceId` fields. The source key is
unique per account across live and deleted documents, and the tombstone
purge carries it to a small ledger, so re-sending a document the owner
deleted answers 200 `deleted: true` and stores nothing. `aiRead=defer`
indexes locally and skips the summary and lab staging.
The detail sheet shows where an imported document came from; backup and
restore carry both fields; Delete All Data clears the ledger.
Refs #1038, #938
Restoring a large account was one request: 94 s for 1.25 million readings.
A reverse proxy that cuts a request at 60 s (nginx's default) showed the
operator an error for a restore that went on to finish on the server.
The restore request now checks the typed confirmation, that the backup
exists and that its stored copy opens, admits one job for the account and
answers 202 with its id. The `backup-restore` queue runs the restore and
records its phase, counts and outcome on a `backup_restore_jobs` row
(migration 0347), which `GET /api/admin/backups/restores` and
`GET /api/admin/backups/restores/{id}` read back.
What a restore checks and writes is unchanged. The body moved from the
route into `restoreBackup` (src/lib/export/restore-backup.ts): every
refusal still happens before the first delete and the replacement is still
one transaction, so a failure part-way leaves the account as it was. While
it runs the account keeps showing its current data, and other writes are
neither refused nor held, as before.
- One restore per account: a partial unique index admits one queued or
running job, and a second request gets 409 `backup.restore.active` naming
the running job. A per-account advisory lock inside the transaction holds
even if a worker thought gone is still writing.
- Time budget: the job's expiry is two hours and a file whose transaction
limit would run past three quarters of it is refused before anything is
deleted (`jobDeadline`, beside `jobBudget`).
- Resuming: the job writes a heartbeat every two seconds. A boot sweep
queues a job whose worker stopped again from the start, at most twice,
then fails it as interrupted. The admission check releases an abandoned
job so it never holds the account's slot.
- The staged file is the stored `DataBackup` row, already encrypted at
rest. It stays whatever the outcome, so a retry needs no new upload. The
job records a digest of the copy and refuses one that was replaced while
it waited.
- Failures carry a stable code and a sentence that says what to do.
- The console follows the job: phase and counts while it runs, the outcome
after, and after a reload it finds the job again from the server. A poll
that fails on the network is never shown as a failed restore. A web
process separate from the worker evicts the account's caches when it
first reports the restore finished. The success line goes through the
outcome module, so an empty file is not drawn as a tick.
The model is registered in the backup plan (not carried) and the wipe plan
(wiped with the account); the queue in the maintenance registrar, the
subsystem surface and the job-fact allowlist; the new code in the error
catalogue; the routes in the OpenAPI contract. The tests that pin restore
semantics now drive the real route and the real job through one helper, and
`backup-restore-job.test.ts` pins the asynchronous contract itself.
Status notes (the per-metric text a model writes) move out of audit_logs
into InsightStatusCache: one row per user, metric and locale, the note
encrypted at rest, upserted in place, with the input and snapshot
fingerprints and a separate negative-cache window.
Migration 0344 creates the table and deletes every audit_logs row whose
action matches insights.%-status.%. The notes are a regenerable cache and
the old rows are plaintext, so nothing is copied; the first nightly run
after the upgrade writes them again. Take a backup before applying.
The table is registered for key rotation, excluded from backups as a
regenerable cache, and deleted by Delete All Data.
Refs #975
The needs-based flow is the one setup flow now. POST /api/onboarding/step
and the six goal slugs go, and with them the two account columns the
wizard kept — its checkpoint and its goal list — through a migration
that drops them; onboarding_completed_at stays as the first-run
redirect's gate. The one durable effect the goals had, the dashboard
tile order, moves to the completion route: the answers now carry both
halves of Q2, which modules are on and what sits on top, and the seed
keeps the wizard's one contract of writing only while the layout column
is still unset.
The OpenAPI table loses the step path and the completion's description
says what the route does today; the demo-allowlist test names a
mutation that still exists.
Nothing recomputes an answer. The module map they produced survives on the
account row, but the map says what is switched on, not that the person told the
flow they take medication daily and have a visit next month — so a restore
without the row opens a blank questionnaire for somebody who already answered
it, and leaves the checklist ordering itself from nothing.
Both ends live in one file, the arrangement the visits and the score already
use, because a reader who greps only the restore route gets a false negative
when the route delegates.
`modulesDerivedAt` is the field that makes this more than history and it is
asserted rather than defaulted: it is the once-only latch on the module
derivation, so a restore that let it default to null would hand the next confirm
permission to re-apply the questionnaire over every module decision taken since,
silently. The round trip seeds it non-null and reads it back.
The file's schema validates the three answer objects loosely and the restore
narrows them through the same fail-soft readers the account payload uses. Being
stricter there would refuse a whole account's restore over one retired chip.
The wipe takes the row for the reason the reset already takes the wizard's own
columns: the record the answers were about is gone, so the answers describe
nothing.
The nightly off-host job reports counts — uploaded, failed, total — and a
count cannot name the account it is failing for. A cohort where one object
has been refused every night for a month reads as "99 uploaded, 1 failed"
each time, which looks like weather rather than like an account with no
disaster-recovery copy at all.
The worker now writes a ledger row when an object lands (account, instant,
size), and Admin → Backups gains a second card that rates every account
against the nightly schedule: fresh inside one run, due when one run
produced nothing, stale when two did not, never when no object has ever
landed. On a host without BACKUP_S3_* the card says off-host backup is not
configured and stops there.
The verdicts are computed from the ledger, never from a bucket listing: the
worker's grant is deliberately PutObject + GetObject, and an admin page must
not put the operator's credentials on its render path to state a number the
database already holds.
The comprehensive-briefing cache (User.insightsCachedText) is one slot
per account with no record of the language it was written in. Every
reader served whatever was there, so a German-language account whose
nightly warm had written English kept seeing English on the dashboard
lead and the Today briefing. The request path only replaced it once the
row was a day old and the provider answered, which with a provider at
its usage limit was never.
Add User.insightsCachedLocale (migration 0335). Both writers store the
locale they generated in; the timestamp-only refresh on an unchanged
snapshot tags the row too, since the fingerprint covers the generation
locale. Every reset that nulls the text nulls the tag.
The snapshot briefing lift and GET /api/insights/generate treat a tag
that differs from the reader's locale as an empty cache: no prose, no
timestamp, the preparing / no-provider state, and the out-of-band warm
enqueued in the reader's locale through the existing path. The
generator's own short-circuit and the POST short-circuit refuse to call
a foreign-language row fresh. A NULL tag (rows written before this
change) is served as before until the next write tags it. The Today
digest derives from the snapshot and follows.
Covered by unit tests on the lift, the route and the generator, and an
integration test that writes the row through Prisma and reads the
snapshot for a reader in another language.
The reminder cadences were the oldest entry on the coverage-pending
register: carried in name, restored never — an account came back silent
until every reminder was rebuilt by hand. Both models are two-ended now,
in the house shape (both ends in one file, reminders-backup.ts): every
column travels, nextDueAt verbatim rather than recomputed, the completion
ledger beside it, tombstones in disaster recovery only, and a portable
export omits a tombstoned reminder together with its ledger rows.
Encounter.reminderId and VaccinationRecord.reminderId stop being dropped
on restore: both remap against the restored reminders and fall to NULL
with a skip report only when the file genuinely lacks the row. The wipe
plan names the ledger child-before-parent; the classification guard,
completeness guards and the integration round trip all demand the new
coverage (proven failing with the section removed).
Refs #223
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.
Research Mode was the opt-in behind the estimated GLP-1 drug-level curve:
turn it on, read a disclaimer, get the chart. The chart stopped consulting
the flag several releases ago and has painted for every account since, so
the switch on Settings → Advanced changed no screen in either position. An
inert control that promises to hide a chart is worse than no control.
The curve is simply part of the medication page now. Gone with the switch:
the acknowledgment dialog, `/api/auth/me/research-mode`, the shared status
DTO and its gate helper, the disclaimer-version stamp, the query key, and
the three user columns the acknowledgment wrote (migration 0321). The
Privacy page's second button pointed at the retired control and goes too.
Settings → Advanced is now what it reads as: reset the data, delete the
account.
- both models are backed up and claimed two-ended: an immunization
history has no other copy once the paper Pass has been transcribed
- the document link is wiped ahead of the dose, and the dose ahead of
the practitioner it points at with SetNull, so the audit row's
per-model counts stay truthful
- the dose note joins ENCRYPTED_COLUMNS and the rotation walk; the batch
code and the other structured columns stay queryable plaintext
`MoodPrediction` holds the expected value of a day beside the day's own
rating: one row per account per local day, with a band and the number of
days the fit used. Two tables rather than four columns on the entry,
because the point of the second value is that the person cannot correct
it into agreement with the first — the distance between them is the
finding, and a value the edit form can write is not a second reading.
The table stays out of the disaster-recovery backup and the reason is
written where the completeness guard reads it: every row is recomputable
from the entries, the contexts and the linked modules, and carrying one
would restore a forecast made by a model version that may no longer
exist against data the restore has just moved. It is in the wipe plan,
because a derived row is still a day-by-day statement about somebody.
The write-surface guard lands red on purpose and names what is missing:
`lib/jobs/mood-prognosis-refresh.ts` does not exist yet, so the writer
set is empty where it should hold exactly that one file. The job closes
it. The rest of the guard is green now — no route, page or component
writes the table, and the matcher proves itself against a call broken
across lines, an aliased client, a read and a different model.
- the wipe plan names it before its parent, so the confirmation screen's count
is the number of rows that existed rather than zero
- the backup plan classifies it as carried and two-ended, and the round-trip
registry seeds one, which is what makes that claim provable
- the sharing surface admits the linked-day read with the reason it may answer
- CHANGELOG for the release
A person's whole 'which doctor' history was one free-text column on a
reminder: they typed the practice name once and could reach nothing else
that happened there. This turns that string into a record.
Five tables in migration 0320, all additive: an encounter that happened
or is booked, the account's own address book of practitioners, and three
links from a visit to the documents, lab results and conditions it
produced. The encounter is slim on purpose — the diagnosis stays on the
linked condition, the address on the linked practitioner, and the reason
each field was kept or cut is written at the model. No module gate, and
the schema says why rather than leaving the absence to be read as an
oversight.
Every carrier lands with the tables rather than after them. All five are
classified as travelling both ways and are proven doing it: a seeded row
of each goes out through the real export builder, the account is deleted
so the schema's cascade decides what survives, and the rows are counted
back through the real restore route. Two references deliberately do not
survive and are named instead of dropped quietly — the reminder a visit
points at is not in the backup, and a link whose far side a portable
export does not carry cannot resolve. The wipe plan deletes the links
before the lab results and documents they hang off, or the audit row
would report zero for rows that existed. The three encrypted columns are
registered for key rotation, without which dropping a legacy key would
make those notes permanently unreadable.
Widening the reminder origin enum is a type error at the reminder DTO,
which is the right outcome: the mapper now refuses an appointment row
outright rather than relabelling it as a checkup.
The admin "Delete all data" action carried its delete list inline. That
list named nine tables, which was most of the schema on the day it was
written, and it stayed at nine while the schema grew to a hundred and
twenty-two models. Laboratory results, documents, cycle logs, journals,
mood, workouts, sleep and Coach conversations all survived the wipe an
operator runs before handing a box on, and the toast said it was done.
The per-account wipe had the same defect and grew a derived plan for it.
The admin route now reads that plan: ADMIN_WIPE_MODELS is WIPE_MODELS
minus a single documented subtraction, so a model added to the schema
lands in both wipes at once.
- AccountGrant is the one difference, with its reason in the plan: it is
the only access path to a managed profile, and this action preserves
accounts by definition. Deleting every grant would leave records
nobody can open, administer or delete.
- The audit history now goes with everything else. It is a record of who
did what from where, which is exactly what clearing a box is for; the
receipt written after the transaction commits replaces it.
- One transaction with the same budget as the account wipe. A
half-applied wipe is worse than none, because the person is told it
succeeded either way.
- The result line reports rows and tables instead of nine fixed names,
which is how nobody noticed: it read the same whether the wipe had
cleared nine tables or ninety.
Guards, both watched red before landing:
admin-data-wipe-completeness.test.ts fails when a user-scoped model
reaches neither the wipe nor a written exemption, and when the route
names a table inline again. The integration guard seeds every table in
the schema for two accounts, runs the route, and asks the database what
is left — drop one model from the plan and it names the surviving table.
- Exclude and erase egress claims with recipient-side lookup support\n- Serialize data wipes with managed-profile Guardian authorization\n- Cover last-Guardian and queued-claim wipe races
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.
An account grant is one account's standing permission to read another
account's record. The row is also the consent record for the act that
created it: there is no second receipt table, because two records of one
act drift and then nobody can say which is true.
That shapes the table. Revocation stamps revoked_at and revoked_by
instead of deleting, so the record can still answer who had access, from
when to when, and who ended it. The uniqueness constraint is therefore
partial — one live grant per (owner, delegate), with history accumulating
underneath — otherwise a re-invitation would either be refused or would
overwrite the first grant's acceptance. Both foreign keys cascade: a
grant naming a user who no longer exists is an authorization nobody can
resolve and nobody can revoke.
The state machine lives in src/lib/sharing/grants.ts and only there. The
acting-account resolver and the grant surfaces both read these rows, and
if each carried its own idea of "active" the drift would be silent in
exactly one direction: the resolver admitting a grant the panel shows as
ended. Pending confers nothing, expiry is checked against the clock of
the request that reads the row, and every transition is a conditional
update so two concurrent accepts cannot both win.
The access column carries READ or WRITE and only READ is ever written.
It exists from day one so that delegated writes become enforcement work
rather than migration work.
Three lifecycle paths, each proven against the real route:
- Delete All Data removes grants from either side. The wipe loop asks
every model for "this account's rows", and a table relating two
accounts has no user_id to answer with, so wipe-plan.ts now declares
the owner columns and translates. Wiping only the grantor side would
leave the account still holding access to other people's records.
- Account deletion removes them in both directions.
- An admin restore leaves them exactly as it found them. Grants are
classified NOT_IN_BACKUP: rolling data back is a decision an admin
makes, but rolling an authorization back is a decision nobody makes,
and a grant revoked on Tuesday must not come back alive out of
Monday's file. After a restore, sharing is off and both people
re-consent.
The backup classification guard was reading a user relation by field
name, so a model naming its account relations grantor and grantee read
as instance-scoped and was excused from having a verdict at all. It now
reads the type. That surfaced InviteToken, classified here too.
A new user-scoped table is outside all three by default, and the two
structural guards said so the moment the model landed: the wipe plan and
the backup verdict list both named it as unclassified. It now joins the
wipe list, rides the backup payload as its own spread section, and comes
back through the restore route.
The classification guards only prove a helper exists in a declared file, so
they would pass an unreferenced restore helper. The integration round trip
goes through the real admin restore route instead, with the table emptied
first so a restore that carries nothing cannot pass by leaving what was
already there.
PATCH /api/auth/me/health-score-config takes the pillars a person
wants counted and stores the rest as the deselection list. A selection
that cannot produce a score is refused with a sentence naming what is
missing, using the same rule the scorer applies at read time. The
write evicts the score caches, and so does the modules route, which
changes the same composition and never did.
The rail gains a same_time_baseline kind and a server builder, which is the
growth path that type documents for itself. It speaks only when today is
actually outside the typical band: a card that reports doing exactly what you
usually do teaches people to stop reading the rail. Dismissing it is keyed by
day and metric, not by the hour, so it stays dismissed instead of coming back
every sixty minutes.
The Insights grid gains a section of four tiles, one per cumulative metric,
each honestly quiet until the history accrues.
The two-trajectory chart it needs did not exist, so DeltaSparkline gains a
second mode rather than a second chart family: the comparison curve rides the
same container, the same fixed height and the same shared y-domain as a dashed
muted line behind the live one, which is the only way the two can be read
against each other.
Six locales. The wipe and backup plans learn about the new table: the profile
cannot be rebuilt once the samples are gone, so it is carried, not derived.
`failing_leg` was one slot, so two legs of the same provider failing in
overlapping windows overwrote each other. The row remembered whichever failed
last, and that leg recovering then cleared an error the other one was still
causing — the ledger forgot a live failure and painted the card green over it.
The slot becomes a set. A failure adds its leg, a success removes its own, and
the error state, the per-kind buckets and the streak anchor clear only once the
set is empty. The array is ordered oldest failure first, so the last element is
the leg that wrote the message `last_error` holds; when that leg recovers while
others are still failing, the message is cleared rather than left describing a
leg that is now healthy.
An unattributed failure adds nothing and removes nothing. A single-leg provider
therefore behaves exactly as before, and a multi-leg provider no longer loses
what its other legs already reported.
The column never reached a client — it is absent from the status serialiser and
from the OpenAPI contract — so it is replaced rather than shimmed.
Three dedicated User columns — base URL, optional bearer, model — for a
provider whose endpoint the user chooses. They are separate from the
OpenAI columns on purpose: the OpenAI arm pins api.openai.com, so it must
share no column with a user-supplied host, and a gateway must never
receive the key issued for OpenAI.
The bearer joins the encrypted-column registry and the rotation script;
the three columns join the account-wipe reset set.
The defect is about rows that survive, so a mocked client proves nothing. The
new integration test seeds a row into every table in the schema, driven by
information_schema and pg_constraint rather than a fixture, runs the wipe and
asks the database what is left. A table added later is seeded and asserted
without anyone remembering to.
It holds all four halves: nothing in the wipe list survives, everything in the
exemption list does, everything reached only by a database cascade is gone,
and the account row comes back reset but able to sign in. A second file pins
the allergy and checkup-reminder deletes actually removing their row.
The reproductive-health note gains a Deletion section. It taught people to
reason carefully about a host being imaged and never said what pressing
delete does; now it says which of the three deletions removes the row and
which only hides it.
The wipe deleted thirteen tables against a schema that carries more than
eighty user-scoped ones. Mental-health assessments, lab results, the
menstrual record, allergies, family history, the document vault, every AI
conversation and live clinician share links all survived a confirmation
that promised to permanently delete all health data. So did
MeasurementRollup, the per-day reconstruction of the measurements the same
transaction had just removed.
The list now lives in src/lib/data-wipe/wipe-plan.ts next to its own
exemption list, and a structural guard enumerates schema.prisma and fails
when a model belongs to neither. Adding a model can no longer widen the set
of things that survive a wipe by default. The same guard covers the User
row's own columns, which had drifted the same way.
The boundary is explicit: everything the account owns goes, except what is
needed to sign in again. The confirmation and the toast say so before the
person types the confirmation rather than after.
Checkup reminders and allergies promised permanence over a soft delete with
no restore path; both now hard-delete, so the dialog is true. The lab
biomarker dialog was checked and is already accurate — it maps to the
hard-deleting /api/biomarkers/[id] route, not to the soft-deleting lab
result route.