Answer the native client's open server asks

- Publish timezone, glucoseUnit and a features block on the account
  payload; glucoseUnit reads as the closed pair the contract names, and
  features.trackIntake tells a separately released client that
  medications take the record-only switch.
- Refuse a new dose for a record-only medication with
  medication.intake.notTracked on every dose-writing route; the bulk
  route reports it per entry as intake_not_tracked, and correcting an
  already resolved event stays open.
- Answer passkey sign-in failures by name instead of a 500:
  passkey.challenge.expired, passkey.unknown, passkey.response.invalid
  and passkey.verification.failed, each with an audit row.
- Carry one account-wide adherence figure in meta.aggregate on the
  batched compliance read, weighted by expected doses, and accept
  ?days=30|60|90|180|365 for a report window.
- Accept ?days=30|90|180|365 on the mood insights read so stability
  covers the period shown, and name the window it covers.
- Keep only the reminder that announces a dose off APNs when the phone
  owns medication reminders; follow-ups for a dose still open are
  pushed again, marked by a followUp flag the reminder cron sets.
- Document which batch validation issues belong to an entry and pin it
  with a test that splits and resends a batch; publish rejectedFields
  items as ValidationIssue.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 10:32:11 +02:00
1 parent 7bcb6f71d9
commit bb563b6506
50 files changed
+1789 -142

No files matched your search

+10 -1
View File
@@ -248,7 +248,10 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"measurement.restore.invalid",
"measurement.update.server_owned_source",
],
medication: ["medication.intake.import.invalid_format"],
medication: [
"medication.intake.import.invalid_format",
"medication.intake.notTracked",
],
medications: [
"medications.category.invalid",
"medications.category.limitReached",
@@ -306,6 +309,12 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"onboarding.restart.rateLimited",
"onboarding.tour.invalid",
],
passkey: [
"passkey.challenge.expired",
"passkey.response.invalid",
"passkey.unknown",
"passkey.verification.failed",
],
personal_records: ["personal_records.invalid_query"],
practitioner: ["practitioner.invalid"],
profile: [
+24
View File
@@ -501,6 +501,30 @@ const accountPayload = z
.looseObject({
id: z.string(),
username: z.string(),
// Enumerated because clients cut days and convert glucose by them: the
// day key and the unit must be the server's, not the device's.
timezone: z
.string()
.describe(
"The IANA zone the server cuts this account's days in: the stored zone, or the instance default when the stored one is unusable. Resolved, never the raw column, so bucket day keys by this value.",
),
glucoseUnit: z
.enum(["mg/dL", "mmol/L"])
.nullable()
.describe(
"The account's chosen glucose display unit, as stored. `null` means the person never chose one (or the stored value is neither unit), and the server then shows glucose in mg/dL.",
),
features: z
.object({
trackIntake: z
.literal(true)
.describe(
"Medications take `trackIntake`: a medication can be kept as a record whose doses are never due, and a dose written against one is refused with `medication.intake.notTracked`.",
),
})
.describe(
"What this server understands, for a client released apart from it. Constant across accounts; a flag appears once the server supports the feature and stays `true`. Absent on servers older than v1.42.0. A client that meets a flag it does not know ignores it.",
),
accountAccess: accountAccessBlock,
moduleAccess: moduleAccessMap,
// v1.39 — enumerated because every AI surface decides on it: a client
+13 -8
View File
@@ -47,6 +47,7 @@ import {
errorEnvelope,
loginPasswordSchema,
notImplementedResponse,
validationIssue,
} from "./shared";
import { passkeyLoginOptionsSchema } from "@/lib/validations/auth";
@@ -633,13 +634,7 @@ const authProfileUpdateResponse = z
"Whether an insurance number is stored. The number itself is encrypted at rest and is never returned — only its presence.",
),
rejectedFields: z
.array(
z.object({
path: z.string(),
code: z.string(),
message: z.string(),
}),
)
.array(validationIssue)
.optional()
.describe(
"Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over.",
@@ -1446,10 +1441,20 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"404": {
description:
"The assertion resolved to a user row that no longer exists.",
"`meta.errorCode` = `passkey.unknown` (v1.42.0): no passkey with this credential id is registered on this server — it was removed here, or belongs to another server. Tell the person to sign in another way. Without a code: the assertion resolved to a user row that no longer exists.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
"401": {
description:
"The sign-in did not prove anything (v1.42.0 codes; before, the first case answered 500). `meta.errorCode` = `passkey.challenge.expired` when the challenge is gone — it expired after five minutes, was already used, or was begun for a different account — so start a new sign-in; `passkey.verification.failed` when the assertion was checked and refused (signature, origin, relying-party id or challenge mismatch).",
content: { "application/json": { schema: errorEnvelope } },
},
"422": {
description:
"`challengeId` or `credential` is missing, or (`meta.errorCode` = `passkey.response.invalid`, v1.42.0) `credential` is not a WebAuthn authentication response.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
+1 -1
View File
@@ -87,7 +87,7 @@ export const ingestPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"422": {
description:
"Validation failed, with every issue listed rather than the first — an integration operator should see the whole shape at once. An empty or unstable `idempotencyKey` fails here: the column is unique table-wide, so an empty key would resolve to whichever unrelated row stored it first.",
"Validation failed, with every issue listed rather than the first — an integration operator should see the whole shape at once. An empty or unstable `idempotencyKey` fails here: the column is unique table-wide, so an empty key would resolve to whichever unrelated row stored it first. Also 422 with `meta.errorCode` = `medication.intake.notTracked` (v1.42.0) when the named medication is kept as a record only (`trackIntake: false`); checked after the token's scope, so a token without this medication's scope learns nothing about it.",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
+1 -1
View File
@@ -1079,7 +1079,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
"422": {
description:
"The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation (`measurement.batch.invalid`, with every issue under `details.issues`), or (`measurement.batch.source_not_permitted`) carried an entry naming a source, any source, under a narrow `measurements:write` credential. Nothing was written in any of the three cases, and all three are permanent for the batch as sent: resending it unchanged is refused the same way.",
'The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation (`measurement.batch.invalid`, with every issue under `details.issues`), or (`measurement.batch.source_not_permitted`) carried an entry naming a source, any source, under a narrow `measurements:write` credential. Nothing was written in any of the three cases, and all three are permanent for the batch as sent: resending it unchanged is refused the same way. Which issues belong to an entry: every issue caused by one entry has a `path` that starts `entries.<n>` (the entry\'s index in the request, then the field, e.g. `entries.3.startDate`, or just `entries.4` when the entry is not an object). Those are the only per-entry issues, so a client may drop the named entries and resend the rest. An issue whose `path` is `""` (the body is not an object), `entries` (missing, not an array, or empty) or `syncTrigger` is about the batch itself, and no split recovers it.',
content: { "application/json": { schema: errorEnvelope } },
},
},
+51 -8
View File
@@ -88,6 +88,7 @@ import {
medicationCadenceResponse,
medicationComplianceResponse,
medicationComplianceSummaryEntry,
medicationComplianceAggregate,
scheduleRevisionResource,
scheduleRevisionListResponse,
medicationExtractRequest,
@@ -212,7 +213,12 @@ const bulkIntakeEntryResult = z
.describe(
"`inserted`/`updated`/`duplicate` — the row landed (advance the cursor). `skipped` — not stored; see `reason`.",
),
reason: z.string().optional(),
reason: z
.string()
.optional()
.describe(
"Why a `skipped` entry was not stored. Among others: `medication_not_found`, `unstable_external_id`, `force_slot_invalid`, `force_slot_occupied`, `injection_site_not_allowed`, and (v1.42.0) `intake_not_tracked` for an entry against a medication kept as a record only (`trackIntake: false`), the per-entry form of `medication.intake.notTracked`. A skip is final: resending the entry gives the same answer.",
),
id: z.string().optional().describe("The landed row id (absent on skips)."),
})
.meta({ id: "BulkMedicationIntakeEntryResult" });
@@ -312,7 +318,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"422": {
description:
"Body validation failed (multi-issue envelope, breadcrumbed as `medications.intake.update.validation-failed`), or `meta.errorCode` = `medications.intake.injection_site.disallowed` when the site is outside the medication's effective allowed set, or `medications.intake.force_slot.invalid` when `forceSlotInstant` is not a real slot of the medication. The latter two carry no issue list — branch on the code.",
"Body validation failed (multi-issue envelope, breadcrumbed as `medications.intake.update.validation-failed`), or `meta.errorCode` = `medications.intake.injection_site.disallowed` when the site is outside the medication's effective allowed set, or `medications.intake.force_slot.invalid` when `forceSlotInstant` is not a real slot of the medication, or (v1.42.0) `medication.intake.notTracked` when the event is still open and its medication is kept as a record only (`trackIntake: false`): resolving or snoozing it would record a new dose that is counted nowhere. Correcting an event that is already taken or skipped stays open. The coded refusals carry no issue list — branch on the code.",
content: { "application/json": { schema: errorEnvelope } },
},
"401": stdResponses["401"],
@@ -1437,6 +1443,11 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...stdResponses,
...recordWriteRateLimitResponse,
"422": {
description:
"Body validation failed (multi-issue envelope), or `meta.errorCode` names the refusal: `medications.intake.injection_site.disallowed`, `medications.intake.force_slot.invalid`, or (v1.42.0) `medication.intake.notTracked` when the medication is kept as a record only (`trackIntake: false`). Its stored history stays; a new dose is refused rather than stored and counted nowhere. The check runs after ownership, so a foreign medication is still a 404.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
get: {
@@ -1699,17 +1710,49 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Medications"],
summary: "Batched adherence read for every medication of the caller",
description:
"Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in the array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero compatibility placeholders for released clients; aware clients must ignore those percentages when applicable=false. complianceDisplay is null.",
"Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in the array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero compatibility placeholders for released clients; aware clients must ignore those percentages when applicable=false. complianceDisplay is null.\n\n" +
"v1.42.0, additive: `meta.aggregate` carries one figure for the whole account (`MedicationComplianceAggregate`, weighted by expected doses across the applicable medications; `null` when none applies), so a client never averages the rows itself. `days` (30, 60, 90, 180 or 365) adds a `complianceN` block in the same shape to every row and to the aggregate, for a report that covers its own window; `meta.days` echoes it. Any other `days` is a 422 rather than a rounded window.",
requestParams: {
query: z.object({
days: z
.enum(["30", "60", "90", "180", "365"])
.optional()
.describe(
"Report window in days. 30 is already `compliance30`; the others add `compliance60` / `compliance90` / `compliance180` / `compliance365`.",
),
}),
},
responses: {
...recordRefusal(),
"200": {
description: "One adherence row per medication.",
description:
"One adherence row per medication, and the account aggregate in `meta`.",
content: {
"application/json": {
schema: dataEnvelope(
z.array(medicationComplianceSummaryEntry),
"ListMedicationComplianceResponse",
),
schema: z
.object({
data: z.array(medicationComplianceSummaryEntry),
error: z.null(),
meta: z
.object({
aggregate: medicationComplianceAggregate.nullable(),
days: z
.union([
z.literal(30),
z.literal(60),
z.literal(90),
z.literal(180),
z.literal(365),
])
.optional()
.describe("The `days` the request asked for."),
requestId: z.string().optional(),
})
.describe(
"Absent on servers older than v1.42.0; a client that reads it treats absence as no aggregate.",
),
})
.meta({ id: "ListMedicationComplianceResponse" }),
},
},
},
@@ -936,6 +936,18 @@ export const medicationComplianceSummaryEntry = z
compliance30: complianceResult.describe(
"Thirty-day adherence summary, or an all-zero compatibility placeholder when applicable is false.",
),
compliance60: complianceResult
.optional()
.describe("Present only on `?days=60`: the trailing 60 days."),
compliance90: complianceResult
.optional()
.describe("Present only on `?days=90`: the trailing 90 days."),
compliance180: complianceResult
.optional()
.describe("Present only on `?days=180`: the trailing 180 days."),
compliance365: complianceResult
.optional()
.describe("Present only on `?days=365`: the trailing 365 days."),
complianceDisplay: complianceDisplay.nullable(),
})
.meta({
@@ -1732,3 +1744,27 @@ export const medicationSideEffect = z
description:
"One logged side effect. Not a clinical record — the account owns it, and it stays deletable at any time rather than locking after a retraction window.",
});
/**
* v1.42.0 — the account-wide adherence figure the batched read carries in
* `meta.aggregate`, beside the per-medication rows.
*/
export const medicationComplianceAggregate = z
.object({
compliance7: complianceResult,
compliance30: complianceResult,
compliance60: complianceResult.nullable().optional(),
compliance90: complianceResult.nullable().optional(),
compliance180: complianceResult.nullable().optional(),
compliance365: complianceResult.nullable().optional(),
medicationCount: z
.number()
.int()
.positive()
.describe("How many applicable medications the figure is built from."),
})
.meta({
id: "MedicationComplianceAggregate",
description:
"One adherence figure for the whole account in the `ComplianceResult` shape, weighted by expected doses: `taken`, `skipped` and `missed` are summed across every medication with `applicable: true`, and `rate` is recomputed from the sums by the per-medication rule (round(100 · taken / (taken + missed)), capped at 100, 100 when nothing was expected). Averaging the per-medication rates would let a weekly injection weigh as much as a three-times-daily tablet. `streak` is the shortest per-medication streak. The `complianceN` block for a requested `days` is present when `days` was sent and is not 30.",
});
+19 -2
View File
@@ -955,7 +955,14 @@ const moodAggregatesResponse = z
days: z.number().int(),
})
.nullable()
.describe("Null for a sparse logger below the day floor."),
.describe(
"Day-to-day steadiness over the trailing `stabilityWindowDays` days. Null below the day floor (seven days with an entry) inside that window, so a short window can be null where the year is not.",
),
stabilityWindowDays: z
.union([z.literal(30), z.literal(90), z.literal(180), z.literal(365)])
.describe(
"The window `stability` covers: the `days` query parameter, or 365 (the whole daily series) when it was not sent. Added in v1.42.0; absent from older servers, whose score always covers the year.",
),
tags: z
.array(
z.object({
@@ -1277,7 +1284,17 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Mood"],
summary: "Pre-computed mood aggregates (v1.8.5)",
description:
"The whole Mood Insights page in one read. The compute runs server-side and only pre-shaped blocks leave — no raw mood row is on this wire, and no LLM is reachable from this path. Stale-while-revalidate: past the fresh window the prior aggregate is served immediately while one background recompute warms a new one, so an active logger never re-pays the cold compute. Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach.",
"The whole Mood Insights page in one read. The compute runs server-side and only pre-shaped blocks leave — no raw mood row is on this wire, and no LLM is reachable from this path. Stale-while-revalidate: past the fresh window the prior aggregate is served immediately while one background recompute warms a new one, so an active logger never re-pays the cold compute. Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach. v1.42.0 — `days` cuts `stability` to the period a client shows; every other block is unchanged by it.",
requestParams: {
query: z.object({
days: z
.enum(["30", "90", "180", "365"])
.optional()
.describe(
"Trailing window for `stability`, in days. Omitted: 365. Any other value is a 422.",
),
}),
},
responses: {
"200": {
description: "The aggregate bundle.",
+4 -9
View File
@@ -56,6 +56,7 @@ import {
recordRefusal,
stdResponses,
updatedAtTokenField,
validationIssue,
} from "./shared";
// v1.18.0 — module enable/disable. The PATCH request is the REAL runtime
@@ -259,7 +260,7 @@ const notificationPrefsResolved = z
clientManaged: z
.boolean()
.describe(
"True when the iOS app owns local medication reminders; the server-side MEDICATION_REMINDER APNs cron is suppressed.",
"True when the iOS app delivers medication reminders locally. The server then keeps the reminder that announces a dose off APNs (the phone shows its own at dose time) and still pushes the follow-ups for a dose that stays open, which the phone does not send (v1.42.0; before, every MEDICATION_REMINDER push was suppressed). Other channels are never affected.",
),
deliveryDefault: z
.enum(["server", "client"])
@@ -587,13 +588,7 @@ const profileUpdateResponse = z
.boolean()
.describe("Whether a KVNR is on file (the value itself is not echoed)."),
rejectedFields: z
.array(
z.object({
path: z.string(),
code: z.string(),
message: z.string(),
}),
)
.array(validationIssue)
.optional()
.describe(
"Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over. The handler has always been able to answer with this key on this path; it was published only on PUT /api/auth/profile.",
@@ -1638,7 +1633,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Notifications"],
summary: "Update per-user notification preferences",
description:
'Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) suppresses server-side MEDICATION_REMINDER APNs; `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert — null switches it off. Rate-limit 60/min per user.',
'Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) keeps the MEDICATION_REMINDER that announces a dose off APNs and leaves the follow-ups for a dose still open on it (v1.42.0); `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert — null switches it off. Rate-limit 60/min per user.',
requestBody: {
required: true,
content: {
+1 -1
View File
@@ -32,7 +32,7 @@ import { createBatchWorkoutSchema as createBatchWorkoutSchemaBase } from "@/lib/
* built for could not read it from the contract. `params` never ships: some
* Zod codes embed the offending value in it and that is user content.
*/
const validationIssue = z
export const validationIssue = z
.object({
path: z
.string()