/* RFC-0214: external source binding. A source descriptor names an external authority and how to obtain a value (kind, endpoint, extraction, expected type, check cadence, tolerance). A claim's sourceRef resolves to a descriptor; the shared Truth Monitor fetches it on a cadence and compares the observed value to the asserted one. This module is the framework-free contract: the zod descriptor schema, value extraction, and tolerance comparison — reused by source.binding.validate and source.monitor.run. Do not perform network I/O — the monitor command/worker fetches; this module only shapes + compares. Do not parse HTML selectors here — that lives in the worker runtime (browser/DOM); the shared lib handles JSON. RFC-0214: initial source descriptor schema, JSON extraction, tolerance compare. */ import { z } from "zod"; export const SOURCE_KINDS = ["http-json", "http-html-selector", "rest-api", "manual"] as const; export type SourceKind = (typeof SOURCE_KINDS)[number]; const ISO8601_DURATION = /^P(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?$/; export const sourceToleranceSchema = z.object({ kind: z.enum(["exact", "relative", "absolute"]), value: z.number().nonnegative(), }); export type SourceTolerance = z.infer; export const sourceDescriptorSchema = z .object({ id: z.string().min(1), title: z.string().min(1), kind: z.enum(SOURCE_KINDS), endpoint: z.string().url().optional(), /** JSONPath-lite (`$.a.b[0].c`) or CSS selector (http-html-selector). */ extract: z.string().min(1).optional(), expectedType: z.enum(["string", "integer", "number", "money", "date", "enum"]), checkEvery: z .string() .regex(ISO8601_DURATION, "must be an ISO 8601 duration (e.g. P3M)") .refine((v) => v !== "P" && v !== "PT", "duration must have at least one component"), tolerance: sourceToleranceSchema.optional(), residency: z.enum(["eu", "any"]).optional().default("eu"), /** RFC-0241: the source's license (e.g. "CC-BY-4.0"), for third-party cited datasets. */ license: z.string().min(1).optional(), /** * RFC-0241: identity-firewall marker for external public-good sources that must be cited, never * branded as a studio project. "ownership-forbidden" is enforced by a dedicated firewall check * (e.g. `hdri.firewall.validate`), not by this schema. */ firewall: z.enum(["ownership-forbidden"]).optional(), }) .strict() .superRefine((d, ctx) => { if (d.kind !== "manual" && !d.endpoint) { ctx.addIssue({ code: "custom", message: `kind "${d.kind}" requires an endpoint`, path: ["endpoint"], }); } if ((d.kind === "http-json" || d.kind === "http-html-selector") && !d.extract) { ctx.addIssue({ code: "custom", message: `kind "${d.kind}" requires an extract path`, path: ["extract"], }); } }); export type SourceDescriptor = z.infer; /** * Read a JSONPath-lite expression `$.a.b[0].c` from a parsed JSON value. * Supports dotted keys and `[index]` array access. Returns undefined on any miss. */ export function extractJsonPath(payload: unknown, path: string): unknown { const trimmed = path.startsWith("$.") ? path.slice(2) : path.startsWith("$") ? path.slice(1) : path; const tokens: string[] = []; for (const seg of trimmed.split(".")) { if (seg === "") continue; const m = seg.match(/^([^[\]]*)((?:\[\d+\])*)$/); if (!m) return undefined; if (m[1]) tokens.push(m[1]); for (const idx of m[2]!.matchAll(/\[(\d+)\]/g)) tokens.push(idx[1]!); } let cur: unknown = payload; for (const tok of tokens) { if (cur === null || cur === undefined) return undefined; if (Array.isArray(cur)) { const i = Number(tok); if (!Number.isInteger(i)) return undefined; cur = cur[i]; } else if (typeof cur === "object") { cur = (cur as Record)[tok]; } else { return undefined; } } return cur; } /** Pull the first number out of a string/number (handles "70 € / Monat", "39,120"). */ export function coerceNumber(value: unknown): number | null { if (typeof value === "number") return Number.isFinite(value) ? value : null; if (typeof value !== "string") return null; const cleaned = value .replace(/\s/g, "") .replace(/[.,](?=\d{3}(?:\D|$))/g, "") // drop thousands separators .replace(",", "."); // remaining comma = decimal point const m = cleaned.match(/-?\d+(?:\.\d+)?/); return m ? Number(m[0]) : null; } /** * Compare an observed value against the asserted one under a descriptor's tolerance. * Numeric types compare numerically (with relative/absolute bands); others compare as * normalized strings (exact). Returns whether the observed value is within tolerance. */ export function compareValues( asserted: unknown, observed: unknown, expectedType: SourceDescriptor["expectedType"], tolerance?: SourceTolerance, ): { withinTolerance: boolean; assertedStr: string; observedStr: string } { const assertedStr = String(asserted ?? ""); const observedStr = String(observed ?? ""); const numeric = expectedType === "integer" || expectedType === "number" || expectedType === "money"; if (numeric) { const a = coerceNumber(asserted); const o = coerceNumber(observed); if (a === null || o === null) { return { withinTolerance: assertedStr === observedStr, assertedStr, observedStr }; } const t = tolerance ?? { kind: "exact" as const, value: 0 }; let within: boolean; if (t.kind === "exact") within = a === o; else if (t.kind === "absolute") within = Math.abs(a - o) <= t.value; else within = a === 0 ? o === 0 : Math.abs(a - o) / Math.abs(a) <= t.value; // relative return { withinTolerance: within, assertedStr, observedStr }; } const norm = (s: string) => s.replace(/\s+/g, " ").trim(); return { withinTolerance: norm(assertedStr) === norm(observedStr), assertedStr, observedStr }; } /** One Truth Monitor finding, written to the outbox and surfaced by source.binding.validate. */ export interface DivergenceRecord { sourceId: string; /** Canonical subject string of the bound claim. */ subject: string; app: string; assertedValue: string; observedValue: string; withinTolerance: boolean; observedAt: string; /** True when the source could not be fetched (CKL-SRC-04, info — not a divergence). */ unreachable?: boolean; }