/* RFC-0213: the authored-content freshness model. Given a claim's temporal window (asOf / validUntil / reviewEvery) and "today", derive a freshness state and the ledger entry shape. Framework-free, deterministic, date-only — the surface Freshness Ledger (RFC-0196) shares this state taxonomy + ledger shape so one health view can read both, but computes state from page substance, not dates. Do not read the filesystem, parse sidecars, or emit Diagnostics — the kernel command does that. Do not compute page-substance decay (surface RFC-0196 owns that input). RFC-0213: initial freshness state model + ledger shapes. */ export type FreshnessState = "fresh" | "review-due" | "expiring-soon" | "expired" | "unsourced"; export type FreshnessCriticality = "advisory" | "important" | "blocking"; const MS_PER_DAY = 86_400_000; /** Parse an ISO date (YYYY-MM-DD) to a UTC epoch-ms at midnight, or null. */ function parseIsoDate(date: string): number | null { if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null; const ms = Date.parse(`${date}T00:00:00Z`); return Number.isNaN(ms) ? null : ms; } function toIso(ms: number): string { return new Date(ms).toISOString().slice(0, 10); } const DURATION_RE = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/; /** * Add an ISO 8601 duration to an ISO date. Calendar-correct for Y/M; W/D add * fixed days; the time component is truncated to whole days (cadences are coarse). * Returns null on malformed input. */ export function addDuration(dateIso: string, duration: string): string | null { const base = parseIsoDate(dateIso); if (base === null) return null; const m = DURATION_RE.exec(duration); if (!m || duration === "P" || duration === "PT") return null; const [, y, mo, w, d] = m; const dt = new Date(base); if (y) dt.setUTCFullYear(dt.getUTCFullYear() + Number(y)); if (mo) dt.setUTCMonth(dt.getUTCMonth() + Number(mo)); const extraDays = (w ? Number(w) * 7 : 0) + (d ? Number(d) : 0); if (extraDays) dt.setUTCDate(dt.getUTCDate() + extraDays); return toIso(dt.getTime()); } export interface FreshnessWindow { asOf: string; validUntil?: string; reviewEvery?: string; } export interface FreshnessEvaluation { state: FreshnessState; reviewDueAt?: string; /** Days until validUntil; negative if past. Computed, not stored. */ daysToExpiry?: number; } /** * Derive freshness state from a window relative to `today` (ISO date). * `unsourced` is decided by the caller (a NEED_THIS value) and short-circuits here. * Precedence: expired > expiring-soon > review-due > fresh. */ export function evaluateFreshness( window: FreshnessWindow, today: string, soonWindowDays = 30, ): FreshnessEvaluation { const todayMs = parseIsoDate(today); const reviewDueAt = window.reviewEvery ? (addDuration(window.asOf, window.reviewEvery) ?? undefined) : undefined; let daysToExpiry: number | undefined; if (window.validUntil) { const vu = parseIsoDate(window.validUntil); if (vu !== null && todayMs !== null) { daysToExpiry = Math.round((vu - todayMs) / MS_PER_DAY); } } let state: FreshnessState = "fresh"; if (daysToExpiry !== undefined && daysToExpiry < 0) { state = "expired"; } else if (daysToExpiry !== undefined && daysToExpiry <= soonWindowDays) { state = "expiring-soon"; } else if (reviewDueAt && todayMs !== null) { const due = parseIsoDate(reviewDueAt); if (due !== null && due <= todayMs) state = "review-due"; } return { state, reviewDueAt, daysToExpiry }; } /** One claim's freshness in the ledger. `subject` is the canonical address string. */ export interface FreshnessLedgerEntry { subject: string; state: FreshnessState; asOf?: string; validUntil?: string; reviewDueAt?: string; criticality: FreshnessCriticality; } /** The deterministic per-app authored Freshness Ledger (mirrors the surface ledger shape). */ export interface AuthoredFreshnessLedger { site: string; entries: FreshnessLedgerEntry[]; summary: Record; } export function emptyFreshnessSummary(): Record { return { fresh: 0, "review-due": 0, "expiring-soon": 0, expired: 0, unsourced: 0 }; }