From 0796d9fe1436b0be04c5bbb6855175fb32176690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 16 Jul 2026 05:46:24 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.28.45=20=E2=80=94=20doctor?= =?UTF-8?q?-report=20+=20privacy=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CONFIRMED findings from the campaign's feature-wiring audit + structural verification: - Sleep rendered nothing in the doctor-report PDF: the sleep section is default-on and the data layer reconstructs per-night SLEEP_DURATION rows, but DOCTOR_REPORT_VITAL_TYPES omitted SLEEP_DURATION, so the vitals table showed nothing (FHIR already emitted it). Added it to the allowlist with a minutes-to-hours conversion; corrected the stale 'intentionally excluded' comment. - Glucose had no doctor-report section toggle — it rendered whenever the global glucose module was on, with no report-level privacy control. The toggle actually already existed in the UI (with i18n) but was dead because the prefs fold dropped the key. Wired it end to end (glucose added to the prefs Zod schema + defaults, default-on to preserve behaviour; gated the render loop + the query). Also fixed a grouped-vs-flat prefs mis-detection: 'glucose' in GROUPED_SECTION_KEYS would have flagged flat blobs (incl. EMPTY prefs) as grouped and defaulted them all on. No migration (prefs is a JSON blob). - environment fed the coach with no exclusion: MODULE_EXCLUDED_SOURCES omitted it, so module-off still leaked audio_env/audio_headphone/audio_event/daylight/ skin_temp to the snapshot. Added, scoped exactly to the environment cluster's five sources. Regression test per fix. --- CHANGELOG.md | 12 ++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- .../__tests__/route.test.ts | 2 + .../__tests__/doctor-report-pdf-core.test.ts | 41 +++++++++++++++++++ .../measurement-type-enum-coverage.test.ts | 7 ++-- src/lib/ai/coach/__tests__/snapshot.test.ts | 39 ++++++++++++++++++ src/lib/ai/coach/snapshot.ts | 14 +++++++ .../__tests__/share-view-data.test.ts | 30 ++++++++++++++ src/lib/doctor-report-data.ts | 18 +++++--- src/lib/doctor-report-pdf-core.ts | 23 ++++++++--- .../__tests__/doctor-report-prefs.test.ts | 13 ++++++ .../__tests__/health-record-export.test.ts | 9 ++++ src/lib/validations/doctor-report-prefs.ts | 9 ++++ src/lib/validations/health-record-export.ts | 9 +++- 16 files changed, 213 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf579d64c..bd9a1a7ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +## [1.28.45] — 2026-07-16 — Doctor report + privacy fixes + +- Sleep now actually appears in the doctor-report PDF. The sleep section was + enabled by default and its data was prepared, but the PDF's vitals table left + it out, so the section rendered empty — it now shows time asleep in hours. +- Glucose can now be included or withheld from a shared report like every other + clinical section. The toggle existed in settings but did nothing; it now + gates the glucose data end to end. +- Turning the environment module off now also keeps its data (audio exposure, + daylight, skin temperature) out of the coach, matching how the other modules + behave. + ## [1.28.44] — 2026-07-16 — Loose ends: a broken link, the API contract, module guards - The "connect a device" link on the sleep insights empty state pointed at a diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 69e11adc9..5b2e5dfaf 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.44 + version: 1.28.45 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index c2d767791..9f9fc92ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.44", + "version": "1.28.45", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index cc5416e87..f0792acaf 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.28.44"; + /* @sw-version-fallback */ "v1.28.45"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts index c8ebc622e..b5c5a5584 100644 --- a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts @@ -90,6 +90,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { mood: true, compliance: true, sleep: false, + glucose: true, cycle: false, labs: true, allergies: true, @@ -116,6 +117,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { mood: true, compliance: false, sleep: false, + glucose: false, cycle: false, labs: false, allergies: false, diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index 22cc7601f..183deca8a 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -173,6 +173,47 @@ describe("renderDoctorReportPdfBytes", () => { const header = String.fromCharCode(...bytes.slice(0, 5)); expect(header).toBe("%PDF-"); }); + + it("renders a sleep vitals row (minutes → hours) when sleep stats exist", async () => { + // SLEEP_DURATION stats are per-night asleep totals in MINUTES; the vitals + // table renders them in hours. 420 min → 7 h, 450 min avg → 7.5 h. + const data = makeData({ + stats: { + ...makeData().stats, + SLEEP_DURATION: { + avg: 450, + min: 390, + max: 510, + count: 7, + latest: 420, + }, + }, + }); + const bytes = renderDoctorReportPdfBytes(data, { + t: getServerTranslator("en").t, + locale: "en", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + // The sleep label and the hours-converted value both appear in the table. + // `num` renders one fixed fraction digit: 420 min → 7.0 h, 450 min → 7.5 h. + expect(text).toContain("Sleep duration"); + expect(text).toContain("7.0 h"); + expect(text).toContain("7.5 h"); + // The raw minute value must NOT leak into the table. + expect(text).not.toContain("420 h"); + expect(text).not.toContain("450 h"); + }); + + it("omits the sleep vitals row when there are no sleep stats", async () => { + const bytes = renderDoctorReportPdfBytes(makeData(), { + t: getServerTranslator("en").t, + locale: "en", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + expect(text).not.toContain("Sleep duration"); + }); }); describe("buildDoctorReportPdfDocument", () => { diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index d4869a3cc..63fb742a3 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -120,8 +120,10 @@ describe("measurementTypeEnum coverage", () => { // Documented exclusions from the doctor-report main vitals table: // - BLOOD_GLUCOSE renders through the per-context `glucoseStats` section - // - SLEEP_DURATION + ACTIVITY_STEPS are intentionally omitted from the - // clinical PDF (lifestyle, not a vital sign — see source comment). + // - ACTIVITY_STEPS is intentionally omitted from the clinical PDF + // (lifestyle, not a vital sign — see source comment). SLEEP_DURATION + // DOES render in the vitals table (per-night asleep hours), gated on the + // default-ON `sleep` section toggle — see doctor-report-pdf-core.ts. // - v1.4.23 Apple Health metrics (HRV, resting HR, active energy, // flights, distance, VO2 max, body temperature) are excluded from // the v1.4.23 release of the doctor PDF — they ship into the @@ -136,7 +138,6 @@ describe("measurementTypeEnum coverage", () => { // doctor-report-pdf-core.ts so the rationale stays discoverable. const PDF_VITAL_EXCLUSIONS = new Set([ "BLOOD_GLUCOSE", - "SLEEP_DURATION", "ACTIVITY_STEPS", "HEART_RATE_VARIABILITY", "RESTING_HEART_RATE", diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index 1e74d4c66..ce3b5cef1 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -635,6 +635,45 @@ describe("buildCoachSnapshot", () => { expect(out.provenance.metrics).not.toContain("glucose"); }); + it("drops the environment/exposure blocks when the environment module is disabled", async () => { + // The opt-in environment cluster owns the audio-exposure / daylight / + // skin-temperature sources. With the module off, none may reach the model. + resolveModuleMapMock.mockResolvedValue({ + ...allModulesEnabled(), + environment: false, + }); + featuresMock.mockResolvedValue({}); + prismaMock.measurement.findMany.mockResolvedValue([ + daysAgo(2, 72, "AUDIO_EXPOSURE_ENV"), + daysAgo(3, 33.1, "SKIN_TEMPERATURE"), + daysAgo(4, 45, "TIME_IN_DAYLIGHT"), + ]); + + const envSources = [ + "audio_env", + "audio_headphone", + "audio_event", + "daylight", + "skin_temp", + ] as const; + const out = await buildCoachSnapshot("user-1", { + sources: [...envSources], + }); + const parsed = JSON.parse(out.snapshotJson); + + // No environment block reaches the prompt. + expect(parsed.audioExposureEnvironment).toBeUndefined(); + expect(parsed.audioExposureHeadphone).toBeUndefined(); + expect(parsed.audioExposureEvent).toBeUndefined(); + expect(parsed.timeInDaylight).toBeUndefined(); + expect(parsed.skinTemperature).toBeUndefined(); + // Every environment source token is stripped from scope. + for (const src of envSources) { + expect(parsed.scope.sources).not.toContain(src); + expect(out.provenance.metrics).not.toContain(src); + } + }); + it("drops the sleep + sleepRhythm blocks when the sleep module is disabled", async () => { resolveModuleMapMock.mockResolvedValue({ ...allModulesEnabled(), diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index f57e88c1a..56b627f8b 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -211,6 +211,9 @@ const CORE_CLUSTERS: ReadonlySet = new Set([ * tokens drops the raw additive timelines too; the * composites are additionally gated below so they never * build off the sleep signal alone. + * - `environment` → the audio-exposure (env / headphone / event), daylight, + * and skin-temperature blocks — the sources the opt-in + * environment cluster owns (`CLUSTER_SOURCES.environment`). * * `cycle` is intentionally absent: its block already resolves through the * fully two-layer cycle gate (`isCycleAvailableForUser` — the per-user @@ -226,6 +229,17 @@ const MODULE_EXCLUDED_SOURCES: Partial> = glucose: ["glucose"], workouts: ["workouts"], recovery: ["hrv", "resting_hr", "vo2_max"], + // The environment/exposure cluster owns exactly these sources (mirrors + // `CLUSTER_SOURCES.environment` in clusters.ts). When the opt-in + // environment module is off, strip its audio-exposure / daylight / + // skin-temperature blocks so the model never sees a disabled domain. + environment: [ + "audio_env", + "audio_headphone", + "audio_event", + "daylight", + "skin_temp", + ], }; /** diff --git a/src/lib/clinician-share/__tests__/share-view-data.test.ts b/src/lib/clinician-share/__tests__/share-view-data.test.ts index 48aac4355..f5dc1a1cf 100644 --- a/src/lib/clinician-share/__tests__/share-view-data.test.ts +++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts @@ -306,6 +306,36 @@ describe("loadShareViewData — grouped sections are folded, not silently defaul expect(opts.sections.labs).toBe(true); }); + it("folds a grouped glucose=OFF toggle down to sections.glucose=false", async () => { + // A diabetic owner withholds glucose from THIS share without disabling the + // glucose module app-wide. The grouped `glucose` toggle must survive the + // fold and reach the aggregator so no glucose panel is built. + await loadShareViewData( + ctx({ + sectionsJson: { + vitals: { bp: true }, + glucose: false, + }, + documentOnly: false, + }), + ); + const opts = collect.mock.calls[0]![2] as { + sections: Record; + }; + expect(opts.sections.glucose).toBe(false); + // A share that leaves glucose unspecified keeps the default-ON behaviour. + vi.clearAllMocks(); + collect.mockResolvedValue({ patient: { displayName: "Shared record" } }); + findDocs.mockResolvedValue([]); + await loadShareViewData( + ctx({ sectionsJson: { vitals: { bp: true } }, documentOnly: false }), + ); + const opts2 = collect.mock.calls[0]![2] as { + sections: Record; + }; + expect(opts2.sections.glucose).toBe(true); + }); + it("still resolves a flat legacy blob through the flat parser unchanged", async () => { // A flat shape has no grouped-only key, so it keeps the exact legacy path. await loadShareViewData( diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 8f533c90b..8f7e0d904 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -104,11 +104,15 @@ export async function collectDoctorReportData( // bmi/compliance) carry no key and are never gated here. if (moduleMap.mood === false) sections.mood = false; if (moduleMap.sleep === false) sections.sleep = false; + if (moduleMap.glucose === false) sections.glucose = false; if (moduleMap.cycle === false) sections.cycle = false; if (moduleMap.labs === false) sections.labs = false; // Module gates with no `sections` key — applied directly to the data slices - // below (glucose panel, recovery/strain wellness scores, workout series). - const glucoseEnabled = moduleMap.glucose !== false; + // below (recovery/strain wellness scores, workout series). Glucose now + // carries its own report section toggle, into which the module gate above + // already folds — so the section flag is the single lever for the glucose + // panel (stats + ranges + clinical metrics). + const glucoseEnabled = sections.glucose !== false; const recoveryEnabled = moduleMap.recovery !== false; const workoutsEnabled = moduleMap.workouts !== false; @@ -140,6 +144,8 @@ export async function collectDoctorReportData( } if (sections.pulse === false) excludedMeasurementTypes.push("PULSE"); if (sections.sleep === false) excludedMeasurementTypes.push("SLEEP_DURATION"); + if (sections.glucose === false) + excludedMeasurementTypes.push("BLOOD_GLUCOSE"); for (const [type, moduleKey] of Object.entries(MEASUREMENT_TYPE_MODULE)) { if (moduleMap[moduleKey] === false) { excludedMeasurementTypes.push(type as MeasurementType); @@ -924,10 +930,10 @@ export async function collectDoctorReportData( /** * Per-measurement-type → section-toggle mapping. Keys not listed here - * (e.g., glucose contexts, body fat, oxygen saturation) bypass the - * toggles for now — those sections will get their own flags in a - * future iteration. The current scope per the maintainer's directive is the - * "big five" plus mood + compliance. + * (e.g., body fat, oxygen saturation) bypass the toggles — those sections + * have no per-report flag. Glucose DOES carry a `glucose` section toggle, + * but `BLOOD_GLUCOSE` is gated at query-exclusion time (mirroring sleep) and + * its panel collapses via `glucoseEnabled`, so it does not ride this map. */ const MEASUREMENT_TYPE_SECTION: Record = { BLOOD_PRESSURE_SYS: "bp", diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 2f76a1905..a4a06623b 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -183,8 +183,12 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = { * smart scales report all three together. SpO2 (Withings ScanWatch type * 54, HealthKit, n8n / Health Connect) is rendered last in the same * table for clinical readability. Glucose ships separately via - * per-context `glucoseStats`. Sleep + activity are intentionally - * excluded from a clinical-focused report. + * per-context `glucoseStats`. SLEEP_DURATION rides this table too: the + * `sleep` section toggle is default-ON and the data layer reconstructs a + * per-night time-asleep total (in minutes) for exactly this row — the + * render loop converts it to hours so value and unit (`h`) agree, matching + * the FHIR sleep Observation. Activity (steps) stays excluded from a + * clinical-focused report. * * v1.10.0 — computed scores (WX-C). The server-derived `*_SCORE` types * (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE) are NOT clinical vitals: @@ -216,6 +220,7 @@ export const DOCTOR_REPORT_VITAL_TYPES = [ "TOTAL_BODY_WATER", "BONE_MASS", "OXYGEN_SATURATION", + "SLEEP_DURATION", ] as const; const MOOD_LABEL_KEYS: Record = { @@ -689,12 +694,18 @@ export function buildDoctorReportPdfDocument( const s = data.stats[type]; if (!s) continue; const unit = unitFor(type); + // SLEEP_DURATION stats are per-night asleep totals in MINUTES (the data + // layer reconstructs them for exactly this row); the vitals unit is hours + // (`h`), so convert to keep value and unit consistent — the same hours + // value the FHIR sleep Observation emits. + const conv = + type === "SLEEP_DURATION" ? (v: number) => v / 60 : (v: number) => v; vitalRows.push([ t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? ""), - `${num(s.latest)} ${unit}`.trim(), - `${num(s.avg)} ${unit}`.trim(), - num(s.min), - num(s.max), + `${num(conv(s.latest))} ${unit}`.trim(), + `${num(conv(s.avg))} ${unit}`.trim(), + num(conv(s.min)), + num(conv(s.max)), String(s.count), ]); } diff --git a/src/lib/validations/__tests__/doctor-report-prefs.test.ts b/src/lib/validations/__tests__/doctor-report-prefs.test.ts index 59cbb0950..c3abd34fc 100644 --- a/src/lib/validations/__tests__/doctor-report-prefs.test.ts +++ b/src/lib/validations/__tests__/doctor-report-prefs.test.ts @@ -57,6 +57,18 @@ describe("parseDoctorReportPrefs", () => { expect(out.mood).toBe(false); }); + it("defaults glucose to ON (per-report control, parity with pre-toggle behaviour)", () => { + const out = parseDoctorReportPrefs(null); + expect(out.glucose).toBe(true); + }); + + it("accepts an explicit glucose=false partial update", () => { + const out = parseDoctorReportPrefs({ glucose: false }); + expect(out.glucose).toBe(false); + // Other sections keep their defaults. + expect(out.labs).toBe(true); + }); + it("defaults labs to ON (recorded to share with a clinician)", () => { const out = parseDoctorReportPrefs(null); expect(out.labs).toBe(true); @@ -90,6 +102,7 @@ describe("resolveDoctorReportPrefs", () => { mood: true, compliance: false, sleep: false, + glucose: false, cycle: false, labs: false, allergies: false, diff --git a/src/lib/validations/__tests__/health-record-export.test.ts b/src/lib/validations/__tests__/health-record-export.test.ts index a859b1a59..d79ad1472 100644 --- a/src/lib/validations/__tests__/health-record-export.test.ts +++ b/src/lib/validations/__tests__/health-record-export.test.ts @@ -104,4 +104,13 @@ describe("toDoctorReportPrefs", () => { toDoctorReportPrefs({ medications: { compliance: false } }).compliance, ).toBe(false); }); + + it("threads the glucose section toggle (default ON, off when unchecked)", () => { + // Default-on preserves the pre-toggle behaviour where glucose always + // rendered; an explicit false withholds glucose from this report. + expect(toDoctorReportPrefs(undefined).glucose).toBe(true); + expect(toDoctorReportPrefs({}).glucose).toBe(true); + expect(toDoctorReportPrefs({ glucose: true }).glucose).toBe(true); + expect(toDoctorReportPrefs({ glucose: false }).glucose).toBe(false); + }); }); diff --git a/src/lib/validations/doctor-report-prefs.ts b/src/lib/validations/doctor-report-prefs.ts index c4a0daddb..2da3c2075 100644 --- a/src/lib/validations/doctor-report-prefs.ts +++ b/src/lib/validations/doctor-report-prefs.ts @@ -31,6 +31,7 @@ export const doctorReportPrefsSchema = z mood: z.boolean(), compliance: z.boolean(), sleep: z.boolean(), + glucose: z.boolean(), cycle: z.boolean(), labs: z.boolean(), allergies: z.boolean(), @@ -53,6 +54,7 @@ export interface DoctorReportPrefs { mood: boolean; compliance: boolean; sleep: boolean; + glucose: boolean; cycle: boolean; labs: boolean; allergies: boolean; @@ -73,6 +75,12 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = { mood: false, // privacy default per the maintainer compliance: true, sleep: true, + // Glucose the user recorded to share with a clinician — ON by default, + // like BP / weight / labs. The per-report toggle lets a diabetic user + // withhold glucose from a SPECIFIC shared report without disabling the + // glucose module app-wide (default-on preserves the pre-toggle behaviour, + // where glucose always rendered). + glucose: true, // Cycle data is opt-in: a user sharing a BP report with a cardiologist // should not auto-leak reproductive data. Same privacy stance as mood. cycle: false, @@ -102,6 +110,7 @@ export const EMPTY_DOCTOR_REPORT_PREFS: DoctorReportPrefs = { mood: false, compliance: false, sleep: false, + glucose: false, cycle: false, labs: false, allergies: false, diff --git a/src/lib/validations/health-record-export.ts b/src/lib/validations/health-record-export.ts index 7563e0e82..37538d4d2 100644 --- a/src/lib/validations/health-record-export.ts +++ b/src/lib/validations/health-record-export.ts @@ -94,13 +94,19 @@ export type ExportSections = z.infer; * signal that a persisted `sectionsJson` blob is the grouped shape rather than * the flat one — the flat shape carries `bp` / `weight` / `pulse` at the top * level, the grouped shape nests them under `vitals`. + * + * `glucose` is deliberately NOT a discriminator: it is a top-level boolean in + * BOTH shapes (the flat `DoctorReportPrefs` gained its own `glucose` section + * toggle), so its presence no longer distinguishes grouped from flat. A real + * grouped export always carries one of the nested groups below, so detection + * stays reliable without it — and a bare `{ glucose: false }` blob correctly + * resolves through the flat parser. */ const GROUPED_SECTION_KEYS = [ "vitals", "cardioFitness", "activity", "medications", - "glucose", ] as const; /** @@ -187,6 +193,7 @@ export function toDoctorReportPrefs( mood: s.mood === true, compliance: m.compliance ?? m.list ?? fallback.compliance, sleep: a.sleep ?? fallback.sleep, + glucose: s.glucose ?? fallback.glucose, // Cycle is opt-in: only true when explicitly requested (privacy). cycle: s.cycle === true, labs: s.labs ?? fallback.labs,