import type { CandidateSet, ConfigSnapshot, EffectivePolicy, ModelParts, PolicyPatch, ProbePolicy, ReviewPolicy, ValidatedSource } from "./types"; import { MAX_EPISODE_PROBES } from "../recovery/limits"; export class ConfigValidationError extends Error { constructor(readonly source: string, message: string) { super(`${source}: ${message}`); } } const record = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); const bad = (source: string, message: string): never => { throw new ConfigValidationError(source, message); }; const number = (value: unknown, name: string, source: string, integer = false, nonNegative = false): number => { if (typeof value !== "number" || !Number.isFinite(value) || (integer && !Number.isInteger(value)) || (nonNegative ? value < 0 : value <= 0)) bad(source, `invalid policy.${name}`); return value as number; }; export function parseModelKey(value: unknown): ModelParts | undefined { if (typeof value !== "string") return; const slash = value.indexOf("/"); return slash > 0 && slash < value.length - 1 ? { provider: value.slice(0, slash), modelId: value.slice(slash + 1) } : undefined; } const patchPolicy = (value: unknown, source: string): PolicyPatch => { if (value === undefined) return {}; if (!record(value)) bad(source, "policy must be an object"); const policy = value as Record; const probeValue = policy.probe; const reviewValue = policy.review; if (probeValue !== undefined && !record(probeValue)) bad(source, "policy.probe must be an object"); if (reviewValue !== undefined && !record(reviewValue)) bad(source, "policy.review must be an object"); const probe: Partial = {}; const review: Partial = {}; if (record(probeValue)) { if ("earlyDiscriminationProbe" in probeValue && probeValue.earlyDiscriminationProbe !== true) bad(source, "earlyDiscriminationProbe cannot be disabled"); if ("silent" in probeValue && probeValue.silent !== true) bad(source, "silent cannot be disabled"); if ("mode" in probeValue && probeValue.mode !== "fixed") bad(source, "invalid policy.mode"); if ("intervalMin" in probeValue) probe.intervalMin = number(probeValue.intervalMin, "intervalMin", source); if ("maxProbes" in probeValue) probe.maxProbes = Math.min(MAX_EPISODE_PROBES, number(probeValue.maxProbes, "maxProbes", source, true)); if ("resetMarginSec" in probeValue) probe.resetMarginSec = number(probeValue.resetMarginSec, "resetMarginSec", source, false, true); if ("outageIntervalMin" in probeValue) probe.outageIntervalMin = number(probeValue.outageIntervalMin, "outageIntervalMin", source); if ("maxOutageProbes" in probeValue) probe.maxOutageProbes = number(probeValue.maxOutageProbes, "maxOutageProbes", source, true); if ("earlyDiscriminationProbe" in probeValue) probe.earlyDiscriminationProbe = true; if ("mode" in probeValue) probe.mode = "fixed"; if ("silent" in probeValue) probe.silent = true; } if (record(reviewValue) && "flagShellActivity" in reviewValue) { if (typeof reviewValue.flagShellActivity !== "boolean") bad(source, "invalid policy.flagShellActivity"); review.flagShellActivity = reviewValue.flagShellActivity as boolean; } return { probe, review }; }; /** OD-1 sugar normalization: a bare string or bare array become a substitutes-only set; the object form * passes its fields through. Returns `undefined` for a shape none of the three accepted forms match. */ function normalizeCandidateInput(value: unknown): { substitutes: string[]; assistant?: string } | undefined { if (typeof value === "string") return { substitutes: [value] }; if (Array.isArray(value)) return { substitutes: [...value] }; if (record(value)) { const rawSubstitutes = (value as Record).substitutes; const rawAssistant = (value as Record).assistant; if (rawSubstitutes !== undefined && !Array.isArray(rawSubstitutes)) return undefined; if (rawAssistant !== undefined && typeof rawAssistant !== "string") return undefined; const substitutes = Array.isArray(rawSubstitutes) ? [...rawSubstitutes] : []; return typeof rawAssistant === "string" ? { substitutes, assistant: rawAssistant } : { substitutes }; } return undefined; } /** D4: the ONE shared implementation of the two within-set candidate rejections — a primary naming * itself ("self edge is not allowed") and a repeated entry ("duplicate candidate entry is not allowed"). * Two consumers, two transports: `validateSource` throws `ConfigValidationError` with source context on * the config path (load and `/fallback set`); `decodeSnapshot` consumes it at the rehydration boundary, * where a violation makes the decode return `undefined` → R17's loud rebuild failure. */ export function candidateSetViolation(primary: string, set: CandidateSet): string | undefined { const entries = set.assistant !== undefined ? [...set.substitutes, set.assistant] : set.substitutes; if (entries.includes(primary)) return "self edge is not allowed"; if (new Set(entries).size !== entries.length) return "duplicate candidate entry is not allowed"; return undefined; } /** R22/D-W7/AC-W9: the empty-set rejection, exported so the config wizard's pre-save gate applies the * exact same rule and message the store applies (one shared implementation, no drift). An * assistant-only set honours one model and is never vacuous. */ export function emptySetViolation(set: { substitutes: readonly string[]; assistant?: string }): string | undefined { return set.substitutes.length === 0 && set.assistant === undefined ? "candidate set must honour at least one model" : undefined; } export function validateSource(input: unknown, source: string): ValidatedSource { if (!record(input)) bad(source, "configuration must be an object"); const object = input as Record; if (object.version !== undefined && object.version !== 1) bad(source, "unsupported version"); const fallbacks: Record = {}; const raw = object.fallbacks; if (raw !== undefined && !record(raw)) bad(source, "fallbacks must be an object"); if (record(raw)) for (const [key, value] of Object.entries(raw)) { if (!parseModelKey(key)) bad(source, "invalid model key"); const normalized = normalizeCandidateInput(value); if (!normalized) bad(source, "invalid candidate set"); const { substitutes, assistant } = normalized!; const entries = assistant !== undefined ? [...substitutes, assistant] : substitutes; // A set that honours nothing is a mistake, not a configuration. The object branch of // normalizeCandidateInput defaults a missing `substitutes` to an empty array, so `{}`, `[]`, an // unknown field name and a singular `substitute` typo all reach here as an empty set. Accepting // them would store a primary with no honoured candidate: the reducer's first-substitute read // yields undefined and the episode behaves exactly as if nothing were configured, so a typo // would silently disable fallback for that model. The scalar loop this replaced rejected all of // them through parseModelKey; this restores that strictness. An assistant-only set is NOT // vacuous and stays legal, because it honours one model. R22/D-W7: the rule and message now live // in the shared `emptySetViolation` export (same position, same message) so the wizard's pre-save // gate cannot drift from this one. const emptyViolation = emptySetViolation({ substitutes, ...(assistant !== undefined ? { assistant } : {}) }); if (emptyViolation) bad(source, emptyViolation); for (const entry of entries) if (!parseModelKey(entry)) bad(source, "invalid model key"); const violation = candidateSetViolation(key, { substitutes, ...(assistant !== undefined ? { assistant } : {}) }); if (violation) bad(source, violation); fallbacks[key] = assistant !== undefined ? { substitutes, assistant } : { substitutes }; } return { fallbacks, policy: patchPolicy(object.policy, source) }; } const defaults: EffectivePolicy = { probe: { earlyDiscriminationProbe: true, mode: "fixed", intervalMin: 60, maxProbes: MAX_EPISODE_PROBES, silent: true, resetMarginSec: 60, outageIntervalMin: 10, maxOutageProbes: 6 }, review: { flagShellActivity: true } }; /** TRAP 2 fix: a real deep freeze — each CandidateSet and its `substitutes` array are frozen individually, * not just the outer record. Shared by `freezeConfig` below and by `pi-adapters.ts`'s runtime config copy. */ export const freezeCandidateSet = (set: CandidateSet): CandidateSet => Object.freeze({ substitutes: Object.freeze([...set.substitutes]), ...(set.assistant !== undefined ? { assistant: set.assistant } : {}) }); export const freezeCandidateSets = (fallbacks: Readonly>): Readonly> => Object.freeze(Object.fromEntries(Object.entries(fallbacks).map(([key, set]) => [key, freezeCandidateSet(set)]))); export const freezeConfig = (fallbacks: Record, policy: EffectivePolicy): ConfigSnapshot => Object.freeze({ version: 1, fallbacks: freezeCandidateSets(fallbacks), policy: Object.freeze({ probe: Object.freeze({ ...policy.probe }), review: Object.freeze({ ...policy.review }) }) }); export function mergeSources(global?: ValidatedSource, project?: ValidatedSource): ConfigSnapshot { const fallbacks = { ...(global?.fallbacks ?? {}), ...(project?.fallbacks ?? {}) }; return freezeConfig(fallbacks, { probe: { ...defaults.probe, ...(global?.policy.probe ?? {}), ...(project?.policy.probe ?? {}) }, review: { ...defaults.review, ...(global?.policy.review ?? {}), ...(project?.policy.review ?? {}) } }); }