Every AI job now asks its capability for the user before it builds a
snapshot, and skips with a `.skipped` annotation naming the reason: the
nightly pregenerate (briefing and statusText, each half on its own), the
status batch and the per-metric crons, the on-demand status worker, the
narrative cron, Coach nudges and their AI composer, the Coach memory
refresh, reaction lines, workout paragraphs, and the document summary,
catch-up and index jobs. Seven of these read no assistant switch at all
before, so the master switch now stops them too.
The cron candidate queries drop people who switched AI analysis off in
SQL, and no longer read `disableCoach` outside the Coach: hiding the
Coach hides the Coach. Coach nudges use the one presence probe, so a
central Codex account is no longer skipped; a missing consent still gets
the deterministic template. The arrival spine asks only the
provider-free half (switches and modules) before it enqueues, keeping
its module graph free of provider code.
Document reading needs an extraction receipt (ai_extraction or ai_full)
for any external provider; the auto-read toggle is the trigger, no longer
a consent shortcut, so a revoked receipt wins over a toggle left on. Lab
report scans get the same rule for a whole chain.
Two structural guards: every file that calls a provider is named with
the reason its call is admitted, and the chokepoints re-check a
capability; every AI job references its capability gate. An integration
matrix runs each worker under the refusing states with a provider spy.
Refs #975
Every status note reader and writer moves to InsightStatusCache: the
generators, the unchanged-data gates, the stale-while-revalidate read,
the ingest debounce, the previous-note prompt block and the
medication-compliance card with its per-medication lines. A stall now
opens a negative-cache window next to the note instead of a stub row
that shadowed it.
Stored model text is never served while its capability is unavailable,
whatever the reason: the status readers and gates check statusText, the
period narrative hides a model-written row while periodNarrative is
unavailable (the deterministic narrative is data and always shown), and
the daily digest drops reaction lines while reactionLines is
unavailable. A read-only miss now reports why (`unavailable` with the
capability's reason) and keeps hasProvider meaning provider presence, so
a withdrawn consent no longer reads as "no provider".
runStatusCompletion takes the capability as a required argument and
re-checks it before the provider chain is resolved, so a job queued
before a switch flipped still stops at the wire. The comprehensive
briefing re-checks briefing the same way. hasUsableStatusProvider is the
one presence probe.
Refs #975
A new receipt kind, ai_extraction, covers reading documents, lab report
scans and typed medication text with an external model. Switching the
document auto-read on now mints ai_extraction instead of ai_full, so it
no longer grants the Coach or the analysis as a side effect. Existing
ai_full receipts keep covering everything.
DELETE /api/consent/ai/latest now runs through withdrawConsent: the
revoke and, when it leaves no receipt covering the analysis, the
deletion of the text a model wrote under it commit in one transaction.
That is the status notes, the cached briefing, model-written period
narratives, reaction lines and workout paragraphs. Coach conversations,
facts and plans and document summaries stay. The purge is audit-logged
as consent.ai.purge with counts only.
Refs #975
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 Coach gate inventory lists the correlations route with the routes
no assistant switch owns. The seeded-question test gives the switch
read a settings row, since a read that fails no longer opens every
switch.
The Coach availability fold and the AI analysis carry-over for people
who hid the Coach are read out of the migration and run twice against
rows shaped like an upgrading instance, including a malformed
preference blob and the concurrency token.
Each state changes one layer against a fully set-up account and checks
the capabilities it owns: master switch, Reading documents, no
provider, the operator's key with and without consent, hiding the
Coach, the AI analysis opt-out, the workouts module, the single Coach
switch, a delegate, and a guardian limited to the operator's key.
AccountPayload gains ai with the AiCapabilities, AiCapabilityState,
AiUnavailableReason and AiProviderState components. The feature-flags
route is marked deprecated, drops correlations and documents
documentAi. The error envelope's meta documents capability, reason and
module, lists the assistant.disabled switches, and the catalogue
carries the three new ai codes.
GET /api/auth/me gains ai: the state of every capability and the
provider block, resolved for the active record and masked to the
grant like moduleAccess. The authority comes from the switch the
payload already resolved, so a delegate reads not_permitted_for_record
and a guardian is limited to the operator's key.
useAiCapability is the web reader and fails closed until the payload
arrives. GET /api/feature-flags becomes a deprecated projection of the
operator switches with a Deprecation header and a link to its
successor.
loadAiCapabilityInputs reads the switches, module access, provider
presence and active consent kinds for one record in parallel, memoised
per request, and fails closed to check_failed. requireAiCapability,
getAiCapability and aiCapabilityForJob resolve for the request's
record, masked to the active grant like moduleAccess, or for a job's
record under the worker's authority.
apiHandler renders AiUnavailableError as one envelope with errorCode,
capability, reason and module. Existing codes stay: the operator
family keeps assistant.disabled.<switch>, modules keep module.disabled,
consent keeps consent.ai.required. New codes are ai.record.notPermitted,
ai.provider.none and ai.unavailable.
probeProviderChain describes the chain that would serve a record
without decrypting, building a client or refreshing a token: which
entries hold a credential, in chain order, whether each reads images,
the in-browser OCR opt-in, and where the credential comes from. It
applies the provider-work authority, appends the central Codex only
behind the opt-in, and falls back to the legacy provider only for an
empty chain. hasAnyConfiguredProvider and resolveProviderAvailability
now answer through it.
The operator switches become master, Coach, Daily Briefing, Status
notes and Reading documents. Correlations goes: it gated statistics,
not anything a model writes. Reading documents covers the document
vault, lab scans and medication extraction.
The Coach module's operator availability now follows the Coach
switch, so there is one place to turn the Coach off server-wide. The
migration folds a stored Coach availability of false into the switch,
and gives everyone who hid the Coach the AI analysis opt-out, since
hiding the Coach no longer stops nightly briefing and status notes.
A switch read that fails now fails closed. The insights module is
described as AI analysis in every locale, and the switch guard now
keeps both retired switches out and freezes the callers of the old
operator-only gate.
A pure resolver turns operator switches, module access, provider-work
authority, provider presence and consent receipts into one state per
capability, reporting the outermost reason. The document provider rank
moves to its own module so the resolver and the document routes read
the same order.
One frozen table names every AI capability with the operator switch,
owning modules, consent rule, modality and provider order that decide
it. The reason enum is listed in precedence order. No I/O, so the
resolver, gates, contract and guards can all depend on it.
The iOS client left public beta. The badge row, the navigation lines and
the iOS section named TestFlight only, which now reads as the single way
in. Both routes stay, with the price and the free-code offer stated
rather than left for someone to discover on the store page.
The archive member was matched against the English lowercase spelling
alone. iOS writes that file name in the phone's language and some
versions capitalise it, so an ordinary export could arrive as
Export.xml or a translated name and was refused with a message telling
the reader their export was invalid.
The basename now matches in any capitalisation, and a single XML member
at the top of the archive is taken as the translated spelling. Apple's
clinical-document file never qualifies: it sits beside the real export
and would parse to nothing. When no member qualifies the error lists the
XML members the archive does hold, so the next report arrives with the
answer in it.
Refs #1008
Picking a symptom without tapping an intensity is how most of them are
logged. The sheet sends severity null for those, the schema took a number
or nothing at all, and the day log came back 422 with two issues for two
symptoms. All the sheet could say was "Could not save. Try again.", and
the cycle saved fine as soon as the symptoms were removed, which sent
people looking in the wrong place.
Nothing else disagreed: the link row has always stored an unrated symptom
as a plain presence, and the writer already maps a non-number to NULL.
Only the gate at the door refused it, on both the create and the patch
path, and no test touched either schema.
Refs #1001
Shipped iOS builds multiply oxygen saturation and body fat by 100 before
upload. The importer scaled them again, so 0.97 was stored as 9700, the
plausibility guard rejected the row as out of range inside an HTTP 200,
and the client treats that skip as final and moves its HealthKit anchor
past the sample. Every reading of those two types was lost, silently, on
every install that syncs them.
The contract says values travel raw and the server scales, and that is
what the OpenAPI description publishes. One comment in the schema said
the opposite, that the client must multiply by 100 first, and that is
the line the app was built against. It is corrected here.
The five percent-shaped HealthKit identifiers now take either spelling:
a value above the fraction ceiling is already a percent and passes
through, anything at or below it is scaled. The domains do not overlap,
and the per-type range guard still rejects what is impossible either
way. Current app builds stop losing readings without an app release.
The tests pin both spellings, the range after conversion, and that the
tolerant converter covers the percent class and nothing else: waist
circumference uses the same factor to turn metres into centimetres, and
a blanket change made a 1.02 m waist into 1.02 cm.
Refs MBombeck/healthlog-iOS#7