From eddb67975dbc4211cdc03964eea1492a54982114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 21 Aug 2026 18:32:29 +0200 Subject: [PATCH] Publish the idempotency contract the server has honoured all along MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `withIdempotency` has made writes safe to retry since v1.5.x. It was described in route comments and in no schema, so an app generated against `docs/api/openapi.yaml` could not learn that a retry was safe. The iOS side asked for it; the mechanism does not change, only what it says about itself. 34 operations now declare `Idempotency-Key` and the `X-Idempotent-Replay` header, plus the 409 that refuses a duplicate still in flight. The description leads with the one silent failure in the mechanism: a key that misses `^[A-Za-z0-9_\-:.]{8,128}$` is IGNORED rather than refused, so the write proceeds unprotected and looks exactly like a request that carried no key. ## What the work turned up Three things the contract had wrong or missing, none of them the thing being built: - `/api/insights/chat` describes `withIdempotency()` in its header comment and applies it nowhere. The Coach chat is not retry-safe. Its prose says it is. - `/api/measurements` exports GET, POST, PUT and DELETE and wraps only POST. Publishing the header per PATH would have promised a safe retry on two methods that do not honour one — the dangerous direction, so the guard works per OPERATION. - `/api/insights/feedback` and `/api/admin/backups/{id}/restore` are not in the document at all. Both are named in the guard's exemption list with a reason, not filtered out silently. ## The guard `idempotency-contract-publication.test.ts` reads the expected set out of the route tree on every run — nothing is hand-listed, so it cannot fall behind the code. It fails in both directions: a new wrapped route that forgets to publish, and a published claim no handler honours. A fourth assertion checks the exemption list itself is still true. It earned its place immediately: the first draft exempted the whole `/api/medications` family on the strength of a grep for path literals that came back empty. The paths are not written as literals there; the emitted document carries 19 of them. The test went red, the claim was wrong, the list is two entries now. Measure the artefact, not the source that builds it. --- docs/api/openapi.yaml | 585 +++++++++++------- .../idempotency-contract-publication.test.ts | 185 ++++++ src/lib/openapi/routes/allergies.ts | 4 + src/lib/openapi/routes/biomarkers.ts | 4 + src/lib/openapi/routes/coach.ts | 4 + src/lib/openapi/routes/custom-metrics.ts | 6 + src/lib/openapi/routes/cycle.ts | 8 + src/lib/openapi/routes/documents.ts | 6 + src/lib/openapi/routes/encounters.ts | 6 + src/lib/openapi/routes/family-history.ts | 4 + src/lib/openapi/routes/illness.ts | 6 + src/lib/openapi/routes/labs.ts | 6 + .../openapi/routes/measurement-reminders.ts | 4 + src/lib/openapi/routes/measurements.ts | 10 + src/lib/openapi/routes/medications/paths.ts | 6 + src/lib/openapi/routes/mental-health.ts | 10 +- src/lib/openapi/routes/mood.ts | 20 +- src/lib/openapi/routes/nutrients.ts | 6 + src/lib/openapi/routes/ocr.ts | 9 +- src/lib/openapi/routes/shared.ts | 93 +++ src/lib/openapi/routes/vaccinations.ts | 4 + src/lib/openapi/routes/workouts.ts | 4 + 22 files changed, 753 insertions(+), 237 deletions(-) create mode 100644 src/__tests__/idempotency-contract-publication.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e9ba1ed55..9a7507378 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -111,6 +111,22 @@ paths: summary: Capture a single cycle day-log (v1.15.0) description: "Upserts on `(userId, source, externalId)` when externalId present, else `(userId, date)`. `note` encrypts at rest. 201 on insert, 200 on update. Gated: `cycle.disabled` 403 when the feature is off." + parameters: + - &a5 + name: Idempotency-Key + in: header + required: false + schema: + type: string + pattern: ^[A-Za-z0-9_\-:.]{8,128}$ + minLength: 8 + maxLength: 128 + description: "Makes this write safe to retry. Send the same key again and the first response is replayed rather than the + work repeated, marked with `X-Idempotent-Replay: true`. A key that does not match the pattern is IGNORED + rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no + key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different + route is a different key. While a first request under this key is still in flight, a second is refused with + 409 rather than queued." requestBody: required: true content: @@ -248,6 +264,25 @@ paths: $ref: "#/components/schemas/CycleDayLogCreatedEnvelope" "401": *a1 "403": *a2 + "409": &a6 + description: "A request with this `Idempotency-Key` is already in progress. Nothing was written by this call. Retry + after a short delay; the response carries `X-Idempotent-Replay: false` to separate it from a replay." + headers: + X-Idempotent-Replay: + required: true + description: "`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that + refuses a duplicate still in flight. Absent when the request carried no usable key." + schema: + type: string + enum: + - "true" + - "false" + description: "`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that + refuses a duplicate still in flight. Absent when the request carried no usable key." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/cycle/day-logs/{id}: @@ -439,6 +474,8 @@ paths: summary: Bulk drain cycle day-logs (Outbox / HealthKit) (v1.15.0) description: "Up to 500 entries; `withIdempotency`; rate-limited `cycle:day-logs:bulk:` 60/min. Per-entry status: inserted | duplicate | updated | skipped. Always 200." + parameters: + - *a5 requestBody: required: true content: @@ -578,13 +615,14 @@ paths: schema: $ref: "#/components/schemas/CycleBulkEnvelope" "401": *a1 - "403": &a5 + "403": &a7 description: "Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). Resolved against the RECORD being read, not the caller: a delegate inside somebody else's record sees the owner's setting." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": *a6 "422": *a3 "429": *a4 /api/cycle/period: @@ -594,6 +632,8 @@ paths: summary: Period-boundary shortcut (v1.15.0) description: One-tap started/ended period. `start` opens a new cycle (closing the prior); `end` stamps periodEndDate. Writes the boundary day-log. Gated. + parameters: + - *a5 requestBody: required: true content: @@ -630,6 +670,7 @@ paths: $ref: "#/components/schemas/CyclePeriodEnvelope" "401": *a1 "403": *a2 + "409": *a6 "422": *a3 "429": *a4 /api/cycle/calendar: @@ -746,7 +787,7 @@ paths: schema: $ref: "#/components/schemas/CyclePurgeEnvelope" "401": *a1 - "403": *a5 + "403": *a7 "422": *a3 "429": *a4 /api/cycle/profile: @@ -980,7 +1021,7 @@ paths: type: string format: binary "401": *a1 - "403": &a6 + "403": &a8 description: "Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections do not reach this surface, and a read grant on a request that writes — the @@ -2499,7 +2540,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/measurements/series: @@ -2551,7 +2592,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/measurements: @@ -2748,7 +2789,7 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 post: @@ -2758,6 +2799,8 @@ paths: description: Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a combined blood-pressure + pulse or dual-value glucose write — in which case `data` is the array of created rows in request order. Use `/api/measurements/batch` for Apple Health upload streams. + parameters: + - *a5 requestBody: required: true content: @@ -3006,7 +3049,7 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 - "403": *a6 + "403": *a8 "409": description: A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write. @@ -3036,7 +3079,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementResponse" "401": *a1 - "403": *a6 + "403": *a8 "404": description: Measurement not found (or owned by another user). content: @@ -3084,7 +3127,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 - "403": *a6 + "403": *a8 "404": description: Measurement not found (or owned by another user). content: @@ -3120,7 +3163,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMeasurementResponse" "401": *a1 - "403": *a6 + "403": *a8 "404": description: Measurement not found (or owned by another user). content: @@ -3138,6 +3181,8 @@ paths: Per-entry status lets the iOS client advance its sync cursor accurately. v1.4.25 W8c adds an optional `deviceType` per entry — feed it from `HKDevice.model` so the cross-source canonical picker can break Apple-Watch-vs-iPhone ties; null/absent stays backward-compatible with v1.4.23 clients. + parameters: + - *a5 requestBody: required: true content: @@ -3152,6 +3197,7 @@ paths: schema: $ref: "#/components/schemas/BatchMeasurementsResponse" "401": *a1 + "409": *a6 "422": *a3 "429": *a4 /api/measurements/by-external-ids: @@ -3188,6 +3234,8 @@ paths: single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:bulk-delete:`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones. + parameters: + - *a5 requestBody: required: true content: @@ -3202,7 +3250,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 - "403": *a6 + "403": *a8 + "409": *a6 "422": *a3 "429": *a4 /api/measurements/restore: @@ -3214,6 +3263,8 @@ paths: counterpart to `/api/measurements/bulk-delete`. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:restore:`. Forged / foreign / not-deleted ids are silently skipped — `restored` is the count of rows actually un-tombstoned. The restored rows re-surface in `/api/sync/changes` as upserts. + parameters: + - *a5 requestBody: required: true content: @@ -3228,7 +3279,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 - "403": *a6 + "403": *a8 + "409": *a6 "422": *a3 "429": *a4 /api/sleep/night: @@ -3258,7 +3310,7 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "403": &a7 + "403": &a9 description: >- Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a @@ -3299,7 +3351,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 - "403": *a7 + "403": *a9 "422": *a3 "429": *a4 /api/workouts: @@ -3348,7 +3400,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "403": *a7 + "403": *a9 "422": *a3 "429": *a4 /api/workouts/{id}: @@ -3384,7 +3436,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": &a10 + "403": &a12 description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' @@ -3415,6 +3467,8 @@ paths: refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.' + parameters: + - *a5 requestBody: required: true content: @@ -3607,6 +3661,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "409": *a6 "413": description: Request body exceeds the 5 MB ceiling. content: @@ -3656,6 +3711,8 @@ paths: mirrored from Apple Health, else the whole batch 422s (`meta.errorCode = medications.intake.bulk.apple_health_not_mirrored`); an over-size batch 422s (`medications.intake.bulk.too_large`); a malformed body 422s (`medications.intake.bulk.invalid`). + parameters: + - *a5 requestBody: required: true content: @@ -3670,6 +3727,7 @@ paths: schema: $ref: "#/components/schemas/BulkMedicationIntakeResponseEnvelope" "401": *a1 + "409": *a6 "422": *a3 "429": *a4 /api/medications: @@ -3689,7 +3747,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 post: @@ -3718,7 +3776,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/medications/layout: @@ -3737,7 +3795,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 put: @@ -3771,7 +3829,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a8 + "422": &a10 description: Request validation failed. When the body carried a `baseUpdatedAt` that could not be parsed as an ISO-8601 timestamp — including an explicit `null` — `meta.errorCode` = `invalid_base_updated_at` and nothing was written. Omit the key entirely for the unconditional write; do not send `null` for it. @@ -3816,7 +3874,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationResponse" "401": *a1 - "403": *a6 + "403": *a8 "404": description: Medication not found (or owned by another user). content: @@ -3853,7 +3911,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationResponse" "401": *a1 - "403": *a6 + "403": *a8 "404": description: Medication not found (or owned by another user). content: @@ -3905,6 +3963,7 @@ paths: schema: type: string required: true + - *a5 requestBody: required: true content: @@ -3925,13 +3984,14 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationIntakeResponse" "401": *a1 - "403": *a6 + "403": *a8 "404": description: Medication not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": *a6 "422": *a3 "429": *a4 get: @@ -3998,7 +4058,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationIntakeEventsResponse" "401": *a1 - "403": *a6 + "403": *a8 "404": description: Medication not found (or owned by another user). content: @@ -4031,7 +4091,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationInventoryResponse" "401": *a1 - "403": *a6 + "403": *a8 "404": description: Medication not found (or owned by another user). content: @@ -4066,7 +4126,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" "401": *a1 - "403": *a6 + "403": *a8 "404": description: Medication not found (or owned by another user). content: @@ -4109,7 +4169,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" "401": *a1 - "403": *a6 + "403": *a8 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -4144,7 +4204,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" "401": *a1 - "403": *a6 + "403": *a8 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -4222,7 +4282,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationCadenceResponse" "401": *a1 - "403": *a6 + "403": *a8 "404": description: Medication not found (or owned by another user). content: @@ -4250,7 +4310,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/medications/{id}/compliance: @@ -4514,7 +4574,7 @@ paths: schema: $ref: "#/components/schemas/ListScheduleRevisionsResponse" "401": *a1 - "403": *a6 + "403": *a8 "404": description: Medication not found (or owned by another user). content: @@ -4552,7 +4612,7 @@ paths: schema: $ref: "#/components/schemas/CreateScheduleRevisionResponse" "401": *a1 - "403": *a6 + "403": *a8 "404": description: Medication not found (or owned by another user). content: @@ -4596,7 +4656,7 @@ paths: schema: $ref: "#/components/schemas/UpdateScheduleRevisionResponse" "401": *a1 - "403": *a6 + "403": *a8 "404": description: Medication or revision not found (or owned by another user). content: @@ -4637,7 +4697,7 @@ paths: schema: $ref: "#/components/schemas/DeleteScheduleRevisionResponse" "401": *a1 - "403": *a6 + "403": *a8 "404": description: Medication or revision not found (or owned by another user). content: @@ -4700,7 +4760,7 @@ paths: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "409": description: A dose-history import is already in progress. content: @@ -4762,7 +4822,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "404": description: No such job for this medication (or owned by another user). content: @@ -4796,7 +4856,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "404": description: No such job for this account (or owned by another user). content: @@ -4870,7 +4930,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a8 + "422": *a10 "429": *a4 /api/auth/me/disable-coach: get: @@ -5068,7 +5128,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a8 + "422": *a10 "429": *a4 /api/auth/me/source-priority: get: @@ -6094,7 +6154,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a8 + "422": *a10 "429": *a4 /api/auth/me/report-selection: get: @@ -6538,7 +6598,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a8 + "422": *a10 "429": *a4 delete: tags: @@ -6627,7 +6687,7 @@ paths: schema: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/insights/coach/seen: @@ -6742,7 +6802,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a8 + "422": *a10 "429": *a4 /api/anamnesis/facts: get: @@ -6760,7 +6820,7 @@ paths: schema: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 post: @@ -6769,6 +6829,8 @@ paths: summary: Create an anamnesis fact description: Creates the first current revision for one closed fact kind. The answer is encrypted before persistence. A second current value for the same kind returns 409; corrections use the revision endpoint. + parameters: + - *a5 requestBody: required: true content: @@ -6868,7 +6930,7 @@ paths: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 - "404": &a9 + "404": &a11 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -6904,7 +6966,7 @@ paths: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 - "404": *a9 + "404": *a11 "409": description: Anamnesis fact changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -7595,7 +7657,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/insights/comprehensive: @@ -7653,7 +7715,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a7 + "403": *a9 "422": *a3 "429": *a4 /api/insights/pregenerate: @@ -7828,7 +7890,7 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/insights/pulse-status: @@ -7857,7 +7919,7 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/insights/weight-status: @@ -7886,7 +7948,7 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/insights/bmi-status: @@ -7915,7 +7977,7 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/insights/mood-status: @@ -7944,7 +8006,7 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/insights/medication-compliance-status: @@ -7973,7 +8035,7 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/insights/metric-status: @@ -8065,7 +8127,7 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/insights/biomarker-assessment: @@ -8107,7 +8169,7 @@ paths: schema: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/insights/derived: @@ -8190,7 +8252,7 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/insights/derived/batch: @@ -8229,7 +8291,7 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/insights/glp1-plateau: @@ -8249,7 +8311,7 @@ paths: schema: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/insights/ecg: @@ -8272,7 +8334,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a7 + "403": *a9 "422": *a3 "429": *a4 post: @@ -8310,7 +8372,7 @@ paths: schema: $ref: "#/components/schemas/EcgIngestCreatedEnvelope" "401": *a1 - "403": *a10 + "403": *a12 "422": *a3 "429": *a4 /api/insights/ecg/{id}: @@ -8350,7 +8412,7 @@ paths: schema: $ref: "#/components/schemas/EcgDetailResponseEnvelope" "401": *a1 - "403": *a7 + "403": *a9 "404": description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one). @@ -8382,7 +8444,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a7 + "403": *a9 "422": *a3 "429": *a4 /api/insights/correlations: @@ -8403,7 +8465,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/insights/patterns: @@ -8421,7 +8483,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a7 + "403": *a9 "422": *a3 "429": *a4 /api/insights/patterns/{id}: @@ -8451,7 +8513,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope" "401": *a1 - "403": *a7 + "403": *a9 "404": description: No current pattern with that id for this account. content: @@ -8476,7 +8538,7 @@ paths: schema: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/insights/breathing-screening: @@ -8495,7 +8557,7 @@ paths: schema: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/insights/labs-changes: @@ -8514,7 +8576,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/mood/prognosis: @@ -8537,7 +8599,7 @@ paths: schema: $ref: "#/components/schemas/MoodPrognosisEnvelope" "401": *a1 - "403": *a7 + "403": *a9 "422": *a3 "429": *a4 /api/mood-entries/bulk: @@ -8550,6 +8612,8 @@ paths: `Idempotency-Key` header too. Per-entry status (`inserted` / `duplicate` / `skipped`) advances the client cursor. Rate-limited 60/min/user. An over-size batch 422s (`meta.errorCode = mood.bulk.too_large`); a malformed body 422s (`mood.bulk.invalid`)." + parameters: + - *a5 requestBody: required: true content: @@ -8564,6 +8628,7 @@ paths: schema: $ref: "#/components/schemas/BulkMoodResponseEnvelope" "401": *a1 + "409": *a6 "422": *a3 "429": *a4 /api/mood/tags: @@ -8674,7 +8739,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a11 + "404": &a13 description: Not found / not owned by the caller. content: application/json: @@ -8709,7 +8774,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a11 + "404": *a13 "422": *a3 "429": *a4 /api/mood/tags/{key}/hidden: @@ -8750,7 +8815,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a11 + "404": *a13 "422": *a3 "429": *a4 /api/mood/tags/groups: @@ -8825,7 +8890,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a11 + "404": *a13 "422": *a3 "429": *a4 delete: @@ -8856,7 +8921,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a11 + "404": *a13 "422": *a3 "429": *a4 /api/mood/tags/layout: @@ -8904,7 +8969,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a8 + "422": *a10 "429": *a4 /api/mood-entries: get: @@ -8987,7 +9052,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntryListEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 post: @@ -9003,6 +9068,8 @@ paths: its own rows. A rated factor outside its catalog scale 422s (`mood.ratedFactor.out_of_range`); a malformed body 422s (`mood.create.invalid`). An unstable `externalId` (an object description or memory address that rotates per launch) is refused at validation. + parameters: + - *a5 requestBody: required: true content: @@ -9018,8 +9085,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a6 - "409": &a12 + "403": *a8 + "409": &a14 description: A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` = `mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write upserts in place. @@ -9050,8 +9117,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a6 - "404": *a11 + "403": *a8 + "404": *a13 "422": *a3 "429": *a4 put: @@ -9085,9 +9152,9 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a6 - "404": *a11 - "409": *a12 + "403": *a8 + "404": *a13 + "409": *a14 "422": *a3 "429": *a4 delete: @@ -9112,8 +9179,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 - "403": *a6 - "404": *a11 + "403": *a8 + "404": *a13 "422": *a3 "429": *a4 /api/mood-entries/restore: @@ -9126,6 +9193,8 @@ paths: feed as an upsert. A forged / foreign / not-deleted id is a silent no-op, never a 404 existence leak. Idempotent via the `Idempotency-Key` header. Rate-limited 60/min (keyed on the ACTING user, so a manager burns their own allowance). A malformed body 422s (`meta.errorCode` = `mood.restore.invalid`). + parameters: + - *a5 requestBody: required: true content: @@ -9140,7 +9209,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" "401": *a1 - "403": *a6 + "403": *a8 + "409": *a6 "422": *a3 "429": *a4 /api/mood-entries/bulk-delete: @@ -9152,6 +9222,8 @@ paths: delete, matching the single-DELETE tombstone contract. A forged / foreign id is a silent no-op, never a 404 existence leak. Idempotent via the `Idempotency-Key` header. Rate-limited 60/min (keyed on the ACTING user). A malformed body 422s (`meta.errorCode` = `mood.bulk-delete.invalid`). + parameters: + - *a5 requestBody: required: true content: @@ -9166,7 +9238,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" "401": *a1 - "403": *a6 + "403": *a8 + "409": *a6 "422": *a3 "429": *a4 /api/mood/linked-context: @@ -9209,7 +9282,7 @@ paths: schema: $ref: "#/components/schemas/MoodLinkedContextEnvelope" "401": *a1 - "403": *a7 + "403": *a9 "422": *a3 "429": *a4 /api/settings/reminder-thresholds: @@ -9228,7 +9301,7 @@ paths: schema: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/consent/ai: @@ -9422,7 +9495,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 post: @@ -9431,6 +9504,8 @@ paths: summary: Create a Vorsorge reminder (v1.17.1) description: Creates a reminder and computes its server-authoritative nextDueAt. Exactly one of intervalDays (rolling) or rrule (RFC-5545) is required. Wrapped in withIdempotency. 201 on insert. + parameters: + - *a5 requestBody: required: true content: @@ -9445,7 +9520,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 - "403": *a6 + "403": *a8 + "409": *a6 "422": *a3 "429": *a4 /api/measurement-reminders/{id}: @@ -9468,8 +9544,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "403": *a6 - "404": &a13 + "403": *a8 + "404": &a15 description: Measurement reminder not found / not owned. content: application/json: @@ -9503,8 +9579,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "403": *a6 - "404": *a13 + "403": *a8 + "404": *a15 "422": *a3 "429": *a4 delete: @@ -9526,8 +9602,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "403": *a6 - "404": *a13 + "403": *a8 + "404": *a15 "422": *a3 "429": *a4 /api/measurement-reminders/{id}/satisfy: @@ -9551,8 +9627,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "403": *a6 - "404": *a13 + "403": *a8 + "404": *a15 "422": *a3 "429": *a4 /api/measurement-reminders/{id}/complete: @@ -9579,8 +9655,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 - "403": *a6 - "404": *a13 + "403": *a8 + "404": *a15 "422": *a3 "429": *a4 /api/measurement-reminders/{id}/skip: @@ -9609,8 +9685,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 - "403": *a6 - "404": *a13 + "403": *a8 + "404": *a15 "422": *a3 "429": *a4 /api/measurement-reminders/{id}/snooze: @@ -9643,8 +9719,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 - "403": *a6 - "404": *a13 + "403": *a8 + "404": *a15 "422": *a3 "429": *a4 /api/measurement-reminders/{id}/history: @@ -9684,8 +9760,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 - "403": *a6 - "404": *a13 + "403": *a8 + "404": *a15 "422": *a3 "429": *a4 /api/labs: @@ -9757,7 +9833,7 @@ paths: schema: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 post: @@ -9768,6 +9844,8 @@ paths: it defaults to `"MANUAL"` when omitted, so the legacy hand-entry contract is unchanged. The on-device-OCR client posts `"OCR"` after the human confirms the parsed values — the raw image never touches the server on this path. The optional note is AES-256-GCM encrypted before write. Audits as `labResult.create`. + parameters: + - *a5 requestBody: required: true content: @@ -9843,7 +9921,8 @@ paths: schema: $ref: "#/components/schemas/CreateLabResultResponse" "401": *a1 - "403": *a6 + "403": *a8 + "409": *a6 "422": *a3 "429": *a4 /api/labs/{id}: @@ -9866,8 +9945,8 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "403": *a6 - "404": &a14 + "403": *a8 + "404": &a16 description: Lab result not found (or owned by another user). content: application/json: @@ -9961,8 +10040,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "403": *a6 - "404": *a14 + "403": *a8 + "404": *a16 "422": *a3 "429": *a4 delete: @@ -9984,8 +10063,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "403": *a6 - "404": *a14 + "403": *a8 + "404": *a16 "422": *a3 "429": *a4 /api/labs/restore: @@ -9995,6 +10074,8 @@ paths: summary: Restore soft-deleted lab results description: Un-tombstones 1..200 of the caller's soft-deleted lab results in one pass (the delete-Undo affordance). A foreign / live id is a silent no-op. Audits via the wide-event ledger. + parameters: + - *a5 requestBody: required: true content: @@ -10009,7 +10090,8 @@ paths: schema: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 - "403": *a6 + "403": *a8 + "409": *a6 "422": *a3 "429": *a4 /api/labs/ocr/capability: @@ -10078,6 +10160,8 @@ paths: biomarker and creates a `LabResult` with `source: "OCR"`; a row that duplicates a live reading is skipped. Idempotent (Idempotency-Key). Audits as `labs.ocr.commit`. `userId` is narrowed from the session, never a body field.' + parameters: + - *a5 requestBody: required: true content: @@ -10148,6 +10232,7 @@ paths: schema: $ref: "#/components/schemas/OcrCommitEnvelope" "401": *a1 + "409": *a6 "422": *a3 "429": *a4 /api/biomarkers: @@ -10165,7 +10250,7 @@ paths: schema: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 post: @@ -10174,6 +10259,8 @@ paths: summary: Define a biomarker description: Creates a user-scoped biomarker. The optional context note is AES-256-GCM encrypted before write. Audits as `biomarker.create`. + parameters: + - *a5 requestBody: required: true content: @@ -10214,8 +10301,8 @@ paths: schema: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 - "403": *a6 - "409": &a16 + "403": *a8 + "409": &a18 description: A biomarker with this name already exists for the caller. content: application/json: @@ -10243,8 +10330,8 @@ paths: schema: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 - "403": *a6 - "404": &a15 + "403": *a8 + "404": &a17 description: Biomarker not found (or owned by another user). content: application/json: @@ -10311,9 +10398,9 @@ paths: schema: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 - "403": *a6 - "404": *a15 - "409": *a16 + "403": *a8 + "404": *a17 + "409": *a18 "422": *a3 "429": *a4 delete: @@ -10336,7 +10423,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a15 + "404": *a17 "422": *a3 "429": *a4 /api/encounters: @@ -10394,7 +10481,7 @@ paths: schema: $ref: "#/components/schemas/ListEncountersEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 post: @@ -10404,6 +10491,8 @@ paths: description: "Creates one visit. A future instant with `status: PLANNED` books an appointment and mints its reminder; a past instant with `status: DONE` and a `reminderId` closes that checkup. Claiming both is a 422 — a visit that has not happened cannot have closed anything. Honours `Idempotency-Key`. Audits as `encounter.visit.create`." + parameters: + - *a5 requestBody: required: true content: @@ -10489,7 +10578,8 @@ paths: schema: $ref: "#/components/schemas/CreateEncounterEnvelope" "401": *a1 - "403": *a6 + "403": *a8 + "409": *a6 "422": *a3 "429": *a4 /api/encounters/suggest: @@ -10519,7 +10609,7 @@ paths: schema: $ref: "#/components/schemas/SuggestEncounterEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/encounters/{id}: @@ -10542,8 +10632,8 @@ paths: schema: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 - "403": *a6 - "404": &a17 + "403": *a8 + "404": &a19 description: Visit not found (or owned by another user). content: application/json: @@ -10643,8 +10733,8 @@ paths: schema: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 - "403": *a6 - "404": *a17 + "403": *a8 + "404": *a19 "422": *a3 "429": *a4 delete: @@ -10668,8 +10758,8 @@ paths: schema: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 - "403": *a6 - "404": *a17 + "403": *a8 + "404": *a19 "422": *a3 "429": *a4 /api/encounters/{id}/restore: @@ -10694,8 +10784,8 @@ paths: schema: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 - "403": *a6 - "404": *a17 + "403": *a8 + "404": *a19 "422": *a3 "429": *a4 /api/encounters/{id}/links: @@ -10744,7 +10834,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a17 + "404": *a19 "422": *a3 "429": *a4 delete: @@ -10792,7 +10882,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a17 + "404": *a19 "422": *a3 "429": *a4 /api/practitioners: @@ -10822,7 +10912,7 @@ paths: schema: $ref: "#/components/schemas/ListPractitionersEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 post: @@ -10830,6 +10920,8 @@ paths: - Records summary: Add a practitioner description: Creates one address-book entry. Honours `Idempotency-Key`. Audits as `practitioner.contact.create`. + parameters: + - *a5 requestBody: required: true content: @@ -10877,7 +10969,8 @@ paths: schema: $ref: "#/components/schemas/CreatePractitionerEnvelope" "401": *a1 - "403": *a6 + "403": *a8 + "409": *a6 "422": *a3 "429": *a4 /api/practitioners/{id}: @@ -10900,8 +10993,8 @@ paths: schema: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 - "403": *a6 - "404": &a18 + "403": *a8 + "404": &a20 description: Practitioner not found (or owned by another user). content: application/json: @@ -10965,8 +11058,8 @@ paths: schema: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 - "403": *a6 - "404": *a18 + "403": *a8 + "404": *a20 "422": *a3 "429": *a4 delete: @@ -10989,8 +11082,8 @@ paths: schema: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 - "403": *a6 - "404": *a18 + "403": *a8 + "404": *a20 "422": *a3 "429": *a4 /api/practitioners/{id}/restore: @@ -11015,8 +11108,8 @@ paths: schema: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 - "403": *a6 - "404": *a18 + "403": *a8 + "404": *a20 "422": *a3 "429": *a4 /api/vaccinations: @@ -11059,7 +11152,7 @@ paths: schema: $ref: "#/components/schemas/ListVaccinationsEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 post: @@ -11070,6 +11163,8 @@ paths: antigens — so a single combined dose settles each of the antigens it contains. Satisfaction is forward-only: a dose older than a reminder's last satisfaction is a no-op, which is what makes back-entering an old paper record safe. Honours `Idempotency-Key`. Audits as `vaccination.record.create`." + parameters: + - *a5 requestBody: required: true content: @@ -11158,7 +11253,8 @@ paths: schema: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 - "403": *a6 + "403": *a8 + "409": *a6 "422": *a3 "429": *a4 /api/vaccinations/{id}: @@ -11181,8 +11277,8 @@ paths: schema: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 - "403": *a6 - "404": &a19 + "403": *a8 + "404": &a21 description: Vaccination not found (or owned by another user). content: application/json: @@ -11288,8 +11384,8 @@ paths: schema: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 - "403": *a6 - "404": *a19 + "403": *a8 + "404": *a21 "422": *a3 "429": *a4 delete: @@ -11313,8 +11409,8 @@ paths: schema: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 - "403": *a6 - "404": *a19 + "403": *a8 + "404": *a21 "422": *a3 "429": *a4 /api/vaccinations/{id}/restore: @@ -11338,8 +11434,8 @@ paths: schema: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 - "403": *a6 - "404": *a19 + "403": *a8 + "404": *a21 "422": *a3 "429": *a4 /api/vaccinations/{id}/booster: @@ -11396,8 +11492,8 @@ paths: schema: $ref: "#/components/schemas/MintVaccinationBoosterEnvelope" "401": *a1 - "403": *a6 - "404": *a19 + "403": *a8 + "404": *a21 "422": *a3 "429": *a4 /api/vaccinations/suggest: @@ -11425,7 +11521,7 @@ paths: schema: $ref: "#/components/schemas/VaccinationSuggestEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/vaccinations/{id}/links: @@ -11472,7 +11568,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a19 + "404": *a21 "422": *a3 "429": *a4 delete: @@ -11518,7 +11614,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a19 + "404": *a21 "422": *a3 "429": *a4 /api/illness/episodes: @@ -11551,7 +11647,7 @@ paths: schema: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 post: @@ -11561,6 +11657,8 @@ paths: description: Creates one episode for the caller. `onsetAt` defaults to 'now' when omitted; the optional note is AES-256-GCM encrypted before write. A `parentConditionId` must reference an owned, live episode. Audits as `illness.episode.create`. Born-gated. + parameters: + - *a5 requestBody: required: true content: @@ -11622,7 +11720,8 @@ paths: schema: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 - "403": *a6 + "403": *a8 + "409": *a6 "422": *a3 "429": *a4 /api/illness/episodes/{id}: @@ -11645,8 +11744,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 - "403": *a6 - "404": &a20 + "403": *a8 + "404": &a22 description: Illness episode not found (or owned by another user). content: application/json: @@ -11724,8 +11823,8 @@ paths: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 - "403": *a6 - "404": *a20 + "403": *a8 + "404": *a22 "422": *a3 "429": *a4 delete: @@ -11748,8 +11847,8 @@ paths: schema: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 - "403": *a6 - "404": *a20 + "403": *a8 + "404": *a22 "422": *a3 "429": *a4 /api/illness/episodes/{id}/restore: @@ -11767,6 +11866,7 @@ paths: schema: type: string required: true + - *a5 responses: "200": description: Episode restored. @@ -11775,8 +11875,9 @@ paths: schema: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 - "403": *a6 - "404": *a20 + "403": *a8 + "404": *a22 + "409": *a6 "422": *a3 "429": *a4 /api/illness/episodes/{id}/resolve: @@ -11812,8 +11913,8 @@ paths: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 - "403": *a6 - "404": *a20 + "403": *a8 + "404": *a22 "422": *a3 "429": *a4 /api/illness/episodes/{id}/day-logs: @@ -11863,8 +11964,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 - "403": *a6 - "404": *a20 + "403": *a8 + "404": *a22 "422": *a3 "429": *a4 post: @@ -11943,8 +12044,8 @@ paths: schema: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 - "403": *a6 - "404": *a20 + "403": *a8 + "404": *a22 "422": *a3 "429": *a4 /api/illness/episodes/{id}/correlation: @@ -11971,7 +12072,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a20 + "404": *a22 "422": *a3 "429": *a4 /api/illness/insights: @@ -12044,7 +12145,7 @@ paths: schema: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 post: @@ -12055,6 +12156,8 @@ paths: PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set. + parameters: + - *a5 requestBody: content: application/json: @@ -12111,7 +12214,8 @@ paths: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a6 + "403": *a8 + "409": *a6 "422": *a3 "429": *a4 /api/mental-health/assessments/{id}: @@ -12137,7 +12241,7 @@ paths: schema: $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/allergies: @@ -12170,7 +12274,7 @@ paths: schema: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 post: @@ -12179,6 +12283,8 @@ paths: summary: Record an allergy (v1.25) description: Creates one allergy/intolerance record for the caller. The free-text reaction + note are AES-256-GCM encrypted before write. Audits as `allergy.create`. + parameters: + - *a5 requestBody: required: true content: @@ -12246,7 +12352,8 @@ paths: schema: $ref: "#/components/schemas/CreateAllergyEnvelope" "401": *a1 - "403": *a6 + "403": *a8 + "409": *a6 "422": *a3 "429": *a4 /api/allergies/{id}: @@ -12269,8 +12376,8 @@ paths: schema: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 - "403": *a6 - "404": &a21 + "403": *a8 + "404": &a23 description: Allergy not found (or owned by another user). content: application/json: @@ -12352,8 +12459,8 @@ paths: schema: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 - "403": *a6 - "404": *a21 + "403": *a8 + "404": *a23 "422": *a3 "429": *a4 delete: @@ -12376,8 +12483,8 @@ paths: schema: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 - "403": *a6 - "404": *a21 + "403": *a8 + "404": *a23 "422": *a3 "429": *a4 /api/family-history: @@ -12401,7 +12508,7 @@ paths: schema: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 post: @@ -12410,6 +12517,8 @@ paths: summary: Record a family-history entry (v1.25) description: Creates one condition-by-relative record for the caller. The free-text note is AES-256-GCM encrypted before write. Audits as `family-history.create`. + parameters: + - *a5 requestBody: required: true content: @@ -12461,7 +12570,8 @@ paths: schema: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 - "403": *a6 + "403": *a8 + "409": *a6 "422": *a3 "429": *a4 /api/family-history/{id}: @@ -12484,8 +12594,8 @@ paths: schema: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 - "403": *a6 - "404": &a22 + "403": *a8 + "404": &a24 description: Family history entry not found (or owned by another user). content: application/json: @@ -12553,8 +12663,8 @@ paths: schema: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 - "403": *a6 - "404": *a22 + "403": *a8 + "404": *a24 "422": *a3 "429": *a4 delete: @@ -12577,8 +12687,8 @@ paths: schema: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 - "403": *a6 - "404": *a22 + "403": *a8 + "404": *a24 "422": *a3 "429": *a4 /api/documents/inbound: @@ -12691,7 +12801,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 post: @@ -12710,6 +12820,8 @@ paths: duplicate (same bytes) returns 200 + `meta.duplicate: true` with the existing row — not an error. `Idempotency-Key` honoured. Read `GET /api/documents/inbound/usage` for the effective limits before offering an upload. AI extraction is a separate opt-in action — see `POST /api/documents/inbound/{id}/extract`." + parameters: + - *a5 requestBody: required: true content: @@ -12769,6 +12881,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentEnvelope" "401": *a1 + "409": *a6 "413": description: "Too large: `meta.reason` is `fileTooLarge` (with `maxFileBytes`) or `quotaExceeded` (with `quotaBytes` + `usedBytes`)." @@ -12823,7 +12936,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/documents/inbound/bulk: @@ -12912,7 +13025,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 patch: @@ -13068,7 +13181,7 @@ paths: type: string format: binary "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/documents/inbound/{id}/thumbnail: @@ -13097,7 +13210,7 @@ paths: type: string format: binary "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 /api/documents/inbound/{id}/extract: @@ -13624,6 +13737,7 @@ paths: conditions / medications) and discards rejections. A low-confidence fact that was never edited cannot be approved (fail-closed). Idempotent (Idempotency-Key). Audits as `documents.inbound.confirm`. parameters: + - *a5 - name: id in: path required: true @@ -13665,6 +13779,7 @@ paths: schema: $ref: "#/components/schemas/InboundConfirmEnvelope" "401": *a1 + "409": *a6 "422": *a3 "429": *a4 /api/custom-metrics: @@ -13682,7 +13797,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 post: @@ -13691,6 +13806,8 @@ paths: summary: Define a custom metric description: Creates a user-scoped custom metric. Re-creating a name that was previously soft-deleted revives that definition. Audits as `customMetric.create`. + parameters: + - *a5 requestBody: required: true content: @@ -13731,7 +13848,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a24 + "409": &a26 description: A custom metric with this name already exists for the caller. content: application/json: @@ -13759,8 +13876,8 @@ paths: schema: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 - "403": *a6 - "404": &a23 + "403": *a8 + "404": &a25 description: Custom metric not found (or owned by another user). content: application/json: @@ -13824,8 +13941,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a23 - "409": *a24 + "404": *a25 + "409": *a26 "422": *a3 "429": *a4 delete: @@ -13848,7 +13965,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a23 + "404": *a25 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries: @@ -13891,8 +14008,8 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 - "403": *a6 - "404": *a23 + "403": *a8 + "404": *a25 "422": *a3 "429": *a4 post: @@ -13906,6 +14023,7 @@ paths: schema: type: string required: true + - *a5 requestBody: required: true content: @@ -13936,7 +14054,8 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a23 + "404": *a25 + "409": *a6 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries/{entryId}: @@ -13986,7 +14105,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a25 + "404": &a27 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -14019,7 +14138,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a25 + "404": *a27 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries/restore: @@ -14171,6 +14290,8 @@ paths: | water | HKQuantityTypeIdentifierDietaryWater | ml | | caffeine | HKQuantityTypeIdentifierDietaryCaffeine | mg | + parameters: + - *a5 requestBody: required: true content: @@ -14193,6 +14314,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": *a6 "422": *a3 "429": *a4 /api/nutrients: @@ -14219,7 +14341,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a26 + "403": &a28 description: >- The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is @@ -14274,7 +14396,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a26 + "403": *a28 "422": *a3 "429": *a4 /api/nutrients/water: @@ -14287,6 +14409,8 @@ paths: \"add\"` increments the manual day total (quick-add chips); `mode: \"set\"` overwrites it (the \"edit today's total\" undo path — there is no per-entry ledger). `day` defaults to the caller's current local day when omitted. Idempotency-Key supported; rate limit 60/min. Behind the opt-in `nutrients` module." + parameters: + - *a5 requestBody: required: true content: @@ -14301,7 +14425,8 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a26 + "403": *a28 + "409": *a6 "422": *a3 "429": *a4 /api/integrations/healthkit: @@ -14489,7 +14614,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a7 + "403": *a9 "422": *a3 "429": *a4 /api/daily/digest/dismiss: @@ -14525,7 +14650,7 @@ paths: schema: $ref: "#/components/schemas/DismissPriorityItemEnvelope" "401": *a1 - "403": *a10 + "403": *a12 "422": *a3 "429": *a4 /api/dashboard/widgets: @@ -14543,7 +14668,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayout" "401": *a1 - "403": *a6 + "403": *a8 "422": *a3 "429": *a4 put: @@ -14577,7 +14702,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a8 + "422": *a10 "429": *a4 delete: tags: @@ -14639,7 +14764,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a27 + "403": &a29 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -14664,7 +14789,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a27 + "403": *a29 "422": *a3 "429": *a4 post: @@ -14759,7 +14884,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a27 + "403": *a29 "404": description: No such invitation for this account. content: @@ -14803,7 +14928,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a27 + "403": *a29 "404": description: No such grant on this account's record. content: @@ -14844,7 +14969,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a27 + "403": *a29 "404": description: No such grant held by this account. content: @@ -15143,7 +15268,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a27 + "403": *a29 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — diff --git a/src/__tests__/idempotency-contract-publication.test.ts b/src/__tests__/idempotency-contract-publication.test.ts new file mode 100644 index 000000000..eb5d93a45 --- /dev/null +++ b/src/__tests__/idempotency-contract-publication.test.ts @@ -0,0 +1,185 @@ +/** + * Every idempotent route says so in the contract, and only those do. + * + * `withIdempotency` has wrapped writes since v1.5.x. `Idempotency-Key` appeared + * in a dozen route comments and in no published schema, so an app generated + * against `docs/api/openapi.yaml` could not tell that a retry was safe — which + * is exactly what the iOS side asked for. Publishing it once is easy; keeping + * it published as routes come and go is what this guard is for. + * + * Both directions matter, and for different reasons: + * + * - A route that gains `withIdempotency` and forgets to publish it leaves the + * capability invisible, which is today's defect repeating itself. + * - A route that publishes the parameter and drops the wrapper is worse: the + * contract then promises a retry is safe when it is not, and a client that + * believes it writes twice. + * + * The wrapper is the source of truth here, not this file. Nothing is + * hand-listed: the expected set is read out of the route tree on every run, so + * the guard cannot fall behind the code it watches. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { buildOpenApiDocument } from "@/lib/openapi/registry"; + +const API_ROOT = join(process.cwd(), "src", "app", "api"); + +/** Every `route.ts` under `src/app/api`, tests excluded. */ +function routeFiles(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(...routeFiles(full)); + continue; + } + if (entry.name === "route.ts") found.push(full); + } + return found; +} + +/** `src/app/api/medications/[id]/intake/route.ts` -> `/api/medications/{id}/intake` */ +function toOpenApiPath(file: string): string { + const rel = relative(API_ROOT, file).split(sep).slice(0, -1); + const segments = rel.map((s) => + s.startsWith("[") && s.endsWith("]") ? `{${s.slice(1, -1)}}` : s, + ); + return `/api/${segments.join("/")}`; +} + +/** + * Method-level, not path-level, and the difference is the whole point. + * + * `/api/measurements` exports GET, POST, PUT and DELETE, and only POST is + * wrapped: + * + * export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement)); + * export const PUT = apiHandler(putMeasurement); + * + * A path-level check would go green on that path the moment ANY operation + * declared the header, and publishing it on PUT as well would promise a safe + * retry the server does not honour. So the unit here is `POST /api/x`, and the + * matcher requires the wrapper in the same assignment as the export. + */ +function idempotentOperationsFromSource(): Set { + const found = new Set(); + // `export const POST = ...withIdempotency...` up to the end of that + // statement. Non-greedy to the next top-level `export const`, so a wrapped + // POST cannot lend its wrapper to the PUT declared below it. + const pattern = + /export\s+const\s+(POST|PUT|PATCH|DELETE)\s*=\s*([\s\S]*?)(?=\n(?:export\s+const|export\s+async\s+function|$))/g; + for (const file of routeFiles(API_ROOT)) { + const source = readFileSync(file, "utf8"); + if (!source.includes("withIdempotency")) continue; + for (const match of source.matchAll(pattern)) { + if (!/\bwithIdempotency\s*[(<]/.test(match[2])) continue; + found.add(`${match[1].toLowerCase()} ${toOpenApiPath(file)}`); + } + } + return found; +} + +/** Operations in the emitted document that declare the `Idempotency-Key`. */ +function idempotentOperationsFromContract(): Set { + const doc = buildOpenApiDocument() as { + paths?: Record>; + }; + const found = new Set(); + for (const [path, item] of Object.entries(doc.paths ?? {})) { + for (const [method, operation] of Object.entries(item)) { + const parameters = (operation as { parameters?: unknown })?.parameters; + if (!Array.isArray(parameters)) continue; + const declares = parameters.some( + (p) => (p as { name?: string })?.name === "Idempotency-Key", + ); + if (declares) found.add(`${method.toLowerCase()} ${path}`); + } + } + return found; +} + +/** + * Idempotent operations that cannot be published, because the PATH is not in + * the document at all. A header cannot hang on a path that does not exist. + * + * Two entries, and the list was four until the test below refused it. The first + * draft claimed the whole `/api/medications` family was missing, on the + * strength of a grep for path literals in `src/lib/openapi/routes/` that came + * back empty. It came back empty because those paths are not written as + * searchable literals there — the emitted document carries 19 of them, + * including every one the iOS client actually argues about. Measure the + * artefact, not the source that builds it. + * + * Listed rather than filtered silently, so the exclusion stays visible in the + * file that would otherwise claim full coverage. + */ +const UNPUBLISHED_PATHS: Readonly> = { + "/api/insights/feedback": + "Coach feedback write; never added to the route table. A real gap, small.", + "/api/admin/backups/{id}/restore": + "Admin surface: cookie-only by construction, so arguably out of scope for a client contract.", +}; + +describe("idempotency — the contract matches the wrapper", () => { + it("finds idempotent operations in the source tree at all", () => { + // Without this the two comparisons below could both pass on empty sets, + // which is the failure mode this whole file exists to prevent elsewhere. + const fromSource = idempotentOperationsFromSource(); + expect(fromSource.size).toBeGreaterThan(20); + }); + + it("publishes the header on every route that wraps withIdempotency", () => { + const fromSource = idempotentOperationsFromSource(); + const fromContract = idempotentOperationsFromContract(); + + const unpublished = [...fromSource] + .filter((p) => !fromContract.has(p)) + .filter((p) => !(p.split(" ")[1] in UNPUBLISHED_PATHS)) + .sort(); + + expect( + unpublished, + `These routes accept an Idempotency-Key and do not say so in the ` + + `contract, so a generated client cannot know a retry is safe. Add ` + + `\`idempotencyKeyParameter\` to the write operation and ` + + `\`...idempotentWrite()\` to its responses.`, + ).toEqual([]); + }); + + it("keeps the unpublished list honest — every entry is still absent", () => { + const doc = buildOpenApiDocument() as { paths?: Record }; + const present = Object.keys(doc.paths ?? {}); + + const nowPublished = Object.keys(UNPUBLISHED_PATHS) + .filter((p) => present.includes(p)) + .sort(); + + expect( + nowPublished, + `These paths are in the contract now, so their exemption above is stale. ` + + `Delete the entry and add \`idempotencyKeyParameter\` to the operation ` + + `— an exemption nobody removes is how a gap becomes permanent.`, + ).toEqual([]); + }); + + it("does not promise idempotency on a route that dropped the wrapper", () => { + const fromSource = idempotentOperationsFromSource(); + const fromContract = idempotentOperationsFromContract(); + + const overclaimed = [...fromContract] + .filter((p) => !fromSource.has(p)) + .sort(); + + expect( + overclaimed, + `The contract offers an Idempotency-Key on these paths and no handler ` + + `honours it. A client that trusts this writes twice on a retry — the ` + + `more dangerous of the two directions.`, + ).toEqual([]); + }); +}); diff --git a/src/lib/openapi/routes/allergies.ts b/src/lib/openapi/routes/allergies.ts index 86a303d39..2be4d0a16 100644 --- a/src/lib/openapi/routes/allergies.ts +++ b/src/lib/openapi/routes/allergies.ts @@ -27,6 +27,8 @@ import { import { dataEnvelope, errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, recordRefusal, stdResponses, } from "./shared"; @@ -102,11 +104,13 @@ export const allergyPaths: NonNullable = { summary: "Record an allergy (v1.25)", description: "Creates one allergy/intolerance record for the caller. The free-text reaction + note are AES-256-GCM encrypted before write. Audits as `allergy.create`.", + parameters: [idempotencyKeyParameter], requestBody: { required: true, content: { "application/json": { schema: allergyCreateSchema } }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: "Allergy created.", diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index b2b7ef8ef..ff9a04202 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -22,6 +22,8 @@ import { import { dataEnvelope, errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, recordRefusal, stdResponses, } from "./shared"; @@ -101,11 +103,13 @@ export const biomarkerPaths: NonNullable = { summary: "Define a biomarker", description: "Creates a user-scoped biomarker. The optional context note is AES-256-GCM encrypted before write. Audits as `biomarker.create`.", + parameters: [idempotencyKeyParameter], requestBody: { required: true, content: { "application/json": { schema: createBiomarkerSchema } }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: "Created biomarker.", diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index 9e0336e3e..ba397af86 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -50,6 +50,8 @@ import { conflictResponse409, dataEnvelope, errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, invalidBaseTokenResponse, recordRefusal, stdResponses, @@ -1285,6 +1287,7 @@ export const coachPaths: NonNullable = { summary: "Create an anamnesis fact", description: "Creates the first current revision for one closed fact kind. The answer is encrypted before persistence. A second current value for the same kind returns 409; corrections use the revision endpoint.", + parameters: [idempotencyKeyParameter], requestBody: { required: true, content: { @@ -1292,6 +1295,7 @@ export const coachPaths: NonNullable = { }, }, responses: { + ...idempotentWrite(), "201": { description: "The newly created current revision.", content: { diff --git a/src/lib/openapi/routes/custom-metrics.ts b/src/lib/openapi/routes/custom-metrics.ts index a150d98f8..d24a113dd 100644 --- a/src/lib/openapi/routes/custom-metrics.ts +++ b/src/lib/openapi/routes/custom-metrics.ts @@ -24,6 +24,8 @@ import { import { dataEnvelope, errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, recordRefusal, stdResponses, } from "./shared"; @@ -159,11 +161,13 @@ export const customMetricPaths: NonNullable = { summary: "Define a custom metric", description: "Creates a user-scoped custom metric. Re-creating a name that was previously soft-deleted revives that definition. Audits as `customMetric.create`.", + parameters: [idempotencyKeyParameter], requestBody: { required: true, content: { "application/json": { schema: createCustomMetricSchema } }, }, responses: { + ...idempotentWrite(), "201": { description: "Created custom metric.", content: { @@ -288,6 +292,7 @@ export const customMetricPaths: NonNullable = { description: "Records a value against the custom metric, snapshotting the metric's unit. Audits as `customMetricEntry.create`.", requestParams: { path: z.object({ id: z.string() }) }, + parameters: [idempotencyKeyParameter], requestBody: { required: true, content: { @@ -295,6 +300,7 @@ export const customMetricPaths: NonNullable = { }, }, responses: { + ...idempotentWrite(), "201": { description: "Created value.", content: { diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index cb80b6e62..4f4956460 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -27,6 +27,8 @@ import { import { dataEnvelope, errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, recordRefusal, stdResponses, } from "./shared"; @@ -461,11 +463,13 @@ export const cyclePaths: NonNullable = { summary: "Capture a single cycle day-log (v1.15.0)", description: "Upserts on `(userId, source, externalId)` when externalId present, else `(userId, date)`. `note` encrypts at rest. 201 on insert, 200 on update. Gated: `cycle.disabled` 403 when the feature is off.", + parameters: [idempotencyKeyParameter], requestBody: { required: true, content: { "application/json": { schema: cycleDayLogInputSchema } }, }, responses: { + ...idempotentWrite(), "200": { description: "Existing day-log updated.", content: { @@ -541,11 +545,13 @@ export const cyclePaths: NonNullable = { summary: "Bulk drain cycle day-logs (Outbox / HealthKit) (v1.15.0)", description: "Up to 500 entries; `withIdempotency`; rate-limited `cycle:day-logs:bulk:` 60/min. Per-entry status: inserted | duplicate | updated | skipped. Always 200.", + parameters: [idempotencyKeyParameter], requestBody: { required: true, content: { "application/json": { schema: cycleBulkSchema } }, }, responses: { + ...idempotentWrite(), "200": { description: "Batch processed (per-entry results).", content: { @@ -565,11 +571,13 @@ export const cyclePaths: NonNullable = { summary: "Period-boundary shortcut (v1.15.0)", description: "One-tap started/ended period. `start` opens a new cycle (closing the prior); `end` stamps periodEndDate. Writes the boundary day-log. Gated.", + parameters: [idempotencyKeyParameter], requestBody: { required: true, content: { "application/json": { schema: cyclePeriodSchema } }, }, responses: { + ...idempotentWrite(), "200": { description: "Cycle + boundary day-log.", content: { diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index f7337a7a2..379f7a57f 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -31,6 +31,8 @@ import { import { dataEnvelope, errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, recordRefusal, stdResponses, } from "./shared"; @@ -359,6 +361,7 @@ export const inboundDocumentPaths: NonNullable = { }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Documents"], summary: "Store a document (no extraction)", description: @@ -397,6 +400,7 @@ export const inboundDocumentPaths: NonNullable = { }, }, responses: { + ...idempotentWrite(), "200": { description: "Duplicate — the same bytes are already stored: the existing row, with `meta.duplicate: true` at the envelope level.", @@ -1096,6 +1100,7 @@ export const inboundDocumentPaths: NonNullable = { description: "The ONLY write path out of staging. Commits the user's approvals into the structured stores (labs / conditions / medications) and discards rejections. A low-confidence fact that was never edited cannot be approved (fail-closed). Idempotent (Idempotency-Key). Audits as `documents.inbound.confirm`.", parameters: [ + idempotencyKeyParameter, { name: "id", in: "path", @@ -1108,6 +1113,7 @@ export const inboundDocumentPaths: NonNullable = { content: { "application/json": { schema: inboundConfirmSchema } }, }, responses: { + ...idempotentWrite(), "200": { description: "Per-decision outcome.", content: { diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index 395f28a29..07df2104f 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -35,6 +35,8 @@ import { import { dataEnvelope, errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, recordRefusal, stdResponses, } from "./shared"; @@ -246,6 +248,7 @@ export const encounterPaths: NonNullable = { }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Records"], summary: "File or book a visit", description: @@ -255,6 +258,7 @@ export const encounterPaths: NonNullable = { content: { "application/json": { schema: encounterCreateSchema } }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: "Visit filed.", @@ -467,6 +471,7 @@ export const encounterPaths: NonNullable = { }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Records"], summary: "Add a practitioner", description: @@ -476,6 +481,7 @@ export const encounterPaths: NonNullable = { content: { "application/json": { schema: practitionerCreateSchema } }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: "Practitioner added.", diff --git a/src/lib/openapi/routes/family-history.ts b/src/lib/openapi/routes/family-history.ts index 79b6624e0..5f71484f7 100644 --- a/src/lib/openapi/routes/family-history.ts +++ b/src/lib/openapi/routes/family-history.ts @@ -24,6 +24,8 @@ import { import { dataEnvelope, errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, recordRefusal, stdResponses, } from "./shared"; @@ -98,6 +100,7 @@ export const familyHistoryPaths: NonNullable = { summary: "Record a family-history entry (v1.25)", description: "Creates one condition-by-relative record for the caller. The free-text note is AES-256-GCM encrypted before write. Audits as `family-history.create`.", + parameters: [idempotencyKeyParameter], requestBody: { required: true, content: { @@ -105,6 +108,7 @@ export const familyHistoryPaths: NonNullable = { }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: "Entry created.", diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 289a9961e..9b51d6f9d 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -32,6 +32,8 @@ import { import { dataEnvelope, errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, recordRefusal, stdResponses, } from "./shared"; @@ -318,6 +320,7 @@ export const illnessPaths: NonNullable = { summary: "Open an illness episode (v1.18.1)", description: "Creates one episode for the caller. `onsetAt` defaults to 'now' when omitted; the optional note is AES-256-GCM encrypted before write. A `parentConditionId` must reference an owned, live episode. Audits as `illness.episode.create`. Born-gated.", + parameters: [idempotencyKeyParameter], requestBody: { required: true, content: { @@ -325,6 +328,7 @@ export const illnessPaths: NonNullable = { }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: "Episode created.", @@ -422,7 +426,9 @@ export const illnessPaths: NonNullable = { description: "Clears `deletedAt`, bringing the episode and its preserved day-logs + encrypted note back into every read. A soft-delete keeps the row and children intact, so restore is a pure flag flip — nothing is re-created (the delete-Undo affordance). Idempotent — restoring a live episode is a no-op. Audits as `illness.episode.restore`. Owner-scoped + born-gated.", requestParams: { path: z.object({ id: z.string() }) }, + parameters: [idempotencyKeyParameter], responses: { + ...idempotentWrite(), ...recordRefusal(), "200": { description: "Episode restored.", diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index 63f351f62..bf0aabe21 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -19,6 +19,8 @@ import { import { dataEnvelope, errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, recordRefusal, stdResponses, } from "./shared"; @@ -139,11 +141,13 @@ export const labsPaths: NonNullable = { summary: "Record a lab result", description: 'Creates a single lab result for the caller. The optional `source` marks provenance (`"MANUAL"` | `"OCR"`); it defaults to `"MANUAL"` when omitted, so the legacy hand-entry contract is unchanged. The on-device-OCR client posts `"OCR"` after the human confirms the parsed values — the raw image never touches the server on this path. The optional note is AES-256-GCM encrypted before write. Audits as `labResult.create`.', + parameters: [idempotencyKeyParameter], requestBody: { required: true, content: { "application/json": { schema: createLabResultSchema } }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: "Created lab result.", @@ -232,6 +236,7 @@ export const labsPaths: NonNullable = { summary: "Restore soft-deleted lab results", description: "Un-tombstones 1..200 of the caller's soft-deleted lab results in one pass (the delete-Undo affordance). A foreign / live id is a silent no-op. Audits via the wide-event ledger.", + parameters: [idempotencyKeyParameter], requestBody: { required: true, content: { @@ -243,6 +248,7 @@ export const labsPaths: NonNullable = { }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "200": { description: "Restore count.", diff --git a/src/lib/openapi/routes/measurement-reminders.ts b/src/lib/openapi/routes/measurement-reminders.ts index f4eb4a2b4..7173ee162 100644 --- a/src/lib/openapi/routes/measurement-reminders.ts +++ b/src/lib/openapi/routes/measurement-reminders.ts @@ -21,6 +21,8 @@ import { import { dataEnvelope, errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, recordRefusal, stdResponses, } from "./shared"; @@ -61,6 +63,7 @@ export const measurementReminderPaths: NonNullable = summary: "Create a Vorsorge reminder (v1.17.1)", description: "Creates a reminder and computes its server-authoritative nextDueAt. Exactly one of intervalDays (rolling) or rrule (RFC-5545) is required. Wrapped in withIdempotency. 201 on insert.", + parameters: [idempotencyKeyParameter], requestBody: { required: true, content: { @@ -68,6 +71,7 @@ export const measurementReminderPaths: NonNullable = }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: "Reminder created.", diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 8224c9dbc..e59584feb 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -20,6 +20,8 @@ import { MODULE_DISABLED_DESCRIPTION, dataEnvelope, errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, recordRefusal, stdResponses, } from "./shared"; @@ -764,6 +766,7 @@ export const measurementPaths: NonNullable = { summary: "Create one measurement (or a small array)", description: "Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a combined blood-pressure + pulse or dual-value glucose write — in which case `data` is the array of created rows in request order. Use `/api/measurements/batch` for Apple Health upload streams.", + parameters: [idempotencyKeyParameter], requestBody: { required: true, content: { @@ -776,6 +779,7 @@ export const measurementPaths: NonNullable = { }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: @@ -889,6 +893,7 @@ export const measurementPaths: NonNullable = { }, "/api/measurements/batch": { post: { + parameters: [idempotencyKeyParameter], tags: ["Measurements"], summary: "Apple Health batch ingest", description: @@ -898,6 +903,7 @@ export const measurementPaths: NonNullable = { content: { "application/json": { schema: batchPayloadSchema } }, }, responses: { + ...idempotentWrite(), "200": { description: "Batch processed.", content: { @@ -940,6 +946,7 @@ export const measurementPaths: NonNullable = { }, "/api/measurements/bulk-delete": { post: { + parameters: [idempotencyKeyParameter], tags: ["Measurements"], summary: "Bulk soft-delete measurements", description: @@ -951,6 +958,7 @@ export const measurementPaths: NonNullable = { }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "200": { description: "Bulk delete processed.", @@ -969,6 +977,7 @@ export const measurementPaths: NonNullable = { }, "/api/measurements/restore": { post: { + parameters: [idempotencyKeyParameter], tags: ["Measurements"], summary: "Restore soft-deleted measurements", description: @@ -980,6 +989,7 @@ export const measurementPaths: NonNullable = { }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "200": { description: "Restore processed.", diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index c33f8316b..a6fc63ef5 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -27,6 +27,8 @@ import { conflictResponse409, dataEnvelope, errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, invalidBaseTokenResponse, recordRefusal, stdResponses, @@ -171,6 +173,7 @@ const medicationIntakeImportJobStatusEnvelope = dataEnvelope( export const medicationPaths: NonNullable = { "/api/medications/intake/bulk": { post: { + parameters: [idempotencyKeyParameter], tags: ["Medications"], summary: "Bulk medication-intake backfill (iOS SyncMode)", description: @@ -180,6 +183,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: bulkIntakePayload } }, }, responses: { + ...idempotentWrite(), "200": { description: "Batch processed (always 200 on a well-formed body).", content: { @@ -410,6 +414,7 @@ export const medicationPaths: NonNullable = { }, "/api/medications/{id}/intake": { post: { + parameters: [idempotencyKeyParameter], tags: ["Medications"], summary: "Log an intake event for a medication", description: @@ -422,6 +427,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: intakeSchema } }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: "Intake event created.", diff --git a/src/lib/openapi/routes/mental-health.ts b/src/lib/openapi/routes/mental-health.ts index 565cf19f7..d170d9294 100644 --- a/src/lib/openapi/routes/mental-health.ts +++ b/src/lib/openapi/routes/mental-health.ts @@ -19,7 +19,13 @@ import { listAssessmentsSchema, } from "@/lib/validations/mental-health"; -import { dataEnvelope, recordRefusal, stdResponses } from "./shared"; +import { + dataEnvelope, + idempotencyKeyParameter, + idempotentWrite, + recordRefusal, + stdResponses, +} from "./shared"; createAssessmentSchema.meta({ id: "CreateMentalHealthAssessmentRequest", @@ -127,12 +133,14 @@ export const mentalHealthPaths: NonNullable = { summary: "Record a completed screener", description: "Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.", + parameters: [idempotencyKeyParameter], requestBody: { content: { "application/json": { schema: createAssessmentSchema }, }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: "Assessment recorded.", diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index ffebfc628..a557f7c15 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -46,15 +46,17 @@ import { WORK_STATUS_KEYS, } from "@/lib/mood/context-vocabulary"; import { - dataEnvelope, - errorEnvelope, - recordRefusal, - stdResponses, MODULE_DISABLED_DESCRIPTION, baseUpdatedAtField, - updatedAtTokenField, conflictResponse409, + dataEnvelope, + errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, invalidBaseTokenResponse, + recordRefusal, + stdResponses, + updatedAtTokenField, } from "./shared"; // ── Request schemas — annotated for spec emission ──────────────────── @@ -762,6 +764,7 @@ export const moodPaths: NonNullable = { }, "/api/mood-entries/bulk": { post: { + parameters: [idempotencyKeyParameter], tags: ["Mood"], summary: "Bulk mood backfill (iOS SyncMode)", description: @@ -771,6 +774,7 @@ export const moodPaths: NonNullable = { content: { "application/json": { schema: bulkMoodPayload } }, }, responses: { + ...idempotentWrite(), "200": { description: "Batch processed (always 200 on a well-formed body).", content: { @@ -1080,6 +1084,7 @@ export const moodPaths: NonNullable = { }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Mood"], summary: "Create a mood entry", description: @@ -1089,6 +1094,7 @@ export const moodPaths: NonNullable = { content: { "application/json": { schema: createMoodEntryRequest } }, }, responses: { + ...idempotentWrite(), "201": { description: "Entry created (or upserted in place via `externalId`), with its persisted tag keys, rated factors and context.", @@ -1165,6 +1171,7 @@ export const moodPaths: NonNullable = { }, "/api/mood-entries/restore": { post: { + parameters: [idempotencyKeyParameter], tags: ["Mood"], summary: "Restore soft-deleted mood entries", description: @@ -1174,6 +1181,7 @@ export const moodPaths: NonNullable = { content: { "application/json": { schema: moodEntryIdsPayload } }, }, responses: { + ...idempotentWrite(), "200": { description: "`restored` counts the rows actually un-tombstoned.", content: { @@ -1192,6 +1200,7 @@ export const moodPaths: NonNullable = { }, "/api/mood-entries/bulk-delete": { post: { + parameters: [idempotencyKeyParameter], tags: ["Mood"], summary: "Soft-delete mood entries in bulk", description: @@ -1201,6 +1210,7 @@ export const moodPaths: NonNullable = { content: { "application/json": { schema: moodEntryIdsPayload } }, }, responses: { + ...idempotentWrite(), "200": { description: "`deleted` counts the rows actually tombstoned.", content: { diff --git a/src/lib/openapi/routes/nutrients.ts b/src/lib/openapi/routes/nutrients.ts index 5cb358e17..99f170029 100644 --- a/src/lib/openapi/routes/nutrients.ts +++ b/src/lib/openapi/routes/nutrients.ts @@ -24,6 +24,8 @@ import { import { dataEnvelope, errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, recordRefusal, stdResponses, } from "./shared"; @@ -50,6 +52,7 @@ const catalogTable = NUTRIENT_DEFINITIONS.map( export const nutrientPaths: NonNullable = { "/api/nutrients/batch": { post: { + parameters: [idempotencyKeyParameter], tags: ["Nutrients"], summary: "Ingest micronutrient day totals (v1.28)", description: @@ -60,6 +63,7 @@ export const nutrientPaths: NonNullable = { content: { "application/json": { schema: nutrientBatchSchema } }, }, responses: { + ...idempotentWrite(), "200": { description: "Batch processed; per-entry statuses inserted | updated | skipped.", @@ -127,6 +131,7 @@ export const nutrientPaths: NonNullable = { }, "/api/nutrients/water": { post: { + parameters: [idempotencyKeyParameter], tags: ["Nutrients"], summary: "Manual water quick-add (v1.29)", description: @@ -138,6 +143,7 @@ export const nutrientPaths: NonNullable = { }, }, responses: { + ...idempotentWrite(), "200": { description: "The MANUAL water row after the write.", content: { diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index 526d2569f..446dfd64f 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -17,7 +17,12 @@ import { z } from "zod/v4"; import { ocrCommitSchema } from "@/lib/validations/labs-ocr"; -import { dataEnvelope, stdResponses } from "./shared"; +import { + dataEnvelope, + idempotencyKeyParameter, + idempotentWrite, + stdResponses, +} from "./shared"; ocrCommitSchema.meta({ id: "OcrCommitRequest", @@ -197,6 +202,7 @@ export const ocrPaths: NonNullable = { }, "/api/labs/ocr/commit": { post: { + parameters: [idempotencyKeyParameter], tags: ["Labs"], summary: "Commit confirmed Lab-OCR rows", description: @@ -206,6 +212,7 @@ export const ocrPaths: NonNullable = { content: { "application/json": { schema: ocrCommitSchema } }, }, responses: { + ...idempotentWrite(), "200": { description: "Inserted + skipped rows.", content: { diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 3c5530c43..0747b8ae1 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -177,6 +177,99 @@ export const stdResponses = { }, }; +// ── Idempotent writes ──────────────────────────────────────────────── + +/** + * What a route wrapped in `withIdempotency` accepts and answers. + * + * The mechanism has worked since long before it was published: send + * `Idempotency-Key` on a write, and a retry of the same key replays the first + * response instead of doing the work twice. It was described in route comments + * and nowhere a client could read it, so an app generated against the contract + * had no way to know a retry was safe. This publishes it. + * + * Three things a client cannot infer and has to be told: + * + * 1. **A malformed key is ignored, not refused.** `getIdempotencyKey` tests + * `^[A-Za-z0-9_\-:.]{8,128}$` and returns null on a miss, so a key that is + * too short or carries a stray character is indistinguishable from sending + * none at all — the write proceeds, unprotected, with a 200. This is the + * one failure here that is silent, which is why it leads. + * 2. **The replay is marked.** A response served from the cache carries + * `X-Idempotent-Replay: true`. Without reading it a client cannot tell a + * fresh 201 from a replayed one, which matters when the body reports what + * was created. + * 3. **A concurrent duplicate is refused, not queued.** While the first + * request is still running, a second one under the same key gets 409 with + * `X-Idempotent-Replay: false` — the header is present in both directions + * precisely so the two cases are told apart. Retry after a delay. + * + * Scope, so nobody reads more into it than is there: only POST, PUT, PATCH and + * DELETE are covered (`SUPPORTED_METHODS`), the key is scoped per user and per + * `(method, path)`, and `isCachableStatus` refuses to cache 5xx or the + * transient 401 / 403 / 408 / 429 — a retry after one of those runs for real. + * + * `src/__tests__/idempotency-contract-publication.test.ts` holds this to the + * routes that actually wrap `withIdempotency`, in both directions, so a new + * idempotent route that forgets to say so fails, and so does a claim here for + * a route that dropped the wrapper. + */ +export const idempotencyKeyParameter = { + name: "Idempotency-Key", + in: "header" as const, + required: false, + schema: { + type: "string" as const, + pattern: "^[A-Za-z0-9_\\-:.]{8,128}$", + minLength: 8, + maxLength: 128, + }, + description: + "Makes this write safe to retry. Send the same key again and the first response is replayed rather than the work repeated, marked with `X-Idempotent-Replay: true`. A key that does not match the pattern is IGNORED rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different route is a different key. While a first request under this key is still in flight, a second is refused with 409 rather than queued.", +}; + +/** + * Response headers are Zod here, not an OpenAPI literal — `zod-openapi` types + * `headers` as a `ZodObject` and emits the schema from it. Writing the literal + * shape by hand typechecks nowhere and was the first thing this file got wrong. + */ +const idempotentReplayHeaders = z.object({ + "X-Idempotent-Replay": z.enum(["true", "false"]).meta({ + description: + "`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that refuses a duplicate still in flight. Absent when the request carried no usable key.", + }), +}); + +interface IdempotentConflictResponse { + "409": { + description: string; + content: { "application/json": { schema: typeof errorEnvelope } }; + headers: typeof idempotentReplayHeaders; + }; +} + +/** + * The 409 an idempotent route answers while an earlier request under the same + * key is still running, plus the replay header on the way back. + * + * Cached by identity like {@link recordRefusal} above, for the same reason: a + * fresh object per call site would print this paragraph thirty-seven times. + */ +let idempotentConflictCache: IdempotentConflictResponse | null = null; + +export function idempotentWrite(): IdempotentConflictResponse { + if (idempotentConflictCache) return idempotentConflictCache; + idempotentConflictCache = { + "409": { + description: + "A request with this `Idempotency-Key` is already in progress. Nothing was written by this call. Retry after a short delay; the response carries `X-Idempotent-Replay: false` to separate it from a replay.", + content: { "application/json": { schema: errorEnvelope } }, + headers: idempotentReplayHeaders, + }, + }; + return idempotentConflictCache; +} + // ── The delegated-record refusal (v1.37.0) ─────────────────────────── /** diff --git a/src/lib/openapi/routes/vaccinations.ts b/src/lib/openapi/routes/vaccinations.ts index 0017741fc..6e60f1643 100644 --- a/src/lib/openapi/routes/vaccinations.ts +++ b/src/lib/openapi/routes/vaccinations.ts @@ -35,6 +35,8 @@ import { measurementReminderDto } from "@/lib/validations/measurement-reminders" import { dataEnvelope, errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, recordRefusal, stdResponses, } from "./shared"; @@ -237,6 +239,7 @@ export const vaccinationPaths: NonNullable = { }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Records"], summary: "Log a vaccination", description: @@ -246,6 +249,7 @@ export const vaccinationPaths: NonNullable = { content: { "application/json": { schema: vaccinationCreateSchema } }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: "Dose logged.", diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index dd5cf5477..99e80aea4 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -13,6 +13,8 @@ import { MODULE_DISABLED_DESCRIPTION, dataEnvelope, errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, moduleDisabledResponse, recordRefusal, stdResponses, @@ -309,6 +311,7 @@ export const workoutPaths: NonNullable = { }, "/api/workouts/batch": { post: { + parameters: [idempotencyKeyParameter], tags: ["Measurements"], summary: "Typed workout batch ingest (v1.4.25 W16b)", description: @@ -318,6 +321,7 @@ export const workoutPaths: NonNullable = { content: { "application/json": { schema: createBatchWorkoutSchema } }, }, responses: { + ...idempotentWrite(), "200": { description: "Batch processed.", content: {