docs(api): publish the error codes the server actually emits

`meta.errorCode` is the mechanism this API chose for machine-readable
errors, and the envelope's own description tells a client to branch on
it rather than on the prose. Of the two hundred and forty-two codes in
the tree, sixteen appeared anywhere in the contract. A client had to
discover each one by triggering the error in testing, and had no way at
all to learn that a new one had appeared.

They are enumerated now, grouped by the surface that emits them, in the
description of the field they belong to. The list is generated from the
code and committed as a literal so it reads in a diff, and the sentence
in the document is rendered from it rather than written beside it.

Nothing is renamed. Four naming conventions coexist — dot.case, bare
snake_case, a camelCase segment and a hyphen — and a code is a wire
value a shipped client branches on, so they stay. New codes are
dot.case, and the guard is what stops a fifth convention arriving
unnoticed.

Three families stay outside the list and say why: the assistant refusal
builds its last segment from a template, the integration probes are
enumerated per operation where the per-provider differences can be
stated, and a route checking a credential of its own may answer 401
with no code.

The guard re-derives the set from the tree and fails both ways — a code
emitted and not listed, and a code listed and no longer emitted, which
is the worse of the two because a client can write a branch for it and
wait forever.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 07:47:10 +02:00
1 parent ab6c1cd917
commit 868efc1c13
4 files changed
+623 -3

No files matched your search

+91 -2
View File
@@ -29501,8 +29501,97 @@ components:
requestId:
type: string
errorCode:
description: Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be
reworded.
description: "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. 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.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 —
ingest.rate_limited; insights — insights.generate.budgetExceeded, insights.generate.outboundScreened,
insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs —
labs.biomarker.create.invalid, labs.biomarker.duplicate, labs.biomarker.notFound,
labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid,
labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, labs.ocr.fileType,
labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported,
labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.update.invalid,
labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected,
labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; managed_profile —
managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee,
managed_profile.guardian.required, managed_profile.not_found; measurement —
measurement.batch.source_not_permitted, measurement.batch.too_large, measurement.bulk-delete.invalid,
measurement.create.invalid, measurement.create.source_not_permitted, measurement.delete.too_large,
measurement.duplicate_timestamp, measurement.restore.invalid, measurement.update.server_owned_source;
medication — medication.intake.import.invalid_format; medications —
medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid,
medications.intake.bulk.too_large, medications.intake.force_slot.invalid,
medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed,
medications.intake.taken_at.before_start, medications.mirror.limit_exceeded; mentalHealth —
mentalHealth.rateLimited; module — module.disabled; modules — modules.body.invalid_json,
modules.invalid; mood — mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large,
mood.create.external_id_not_delegable, mood.create.invalid, mood.duplicate_timestamp,
mood.linkedContext.invalid, mood.list.invalid, mood.not_found, mood.ratedFactor.out_of_range,
mood.restore.invalid, mood.update.invalid; notification-prefs — notification-prefs.body.invalid_json;
nutrient — nutrient.batch.invalid, nutrient.batch.too_large, nutrient.daily.invalid,
nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day; onboarding —
onboarding.complete.invalid, onboarding.disclaimer.invalid, onboarding.step.completed,
onboarding.step.concurrent, onboarding.step.invalid, onboarding.step.outOfOrder,
onboarding.step.rateLimited, onboarding.tour.invalid, onboarding.user.notFound; personal_records —
personal_records.invalid_query; practitioner — practitioner.invalid; profile —
profile.update.emailInUse, profile.update.invalidBody, profile.update.nothingSaved; report-selection —
report-selection.body.invalid_json, report-selection.body.invalid_shape; route — route.retired;
share-link — share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited,
share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing —
sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked,
sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope,
sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted,
sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended,
sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json,
source-priority.body.invalid_shape; tokens — tokens.measurements.ceiling_reached; vaccination —
vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.encounter-not-found,
vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict,
dashboard_layout_conflict, health_score_config_conflict, insights_layout_conflict,
invalid_base_updated_at, medication_layout_conflict, modules_conflict, mood_tag_layout_conflict,
not_configured, notification_prefs_conflict, oidc_only, private_origin_not_approved, rate_limited_self,
too_many_rows, upstream_invalid_json, vapid_not_configured"
type: string
additionalProperties: {}
required:
+189
View File
@@ -0,0 +1,189 @@
/**
* The published `meta.errorCode` list is the list the server actually emits.
*
* `meta.errorCode` is the mechanism this API chose for machine-readable errors
* and the envelope tells a client to branch on it — but of the two hundred and
* forty-two codes in the tree, sixteen appeared anywhere in the contract. A
* client had to discover each one by triggering the error, and had no way at
* all to learn a new one had appeared. The catalogue publishes them; this holds
* the catalogue to the code, in both directions:
*
* - A code emitted and not listed is today's defect repeating itself, and it
* is also how a fifth naming convention would get in unnoticed.
* - A code listed and no longer emitted is a promise the server stopped
* keeping, which is worse than an omission: a client can write a branch for
* it and wait forever.
*
* What it reads: every `.ts` under `src/app/api` outside a `__tests__`
* directory, plus the named library modules that build a refusal envelope of
* their own. Comment lines are stripped first, so a code quoted in prose is not
* mistaken for one that ships.
*
* Its limits, stated rather than implied. A new library module that raises a
* coded refusal has to be added to `ENVELOPE_MODULES` below — the route tree is
* swept by rule, that list is not. A code assembled at runtime from a template
* cannot be enumerated at all, and the one family that does so is named in the
* catalogue's own doc comment. And `IntegrationStatus.errorCode` is a different
* field with the same name — it records an upstream HTTP status in the sync
* ledger and never reaches a response — which is why the sweep is scoped to
* the modules that answer requests rather than to `src/lib` wholesale.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { describe, expect, it } from "vitest";
import { ERROR_CODE_CATALOGUE } from "@/lib/openapi/error-codes";
const API_ROOT = join(process.cwd(), "src", "app", "api");
/**
* Library modules that build a coded refusal without going through a route
* file. Everything else under `src/lib` is out of scope on purpose — see the
* note on `IntegrationStatus.errorCode` in the file header.
*/
const ENVELOPE_MODULES = [
"src/lib/ai/consent-guard.ts",
"src/lib/api-errors.ts",
"src/lib/api-handler.ts",
"src/lib/auth/profile-update.ts",
"src/lib/clinician-share/report-download.ts",
"src/lib/cycle/gate.ts",
"src/lib/documents/ai-route-support.ts",
"src/lib/documents/attach-validate.ts",
"src/lib/export/backup-blob.ts",
"src/lib/http/retired-routes.ts",
"src/lib/illness/gate.ts",
"src/lib/modules/gate.ts",
"src/lib/optimistic-lock.ts",
"src/lib/sharing/record-session-fence-contract.ts",
];
function apiFiles(dir: string): string[] {
const found: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "__tests__") continue;
found.push(...apiFiles(full));
continue;
}
if (entry.name.endsWith(".ts")) found.push(full);
}
return found;
}
/** Drop comment lines so a code quoted in prose is not read as one that ships. */
function withoutComments(source: string): string {
return source
.split("\n")
.filter((line) => {
const trimmed = line.trim();
return !(
trimmed.startsWith("*") ||
trimmed.startsWith("//") ||
trimmed.startsWith("/*")
);
})
.join("\n");
}
/** The three shapes a code is written in across the tree. */
function codesIn(source: string): string[] {
const found: string[] = [];
// `errorCode: "x"`, `errorCode = "x"`, `readonly errorCode = "x"`, and the
// annotated `errorCode: string = "x"` on the step-up error's constructor.
for (const m of source.matchAll(
/errorCode\s*(?::\s*[A-Za-z.[\]|" ]+)?\s*[:=]\s*"([^"]+)"/g,
)) {
found.push(m[1]);
}
// A single shared constant: `export const MODULE_DISABLED_ERROR_CODE = "…"`.
for (const m of source.matchAll(/\b[A-Z][A-Z0-9_]*_CODE\s*=\s*"([^"]+)"/g)) {
found.push(m[1]);
}
// A family in one object: `export const AUTH_ERROR_CODES = { … }`.
for (const m of source.matchAll(/\b[A-Z][A-Z0-9_]*_CODES\s*=\s*\{/g)) {
const start = source.indexOf("{", m.index);
let depth = 0;
let end = start;
for (let j = start; j < source.length; j++) {
if (source[j] === "{") depth++;
else if (source[j] === "}") {
depth--;
if (depth === 0) {
end = j;
break;
}
}
}
for (const value of source.slice(start, end).matchAll(/:\s*"([^"]+)"/g)) {
found.push(value[1]);
}
}
return found;
}
/** Every code the tree emits, mapped to the files that emit it. */
function emittedCodes(): Map<string, string[]> {
const emitted = new Map<string, string[]>();
const files = [
...apiFiles(API_ROOT),
...ENVELOPE_MODULES.map((path) => join(process.cwd(), path)),
];
for (const file of files) {
const source = withoutComments(readFileSync(file, "utf8"));
for (const code of codesIn(source)) {
const where = relative(process.cwd(), file).split(sep).join("/");
const seen = emitted.get(code);
if (seen) {
if (!seen.includes(where)) seen.push(where);
} else {
emitted.set(code, [where]);
}
}
}
return emitted;
}
const emitted = emittedCodes();
const published = new Set(Object.values(ERROR_CODE_CATALOGUE).flat());
describe("the published errorCode catalogue", () => {
it("finds the codes it is meant to judge", () => {
// A matcher that stopped matching would otherwise agree with an empty
// catalogue. Two hundred and forty-two when this was written.
expect(emitted.size).toBeGreaterThan(200);
});
it("lists every code the server emits", () => {
const undocumented = [...emitted.entries()]
.filter(([code]) => !published.has(code))
.map(([code, files]) => `${code} (${files[0]})`)
.sort();
expect(undocumented).toEqual([]);
});
it("emits every code it lists", () => {
const stale = [...published].filter((code) => !emitted.has(code)).sort();
expect(stale).toEqual([]);
});
it("files every code under the surface its own prefix names", () => {
const misfiled: string[] = [];
for (const [surface, codes] of Object.entries(ERROR_CODE_CATALOGUE)) {
for (const code of codes) {
const expected = code.includes(".")
? code.slice(0, code.indexOf("."))
: "(unprefixed)";
if (expected !== surface) misfiled.push(`${code} under ${surface}`);
}
}
expect(misfiled).toEqual([]);
});
it("keeps no duplicate between two surfaces", () => {
const all = Object.values(ERROR_CODE_CATALOGUE).flat();
expect(all.length).toBe(new Set(all).size);
});
});
+339
View File
@@ -0,0 +1,339 @@
/**
* Every `meta.errorCode` the API emits, grouped by the surface that emits it.
*
* `meta.errorCode` is the mechanism this API chose for machine-readable errors
* — the envelope's own description tells a client to branch on it rather than
* on the prose — and until this file existed a client could not enumerate it.
* Sixteen of the two hundred and forty-two codes appeared somewhere in
* `docs/api/openapi.yaml`; the rest existed only in server source, so the only
* way to learn one was to trigger the error in testing and the only way to
* learn a new one had appeared was to trigger that too.
*
* The list is generated from the code and then committed as a literal, so it is
* reviewable in a diff and stable across a release. `error-code-catalogue.test.ts`
* re-derives it from the tree on every run and fails in both directions: a code
* emitted and not listed, and a code listed and no longer emitted.
*
* Naming: four conventions coexist here — dot.case (the majority and the one
* to use), bare snake_case, a camelCase segment, and a hyphen. Nothing is
* renamed: a code is a wire value and a shipped client branches on it. New
* codes are dot.case.
*
* Not in this list, and deliberately:
*
* - `assistant.disabled.<surface>` is built from a template at runtime
* (`src/lib/feature-flags/index.ts`), so the surface segment is open.
* `assistant.disabled.coach` is the one the iOS contract names.
* - The integration-probe classes — `credentials_rejected`, `rate_limited`,
* `upstream_error`, `timeout`, `connection_failed`, and the per-provider
* additions beside them — are chosen by each `/test` route's own
* categoriser and are already enumerated in those operations'
* descriptions, where the per-provider differences can be stated.
* - `IntegrationStatus.errorCode` is a different field with the same name: it
* records an upstream HTTP status in the sync ledger and never reaches a
* response envelope.
*/
export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
{
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.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: ["ingest.rate_limited"],
insights: [
"insights.generate.budgetExceeded",
"insights.generate.outboundScreened",
"insights.generate.profileScopeChanged",
"insights.generate.scopeChanged",
],
labs: [
"labs.biomarker.create.invalid",
"labs.biomarker.duplicate",
"labs.biomarker.notFound",
"labs.biomarker.update.invalid",
"labs.biomarker.update.referenceRangeInvalid",
"labs.create.invalid",
"labs.ocr.budgetExceeded",
"labs.ocr.extractFailed",
"labs.ocr.fileTooLarge",
"labs.ocr.fileType",
"labs.ocr.localOcrDisabled",
"labs.ocr.pdfNeedsAnthropic",
"labs.ocr.providerUnsupported",
"labs.ocr.rateLimited",
"labs.restore.invalid",
"labs.result.notFound",
"labs.update.invalid",
"labs.update.linkedFieldsImmutable",
"labs.update.numericExpected",
"labs.update.qualitativeExpected",
"labs.update.referenceRangeInvalid",
"labs.update.sourceReferenceRangeInvalid",
],
managed_profile: [
"managed_profile.guardian.duplicate",
"managed_profile.guardian.managed_invitee",
"managed_profile.guardian.required",
"managed_profile.not_found",
],
measurement: [
"measurement.batch.source_not_permitted",
"measurement.batch.too_large",
"measurement.bulk-delete.invalid",
"measurement.create.invalid",
"measurement.create.source_not_permitted",
"measurement.delete.too_large",
"measurement.duplicate_timestamp",
"measurement.restore.invalid",
"measurement.update.server_owned_source",
],
medication: ["medication.intake.import.invalid_format"],
medications: [
"medications.intake.bulk.apple_health_not_mirrored",
"medications.intake.bulk.invalid",
"medications.intake.bulk.too_large",
"medications.intake.force_slot.invalid",
"medications.intake.force_slot.occupied",
"medications.intake.injection_site.disallowed",
"medications.intake.taken_at.before_start",
"medications.mirror.limit_exceeded",
],
mentalHealth: ["mentalHealth.rateLimited"],
module: ["module.disabled"],
modules: ["modules.body.invalid_json", "modules.invalid"],
mood: [
"mood.bulk-delete.invalid",
"mood.bulk.invalid",
"mood.bulk.too_large",
"mood.create.external_id_not_delegable",
"mood.create.invalid",
"mood.duplicate_timestamp",
"mood.linkedContext.invalid",
"mood.list.invalid",
"mood.not_found",
"mood.ratedFactor.out_of_range",
"mood.restore.invalid",
"mood.update.invalid",
],
"notification-prefs": ["notification-prefs.body.invalid_json"],
nutrient: [
"nutrient.batch.invalid",
"nutrient.batch.too_large",
"nutrient.daily.invalid",
"nutrient.read.invalid",
"nutrient.water.invalid",
"nutrient.water.invalid_day",
],
onboarding: [
"onboarding.complete.invalid",
"onboarding.disclaimer.invalid",
"onboarding.step.completed",
"onboarding.step.concurrent",
"onboarding.step.invalid",
"onboarding.step.outOfOrder",
"onboarding.step.rateLimited",
"onboarding.tour.invalid",
"onboarding.user.notFound",
],
personal_records: ["personal_records.invalid_query"],
practitioner: ["practitioner.invalid"],
profile: [
"profile.update.emailInUse",
"profile.update.invalidBody",
"profile.update.nothingSaved",
],
"report-selection": [
"report-selection.body.invalid_json",
"report-selection.body.invalid_shape",
],
route: ["route.retired"],
"share-link": [
"share-link.documents.invalid",
"share-link.report.notFound",
"share-link.report.rateLimited",
"share-link.selection.forbidden_leaf",
"share-link.selection.unknown_leaf",
],
sharing: [
"sharing.accept.expired",
"sharing.accept.not_found",
"sharing.accept.not_pending",
"sharing.accept.revoked",
"sharing.access.denied",
"sharing.invite.duplicate",
"sharing.invite.invalid",
"sharing.invite.invalid_scope",
"sharing.invite.manage_browser_only",
"sharing.invite.refused",
"sharing.invite.self",
"sharing.not_permitted",
"sharing.renounce.already_ended",
"sharing.renounce.not_found",
"sharing.revoke.already_ended",
"sharing.revoke.not_found",
"sharing.session.changed",
"sharing.switch.invalid",
"sharing.switch.wrong_transport",
],
"source-priority": [
"source-priority.body.invalid_json",
"source-priority.body.invalid_shape",
],
tokens: ["tokens.measurements.ceiling_reached"],
vaccination: [
"vaccination.booster-invalid",
"vaccination.booster-no-antigen",
"vaccination.encounter-not-found",
"vaccination.identity-required",
"vaccination.invalid",
"vaccination.link.invalid",
"vaccination.not-found",
"vaccination.practitioner-not-found",
],
workout: [
"workout.batch.invalid",
"workout.batch.payload_too_large",
"workout.batch.too_large",
],
"(unprefixed)": [
"about_me_conflict",
"ai_response_truncated",
"coach_prefs_conflict",
"dashboard_layout_conflict",
"health_score_config_conflict",
"insights_layout_conflict",
"invalid_base_updated_at",
"medication_layout_conflict",
"modules_conflict",
"mood_tag_layout_conflict",
"not_configured",
"notification_prefs_conflict",
"oidc_only",
"private_origin_not_approved",
"rate_limited_self",
"too_many_rows",
"upstream_invalid_json",
"vapid_not_configured",
],
} as const;
/**
* The catalogue as one sentence for the published `meta.errorCode` description.
*
* Rendered rather than hand-written so the document cannot drift from the list
* the guard checks: there is one source, and it is the object above.
*/
export function renderErrorCodeCatalogue(): string {
return Object.entries(ERROR_CODE_CATALOGUE)
.map(([surface, codes]) => `${surface} — ${codes.join(", ")}`)
.join("; ");
}
+4 -1
View File
@@ -6,6 +6,8 @@
* runtime request parsing, so the wire contract stays single-source.
*/
import { z } from "zod/v4";
import { renderErrorCodeCatalogue } from "../error-codes";
import {
createMeasurementSchema as createMeasurementSchemaBase,
listMeasurementsSchema as listMeasurementsSchemaBase,
@@ -82,7 +84,8 @@ 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.",
"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. " +
renderErrorCodeCatalogue(),
),
})
.optional(),