A calendar feed is a standing credential whose contents end up in a
third party's calendar cloud. Putting a record's cycle dates there is
the decision of the person they are about, or of the guardian of
somebody who cannot make it, and no delegate of an adult's record,
MANAGE included.
The subscription routes resolve through requireGuardianAuth instead of
requireRecordAuth("manage", "cycle"): the owner on their own record, or
a MANAGE grant on a record carrying the managed-profile marker. Every
verb follows, the revoke too. The feed re-reads the marker on each fetch
as well as the guardian's grant, so a row minted for somebody else on an
adult's record is a 404 whatever grant stands behind it. The settings
card is offered only where the routes answer.
The routes move from the delegable and MANAGE literals to the guardian
list, and the MANAGE handler inventory is back to 93.
GET /api/cycle/feed/<hlf_token>[.ics] answers any calendar app with the
record's forecast as RFC 5545: all-day events for the next few periods
over their widening windows, optionally past periods, and for a
conception goal that asks for it the next fertile window. Dates and a
title only; discreet titles and the calendar name "HealthLog" by default.
The body is built live on every fetch in the record's own zone and
language, never the request's. ETag is the content hash and a matching
If-None-Match answers 304; DTSTAMP moves only when the content does.
The feed is anonymous, so every gate is read per fetch: the auth-surface
limit before any lookup, the token's shape, the HMAC lookup of a live
feed, a per-token bucket, then the record and carrier not pending
deletion, the operator's external-access switch, cycle tracking for the
record, and for a feed a delegate minted, a live MANAGE grant covering
cycle. Every miss is the same 404.
/api/cycle/feed-subscription (status, turn on, options, turn off) and
its rotate are cookie-only through requireRecordAuth("manage", "cycle"),
with a fresh proof on turning on and rotating and a per-actor mint
bucket. The URL is returned once; the status carries the prefix, the
options, the fetch statistics as a calendar-app family and the
projection state.
The hlf_ family is registered with the path and token redactors, the
secret-shape refusal, the proxy's public paths and edge headers, the
URL-credential, sharing, recent-proof, step-up and module-gate guards,
the error-code catalogue, the activity feed and the destructive-control
registry. The cycle settings tab gains the subscription card and the
predictions panel lists the later cycles.
The calendar read, the Coach and MCP cycle block and the coming calendar
feed now share one loader for the forecast's inputs (cycles, day logs,
overnight wrist temperatures) and one gate (`gatedPrediction`: no
forecast while prediction is off or raw-chart mode is on). The Coach
block reads the calendar's default day-log window, so its next period
is the date the calendar page shows, and resolves the luteal length
through the engine's own clamp.
`projectCycles` extends the engine's next-period forecast a few cycles
ahead. Cycle 1 is the engine's result unchanged; later starts are
anchored on it from the fractional median length, so six cycles out
carry no rounding drift; later bands widen as the SD of a sum of k
lengths with an estimated mean, with the engine's sparsity penalty, and
never narrow. The list stops where a band passes ten days or half a
cycle, holds the next period only while the engine is still learning or
for perimenopause, and is empty once the next period's band has passed
with nothing logged.
`/api/cycle/calendar` publishes it additively as `projection` and
`projectionState`; the Coach block carries it as `upcoming`.
The panel field offers the catalog panels in the page language and the
person's own panels, which GET /api/biomarkers now lists as
customPanels (one spelling per folded form). The field stays free
text; a catalog panel typed in any language lands in the same category.
An edit used to omit an emptied panel, context or bound, so the stored
value survived the save. The form now sends null for a field that held
a value and was emptied. A new marker still omits empty fields, and a
field that was empty when the edit began stays out of the body, so a
context the list could not decrypt is never wiped by an unrelated save.
Refs #1349
GET /api/labs/overview now answers categories and grouped next to the
markers. A marker's category is its panel: a catalog panel in any
shipped language folds to one key, any other panel becomes the
person's own category under an opaque hash key, and a marker without
a panel falls back to the catalog panel of its name. ALIAS_PANEL
places the ten analytes that have names but no seed. Each category
carries its label in the caller's language, its members, counts per
range status and one combined status (above wins over below; no
alarm tone). grouped is true only when grouping adds something.
The list renders this as given: with groupBy set to category it shows
a disclosure header per category in both views, the person's sort
applies inside each category, and groupsOpen decides which start open.
A category opened or closed by hand stays so in this browser until the
choice changes. Settings > Arrangement > Labs gets both choices. The
overview query key carries the locale, since the labels are localised.
Refs #1349
The list layout gains two choices that only the labs list reads:
groupBy (none or category, default none) and groupsOpen (expanded,
flagged or collapsed, default expanded). The resolver keeps a valid
stored value and drops anything else to the default, the PUT merges
both field by field, and the hook exposes a setter for each. A
browser value from before the server column never held them, so the
legacy parse fills them from the defaults.
The inbound route admits two uploads per person (four per process) and
answers the next with 429 `uploadBusy` and `Retry-After: 1`. The vault
queue sent three at once, so a multi-file drop provoked that answer
itself, and the card read it as the hourly limit: "Try again in a few
minutes".
The queue now runs at `DOCUMENT_UPLOAD_PER_USER_CONCURRENCY`, moved to
`inbound-constants.ts` so client and route share one number. A busy
answer is classified on its own, carries its Retry-After, and is retried
for the same file (Retry-After clamped to 1-10 s plus jitter, at most six
attempts within 30 s) while the row stays in its uploading state. Only a
spent budget shows an error, with copy that says the server was busy.
Unmounting aborts any pending wait. The Coach's new-file attach uses the
same retry. The hourly-limit copy now names the hour.
The workout batch kept `route` only for entries it inserted. An entry
matching a stored workout reported `duplicate` and its track was dropped,
so turning on `storeWorkoutRoutes` only helped workouts recorded from then
on.
A matching entry that carries a route now attaches it when the stored
workout has no track and the account keeps tracks, the same way the
heart-rate series enrichment works: the planner probes for a stored
track, the write seals it like an insert does, and the entry reports
`enriched` (once, also when it attaches series and track together). The
climb derived from the track fills an empty `elevationM`, as it would on
insert. A workout that already has a track keeps it, and with routes off
nothing is written.
Track rows now go through `createManyAndReturn` on the unique
`workoutId`, so a track that lands between the probe and the write wins
and this entry settles as a duplicate instead of failing or adding a
second row.
A read whose readings all lie past the lookback limit was reported on its
step as outside_window, and its fetch entry carried no reason, so a client
could not tell an empty window from data the limit keeps out. The step now
carries reason outside_reach and the fetch entry mirrors it. The method
line and the web trail keep the window sentence; the answer names the
limit as before.
A report that prints a text result such as "SEE NOTE" with a numeric
range beside it minted the marker without bounds: the document commit,
the scan commit, the free-text lab write and the CSV import each passed
null bounds for a qualitative reading, and the CSV row reader and the
scan review payload dropped the range before that. The marker's first
number then had nothing to be judged against.
Every mint path now passes the printed window. A range that is text
itself yields no bounds, an existing marker keeps its own, a transposed
window is not minted, and the reading itself still records no window.
The scan review shows the range on a text row that mints a new marker.
Refs #1313
The details of the Garmin and Oura cards listed measurement types and
workouts only, so cycle days a wearable imported appeared nowhere, and a
bug report could not say whether any had arrived. The status envelope now
carries a `CYCLE_DAYS` entry per cycle-importing source with the newest
day and the count, and the freshness list renders it. It counts as a
sparse type, so a month between periods never reads as a dead pipe.
Refs #1333
GET /api/biomarkers/{id}/series answers the line labSeries on
/api/timeline draws for that marker, gated on the labs module instead of
the opt-in timeline. The window rules move into resolveTimelineWindow and
the shared query rules into withWindowRules, so both routes cut the same
window and read the same loader; all starts at the marker's first reading.
Refs #1342
GET /api/sync/changes?start=now answers an empty page whose cursor sits at
serverNow in every domain, in the same opaque format the feed hands out.
A client that only needs what changes from now on, such as the Apple
Health write-back, pages forward from it instead of draining the whole
history first. start and cursor together are refused with 422.
Refs #1339
GET /api/integrations/healthkit carries acceptedTypes, the quantity and
category identifiers the batch route maps today. The list is derived from
APPLE_HEALTH_TYPE_MAP alone, so a deferred type is never in it and the
client no longer needs a release to follow a mapping change.
Refs #1340
The web leg of the OIDC callback already names the purge date when the
account is pending deletion; the native leg redirected with the bare
reason. It now adds the same purgeAt parameter to the custom-scheme
redirect, and only the date.
Refs #1341
Weigh-ins: Garmin's document has no samplePk and gives kilograms, the live
service sends a samplePk and grams. A weigh-in now takes its id from
samplePk or else its own GMT instant, reads each mass by magnitude (above
1000 is grams), and is written whole or not at all: a missing weight, a
missing instant or one implausible value drops every row of it, counted
on the wide event by reason.
Refused resources: a lasting 4xx on one resource (a cycle summary for an
account that tracks no cycle) is noted once on the progress column,
counts as done for the backfill, is skipped by the hourly sync and is
listed in the card's details. It no longer holds the backfill open or
turns the connection red. The cycle leg never parks the connection,
whatever it is refused with.
Backfill: weigh-ins walk back to the start of Garmin Connect on their
own, so history before the first watch day and a scale-only account
arrive. Progress and the completion stamp are written only while the
connection the slice started on is still the stored one.
Key: connect sends the key to the one service the person picks (Garmin
Connect or Garmin Connect China), never to both. A parked key is asked
about again once a day with a single token exchange and reconnected when
Garmin accepts it. The key's end is read from the token's own iat and
exp, an estimate the key outlived is cleared after an accepted exchange,
and the validity can be corrected from the card's menu (PATCH
/api/garmin/key-expiry, with the day the key was created) without
pasting it again.
Sync: an incremental run reaches back to the last successful sync, up to
a year, so an outage longer than the 30-day window leaves no gap; a night
Garmin no longer reports (deleted, or turned into a nap) is retired.
Card: connect errors are translated from meta.errorCode in every locale.
Types: BODY_BATTERY, STRESS_LEVEL, TRAINING_READINESS and
ACUTE_TRAINING_LOAD get source ladders of their own (the device or the
bridge first, then the hand-entered sources in their usual order), join
the ranked types and the provenance picker, and the recovery-empty
description names them.
Garmin Connect now offers a Personal API: a person creates a key for
their own account and any client can buy short-lived tokens with it.
HealthLog takes that key in a new Garmin card, checks it with Garmin
before storing it sealed on the user row, and polls every hour.
- Transport: key exchange with a per-process token cache keyed on the
key, one re-exchange on a refused token and then reauth_required, the
429 Retry-After carried to the caller, the China host tried when the
global one does not know the key, answers read with a byte cap, all
egress through safeFetch to the two fixed hosts.
- Sync: daily summaries, sleep summaries, HRV, weigh-ins and the
activity search, chunked to each resource's documented range, read one
after another and stopped at the first rate limit; today's menstrual
cycle summary adds logged period days the person has not logged.
- Backfill: a resumable walk through the shared admission lane that
learns where the history starts from the daily summaries and the
activities, waits out Garmin's rate limit and stamps its marker.
- Types: GARMIN source; BODY_BATTERY and STRESS_LEVEL for Garmin's own
day scores, kept apart from the computed STRESS_SCORE; TRAINING_READINESS
and ACUTE_TRAINING_LOAD for figures a bridge pushes through the ingest
token. All four chart on the recovery page and sit on the ladders,
units, ranges, labels and report groups like the device-native scores.
- Key expiry: learned from a token capped short of a day, else estimated
from the validity picked at connect; the card warns a week ahead and
asks for a new key once Garmin refuses the old one.
- Ledger, health, freshness, watchdog, backup classes, wipe plan,
encrypted-column registry, rotation script, OpenAPI and all ten
locales carry the new integration. The old "no Garmin connector" note
is gone.
Home monitoring reads morning and evening readings as separate sets, so
the blood pressure page now shows the morning, midday and evening means
side by side over the chart's range, each with its count and pulse, and
the morning-minus-evening difference. A control under them narrows the
chart to one part of the day; the choice lives in `?partOfDay=` so it
survives a reload and a shared link, and the whole day keeps a bare URL.
The split is resolved on the server in the record's own time zone and
published as one response, `GET /api/measurements/blood-pressure/time-of-day`,
so every client renders the same numbers. Morning is 04:00 to 11:59,
midday 12:00 to 17:59, evening 18:00 to 03:59: the guidelines define the
sets by waking and bedtime rather than by the clock, so the windows are
wide, and a reading after midnight is a late evening one. Pairs follow
the blood-pressure series' rule (same source within five minutes); the
pulse is the reading's own. A part shows a mean from three readings on.
`GET /api/measurements` takes `partOfDay` on the blood-pressure and pulse
series read: each local day folds only that part's readings, a day runs
from 04:00, and long ranges fold weekly instead of reading rollups that
cannot tell the hour.
Refs #1331
A reset mock goes back to the implementation its factory gave it, which
is the fresh session and the open capability these cases want; the cast
replacements grew the test-realism count.
Cases leaned on what an earlier case set up or left behind: a once-value
the code under test never consumed, an implementation swapped in and
kept by clearAllMocks, a payload only another block's setup provided,
the zone resolver's per-user cache, module state in a hoisted object,
and a scope set one case discovered for the next. Each now resets or
states what it reads.
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
The briefing's lab input passed the stored "1" to the model, the unit
mismatch refusal read "is in 1", and the trend card left a gap where a
language puts the unit before a particle. get_labs says that a value
without a unit has an empty unit, and a lab link id is scrubbed like its
label. The review row is back at its baseline length.
The route test recognised the bucket statement by its old GROUP BY
column, and the new fall-back-night test reads from a fixed date, which
the dated-fixture guard now allowlists with its reason.
The realism ratchet counts as-never casts and the version-stamp ratchet
refuses comment lines that open with a release; both caught the previous
commits.
String(profile.user_id) turned a missing id into the literal "undefined",
so every such connection would have looked like one WHOOP account and the
unique account id would have refused the second record. The callback now
treats it like a failed exchange (reason=token), as the Polar callback
already does for a token body without x_user_id.
The profile's resource name is `users/{user}/profile`, and the connect
stored its last segment, the same constant for every account. Since the
one-account-per-record check, a second record on an instance could not
connect or reconnect Google Health: it was refused as `already_linked`.
The connect now stores the `{user}` segment, reads `users/me/identity`
when the profile answers through the `me` alias, and stores the id as
unknown when neither names the account. A migration makes the column
nullable and clears the placeholder, so the next connect stores the
real id.
The upload creates a passwordless account before it reads the file and
deletes it again when the file is refused. The only audit row was
written after a successful store, so a refused upload whose delete also
failed left an account nobody could sign into and no record of where it
came from.
The account and its admin.user.create row (outcome "created") now
commit together, and the discard writes admin.user.delete with outcome
"deleted" or "failed", so a leftover account always has a trace. The
runbook says how to remove one by hand.
A restore read the account settings under the operator's AI endpoint
policy whenever the receiving account was an admin. For a copy put into
a chosen account, the settings were written by the erased account's
owner, not by the operator, yet an admin account could be chosen as the
target, and an ordinary target promoted later got the same trust.
Admin accounts are now refused as a target, in the check and the
picker, with backup.target.admin_account. A copy uploaded into a chosen
account is stored as RETARGETED_UPLOAD_<ms>, and its restore never reads
its settings as the operator's, whatever the account's role is by then.
Every write to the per-sign-in Codex memory checked the account digest,
but the reads did not. A credential reconnected to another ChatGPT
account therefore tried the old account's working model first, sorted
by its refusals and trusted its model list, until the first write
rebound the entry, and the vision gate kept answering from the old
account in the meantime.
The client reads (working model, refusals, model list) now take the
credential and treat an entry learned under another account as empty.
The vision gate holds no account id, so the connect routes rebind the
entry and the disconnect routes drop it, and the gate reads the entry
as its process last bound it.
The target refusals answer with eight new meta.errorCode values; they
are now constants the catalogue sweep reads and entries in the
published catalogue, so a client can branch on them without triggering
each one. The upload handler had grown past its size baseline with the
target branch, so the target checks, the file read and the schema check
move into named functions beside it, unchanged. The target check route
holds its params to a strict schema like every other route.
The timeline showed a lab report as an event on the axis, but the lines
under the lanes could only be measurements. A lab marker can now be named
in `values` as `lab:<biomarkerId>`, beside the measurement types and
`MOOD`, so one choice, one request and one stored list carry every line.
`GET /api/timeline` answers with `labSeries`, one entry per named marker
with its readings in the window as single points (lab values come a few a
year, so they are never averaged into buckets), each dated by the record's
local day, judged against its own window and linked to the report it was
read from when the caller may see documents. `availableLabs` lists every
marker the record has a numeric reading for, so a client offers only
those.
A reading stored in another unit than its marker's is converted through
the unit-conversion registry where the pair is known, and otherwise left
out and counted in `excluded`, never drawn on a scale it is not in. A
marker id that is not in the record yields no line instead of a 422, so a
stored choice survives a deleted marker; ids are capped at 24 a request.
Refs #1299
After an account is erased the operator still has its disaster-recovery
file, and the upload refused it because its owner was gone. The refusal
now carries backup.owner.missing, and the file can be sent again with a
target in the query: an existing account that holds no records yet,
confirmed by typing its username, or a new account created for the copy.
The file's owner is rewritten to that account as it is read, so the
stored copy, its preview, its download and its restore are that
account's, and the restore that follows is the ordinary one.
The new account has no password and no email; an admin sets its first
password with the existing reset. A file whose own account is still here
is refused with a target, and an account made for a refused file is
removed again. The refusal of an unknown owner no longer writes the
owner's id into the audit trail, since the erasure took every row that
named it.
A restore replaces the account's records and has no merge, so the copy
of an account that no longer exists may only go into an account that
holds none of the records a backup carries. The check reads every
backed-up model by its userId, reaches the owner-less children through
their parent, and is held to the schema so a new model cannot be read
as empty. Managed profiles and accounts pending deletion are refused as
well. The admin console asks GET /api/admin/backups/targets/{userId}
before it sends a file again, so a refusal costs no upload.
The admin recompute for one account called the full fold without a
statement budget, so on the large accounts it is meant for each yearly
statement ran under the database's ordinary limit and timed out, as the
boot job had before it got its own. It now passes the same per-statement
budget.
The MFA status read listed oidc among the proof methods for a Bearer
caller too, although the provider round trip stamps a browser session
and nothing else, and the API contract says a token is never offered
it. The status read now leaves it out on the Bearer path.
The createdAt field and the locale import grew files the size ratchet
holds. The account payload's insurance-number decryption moves into
its own helper, the import joins the zod one, and two comments lose a
wrapped line, so no baseline has to rise.
The header always said "welcome back", including to an account the
setup flow had created a few minutes earlier.
/api/auth/me now publishes the account's createdAt, and the greeting
welcomes an account in its first day ("Welcome, name.") and welcomes
back every other one, including an account whose older server does not
send the field. Until the account is known, during the SSR pass and the
hydration render, the line is the time-of-day greeting alone, so
nothing claims "back" before the client knows it is true.
There was no way for an admin to remove someone else's account; the
users list only offered the undo for a deletion the account itself or a
Guardian had asked for.
DELETE /api/admin/users/{id} takes the account's own deletion path,
now shared as requestAccountDeletion: with a grace window the account
is locked at once (credentials revoked, record untouched) and erased by
the purge job when the window ends, and the existing undo gives it back
until then; with ACCOUNT_DELETION_GRACE_DAYS=0 it is erased in the
request. The route is cookie-only behind requireAdmin and the
recent-proof gate, takes the typed DELETE_ACCOUNT confirmation, and
refuses the caller's own account, a managed profile (its Guardians
delete it), an account already pending deletion, the last admin who
can sign in and the last Guardian of a managed profile, the last three
decided under their locks inside the transaction. Both the admin and
the account get an audit row.
The users list says who asked for a pending deletion, now including an
admin, and flags managed profiles. Each other account's row carries a
delete button whose confirmation names the purge date, or says the
erasure is immediate when the server keeps no window.
An account created through OIDC holds no password, passkey or second
factor, so the recent-proof gate in front of token minting, MCP
connections, exports and the admin reset had nothing to ask it for and
the web could only say "sign in again" (#1296).
GET /api/auth/oidc/reproof sends the browser back to the configured
provider with prompt=login and max_age=0, its own state, nonce and PKCE
verifier, and the signed-in account and session sealed into the state
cookie. The shared callback branches on that intent: it refuses unless
the browser still carries the bound session (checked before the code is
redeemed), the ID token names the account's linked issuer and subject,
and auth_time lies inside the gate's five-minute window. It then stamps
Session.reproofAt exactly as a password re-proof does, creates no
session, and returns to the page it left with ?reproof=done|failed.
recentProofMethods offers "oidc" only to an account without a second
factor whose identity is linked while a provider is configured, and the
fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the
list as proofMethods, so the passkey card offers what the account can
actually give, and register-options takes { method: "oidc" } on a fresh
provider re-proof, so such an account can add a passkey as a second way
to confirm. Both re-proof dialogs offer the provider and a toast reports
the outcome on return.
The callback's missing-state branch and the passkey card's enrollment
flow move into their own functions to keep both handlers under the size
ratchet.
Systolic, diastolic and the cuff's pulse are three rows with no link
column, and the single DELETE tombstoned only the row it was given, so
deleting a reading's systolic left its diastolic and pulse behind as half
a reading. The bulk delete had the same gap.
A reading is the rows with the same owner, the same source and the
identical measuredAt: that is how the entry form, the MCP write, a
Withings measure group and a HealthKit correlation all store one, and how
the FHIR read pairs a diastolic onto its systolic. The looser five-minute
pairing of the chart reads is right for drawing a point and wrong for
taking rows away.
Deleting a systolic or diastolic now tombstones the rest of its reading in
the same transaction with the same deletedAt, each row with its own
syncVersion bump so paired clients see every tombstone. The restore route
brings back the rows deleted together with a named half (identical
deletedAt), so the undo, which names only the row that was tapped, puts
the whole reading back, from the web list and from any client that calls
it the same way. A pulse is still deleted on its own, and a row deleted at
another moment stays deleted. The response shapes do not change; the
OpenAPI descriptions say what the counts now include.
On an account with tens of millions of readings the boot full backfill
never finished. It ran four aggregates over the whole five-year window,
each held to the connection's 60 s statement limit, so every attempt
was cancelled with 57014, the reading extremes added by 0417 stayed
NULL, and the readers that need them fell back to the raw table and
timed out as well (#1298).
The full backfill now folds the window one UTC calendar year per pass,
newest first, and every statement runs under its own five-minute limit,
the shape the pulse day-mean rebuild already uses. Calendar-year cuts
write exactly the buckets one pass writes: day, month and year buckets
fall inside one window and a week across New Year is folded whole by
both. Each window commits on its own, so a run that reaches its job
budget hands the remaining years to a follow-up job instead of failing
and starting over on the retry.
The boot discovery read every live reading in the window to find
uncovered days, the same scan that kept the fold from finishing. It
now walks the days per account and type off the live index, so its
cost follows the number of days with readings, and runs under the same
limit. The full backfill code moves to its own module.
A lab report usually prints the collection date once, in its header. The
extraction puts that date on the document (reportDate) and leaves each
value's own date empty, and the confirm step filed every such value under
the moment it was confirmed, so an old report showed up as today's
results.
The confirm route now reads the document's report date and filing date
with the document it already loads, and an undated value takes the report
date, else the filing date, else now: the order the day view places a
document by.
A stated date, a lab value's or a condition's onset, now counts only when
it names a real calendar day. 2021-02-30 used to be stored as 2 March, and
2021-13-01 became an invalid date that failed the whole confirm.
The list reads ordered schedules by their window and id, but the detail,
create, update, compliance, dose-history, GLP-1 and efficacy reads took
them in whatever order Postgres found them, so two schedules created in
the same millisecond could swap places between two answers. Every read
now uses MEDICATION_SCHEDULE_ORDER, and the guard checks every
schedules include, not only the list reads.
The fixes above grew six files over 800 lines and three long functions, added
casts in tests and one error code the catalogue did not list. The claimable
profile check moves into its own module, the guardian count is reused by the
revoke path, the lifecycle fallback lives beside the lifecycle rules, the
key-needs list is its own component, the coach sweep wraps its filters with
ownedByActiveAccount, comments are tightened, and the test fixtures are typed
instead of cast. The enumeration guard reads a write keyed by one handed id
(id: runId, id: job.data.jobId) as scoped, so those need no marker.
account.deletion.no_guardian joins the catalogue; two baselines go down.
The per-account free-text encryption backfill seals what is already stored
and changes nothing the record holds, which is why its module is exempt from
the frozen-account rule; the new pending-deletion gate dropped its per-user
jobs anyway. A queue can now say it acts for pending accounts
(actsForPendingAccounts), and this one does.
Also types the ingest route test's token fixture to the row it now reads,
and drops an unused variable from the deletion grace test.