feat(api): give every error envelope a meta.errorCode

Roughly half of the refusals answered prose alone, so a client could branch
on the code for one 404 and had to fall back to the status for the next.
apiHandler now completes every error envelope with the status's generic
http.* code when the route named none; a route's own code still wins. The
family joins the published catalogue, and the profile form only shows a
code's sentence when the code has one.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 05:53:37 +02:00
1 parent c7f69fc212
commit fbbb535832
11 files changed
+219 -50

No files matched your search

+38 -29
View File
@@ -80,10 +80,10 @@ paths:
403 refusals carry `auth.scope.insufficient` (the credential is valid but its scope does not reach this
route, including any Bearer token against an admin surface) and `auth.admin.required` (a cookie session that
is not an admin's). A route that checks a credential of its own — sign-in, the MFA challenge exchange, a
password confirmation — may still answer 401 with prose and no code, so a missing `meta.errorCode` means the
route refused the credential it was handed, not that the response is malformed. A session that could not be
checked because the session store was unreachable is NOT a 401: it answers 503 with
`auth.session.unavailable`, and the client keeps its credential and retries."
password confirmation — may answer 401 with the generic `http.unauthenticated`, which means the route
refused the credential it was handed. A session that could not be checked because the session store was
unreachable is NOT a 401: it answers 503 with `auth.session.unavailable`, and the client keeps its
credential and retries."
content:
application/json:
schema:
@@ -36097,15 +36097,19 @@ components:
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.<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.budget.exceeded, ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling,
client branches on. Every error envelope carries a code: when the route has no reason of its own to
name, the code is the status's generic one from the `http` family (`http.not_found` for a 404,
`http.internal` for a 500, `http.client_error` / `http.server_error` for a status without its own
entry), so `http.*` says which kind of refusal it was and never why. 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 carries the generic `http.unauthenticated`. ai — ai.budget.exceeded,
ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling,
ai.usage.defaultAboveCeiling, ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider
— ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut,
aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis —
@@ -36152,18 +36156,22 @@ components:
environment.range_too_large, environment.travel.not_found, environment.travel_suggestion.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.apple_health.busy,
import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded,
insights.generate.outboundScreened, insights.generate.profileScopeChanged,
insights.generate.scopeChanged; labs — labs.biomarker.alias.taken, labs.biomarker.create.invalid,
labs.biomarker.duplicate, labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch,
labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid,
labs.create.invalid, labs.link.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.unit.mismatch, labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected,
health_score_config.too_narrow; http — http.bad_gateway, http.bad_request, http.client_error,
http.conflict, http.forbidden, http.gateway_timeout, http.gone, http.internal, http.invalid,
http.method_not_allowed, http.not_found, http.payload_too_large, http.rate_limited, http.server_error,
http.unauthenticated, http.unavailable, http.unsupported_media_type; 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.apple_health.busy, import.write_failed; ingest — ingest.rate_limited; insights —
insights.generate.budgetExceeded, insights.generate.outboundScreened,
insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs —
labs.biomarker.alias.taken, labs.biomarker.create.invalid, labs.biomarker.duplicate,
labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch, labs.biomarker.notFound,
labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid,
labs.link.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.unit.mismatch,
labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected,
labs.update.qualitativeExpected, labs.update.referenceRangeInvalid,
labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found;
managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee,
@@ -36260,10 +36268,11 @@ components:
- data
- error
additionalProperties: false
description: "Standard error response: `data` is null, `error` is human prose. `meta.errorCode` carries the stable
machine code where one exists, and `meta` may carry further per-refusal context. A 422 from the multi-issue
validator additionally carries `details.issues` — every field the request got wrong, so a client can fix them in
one round-trip."
description: "Standard error response: `data` is null, `error` is human prose. `meta.errorCode` always carries a stable
machine code — the route's own where it names one, otherwise the status's generic `http.*` code —
`meta.requestId` names the request for a support conversation, and `meta` may carry further per-refusal context.
A 422 from the multi-issue validator additionally carries `details.issues` — every field the request got wrong,
so a client can fix them in one round-trip."
ValidationIssue:
type: object
properties:
@@ -344,7 +344,7 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => {
await expect(res.json()).resolves.toEqual({
data: null,
error: "Medication intake import already in progress",
meta: { requestId: expect.any(String) },
meta: { requestId: expect.any(String), errorCode: "http.conflict" },
});
expect(prisma.medicationIntakeImportJob.create).not.toHaveBeenCalled();
expect(getGlobalBoss).not.toHaveBeenCalled();
@@ -122,7 +122,10 @@ describe("POST /api/settings/email/test", () => {
expect(response.status).toBe(500);
const json = await response.json();
expect(json.meta).toEqual({ requestId: expect.any(String) });
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "http.internal",
});
});
it("answers a delivered test with 200", async () => {
@@ -187,7 +187,11 @@ describe("PUT /api/settings/webhook — private targets", () => {
expect(response.status).toBe(422);
const json = await response.json();
expect(json.error).toBe("Invalid data");
expect(json.meta).toEqual({ requestId: expect.any(String) });
// No private-origin code: only the status's generic one.
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "http.invalid",
});
});
it("saves a listed loopback target and refuses an unlisted one with the listable code", async () => {
@@ -476,7 +480,10 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself",
expect(response.status).toBe(500);
const json = await response.json();
expect(json.meta).toEqual({ requestId: expect.any(String) });
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "http.internal",
});
expect(emitIfSampledMock).toHaveBeenCalledWith(
expect.objectContaining({ level: "error" }),
);
@@ -368,7 +368,12 @@ export function AccountSection() {
setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t));
if (errorCode === "profile.update.nothingSaved" && field) {
setSaveMsg({ key: "settings.profileNothingSaved", params: { field } });
} else if (errorCode) {
} else if (
errorCode &&
t(`apiErrors.${errorCode}`) !== `apiErrors.${errorCode}`
) {
// Only a code with a sentence of its own: every refusal carries a
// code now, and the generic `http.*` family has no translation.
setSaveMsg({ key: `apiErrors.${errorCode}` });
} else {
setSaveMsg({ key: "settings.savingError" });
@@ -21,7 +21,12 @@ vi.mock("next/headers", () => ({
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess, withErrorRequestId } from "@/lib/api-response";
import {
apiError,
apiSuccess,
defaultErrorCodeForStatus,
withErrorEnvelopeDefaults,
} from "@/lib/api-response";
const INCOMING = "3f9a2c1b-0000-4000-8000-000000000001";
@@ -48,6 +53,7 @@ describe("meta.requestId on every error envelope", () => {
it("adds it to an envelope without meta and to a hand-built one", async () => {
const plain = await call(async () => apiError("Not found", 404));
expect((await plain.json()).meta).toEqual({
errorCode: "http.not_found",
requestId: plain.headers.get("x-request-id"),
});
const hand = await call(async () =>
@@ -127,7 +133,7 @@ describe("meta.requestId on every error envelope", () => {
});
it("never overwrites an id the handler set itself", async () => {
const res = await withErrorRequestId(
const res = await withErrorEnvelopeDefaults(
NextResponse.json(
{ data: null, error: "x", meta: { requestId: "kept-by-handler" } },
{ status: 500 },
@@ -137,3 +143,62 @@ describe("meta.requestId on every error envelope", () => {
expect((await res.json()).meta.requestId).toBe("kept-by-handler");
});
});
describe("meta.errorCode on every error envelope", () => {
it("defaults the code from the status when the route named none", async () => {
const thrown = await call(async () => {
throw new HttpError(403, "Forbidden");
});
expect((await thrown.json()).meta.errorCode).toBe("http.forbidden");
const crash = await call(async () => {
throw new Error("boom");
});
expect((await crash.json()).meta.errorCode).toBe("http.internal");
const hand = await call(async () =>
NextResponse.json({ data: null, error: "Gone" }, { status: 410 }),
);
expect((await hand.json()).meta.errorCode).toBe("http.gone");
const bad = await call(async () => {
throw new SyntaxError("Unexpected token");
});
expect((await bad.json()).meta.errorCode).toBe("http.bad_request");
});
it("keeps a code the route named, and its other meta", async () => {
const res = await call(async () =>
apiError("Nope", 409, { errorCode: "probe.conflict", extra: 1 }),
);
const meta = (await res.json()).meta;
expect(meta.errorCode).toBe("probe.conflict");
expect(meta.extra).toBe(1);
});
it("falls back to the class code for an unlisted status", () => {
expect(defaultErrorCodeForStatus(418)).toBe("http.client_error");
expect(defaultErrorCodeForStatus(507)).toBe("http.server_error");
expect(defaultErrorCodeForStatus(422)).toBe("http.invalid");
});
it("adds the code even when the handler already set the id", async () => {
const res = await withErrorEnvelopeDefaults(
NextResponse.json(
{ data: null, error: "x", meta: { requestId: "kept-by-handler" } },
{ status: 404 },
),
"ignored-0000",
);
expect(await res.json()).toEqual({
data: null,
error: "x",
meta: { requestId: "kept-by-handler", errorCode: "http.not_found" },
});
});
it("leaves a success untouched", async () => {
const ok = await call(async () => apiSuccess({ fine: true }));
expect((await ok.json()).meta).toBeUndefined();
});
});
+2 -2
View File
@@ -227,7 +227,7 @@ describe("auth failures carry a machine-readable errorCode", () => {
expect(body.meta?.errorCode).toBe(AUTH_ERROR_CODES.missing);
});
it("leaves an HttpError thrown without a code exactly as it was", async () => {
it("gives an HttpError thrown without a code the status's generic one", async () => {
const handler = apiHandler(async (_request: Request) => {
throw new HttpError(404, "Not found");
});
@@ -239,7 +239,7 @@ describe("auth failures carry a machine-readable errorCode", () => {
expect(await res.json()).toEqual({
data: null,
error: "Not found",
meta: { requestId: expect.any(String) },
meta: { requestId: expect.any(String), errorCode: "http.not_found" },
});
});
+5 -4
View File
@@ -55,7 +55,7 @@ import {
} from "./sharing/record-session-fence";
import { refuseOnKeyMismatch } from "./boot/key-mismatch-refusal";
import { acceptIncomingRequestId } from "./http/request-id";
import { withErrorRequestId } from "./api-response";
import { withErrorEnvelopeDefaults } from "./api-response";
import {
capturedRateLimit,
rateLimitResponseHeaders,
@@ -365,9 +365,10 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
}
}
// Every error envelope names its request id in `meta.requestId`, so
// the id survives a proxy that strips the header (see
// `withErrorRequestId`).
const nr = (await withErrorRequestId(
// the id survives a proxy that strips the header, and carries a
// `meta.errorCode`, the status's default when the route named none
// (see `withErrorEnvelopeDefaults`).
const nr = (await withErrorEnvelopeDefaults(
response as Response,
evt.getRequestId(),
)) as NextResponse;
+65 -5
View File
@@ -309,20 +309,69 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
}
/**
* Put the request id into `meta.requestId` of an error envelope.
* The `meta.errorCode` an error envelope carries when its route named none.
*
* `meta.errorCode` is what a client is told to branch on, and roughly half of
* the refusals in the tree carried one: the rest answered prose alone, so a
* client could branch on the code for one 404 and had to fall back to the
* status for the next. Every error envelope now has a code. A route that names
* its own keeps it; one that does not gets the status's code from this table,
* so the generic family is predictable from the status alone and a client
* never has to special-case an absent field.
*
* Deliberately coarse. These say which kind of refusal it was, never why —
* a route that has a reason worth branching on names its own code. Statuses
* not listed fall back to `http.client_error` / `http.server_error`.
*/
export const DEFAULT_ERROR_CODES = {
400: "http.bad_request",
401: "http.unauthenticated",
403: "http.forbidden",
404: "http.not_found",
405: "http.method_not_allowed",
409: "http.conflict",
410: "http.gone",
413: "http.payload_too_large",
415: "http.unsupported_media_type",
422: "http.invalid",
429: "http.rate_limited",
500: "http.internal",
502: "http.bad_gateway",
503: "http.unavailable",
504: "http.gateway_timeout",
client: "http.client_error",
server: "http.server_error",
} as const;
/** The default code for an error status, see {@link DEFAULT_ERROR_CODES}. */
export function defaultErrorCodeForStatus(status: number): string {
const listed = (DEFAULT_ERROR_CODES as Record<number, string | undefined>)[
status
];
if (listed) return listed;
return status >= 500
? DEFAULT_ERROR_CODES.server
: DEFAULT_ERROR_CODES.client;
}
/**
* Complete an error envelope: `meta.requestId` always, and `meta.errorCode`
* from the status when the route named none.
*
* Every 4xx and 5xx a route answers with the `{ data: null, error, meta? }`
* envelope carries the full request id beside the `x-request-id` header, so a
* client behind a proxy that strips headers can still show the id a person
* reads out to the operator, who finds the event in the admin log with it.
* The code follows {@link DEFAULT_ERROR_CODES}.
*
* Applied once, by `apiHandler`, to whatever the handler answered, so a route
* that builds its error with `apiError`, with `NextResponse.json` by hand or by
* throwing cannot be the one that forgets it. Anything that is not a JSON
* envelope (a redirect, a stream, a FHIR `OperationOutcome`, a body that does
* not parse) is returned untouched, and an id the handler already set wins.
* not parse) is returned untouched, and an id or code the handler already set
* wins.
*/
export async function withErrorRequestId(
export async function withErrorEnvelopeDefaults(
response: Response,
requestId: string,
): Promise<Response> {
@@ -354,12 +403,23 @@ export async function withErrorRequestId(
}
const meta = body.meta;
if (meta !== undefined && !isPlainObject(meta)) return response;
if (meta && typeof meta.requestId === "string") return response;
const hasId = typeof meta?.requestId === "string";
const hasCode = typeof meta?.errorCode === "string";
if (hasId && hasCode) return response;
const headers = new Headers(response.headers);
headers.delete("content-length");
return new NextResponse(
JSON.stringify({ ...body, meta: { ...(meta ?? {}), requestId } }),
JSON.stringify({
...body,
meta: {
...(meta ?? {}),
...(hasCode
? {}
: { errorCode: defaultErrorCodeForStatus(response.status) }),
...(hasId ? {} : { requestId }),
},
}),
{ status: response.status, statusText: response.statusText, headers },
);
}
+19
View File
@@ -211,6 +211,25 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"health_score_config.invalid",
"health_score_config.too_narrow",
],
http: [
"http.bad_gateway",
"http.bad_request",
"http.client_error",
"http.conflict",
"http.forbidden",
"http.gateway_timeout",
"http.gone",
"http.internal",
"http.invalid",
"http.method_not_allowed",
"http.not_found",
"http.payload_too_large",
"http.rate_limited",
"http.server_error",
"http.unauthenticated",
"http.unavailable",
"http.unsupported_media_type",
],
illness: [
"illness.day-log.invalid",
"illness.disabled",
+3 -3
View File
@@ -85,7 +85,7 @@ 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.<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. " +
"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. Every error envelope carries a code: when the route has no reason of its own to name, the code is the status's generic one from the `http` family (`http.not_found` for a 404, `http.internal` for a 500, `http.client_error` / `http.server_error` for a status without its own entry), so `http.*` says which kind of refusal it was and never why. 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 carries the generic `http.unauthenticated`. " +
renderErrorCodeCatalogue(),
),
capability: z
@@ -118,7 +118,7 @@ export const errorEnvelope = z
.meta({
id: "ErrorEnvelope",
description:
"Standard error response: `data` is null, `error` is human prose. `meta.errorCode` carries the stable machine code where one exists, and `meta` may carry further per-refusal context. A 422 from the multi-issue validator additionally carries `details.issues` — every field the request got wrong, so a client can fix them in one round-trip.",
"Standard error response: `data` is null, `error` is human prose. `meta.errorCode` always carries a stable machine code — the route's own where it names one, otherwise the status's generic `http.*` code — `meta.requestId` names the request for a support conversation, and `meta` may carry further per-refusal context. A 422 from the multi-issue validator additionally carries `details.issues` — every field the request got wrong, so a client can fix them in one round-trip.",
});
export function dataEnvelope<T extends z.ZodType>(payload: T, id: string) {
@@ -279,7 +279,7 @@ export const malformedJsonResponse = {
export const stdResponses = {
"401": {
description:
"Authentication required or invalid credentials. The generic auth gates — the ones every route passes through before its own body runs — name the reason in `meta.errorCode`, and that is the field to branch on there; the English sentence beside it may be reworded or localised at any time. `auth.missing`: no session cookie and no Bearer header; send the user to sign in. `auth.token.expired`: the Bearer token's lifetime has passed; refresh and retry the request. `auth.token.invalid`: unknown, revoked, or the owning account is gone; discard the credential and sign in again — retrying will not help. `auth.mfa.code_invalid`: a second factor presented on a step-up flow did not verify; re-prompt for a code and keep the session. The related 403 refusals carry `auth.scope.insufficient` (the credential is valid but its scope does not reach this route, including any Bearer token against an admin surface) and `auth.admin.required` (a cookie session that is not an admin's). A route that checks a credential of its own — sign-in, the MFA challenge exchange, a password confirmation — may still answer 401 with prose and no code, so a missing `meta.errorCode` means the route refused the credential it was handed, not that the response is malformed. A session that could not be checked because the session store was unreachable is NOT a 401: it answers 503 with `auth.session.unavailable`, and the client keeps its credential and retries.",
"Authentication required or invalid credentials. The generic auth gates — the ones every route passes through before its own body runs — name the reason in `meta.errorCode`, and that is the field to branch on there; the English sentence beside it may be reworded or localised at any time. `auth.missing`: no session cookie and no Bearer header; send the user to sign in. `auth.token.expired`: the Bearer token's lifetime has passed; refresh and retry the request. `auth.token.invalid`: unknown, revoked, or the owning account is gone; discard the credential and sign in again — retrying will not help. `auth.mfa.code_invalid`: a second factor presented on a step-up flow did not verify; re-prompt for a code and keep the session. The related 403 refusals carry `auth.scope.insufficient` (the credential is valid but its scope does not reach this route, including any Bearer token against an admin surface) and `auth.admin.required` (a cookie session that is not an admin's). A route that checks a credential of its own — sign-in, the MFA challenge exchange, a password confirmation — may answer 401 with the generic `http.unauthenticated`, which means the route refused the credential it was handed. A session that could not be checked because the session store was unreachable is NOT a 401: it answers 503 with `auth.session.unavailable`, and the client keeps its credential and retries.",
content: { "application/json": { schema: errorEnvelope } },
},
"422": {