From 2bb49ae76417ef102beb6ce458cd3d9f8bba158f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 16:49:49 +0200 Subject: [PATCH] chore(source-priority): observable parse failures + frozen resolved blob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reconcile fixes on parseSourcePriority(), one observability and one invariant guard: - Emit a wide-event tag when the persisted JSON fails the Zod schema so a future schema-tightening regression that silently nukes a user's saved ladder surfaces in ops dashboards. annotate() is a no-op outside a request context, so static settings-page renders and unit tests stay side-effect-free. - Deep-freeze the resolved blob. The top-level flat keys share their backing arrays with `metricPriority` via the spread, so a caller mutating `resolved.metricPriority.weight = […]` would silently desync the two views. Freezing every ladder + the containers + the resolved root surfaces the mutation at runtime in strict mode. --- src/lib/validations/source-priority.ts | 40 ++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts index db6a0b35e..0afafe2aa 100644 --- a/src/lib/validations/source-priority.ts +++ b/src/lib/validations/source-priority.ts @@ -34,6 +34,7 @@ */ import { z } from "zod/v4"; +import { annotate } from "@/lib/logging/context"; import { measurementSourceEnum, measurementTypeEnum, @@ -272,7 +273,24 @@ export function parseSourcePriority(raw: unknown): ResolvedSourcePriority { if (raw == null) return buildResolved({}, {}, {}); const parsed = sourcePrioritySchema.safeParse(raw); - if (!parsed.success) return buildResolved({}, {}, {}); + if (!parsed.success) { + // v1.4.25 W10 reconcile (Sr-M2) — emit a wide-event tag so a future + // schema-tightening regression that silently nukes a user's saved + // ladder surfaces in ops dashboards rather than as a "why did my + // priorities reset?" user report. `annotate()` is a no-op outside a + // request context, so the static settings-page render and tests + // remain side-effect-free. + annotate({ + meta: { + sourcePriority: { + parse: "failed", + issueCount: parsed.error.issues.length, + firstIssuePath: parsed.error.issues[0]?.path.join(".") ?? null, + }, + }, + }); + return buildResolved({}, {}, {}); + } // Pull the W5e flat per-metric ladder off the top level. Drop the // two W8c container keys so the rest of the object is just the flat @@ -296,11 +314,29 @@ function buildResolved( ...flat, ...nested, }; - return { + const resolved: ResolvedSourcePriority = { ...merged, metricPriority: merged, deviceTypePriority, }; + // v1.4.25 W10 reconcile (Code-M1) — deep-freeze the resolved blob so + // a caller who mutates `resolved.metricPriority.weight = …` after + // parseSourcePriority() returns trips at runtime instead of silently + // desyncing the two views (the top-level flat keys share their + // backing object with `metricPriority` via the spread above, but + // anything written through the alias post-freeze raises in strict + // mode and surfaces in dev). Freezing the leaves keeps the contract + // immutable end-to-end without copying the source ladders. + for (const ladder of Object.values(merged)) { + Object.freeze(ladder); + } + for (const ladder of Object.values(deviceTypePriority)) { + if (ladder) Object.freeze(ladder); + } + Object.freeze(merged); + Object.freeze(deviceTypePriority); + Object.freeze(resolved); + return resolved; } /**