diff --git a/e2e/settings-score.spec.ts b/e2e/settings-score.spec.ts
new file mode 100644
index 000000000..2f8f3a24a
--- /dev/null
+++ b/e2e/settings-score.spec.ts
@@ -0,0 +1,293 @@
+import { expect, test } from "./setup/test";
+
+import { STORAGE_STATE_PATH } from "./setup/global-setup";
+import { settleBeforeMeasure } from "./utils/settle";
+
+/**
+ * Settings → Health Score: the surface that decides which pillars count.
+ *
+ * Everything asserted here goes through stable `data-slot` / `data-*`
+ * attributes rather than viewport-dependent text, so the spec keeps working
+ * at any width and in any locale (only the copy assertions read English, and
+ * the suite pins the English locale through the storage state).
+ *
+ * The refusal case is driven through the REAL write. Deselecting down to
+ * activity and wellbeing genuinely fails the server's breadth rule, and a
+ * refused write persists nothing, so the case leaves the shared account
+ * exactly as it found it. Mocking the 422 would have proved that the page
+ * can render a message, not that the rule is enforced or that the page reads
+ * the reason the server actually sends.
+ *
+ * The analytics read IS mocked, because it is the page's secondary read and
+ * the states it drives — waiting for data, safety signposting — depend on
+ * what the seeded account happens to hold. Fixing it makes those two rows
+ * deterministic without touching the write path under test.
+ */
+
+/** A slim Health Score report: only the fields the settings page reads. */
+function scoreReport() {
+ const coverage = {
+ requiredInputs: 3,
+ presentInputs: 2,
+ historyDays: 14,
+ missing: [],
+ };
+ const provenance = {
+ inputs: [],
+ source: "live",
+ windowDays: 90,
+ computedAt: new Date().toISOString(),
+ };
+ const insufficient = (reason: string) => ({
+ status: "insufficient",
+ reason,
+ coverage,
+ provenance,
+ });
+ return {
+ composite: insufficient("three_domains_required"),
+ pillars: [
+ {
+ id: "LIPIDS",
+ domain: "cardiometabolic",
+ result: insufficient("not_tracked"),
+ },
+ {
+ id: "WELLBEING",
+ domain: "wellbeing",
+ result: insufficient("crisis_signposting"),
+ },
+ {
+ id: "BLOOD_PRESSURE",
+ domain: "cardiometabolic",
+ result: insufficient("read_failed"),
+ },
+ ],
+ delta: null,
+ deltaReason: "no_current_score",
+ scoreVersion: 2,
+ weightGoal: insufficient("no_goal"),
+ // Raised and never dismissed, which is the state every account is in:
+ // nothing has ever rendered this notice.
+ algorithmNotice: { itemKey: "health_score_algorithm:2", dismissed: false },
+ };
+}
+
+async function mockAnalytics(page: import("@playwright/test").Page) {
+ await page.route(/\/api\/analytics(\?|$)/, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: { summaries: {}, healthScore: scoreReport() },
+ error: null,
+ }),
+ });
+ });
+}
+
+test.describe("Settings → Health Score", () => {
+ test.use({ storageState: STORAGE_STATE_PATH });
+
+ test.beforeEach(async ({ page }) => {
+ await mockAnalytics(page);
+ });
+
+ test("groups the pillars by area and makes the three axes legible", async ({
+ page,
+ }) => {
+ await page.goto("/settings/score", { waitUntil: "domcontentloaded" });
+ const card = page.locator('[data-slot="score-config-card"]');
+ await settleBeforeMeasure(page, card);
+
+ // The heading + subheading pair the settings IA requires, owned by the
+ // shell and not re-declared inside the card.
+ await expect(page.locator("#settings-section-score-title")).toBeVisible();
+ await expect(
+ page.locator("#settings-section-score-title + p"),
+ ).toBeVisible();
+
+ // One concept, one card. A second card beside it would be the
+ // top/bottom split of one decision the rules forbid; the notice is a
+ // different concept and is allowed its own.
+ await expect(page.locator('[data-slot="score-config-card"]')).toHaveCount(
+ 1,
+ );
+
+ // The cardiometabolic triple, visibly under one heading.
+ const cardio = page.locator(
+ '[data-slot="score-config-group"][data-domain="cardiometabolic"]',
+ );
+ await expect(cardio).toHaveCount(1);
+ await expect(cardio.locator('[data-slot="score-pillar-row"]')).toHaveCount(
+ 3,
+ );
+ await expect(
+ cardio.locator('[data-slot="score-config-group-note"]'),
+ ).toBeVisible();
+
+ // Every visible row names recording and showing beside its switch, and
+ // the card says in words that this page leaves those two alone.
+ const rows = page.locator('[data-slot="score-pillar-row"]');
+ const rowCount = await rows.count();
+ expect(rowCount).toBeGreaterThan(0);
+ await expect(
+ page.locator('[data-slot="score-pillar-axis-recorded"]'),
+ ).toHaveCount(rowCount);
+ await expect(
+ page.locator('[data-slot="score-pillar-axis-shown"]'),
+ ).toHaveCount(rowCount);
+ await expect(
+ page.locator('[data-slot="score-config-three-axes"]'),
+ ).toContainText("keeps being recorded");
+ });
+
+ test("shows the pillar it cannot score as present, disabled and honest", async ({
+ page,
+ }) => {
+ await page.goto("/settings/score", { waitUntil: "domcontentloaded" });
+ const fitness = page.locator(
+ '[data-slot="score-pillar-row"][data-pillar="FITNESS"]',
+ );
+ await settleBeforeMeasure(page, fitness);
+
+ await expect(fitness).toHaveAttribute("data-selectable", "false");
+ await expect(fitness).toHaveAttribute("data-eligibility", "unavailable");
+ // No switch at all, rather than one that no-ops behind `disabled`.
+ await expect(
+ fitness.locator('[data-slot="score-pillar-switch"]'),
+ ).toHaveCount(0);
+ await expect(
+ fitness.locator('[data-slot="score-pillar-unavailable"]'),
+ ).toHaveAttribute("aria-disabled", "true");
+ });
+
+ test("tells waiting for data apart from safety guidance and a failed read", async ({
+ page,
+ }) => {
+ await page.goto("/settings/score", { waitUntil: "domcontentloaded" });
+ const card = page.locator('[data-slot="score-config-card"]');
+ await settleBeforeMeasure(page, card);
+
+ const state = (pillar: string) =>
+ page.locator(
+ `[data-slot="score-pillar-row"][data-pillar="${pillar}"] [data-slot="score-pillar-state"]`,
+ );
+
+ await expect(state("LIPIDS")).toHaveAttribute("data-state", "waiting");
+ // Safety signposting is its own state and never a configuration error.
+ await expect(state("WELLBEING")).toHaveAttribute("data-state", "crisis");
+ // A failed read is a failure, not absence.
+ await expect(state("BLOOD_PRESSURE")).toHaveAttribute(
+ "data-state",
+ "read_failed",
+ );
+ });
+
+ test("renders the score notice and lets it be dismissed", async ({
+ page,
+ }) => {
+ await page.goto("/settings/score", { waitUntil: "domcontentloaded" });
+ const notice = page.locator('[data-slot="score-change-notice"]');
+ await settleBeforeMeasure(page, notice);
+
+ await expect(notice).toBeVisible();
+
+ let dismissed: string | null = null;
+ await page.route(/\/api\/daily\/digest\/dismiss$/, async (route) => {
+ dismissed = String(route.request().postDataJSON()?.itemKey ?? "");
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ data: { dismissed: true }, error: null }),
+ });
+ });
+ // The refetch after the dismissal has to come back already dismissed,
+ // or the notice would simply be raised again and the test would be
+ // asserting a race rather than the behaviour.
+ await page.route(/\/api\/analytics(\?|$)/, async (route) => {
+ const report = scoreReport();
+ report.algorithmNotice.dismissed = true;
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: { summaries: {}, healthScore: report },
+ error: null,
+ }),
+ });
+ });
+
+ await notice.locator('[data-slot="score-change-notice-dismiss"]').click();
+
+ await expect(notice).toHaveCount(0);
+ expect(dismissed).toBe("health_score_algorithm:2");
+ });
+
+ test("refuses a selection too narrow to produce a score, in plain language", async ({
+ page,
+ }) => {
+ await page.goto("/settings/score", { waitUntil: "domcontentloaded" });
+ const card = page.locator('[data-slot="score-config-card"]');
+ await settleBeforeMeasure(page, card);
+
+ // Start from everything, then take out every pillar except activity and
+ // wellbeing: two areas of health and no physical measurement, which the
+ // server refuses.
+ await page.locator('[data-slot="score-config-preset-all"]').click();
+ const keep = new Set(["ACTIVITY", "WELLBEING"]);
+ const rows = page.locator(
+ '[data-slot="score-pillar-row"][data-selectable="true"]',
+ );
+ for (let index = 0; index < (await rows.count()); index += 1) {
+ const row = rows.nth(index);
+ const pillar = await row.getAttribute("data-pillar");
+ if (!pillar || keep.has(pillar)) continue;
+ if ((await row.getAttribute("data-counts")) === "false") continue;
+ await row.locator('[data-slot="score-pillar-switch"]').click();
+ }
+
+ await page.locator('[data-slot="score-config-save"]').click();
+
+ const refusal = page.locator('[data-slot="score-config-refusal"]');
+ await expect(refusal).toBeVisible();
+ await expect(refusal).toHaveAttribute("role", "alert");
+ // The server's machine reason, rendered as a sentence a person can act
+ // on, not as an error code and not as the server's English fallback.
+ await expect(refusal).toContainText("physical measurement");
+ await expect(refusal).not.toContainText("_required");
+ });
+
+ test("keeps the content column the same width as its neighbours", async ({
+ page,
+ }, testInfo) => {
+ test.skip(
+ testInfo.project.name !== "chromium-desktop",
+ "viewport-driven assertion; desktop project only",
+ );
+ await page.setViewportSize({ width: 1440, height: 900 });
+
+ async function columnWidth(route: string, sentinel: string) {
+ await page.goto(route, { waitUntil: "domcontentloaded" });
+ await settleBeforeMeasure(page, page.locator(sentinel));
+ return page.evaluate(() => {
+ const column = document.querySelector("section[aria-labelledby]");
+ const grid = column?.closest("div.grid") ?? null;
+ const cell = grid?.lastElementChild ?? null;
+ return cell ? Math.round(cell.getBoundingClientRect().width) : -1;
+ });
+ }
+
+ const modules = await columnWidth(
+ "/settings/modules",
+ '[data-slot="card"]',
+ );
+ const score = await columnWidth(
+ "/settings/score",
+ '[data-slot="score-config-card"]',
+ );
+
+ expect(modules).toBeGreaterThan(0);
+ expect(Math.abs(score - modules)).toBeLessThanOrEqual(1);
+ });
+});
diff --git a/src/components/insights/__tests__/health-score-coverage-axis-guard.test.tsx b/src/components/insights/__tests__/health-score-coverage-axis-guard.test.tsx
index 0107d54b7..085688c61 100644
--- a/src/components/insights/__tests__/health-score-coverage-axis-guard.test.tsx
+++ b/src/components/insights/__tests__/health-score-coverage-axis-guard.test.tsx
@@ -29,8 +29,11 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
+import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
+import { I18nProvider } from "@/lib/i18n/context";
+import { CoverageMeter } from "../derived/coverage-meter";
import en from "../../../../messages/en.json";
const CARD_FILE = join(
@@ -231,3 +234,40 @@ describe("the composite's coverage meter names its own axis", () => {
);
});
});
+
+/**
+ * The attribute guard above proves the override is written down. This
+ * proves it does something: a prop that is declared and then ignored is
+ * exactly the shape of a check that cannot fail.
+ */
+describe("the axis override reaches the markup", () => {
+ const coverage = {
+ requiredInputs: 3,
+ presentInputs: 3,
+ historyDays: 20,
+ missing: [],
+ };
+
+ function render(axisLabel?: string): string {
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+ }
+
+ it("replaces the fraction in the label and the accessible name", () => {
+ const html = render("Covers the three areas the score needs");
+
+ expect(html).toContain("Covers the three areas the score needs");
+ expect(html).not.toContain(">3/3<");
+ expect(html).toMatch(/aria-label="Covers the three areas the score needs/);
+ });
+
+ it("keeps the fraction for every caller that does not override it", () => {
+ const html = render();
+
+ expect(html).toContain("3/3");
+ expect(html).toContain("3 of 3 inputs");
+ });
+});
diff --git a/src/components/settings/__tests__/score-change-notice.test.tsx b/src/components/settings/__tests__/score-change-notice.test.tsx
index f78415f70..4c50ba780 100644
--- a/src/components/settings/__tests__/score-change-notice.test.tsx
+++ b/src/components/settings/__tests__/score-change-notice.test.tsx
@@ -97,15 +97,51 @@ describe("dismissal", () => {
});
describe("localisation", () => {
- it("renders both shapes in each shipped locale", () => {
- for (const locale of ["de", "fr", "es", "it", "pl"] as Locale[]) {
- for (const version of [0, 2]) {
- const html = render(
- { configVersion: version, changedAt: "2026-07-31T09:00:00.000Z" },
- locale,
- );
- expect(html).not.toContain("settings.sections.score");
- }
+ // Not an absence check: `t()` falls back to English, so a missing key
+ // renders English rather than a key path. These are each locale's own
+ // titles, so an English fallback, a copied value or a mis-wired bundle
+ // all fail here. Key parity across bundles is a repo-wide guard already.
+ const TITLES: Record = {
+ de: {
+ upgrade: "Was zählt, entscheidest jetzt du",
+ changed: "Du hast geändert, was zählt",
+ },
+ fr: {
+ upgrade: "Ce qui compte, c'est vous qui le décidez",
+ changed: "Vous avez changé ce qui compte",
+ },
+ es: {
+ upgrade: "Ahora decides tú qué cuenta",
+ changed: "Has cambiado qué cuenta",
+ },
+ it: {
+ upgrade: "Ora sei tu a decidere cosa conta",
+ changed: "Hai cambiato cosa conta",
+ },
+ pl: {
+ upgrade: "Teraz to Ty decydujesz, co się liczy",
+ changed: "Zmieniłeś to, co się liczy",
+ },
+ };
+
+ it("renders both shapes in each locale's own words", () => {
+ for (const [locale, titles] of Object.entries(TITLES)) {
+ const upgrade = render(
+ { configVersion: 0, changedAt: null },
+ locale as Locale,
+ );
+ expect(upgrade, `${locale} upgrade note`).toContain(
+ // The apostrophe in the French title is escaped in the markup.
+ titles.upgrade.replace(/'/g, "'"),
+ );
+ expect(upgrade).not.toContain("What counts is now yours to choose");
+
+ const changed = render(
+ { configVersion: 2, changedAt: "2026-07-31T09:00:00.000Z" },
+ locale as Locale,
+ );
+ expect(changed, `${locale} changed note`).toContain(titles.changed);
+ expect(changed).not.toContain("You changed what counts");
}
});
});
diff --git a/src/components/settings/__tests__/score-pillar-row.test.tsx b/src/components/settings/__tests__/score-pillar-row.test.tsx
index 2fb6846bf..85101c7ee 100644
--- a/src/components/settings/__tests__/score-pillar-row.test.tsx
+++ b/src/components/settings/__tests__/score-pillar-row.test.tsx
@@ -139,12 +139,31 @@ describe("a pillar this build cannot score", () => {
});
describe("localisation", () => {
- it("renders every state in each shipped locale", () => {
- for (const locale of ["de", "fr", "es", "it", "pl"] as Locale[]) {
- const html = render({ ...base, eligibility: "waiting" }, locale);
- // A missing key falls through to the key path; seeing one here means
- // a locale bundle is short.
- expect(html).not.toContain("settings.sections.score");
+ // Asserting the absence of a key path would prove nothing: `t()` falls
+ // back to the English bundle, so a missing locale key renders English
+ // rather than the key. Locale PARITY is already a repo-wide guard
+ // (`i18n-locale-integrity`); what belongs here is that the row actually
+ // reaches each locale's own words, which fails on a missing key, on an
+ // English value copied into a bundle, and on a locale wired to the
+ // wrong strings.
+ const WAITING: Record = {
+ de: "Ausgewählt, wartet auf Daten",
+ fr: "Sélectionné, en attente de données",
+ es: "Seleccionado, esperando datos",
+ it: "Selezionato, in attesa di dati",
+ pl: "Wybrany, czeka na dane",
+ };
+
+ it("renders each locale's own words, not the English fallback", () => {
+ for (const [locale, expected] of Object.entries(WAITING)) {
+ const html = render(
+ { ...base, eligibility: "waiting" },
+ locale as Locale,
+ );
+ expect(html, `${locale} row`).toContain(expected);
+ expect(html, `${locale} row fell back to English`).not.toContain(
+ "Selected, waiting for data",
+ );
}
});
});
diff --git a/src/components/settings/__tests__/score-refusal-consumer-guard.test.ts b/src/components/settings/__tests__/score-refusal-consumer-guard.test.ts
new file mode 100644
index 000000000..2a864de77
--- /dev/null
+++ b/src/components/settings/__tests__/score-refusal-consumer-guard.test.ts
@@ -0,0 +1,138 @@
+/**
+ * The write-time refusal has two ends, and this holds them together.
+ *
+ * The server owns the breadth rule and answers a too-narrow selection
+ * with a machine reason in `meta.reason`. The settings surface turns that
+ * reason into a sentence in the reader's own language. Nothing in the
+ * type system connects the two: rename a reason on the route, and the
+ * client silently falls through to "couldn't save, try again" while the
+ * person is left with no idea what is wrong with their selection. That is
+ * a refusal that stops refusing anything in particular, and it looks
+ * perfectly healthy in every unit test on either side.
+ *
+ * So: the reasons the rule can produce, the reasons the route has copy
+ * for, and the reasons the client has copy for must be the same set. The
+ * rule's own list is read out of `breadth.ts` rather than restated here,
+ * because a restated list is a fourth place to forget.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import ts from "typescript";
+import { describe, expect, it } from "vitest";
+
+import en from "../../../../messages/en.json";
+import { REFUSAL_KEYS } from "../score-section";
+
+const ROOT = process.cwd();
+const RULE_FILE = join(ROOT, "src/lib/analytics/score/breadth.ts");
+const ROUTE_FILE = join(
+ ROOT,
+ "src/app/api/auth/me/health-score-config/route.ts",
+);
+
+function parse(path: string): ts.SourceFile {
+ return ts.createSourceFile(
+ path,
+ readFileSync(path, "utf8"),
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TS,
+ );
+}
+
+function walk(node: ts.Node, visit: (node: ts.Node) => void): void {
+ visit(node);
+ node.forEachChild((child) => walk(child, visit));
+}
+
+/** The string members of an exported union type alias. */
+function unionMembers(file: ts.SourceFile, name: string): string[] {
+ const members: string[] = [];
+ walk(file, (node) => {
+ if (!ts.isTypeAliasDeclaration(node)) return;
+ if (node.name.text !== name) return;
+ const type = node.type;
+ const parts = ts.isUnionTypeNode(type) ? type.types : [type];
+ for (const part of parts) {
+ if (
+ ts.isLiteralTypeNode(part) &&
+ ts.isStringLiteral(part.literal) &&
+ part.literal.text.length > 0
+ ) {
+ members.push(part.literal.text);
+ }
+ }
+ });
+ return members;
+}
+
+/** The property names of a top-level object-literal constant. */
+function objectKeys(file: ts.SourceFile, name: string): string[] {
+ const keys: string[] = [];
+ walk(file, (node) => {
+ if (!ts.isVariableDeclaration(node)) return;
+ if (!ts.isIdentifier(node.name) || node.name.text !== name) return;
+ let initializer = node.initializer;
+ while (
+ initializer &&
+ (ts.isAsExpression(initializer) ||
+ ts.isSatisfiesExpression(initializer) ||
+ ts.isParenthesizedExpression(initializer))
+ ) {
+ initializer = initializer.expression;
+ }
+ if (!initializer || !ts.isObjectLiteralExpression(initializer)) return;
+ for (const property of initializer.properties) {
+ if (!ts.isPropertyAssignment(property)) continue;
+ const propertyName = property.name;
+ if (ts.isIdentifier(propertyName)) keys.push(propertyName.text);
+ else if (ts.isStringLiteral(propertyName)) keys.push(propertyName.text);
+ }
+ });
+ return keys;
+}
+
+function message(key: string): string | undefined {
+ let cursor: unknown = en;
+ for (const segment of key.split(".")) {
+ if (cursor === null || typeof cursor !== "object") return undefined;
+ cursor = (cursor as Record)[segment];
+ }
+ return typeof cursor === "string" ? cursor : undefined;
+}
+
+const ruleReasons = unionMembers(parse(RULE_FILE), "ScoreBreadthFailure");
+const routeReasons = objectKeys(parse(ROUTE_FILE), "BREADTH_REFUSAL");
+
+describe("every refusal the rule can give reaches the reader", () => {
+ it("reads the rule's own list rather than a copy of it", () => {
+ // A matcher that finds nothing agrees with everything.
+ expect(ruleReasons.length).toBeGreaterThan(0);
+ expect(ruleReasons).toContain("three_domains_required");
+ });
+
+ it("gives the route a sentence for each of them", () => {
+ expect([...routeReasons].sort()).toEqual([...ruleReasons].sort());
+ });
+
+ it("gives the settings surface localised copy for each of them", () => {
+ expect(Object.keys(REFUSAL_KEYS).sort()).toEqual([...ruleReasons].sort());
+ });
+
+ it("resolves every one of those keys to real copy", () => {
+ for (const [reason, key] of Object.entries(REFUSAL_KEYS)) {
+ const copy = message(key);
+ expect(copy, `${reason} → ${key}`).toBeTypeOf("string");
+ expect(copy?.length ?? 0).toBeGreaterThan(20);
+ }
+ });
+
+ it("stays quiet on a constant that is not the refusal map", () => {
+ // The matcher is name-anchored, not shape-anchored: it must not pick
+ // up the neighbouring object literals on either file and quietly
+ // widen what the comparison above is comparing.
+ expect(objectKeys(parse(ROUTE_FILE), "NOT_A_REAL_CONSTANT")).toEqual([]);
+ expect(unionMembers(parse(RULE_FILE), "NotARealType")).toEqual([]);
+ });
+});
diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx
index 29768ea0d..f89c37ea2 100644
--- a/src/components/settings/score-section.tsx
+++ b/src/components/settings/score-section.tsx
@@ -79,6 +79,7 @@ import {
} from "@/lib/analytics/score/types";
import type { HealthScoreReport } from "@/lib/analytics/score/types";
import type { ScoreModuleKey } from "@/lib/analytics/score/modules";
+import type { ScoreBreadthFailure } from "@/lib/analytics/score/breadth";
/** The resolved configuration `GET /api/auth/me/health-score-config` returns. */
interface HealthScoreConfigPayload {
@@ -99,8 +100,14 @@ interface HealthScoreConfigPayload {
* exists for callers without a locale, and rendering it would leave five
* of the six locales reading English at exactly the moment the person
* needs to understand something.
+ *
+ * Typed as a total map over `ScoreBreadthFailure` rather than over
+ * `string`, so a third refusal reason added to the rule cannot ship with
+ * this surface quietly falling through to a generic "couldn't save". The
+ * TYPE is imported, never the rule: the breadth question is answered
+ * server-side and answered once.
*/
-const REFUSAL_KEYS: Record = {
+export const REFUSAL_KEYS: Record = {
measured_physiological_domain_required:
"settings.sections.score.refusal.physiological",
three_domains_required: "settings.sections.score.refusal.domains",
@@ -213,8 +220,13 @@ export function ScoreSection() {
// only translates the reason it gave.
if (err instanceof ApiError && err.status === 422) {
const reason = err.meta?.reason;
+ // The wire carries an unknown string until it is checked against
+ // the map; an unrecognised one falls through to the generic
+ // failure rather than rendering an empty alert.
const key =
- typeof reason === "string" ? REFUSAL_KEYS[reason] : undefined;
+ typeof reason === "string" && reason in REFUSAL_KEYS
+ ? REFUSAL_KEYS[reason as ScoreBreadthFailure]
+ : undefined;
if (key) {
setRefusal(key);
return;