feat(api): carry the score's basis on every surface that carries the score

`scoreBasis` rides the composite, the dashboard snapshot's flattened hero
block and the daily digest's score. It says how many distinct areas of
health the number counted, how many the method recommends, which tier that
lands in, and whether any counted pillar was a physiological measurement.

Optional everywhere, because two of the three surfaces are served out of a
cache and a payload written before this release cannot grow a field. The
snapshot and digest blocks have taken six additive fields this way already;
this is the seventh.

Resolved on the server for the same reason `configured` is. Counting
`composition` client-side would get it wrong more often than right — three
of the seven pillars share the cardiometabolic area, so a three-pillar
score can rest on one.

One shared component, `HealthScoreBasis`, declared once in the insights
schema module and referenced from the other two registry modules, rather
than three inline copies that drift the first time the block gains a field.
Declared through the assigning `.meta()` form: a bare `.meta()` statement
registers an instance nothing references, and the id then never becomes a
component.

The composite's `insufficient` reason narrows to the single value
`no_usable_data` and says so in the published description. That field is an
open string in the document, so the change is additive on the wire even
though the value space shrank.

The score-config 422 description drops the two retired reasons and states
plainly that the empty selection is now the only refusal, so a client does
not keep a three-area rule of its own in front of a write that no longer
has one.
This commit is contained in:
Marc-André Bombeck committed 2026-08-30 14:16:06 +02:00
1 parent 4ad00406d3
commit aadc3a8e89
8 files changed
+163 -11

No files matched your search

+62 -7
View File
@@ -6454,11 +6454,13 @@ paths:
"422":
description: "The write was refused and nothing was stored. `meta.errorCode` distinguishes the cases:
`health_score_config.too_narrow` — the selection cannot produce a score (`meta.reason` is
`measured_physiological_domain_required` or `three_domains_required`, and the `error` string is the sentence
to show the person); `health_score_config.invalid` — the body failed validation, with the per-issue list
under `details.issues`; `invalid_base_updated_at` — `baseUpdatedAt` was present but unparseable, `null`
included, which is rejected rather than falling back to the unconditional write. A body that is not JSON at
all returns the bare message `health-score-config.body.invalid_json` with no errorCode."
`no_pillars_selected`, and the `error` string is the sentence to show the person). Since v1.38 the empty
selection is the ONLY one refused: two areas of health, or one, now save and produce a score that labels its
own breadth, so a client must not pre-empt the write with a three-area rule of its own.
`health_score_config.invalid` — the body failed validation, with the per-issue list under `details.issues`;
`invalid_base_updated_at` — `baseUpdatedAt` was present but unparseable, `null` included, which is rejected
rather than falling back to the unconditional write. A body that is not JSON at all returns the bare message
`health-score-config.body.invalid_json` with no errorCode."
content:
application/json:
schema:
@@ -48915,6 +48917,12 @@ components:
configuration itself is never on this wire. Optional so older cached snapshots without the field
stay valid."
type: boolean
scoreBasis:
description: What the number rests on. Since v1.38 a score is served from one or two areas of health as well as three,
computed identically at each, so this is the only field that tells a narrow score from a broad one —
render it beside the number. Optional so older cached snapshots without the field stay valid; the
live builder always sets it.
$ref: "#/components/schemas/HealthScoreBasis"
deltaReason:
anyOf:
- type: string
@@ -49301,6 +49309,43 @@ components:
- score
- band
additionalProperties: false
HealthScoreBasis:
type: object
properties:
domains:
type: integer
minimum: 1
maximum: 9007199254740991
description: Distinct areas of health counted. NOT the length of `composition` — three of the seven pillars share the
cardiometabolic area, so a three-pillar score can rest on one area.
recommended:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: "How many distinct areas the method recommends. A recommendation, not a floor: a score below it is computed
and served, it is just narrower."
tier:
type: string
enum:
- full
- partial
- minimal
description: "`full` = at or above the recommended breadth, `partial` = two areas, `minimal` = one. The arithmetic is
identical at every tier — the same mean, the same worst-pillar band drag, the same noise floor — so this is
the only thing that distinguishes a narrow score from a broad one on the wire. Render it beside the number."
physiological:
type: boolean
description: "Whether any counted pillar rests on a physiological measurement (blood pressure, glucose, sleep, body
shape, cholesterol). False means the score is built from activity and/or wellbeing alone. A statement of
scope, not a warning: the score still exists and is still honest about what produced it."
required:
- domains
- recommended
- tier
- physiological
additionalProperties: false
description: What the Health Score rests on, resolved server-side. Present on every score computed from at least one
usable pillar; optional on every shape that carries it, so payloads cached before v1.38 stay valid.
InsightsComprehensiveResponseEnvelope:
type: object
properties:
@@ -50152,6 +50197,11 @@ components:
type: boolean
description: True when the account's own recipe narrows the composition below what its defaults would resolve to today.
The configuration itself never rides this wire.
scoreBasis:
description: What the number rests on. Since v1.38 a score is produced from one or two areas of health as well as three,
with identical arithmetic at each, so this block is the only thing on the wire that distinguishes
them. Optional so a composite cached before v1.38 stays valid; the live scorer always sets it.
$ref: "#/components/schemas/HealthScoreBasis"
noiseFloor:
type: number
scoreVersion:
@@ -64143,14 +64193,19 @@ components:
type: array
items:
type: string
scoreBasis:
description: What the value rests on. Since v1.38 a score is produced from one or two areas of health as well as three,
so this is what distinguishes a narrow score from a broad one. Optional so older cached digests
without the field stay valid.
$ref: "#/components/schemas/HealthScoreBasis"
required:
- value
- band
- delta
additionalProperties: false
- type: "null"
description: Health score + band + week-over-week delta, plus whether the account authored the composition; null when
none.
description: Health score + band + week-over-week delta, plus whether the account authored the composition and how broad
the set behind it is; null when none.
topSignal:
anyOf:
- type: object
+9
View File
@@ -27,6 +27,7 @@ import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
import type { ModuleKey } from "@/lib/modules/registry";
import type { ServerTranslator } from "@/lib/i18n/server-translator";
import type {
ScoreBasis,
ScoreDeltaReason,
ScorePillarId,
} from "@/lib/analytics/score/types";
@@ -113,6 +114,14 @@ export interface DailyDigestScore {
* cached digests, like its siblings above.
*/
configured?: boolean;
/**
* v1.38 — how broad the set behind the number is, carried straight
* from the snapshot's health-score block. Since v1.38 a score exists
* at one or two areas of health as well as three and is computed the
* same way at each, so this block is what tells a narrow score from a
* broad one. Omitted by older cached digests, like its siblings above.
*/
scoreBasis?: ScoreBasis;
}
/** A broken integration, deterministically derived from `IntegrationStatus`. */
+1
View File
@@ -479,6 +479,7 @@ export async function loadDailyDigest(
scoreVersion: snapshot.healthScore.scoreVersion,
composition: snapshot.healthScore.composition,
configured: snapshot.healthScore.configured,
scoreBasis: snapshot.healthScore.scoreBasis,
}
: null;
+16
View File
@@ -85,6 +85,7 @@ import {
import type {
RestModeAnnotation,
ScoreBand,
ScoreBasis,
ScoreDeltaReason,
ScorePillarId,
} from "@/lib/analytics/score/types";
@@ -340,6 +341,20 @@ export interface DashboardSnapshotHealthScore {
* builder always sets it.
*/
configured?: boolean;
/**
* v1.38 — what the number rests on: how many distinct areas of health
* were counted, how many the method recommends, the resulting tier
* (`full` / `partial` / `minimal`) and whether any counted pillar was a
* physiological measurement. Since v1.38 a score exists at one or two
* areas as well as three, computed exactly the same way, so this block
* is the only thing that tells a narrow score from a broad one — a
* surface that renders the number should render its basis too.
* Counting `composition` is not a substitute: three of the seven
* pillars share the cardiometabolic area. Optional on the type
* (additive contract) so older cached snapshots stay valid; the live
* builder always sets it when a score exists.
*/
scoreBasis?: ScoreBasis;
deltaReason?: ScoreDeltaReason | null;
scoreVersion?: number;
bandSetter?: ScorePillarId | null;
@@ -803,6 +818,7 @@ async function buildExtras(
confidence: scoreResult.composite.confidence,
composition: scoreResult.composite.value.composition,
configured: scoreResult.composite.value.configured,
scoreBasis: scoreResult.composite.value.scoreBasis,
deltaReason: scoreResult.deltaReason,
scoreVersion: scoreResult.scoreVersion,
bandSetter: scoreResult.composite.value.bandSetter,
+6
View File
@@ -26,6 +26,7 @@ import {
derivedCoverage,
derivedProvenance,
glucoseClinicalSchema,
healthScoreBasis,
} from "./insights/schemas";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
@@ -317,6 +318,11 @@ const compositeValue = z.object({
.describe(
"True when the account's own recipe narrows the composition below what its defaults would resolve to today. The configuration itself never rides this wire.",
),
scoreBasis: healthScoreBasis
.optional()
.describe(
"What the number rests on. Since v1.38 a score is produced from one or two areas of health as well as three, with identical arithmetic at each, so this block is the only thing on the wire that distinguishes them. Optional so a composite cached before v1.38 stays valid; the live scorer always sets it.",
),
noiseFloor: z.number(),
scoreVersion: z.number().int(),
});
+7 -1
View File
@@ -21,6 +21,7 @@ import {
recordRefusal,
stdResponses,
} from "./shared";
import { healthScoreBasis } from "./insights/schemas";
const priorityItemSchema = z
.object({
@@ -102,10 +103,15 @@ const dailyDigestResponse = z
.array(z.string())
.optional()
.describe("Registry-ordered pillar ids the value was composed of."),
scoreBasis: healthScoreBasis
.optional()
.describe(
"What the value rests on. Since v1.38 a score is produced from one or two areas of health as well as three, so this is what distinguishes a narrow score from a broad one. Optional so older cached digests without the field stay valid.",
),
})
.nullable()
.describe(
"Health score + band + week-over-week delta, plus whether the account authored the composition; null when none.",
"Health score + band + week-over-week delta, plus whether the account authored the composition and how broad the set behind it is; null when none.",
),
topSignal: z
.object({
+61 -2
View File
@@ -296,6 +296,51 @@ export const derivedProvenance = z
})
.meta({ id: "DerivedProvenance" });
/**
* v1.38 — what a Health Score rests on.
*
* Declared once and referenced from every surface that carries a score
* (the full report, the dashboard snapshot's flattened block, the daily
* digest), so the three cannot drift into describing the same object
* three different ways.
*
* OPTIONAL wherever it appears. It is a new field on shapes that already
* ship, and a cached snapshot or digest written before this release has
* no way to grow one; a required field would have made every one of them
* invalid against the published contract on the day it was published.
*/
export const healthScoreBasis = z
.object({
domains: z
.number()
.int()
.min(1)
.describe(
"Distinct areas of health counted. NOT the length of `composition` — three of the seven pillars share the cardiometabolic area, so a three-pillar score can rest on one area.",
),
recommended: z
.number()
.int()
.describe(
"How many distinct areas the method recommends. A recommendation, not a floor: a score below it is computed and served, it is just narrower.",
),
tier: z
.enum(["full", "partial", "minimal"])
.describe(
"`full` = at or above the recommended breadth, `partial` = two areas, `minimal` = one. The arithmetic is identical at every tier — the same mean, the same worst-pillar band drag, the same noise floor — so this is the only thing that distinguishes a narrow score from a broad one on the wire. Render it beside the number.",
),
physiological: z
.boolean()
.describe(
"Whether any counted pillar rests on a physiological measurement (blood pressure, glucose, sleep, body shape, cholesterol). False means the score is built from activity and/or wellbeing alone. A statement of scope, not a warning: the score still exists and is still honest about what produced it.",
),
})
.meta({
id: "HealthScoreBasis",
description:
"What the Health Score rests on, resolved server-side. Present on every score computed from at least one usable pillar; optional on every shape that carries it, so payloads cached before v1.38 stay valid.",
});
// v1.13.2 — per-derived-SCORE assessment text. Additive, non-breaking field
// on the derived response; the iOS field-name contract is LOCKED.
export const derivedAssessment = z
@@ -1227,6 +1272,11 @@ export const dashboardSnapshotResponse = z
.describe(
"True when the account's own recipe narrows the score's composition below what its defaults would resolve to today. Server-resolved, so a client never interprets a configuration blob: an account that kept every pillar reads false, and so does one whose disabled modules alone narrow the set. The configuration itself is never on this wire. Optional so older cached snapshots without the field stay valid.",
),
scoreBasis: healthScoreBasis
.optional()
.describe(
"What the number rests on. Since v1.38 a score is served from one or two areas of health as well as three, computed identically at each, so this is the only field that tells a narrow score from a broad one — render it beside the number. Optional so older cached snapshots without the field stay valid; the live builder always sets it.",
),
deltaReason: z
.enum([
"algorithm_changed",
@@ -1848,7 +1898,11 @@ const healthScoreDerivedInsufficient = z.object({
status: z.literal("insufficient"),
coverage: derivedCoverage,
provenance: derivedProvenance,
reason: z.string(),
reason: z
.string()
.describe(
"Why no score exists. `no_usable_data` — not one selected pillar has enough recent data to grade. Since v1.38 this is the only value the composite produces; the former `three_domains_required` / `measured_physiological_domain_required` refusals are gone, because a narrow set now scores and labels itself.",
),
});
const healthScoreComposite = z
@@ -1874,6 +1928,11 @@ const healthScoreComposite = z
.describe(
"True when the account's own recipe narrows the composition below what its defaults would resolve to today. The configuration blob itself is never on this wire.",
),
scoreBasis: healthScoreBasis
.optional()
.describe(
"What the number rests on — how many distinct areas of health were counted against how many the method recommends, and whether any of them was a physiological measurement. Optional so a composite cached before v1.38 stays valid; the live scorer always sets it.",
),
noiseFloor: z.number(),
scoreVersion: z.number().int(),
}),
@@ -1884,7 +1943,7 @@ const healthScoreComposite = z
healthScoreDerivedInsufficient,
])
.describe(
"Discriminated on `status`. The `insufficient` arm carries NO `value` and NO `confidence` key at all — they are absent, not null — which is how a composite reads before three distinct domains are eligible.",
"Discriminated on `status`. The `insufficient` arm carries NO `value` and NO `confidence` key at all — they are absent, not null — which is how a composite reads when NO pillar has usable data. Since v1.38 that is the only case: one or two eligible areas of health produce a score too, labelled by `value.scoreBasis`.",
);
const healthScorePillar = z.object({
+1 -1
View File
@@ -1173,7 +1173,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
// quietly omits the refusal a person actually hits.
"422": {
description:
"The write was refused and nothing was stored. `meta.errorCode` distinguishes the cases: `health_score_config.too_narrow` — the selection cannot produce a score (`meta.reason` is `measured_physiological_domain_required` or `three_domains_required`, and the `error` string is the sentence to show the person); `health_score_config.invalid` — the body failed validation, with the per-issue list under `details.issues`; `invalid_base_updated_at` — `baseUpdatedAt` was present but unparseable, `null` included, which is rejected rather than falling back to the unconditional write. A body that is not JSON at all returns the bare message `health-score-config.body.invalid_json` with no errorCode.",
"The write was refused and nothing was stored. `meta.errorCode` distinguishes the cases: `health_score_config.too_narrow` — the selection cannot produce a score (`meta.reason` is `no_pillars_selected`, and the `error` string is the sentence to show the person). Since v1.38 the empty selection is the ONLY one refused: two areas of health, or one, now save and produce a score that labels its own breadth, so a client must not pre-empt the write with a three-area rule of its own. `health_score_config.invalid` — the body failed validation, with the per-issue list under `details.issues`; `invalid_base_updated_at` — `baseUpdatedAt` was present but unparseable, `null` included, which is rejected rather than falling back to the unconditional write. A body that is not JSON at all returns the bare message `health-score-config.body.invalid_json` with no errorCode.",
content: { "application/json": { schema: errorEnvelope } },
},
},