docs(api): describe the AI capability block and refusal meta

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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 07:51:16 +02:00
1 parent 464522f315
commit cc4934897c
5 files changed
+336 -66

No files matched your search

+216 -51
View File
@@ -770,10 +770,14 @@ paths:
get:
tags:
- Meta
summary: The operator's assistant feature matrix
summary: The operator's assistant switches (deprecated)
deprecated: true
description: >-
Which assistant surfaces this deployment offers. The master flag is applied server-side before the shape leaves
the handler, so every sub-flag is already false when the master is off and a client never composes the two.
Deprecated: read `ai` on GET /api/auth/me instead, which resolves each AI capability for the record from the
switches, the record's modules, the provider, consent and who is asking. This route projects the operator
switches alone and is removed in the first release after the native build that reads `ai` ships. Responses carry
`Deprecation: true` and `Link: </api/auth/me>; rel="successor-version"`. The master is applied server-side, so
every sub-switch is already false when it is off.
An ACTOR surface: it answers about the deployment, not about a record, so it keeps answering while the caller is
@@ -30022,40 +30026,42 @@ components:
naming conventions coexist and none of them will be renamed, because a code is a wire value a shipped
client branches on. Treat an unlisted code the way you would treat an unlisted enum member — as a
refusal you do not recognise, not as a malformed response — since the list grows with the surfaces.
Three families are outside it on purpose: `assistant.disabled.<surface>` is built from a template so the
last segment is open (`assistant.disabled.coach` is the one the native client names); the
integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`,
`connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead,
where the differences can be stated; and a 401 raised by a route checking a credential of its own may
carry no code at all. ai_provider — ai_provider.invalid, ai_provider.no_fields; allergy —
allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.fact.conflict,
anamnesis.fact.currentExists, anamnesis.fact.invalidValue; auth — auth.admin.required,
auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid,
auth.refresh.reuse, auth.refresh.revoked, auth.scope.insufficient, auth.stepup.required,
auth.token.expired, auth.token.invalid; backup — backup.payload.undecryptable, backup.section.missing;
coach — coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict,
coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed,
coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid,
coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape;
consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit;
cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach —
disable-coach.body.invalid_json; documents — documents.chat.conversationNotFound,
documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound,
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery,
documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound,
documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic,
documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited,
documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.uploadBusy,
documents.inbound.uploadTimeout; encounter — encounter.invalid, encounter.link.invalid,
encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; environment
— environment.invalid, environment.no_home, environment.range_too_large, environment.travel.not_found;
export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid;
feedback — feedback.body.invalid; health_score_config — health_score_config.invalid,
Three families are outside it on purpose: `assistant.disabled.<switch>` is built from a template so the
last segment is open — it is one of `coach`, `briefing`, `insightStatus`, `documentAi` and `enabled`,
and `assistant.disabled.coach` is the one the native client names; the integration-probe classes
(`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the
per-provider additions) are enumerated in each `/test` operation instead, where the differences can be
stated; and a 401 raised by a route checking a credential of its own may carry no code at all. ai —
ai.provider.none, ai.record.notPermitted, ai.unavailable; ai_provider — ai_provider.invalid,
ai_provider.no_fields; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis —
anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue; auth —
auth.admin.required, auth.already_authenticated, auth.mfa.code_invalid, auth.missing,
auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, auth.scope.insufficient,
auth.stepup.required, auth.token.expired, auth.token.invalid; backup — backup.payload.undecryptable,
backup.section.missing; coach — coach.conversation.invalidTitle, coach.conversation.notFound,
coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound,
coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable,
coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json,
coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle — cycle.bulk.invalid,
cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid,
cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid,
cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json,
cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; documents —
documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed,
documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded,
documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound,
documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending,
documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType,
documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate,
documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed,
documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported,
documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate,
documents.inbound.restoreGone, documents.inbound.uploadBusy, documents.inbound.uploadTimeout; encounter
— encounter.invalid, encounter.link.invalid, encounter.practitioner-not-found,
encounter.reminder-conflict, encounter.reminder-not-found; environment — environment.invalid,
environment.no_home, environment.range_too_large, environment.travel.not_found; export —
export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback —
feedback.body.invalid; health_score_config — health_score_config.invalid,
health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled,
illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window,
illness.episode.parent-not-found, illness.insights.invalid; import — import.write_failed; ingest —
@@ -30113,6 +30119,22 @@ components:
notification_prefs_conflict, oidc_only, private_origin_not_approved, private_origin_not_grantable,
rate_limited_self, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured"
type: string
capability:
description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`,
`briefing`, `periodNarrative`, `statusText`, `workoutInsights`, `reactionLines`, `aboutMeQuestions`,
`documentAi`, `labsOcr`, `medicationExtract`)."
type: string
reason:
description: "On an AI refusal: the outermost reason, one of `AiUnavailableReason`. The code maps from it:
`operator_disabled` → `assistant.disabled.<switch>` (or `module.disabled` with `module` when the
operator's module availability closed it), `not_permitted_for_record` → `ai.record.notPermitted`,
`module_disabled` and `user_disabled` → `module.disabled` with `module`, `no_provider` → the route's own
typed code where it has one, otherwise `ai.provider.none` (422), `consent_required` →
`consent.ai.required`, `check_failed` → `ai.unavailable` (503)."
type: string
module:
description: "Beside `module.disabled`: the module that is off. On an AI refusal it is the capability's owning module."
type: string
additionalProperties: {}
required:
- data
@@ -31786,33 +31808,34 @@ components:
properties:
enabled:
type: boolean
description: Master kill-switch for every assistant surface.
description: Master switch for every AI feature.
coach:
type: boolean
description: Coach drawer, chat stream, history.
description: The Coach.
briefing:
type: boolean
description: Daily Briefing card and its recommendations.
description: The daily briefing and model-written period narratives.
insightStatus:
type: boolean
description: Per-metric status cards on the insight sub-pages.
correlations:
description: "Status notes: per-metric notes, workout notes and reaction lines."
documentAi:
type: boolean
description: Correlation narration on the insights page.
description: "Reading documents: the document vault's AI reads, lab report scans and medication extraction."
required:
- enabled
- coach
- briefing
- insightStatus
- correlations
- documentAi
additionalProperties: false
description: "The resolved matrix. The master is already applied: when `enabled` is false every sub-flag is false in
this payload, so a client reads `coach` directly and never composes `enabled && coach`."
description: "The operator's switches, master applied: when `enabled` is false every sub-switch is false in this
payload. A switch read that fails answers every switch false."
required:
- assistant
additionalProperties: false
description: The operator's assistant visibility matrix, resolved. Gates the server-routed AND the on-device assistant
surfaces, so a client hides the surface end-to-end rather than degrading it.
description: "The operator's assistant switches, and nothing else. Deprecated: whether a capability is available also
depends on the record's modules, the provider, consent and who is asking, and `ai` on GET /api/auth/me is that
resolved answer."
UpdateCheckEnvelope:
type: object
properties:
@@ -32258,7 +32281,7 @@ components:
type: boolean
assistantInsightStatusEnabled:
type: boolean
assistantCorrelationsEnabled:
assistantDocumentAiEnabled:
type: boolean
moduleAvailabilityJson:
anyOf:
@@ -32306,7 +32329,6 @@ components:
- assistantCoachEnabled
- assistantBriefingEnabled
- assistantInsightStatusEnabled
- assistantCorrelationsEnabled
- moduleAvailabilityJson
- documentMaxFileBytes
- documentQuotaBytes
@@ -67276,6 +67298,8 @@ components:
$ref: "#/components/schemas/AccountAccess"
moduleAccess:
$ref: "#/components/schemas/ModuleAccessMap"
ai:
$ref: "#/components/schemas/AiAccountBlock"
recordSession:
anyOf:
- $ref: "#/components/schemas/RecordSessionState"
@@ -67301,6 +67325,7 @@ components:
- username
- accountAccess
- moduleAccess
- ai
- recordSession
- notificationPrefs
- onboarding
@@ -67314,7 +67339,8 @@ components:
rather than the record's true state. With no switch — which is every native request, since the Bearer transport
carries none — the two are the same account and nothing is masked. `moduleAccess` says WHY each module is or is
not there — the record's own switch, the edge of the grant, or the operator's instance-wide one — beside the
`modules` booleans it never contradicts."
`modules` booleans it never contradicts. `ai` (v1.39) is the resolved AI capability map for the same record,
with the same masking; it replaces reading `GET /api/feature-flags`."
AccountAccess:
type: object
properties:
@@ -67704,6 +67730,145 @@ components:
the one nothing further in can change. Own-record sessions and unscoped grants never produce `not_granted`.
Every key is present, and `modules[key] === (moduleAccess[key] === \"enabled\")` holds for every key — this
field adds a reason, never a different gate."
AiAccountBlock:
type: object
properties:
capabilities:
$ref: "#/components/schemas/AiCapabilities"
provider:
$ref: "#/components/schemas/AiProviderState"
required:
- capabilities
- provider
additionalProperties: false
description: Which AI capabilities the record this session is inside has, and why not when it has none. Resolved on the
server for the ACTIVE RECORD and masked to the sections the active grant opens, exactly like `modules` and
`moduleAccess`; with no switch (every native request) it describes the caller's own record.
AiCapabilities:
type: object
properties:
coach:
description: The "coach" capability. Covered by the operator's "coach" switch.
$ref: "#/components/schemas/AiCapabilityState"
briefing:
description: The "briefing" capability. Covered by the operator's "briefing" switch.
$ref: "#/components/schemas/AiCapabilityState"
periodNarrative:
description: The "periodNarrative" capability. Covered by the operator's "briefing" switch.
$ref: "#/components/schemas/AiCapabilityState"
statusText:
description: The "statusText" capability. Covered by the operator's "insightStatus" switch.
$ref: "#/components/schemas/AiCapabilityState"
workoutInsights:
description: The "workoutInsights" capability. Covered by the operator's "insightStatus" switch.
$ref: "#/components/schemas/AiCapabilityState"
reactionLines:
description: The "reactionLines" capability. Covered by the operator's "insightStatus" switch.
$ref: "#/components/schemas/AiCapabilityState"
aboutMeQuestions:
description: The "aboutMeQuestions" capability. Covered by the operator's "coach" switch.
$ref: "#/components/schemas/AiCapabilityState"
documentAi:
description: The "documentAi" capability. Covered by the operator's "documentAi" switch.
$ref: "#/components/schemas/AiCapabilityState"
labsOcr:
description: The "labsOcr" capability. Covered by the operator's "documentAi" switch.
$ref: "#/components/schemas/AiCapabilityState"
medicationExtract:
description: The "medicationExtract" capability. Covered by the operator's "documentAi" switch.
$ref: "#/components/schemas/AiCapabilityState"
required:
- coach
- briefing
- periodNarrative
- statusText
- workoutInsights
- reactionLines
- aboutMeQuestions
- documentAi
- labsOcr
- medicationExtract
additionalProperties: false
description: Every AI capability, always all present. `coach` — chat, fenced document chat, attachments, Coach memory
upkeep, AI nudges, every Coach launcher. `briefing` — the daily briefing and every place its text is lifted
into. `periodNarrative` — the model-written half of a period narrative (the deterministic narrative is data).
`statusText` — per-metric status notes and the AI override of a derived assessment. `workoutInsights` — the
paragraph on a workout. `reactionLines` — the line written after a new reading. `aboutMeQuestions` —
model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest,
summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image.
`medicationExtract` — turning a typed medication description into a schedule.
AiUnavailableReason:
type: string
enum:
- check_failed
- operator_disabled
- not_permitted_for_record
- module_disabled
- user_disabled
- no_provider
- consent_required
description: "Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is
reported, because it names the layer that would have to change first. `check_failed` — an input could not be
read and the answer failed closed. `operator_disabled` — the operator's master switch, the capability's own
switch, or the operator's instance-wide availability of its module. `not_permitted_for_record` — AI work is not
admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off
for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI
opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else.
`no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider
chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does
not know treats the capability as unavailable."
AiCapabilityState:
type: object
properties:
available:
type: boolean
reason:
anyOf:
- $ref: "#/components/schemas/AiUnavailableReason"
- type: "null"
description: "`null` exactly when `available` is true."
onDeviceAllowed:
type: boolean
description: "Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`,
`module_disabled`, `user_disabled` and `check_failed`: the operator's and the person's decisions hold on the
device too. True otherwise, since a missing server provider or a missing consent for server egress do not
concern a model that runs on the device. Resolved here so the client never decides which reasons apply off
the server."
required:
- available
- reason
- onDeviceAllowed
additionalProperties: false
description: "One AI capability, resolved for one record from every layer that can say no. Render from it; never
recompute it. Data never depends on it: measurements, scores, statistics and device records load whatever it
says."
AiProviderState:
type: object
properties:
configured:
type: boolean
description: "At least one configured provider can serve text for this record. Presence only: no key is tried, so a
revoked key still reads as configured until a generation fails."
managedBy:
anyOf:
- type: string
enum:
- user
- local
- server
- type: "null"
description: "Where the serving credential comes from: the person's own (`user`), a self-hosted model they pointed at
(`local`), or one the operator holds (`server`). `null` when nothing is configured."
canConfigure:
type: boolean
description: "Whether the person in front of the screen may set up a provider for this record: false inside somebody
else's record and while the operator's master switch is off. A setup hint is shown only when this is true."
required:
- configured
- managedBy
- canConfigure
additionalProperties: false
description: The account-level companion to the capability map.
RecordSessionState:
type: object
properties:
+6 -2
View File
@@ -35,7 +35,7 @@ import {
updateManagedProfileSchema,
} from "@/lib/validations/managed-profiles";
import { onboardingStateResource } from "./onboarding";
import { moduleAccessMap } from "./profile";
import { aiAccountBlock, moduleAccessMap } from "./profile";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
/**
@@ -451,6 +451,10 @@ const accountPayload = z
username: z.string(),
accountAccess: accountAccessBlock,
moduleAccess: moduleAccessMap,
// v1.39 — enumerated because every AI surface decides on it: a client
// renders an AI surface only when its capability is available, and never
// derives the answer from the switches, the modules or the provider.
ai: aiAccountBlock,
recordSession: recordSessionState.nullable(),
// Enumerated because a client DECIDES on it rather than displays it: the
// medication surface hides the server-side reminder switch when
@@ -476,7 +480,7 @@ const accountPayload = z
.meta({
id: "AccountPayload",
description:
"The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. Additional properties are the preference fields this spec does not yet enumerate. Under an active switch the identity and preference fields still describe the CALLER, because display preferences belong to the person at the keyboard rather than to the record they are reading. Two fields are the exception (v1.38.14): `modules` and `cycleTrackingEnabled` describe the ACTIVE RECORD, since every surface they gate shows the record's data — and they are masked to the sections the active grant opens, so a scoped grant reads `false` for a module outside it rather than the record's true state. With no switch — which is every native request, since the Bearer transport carries none — the two are the same account and nothing is masked. `moduleAccess` says WHY each module is or is not there — the record's own switch, the edge of the grant, or the operator's instance-wide one — beside the `modules` booleans it never contradicts.",
"The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. Additional properties are the preference fields this spec does not yet enumerate. Under an active switch the identity and preference fields still describe the CALLER, because display preferences belong to the person at the keyboard rather than to the record they are reading. Two fields are the exception (v1.38.14): `modules` and `cycleTrackingEnabled` describe the ACTIVE RECORD, since every surface they gate shows the record's data — and they are masked to the sections the active grant opens, so a scoped grant reads `false` for a module outside it rather than the record's true state. With no switch — which is every native request, since the Bearer transport carries none — the two are the same account and nothing is masked. `moduleAccess` says WHY each module is or is not there — the record's own switch, the edge of the grant, or the operator's instance-wide one — beside the `modules` booleans it never contradicts. `ai` (v1.39) is the resolved AI capability map for the same record, with the same masking; it replaces reading `GET /api/feature-flags`.",
});
/**
+15 -12
View File
@@ -252,28 +252,30 @@ const assistantFlagsResponse = z
.object({
assistant: z
.object({
enabled: z
.boolean()
.describe("Master kill-switch for every assistant surface."),
coach: z.boolean().describe("Coach drawer, chat stream, history."),
enabled: z.boolean().describe("Master switch for every AI feature."),
coach: z.boolean().describe("The Coach."),
briefing: z
.boolean()
.describe("Daily Briefing card and its recommendations."),
.describe("The daily briefing and model-written period narratives."),
insightStatus: z
.boolean()
.describe("Per-metric status cards on the insight sub-pages."),
correlations: z
.describe(
"Status notes: per-metric notes, workout notes and reaction lines.",
),
documentAi: z
.boolean()
.describe("Correlation narration on the insights page."),
.describe(
"Reading documents: the document vault's AI reads, lab report scans and medication extraction.",
),
})
.describe(
"The resolved matrix. The master is already applied: when `enabled` is false every sub-flag is false in this payload, so a client reads `coach` directly and never composes `enabled && coach`.",
"The operator's switches, master applied: when `enabled` is false every sub-switch is false in this payload. A switch read that fails answers every switch false.",
),
})
.meta({
id: "AssistantFlagsResponse",
description:
"The operator's assistant visibility matrix, resolved. Gates the server-routed AND the on-device assistant surfaces, so a client hides the surface end-to-end rather than degrading it.",
"The operator's assistant switches, and nothing else. Deprecated: whether a capability is available also depends on the record's modules, the provider, consent and who is asking, and `ai` on GET /api/auth/me is that resolved answer.",
});
// ── Update check ─────────────────────────────────────────────────────
@@ -391,9 +393,10 @@ export const metaPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/feature-flags": {
get: {
tags: ["Meta"],
summary: "The operator's assistant feature matrix",
summary: "The operator's assistant switches (deprecated)",
deprecated: true,
description:
"Which assistant surfaces this deployment offers. The master flag is applied server-side before the shape leaves the handler, so every sub-flag is already false when the master is off and a client never composes the two.\n\n" +
'Deprecated: read `ai` on GET /api/auth/me instead, which resolves each AI capability for the record from the switches, the record\'s modules, the provider, consent and who is asking. This route projects the operator switches alone and is removed in the first release after the native build that reads `ai` ships. Responses carry `Deprecation: true` and `Link: </api/auth/me>; rel="successor-version"`. The master is applied server-side, so every sub-switch is already false when it is off.\n\n' +
"An ACTOR surface: it answers about the deployment, not about a record, so it keeps answering while the caller is acting on somebody else's — the Coach launcher and the assistant chrome are gated on it, and a refusal here would delete a piece of the shell rather than a piece of the data. A request that attaches the per-request account selector (the `AccountSelector` header parameter) is refused with 403 `sharing.not_permitted` rather than quietly answered.\n\n" +
"Served with `Cache-Control: private, max-age=60`: an operator's toggle propagates within a minute, and the flag read stays off the hot mount path in the meantime.",
responses: {
+80
View File
@@ -11,6 +11,11 @@ import { notificationPrefsSchema } from "@/lib/validations/notification-prefs";
import { sourcePrioritySchema } from "@/lib/validations/source-priority";
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
import { MODULE_KEYS } from "@/lib/modules/registry";
import {
AI_CAPABILITIES,
AI_CAPABILITY_KEYS,
AI_UNAVAILABLE_REASONS,
} from "@/lib/ai/capabilities/types";
import { SCORE_PILLAR_IDS } from "@/lib/analytics/score/types";
import { savedReportProfileSchema } from "@/lib/report-selection/profile-shape";
import { injectionSiteEnum } from "@/lib/validations/medication";
@@ -115,6 +120,81 @@ export const moduleAccessMap = z
"Why each toggleable module is or is not available for the record this session is inside. `enabled` — the module is on and the client may paint it. `disabled` — the record has it switched off. `not_granted` — the active grant's sections do not open the module's section, which also covers every module that reads across the whole record (achievements, the assistant surfaces, the export, environment, the account's own MCP endpoint) since no scoped grant opens one. `unavailable` — the operator switched the module off for the whole instance. Precedence, highest first: `unavailable` > `not_granted` > `disabled` > `enabled`, so the reason published is the one nothing further in can change. Own-record sessions and unscoped grants never produce `not_granted`. Every key is present, and `modules[key] === (moduleAccess[key] === \"enabled\")` holds for every key — this field adds a reason, never a different gate.",
});
// ── AI capabilities (`ai` on GET /api/auth/me) ─────────────────────────────
export const aiUnavailableReason = z.enum(AI_UNAVAILABLE_REASONS).meta({
id: "AiUnavailableReason",
description:
"Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is reported, because it names the layer that would have to change first. `check_failed` — an input could not be read and the answer failed closed. `operator_disabled` — the operator's master switch, the capability's own switch, or the operator's instance-wide availability of its module. `not_permitted_for_record` — AI work is not admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else. `no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does not know treats the capability as unavailable.",
});
export const aiCapabilityState = z
.object({
available: z.boolean(),
reason: aiUnavailableReason
.nullable()
.describe("`null` exactly when `available` is true."),
onDeviceAllowed: z
.boolean()
.describe(
"Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`, `module_disabled`, `user_disabled` and `check_failed`: the operator's and the person's decisions hold on the device too. True otherwise, since a missing server provider or a missing consent for server egress do not concern a model that runs on the device. Resolved here so the client never decides which reasons apply off the server.",
),
})
.meta({
id: "AiCapabilityState",
description:
"One AI capability, resolved for one record from every layer that can say no. Render from it; never recompute it. Data never depends on it: measurements, scores, statistics and device records load whatever it says.",
});
export const aiCapabilities = z
.object(
Object.fromEntries(
AI_CAPABILITY_KEYS.map((key) => [
key,
aiCapabilityState.describe(
`The "${key}" capability. Covered by the operator's "${AI_CAPABILITIES[key].operatorSwitch}" switch.`,
),
]),
),
)
.meta({
id: "AiCapabilities",
description:
"Every AI capability, always all present. `coach` — chat, fenced document chat, attachments, Coach memory upkeep, AI nudges, every Coach launcher. `briefing` — the daily briefing and every place its text is lifted into. `periodNarrative` — the model-written half of a period narrative (the deterministic narrative is data). `statusText` — per-metric status notes and the AI override of a derived assessment. `workoutInsights` — the paragraph on a workout. `reactionLines` — the line written after a new reading. `aboutMeQuestions` — model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest, summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image. `medicationExtract` — turning a typed medication description into a schedule.",
});
export const aiProviderState = z
.object({
configured: z
.boolean()
.describe(
"At least one configured provider can serve text for this record. Presence only: no key is tried, so a revoked key still reads as configured until a generation fails.",
),
managedBy: z
.enum(["user", "local", "server"])
.nullable()
.describe(
"Where the serving credential comes from: the person's own (`user`), a self-hosted model they pointed at (`local`), or one the operator holds (`server`). `null` when nothing is configured.",
),
canConfigure: z
.boolean()
.describe(
"Whether the person in front of the screen may set up a provider for this record: false inside somebody else's record and while the operator's master switch is off. A setup hint is shown only when this is true.",
),
})
.meta({
id: "AiProviderState",
description: "The account-level companion to the capability map.",
});
export const aiAccountBlock = z
.object({ capabilities: aiCapabilities, provider: aiProviderState })
.meta({
id: "AiAccountBlock",
description:
"Which AI capabilities the record this session is inside has, and why not when it has none. Resolved on the server for the ACTIVE RECORD and masked to the sections the active grant opens, exactly like `modules` and `moduleAccess`; with no switch (every native request) it describes the caller's own record.",
});
const moduleMapEnvelopeInner = z
.object({ modules: moduleMapResolved, updatedAt: updatedAtTokenField })
.meta({ id: "ModulesResponse" });
+19 -1
View File
@@ -84,9 +84,27 @@ export const errorEnvelope = z
.string()
.optional()
.describe(
"Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be reworded. Every code the API emits today is enumerated below, grouped by the surface that emits it; four naming conventions coexist and none of them will be renamed, because a code is a wire value a shipped client branches on. Treat an unlisted code the way you would treat an unlisted enum member — as a refusal you do not recognise, not as a malformed response — since the list grows with the surfaces. Three families are outside it on purpose: `assistant.disabled.<surface>` is built from a template so the last segment is open (`assistant.disabled.coach` is the one the native client names); the integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a credential of its own may carry no code at all. " +
"Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be reworded. Every code the API emits today is enumerated below, grouped by the surface that emits it; four naming conventions coexist and none of them will be renamed, because a code is a wire value a shipped client branches on. Treat an unlisted code the way you would treat an unlisted enum member — as a refusal you do not recognise, not as a malformed response — since the list grows with the surfaces. Three families are outside it on purpose: `assistant.disabled.<switch>` is built from a template so the last segment is open — it is one of `coach`, `briefing`, `insightStatus`, `documentAi` and `enabled`, and `assistant.disabled.coach` is the one the native client names; the integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a credential of its own may carry no code at all. " +
renderErrorCodeCatalogue(),
),
capability: z
.string()
.optional()
.describe(
"On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, `briefing`, `periodNarrative`, `statusText`, `workoutInsights`, `reactionLines`, `aboutMeQuestions`, `documentAi`, `labsOcr`, `medicationExtract`).",
),
reason: z
.string()
.optional()
.describe(
"On an AI refusal: the outermost reason, one of `AiUnavailableReason`. The code maps from it: `operator_disabled` → `assistant.disabled.<switch>` (or `module.disabled` with `module` when the operator's module availability closed it), `not_permitted_for_record` → `ai.record.notPermitted`, `module_disabled` and `user_disabled` → `module.disabled` with `module`, `no_provider` → the route's own typed code where it has one, otherwise `ai.provider.none` (422), `consent_required` → `consent.ai.required`, `check_failed` → `ai.unavailable` (503).",
),
module: z
.string()
.optional()
.describe(
"Beside `module.disabled`: the module that is off. On an AI refusal it is the capability's owning module.",
),
})
.optional(),
})