/* RFC-0217: The Claim Ledger — event schema, append helpers, as-of/lineage query, and knowledge-graph/temporal-SEO projectors. The ledger is an append-only NDJSON file (`src/content/ledger/claims.ndjson`); each line is one immutable ClaimEvent. The projector produces two generated JSON artifacts (knowledge.generated.yaml + seo/temporal.generated.yaml) on demand. Framework-free; no filesystem access. RFC-0217, CKL, ledger, claim event, lineage, temporal SEO, knowledge graph Define ClaimEvent, ClaimLineage, TemporalSeo, KnowledgeGraph contracts. stableEventId — deterministic event identifier. buildLineage — assemble lineage from flat event list for a subject. queryAsOf — value of a subject as of a given date. projectGraph — current claims × lineage as nodes+edges. projectTemporalSeo — per-page datePublished/dateModified. Do not read/write the filesystem — the kernel command does that. Do not parse sidecar files — consume pre-parsed events only. The immutable ledger record shape. sha256-based deterministic event id. Lineage for a subject: current + history (newest→oldest). Value as of a given date. Deterministic projections. RFC-0217: initial ledger contract and projection helpers. */ import { createHash } from "node:crypto"; import type { ClaimProvenanceKind } from "./claim.ts"; export type ClaimEventKind = "genesis" | "verify-update" | "verify-noop" | "translate" | "supersede" | "retire"; export interface ClaimEvent { /** Stable deterministic id for this event line. */ id: string; /** ISO timestamp (event time, UTC). */ ts: string; /** Canonical subject address (RFC-0211). */ subject: string; /** String value (omitted for large prose; then valueHash set). */ value?: string; /** sha256: of the value for large prose. */ valueHash?: string; provenance: ClaimProvenanceKind; sourceRef?: string; /** ISO date of the fact (when the value was true). */ asOf: string; /** Id of the event this supersedes (linking lineage). */ supersedes?: string; /** Agent or human handle that produced this event. */ actor: string; event: ClaimEventKind; } export interface ClaimLineage { subject: string; current: ClaimEvent; /** Full history including current, newest → oldest. */ history: ClaimEvent[]; } export interface TemporalSeo { /** Route/page slug. */ page: string; /** ISO date of earliest genesis event for claims on this page. */ datePublished: string; /** ISO date of latest verify-update event for claims on this page. */ dateModified: string; /** ISO interval "YYYY-MM-DD/YYYY-MM-DD" when earliest ≠ latest. */ temporalCoverage?: string; } export interface KnowledgeGraphNode { subject: string; currentValue?: string; provenance: ClaimProvenanceKind; asOf: string; sourceRef?: string; latestEventId: string; historyCount: number; } export interface KnowledgeGraph { generatedAt: string; app: string; nodes: KnowledgeGraphNode[]; } export const LEDGER_GENERATED_MARKER = "GENERATED. Do not change this line unless the file contains project specific changes."; /** * Deterministic event id: sha256 of (subject + asOf + event + actor + value). * The value (or valueHash for large prose) is part of the identity so that two * genuinely distinct corrections on the same day by the same actor produce * distinct events, while re-appending an identical event stays idempotent. */ export function stableEventId( subject: string, asOf: string, event: ClaimEventKind, actor: string, value?: string, ): string { const key = `${subject}|${asOf}|${event}|${actor}|${value ?? ""}`; return `evt_${createHash("sha256").update(key, "utf8").digest("hex").slice(0, 16)}`; } /** Parse an NDJSON string into a list of ClaimEvents (skips blank lines). */ export function parseNdjson(ndjson: string): ClaimEvent[] { return ndjson .split("\n") .filter((l) => l.trim().length > 0) .map((l) => JSON.parse(l) as ClaimEvent); } /** Serialize a ClaimEvent to a single NDJSON line (no trailing newline). */ export function serializeEvent(event: ClaimEvent): string { return JSON.stringify(event); } /** Build a lineage chain for a subject from the flat event list. */ export function buildLineage(events: ClaimEvent[], subject: string): ClaimLineage | null { const relevant = events.filter((e) => e.subject === subject); if (relevant.length === 0) return null; // Sort newest first by ts. const sorted = [...relevant].sort((a, b) => b.ts.localeCompare(a.ts)); return { subject, current: sorted[0], history: sorted, }; } /** * Query the value of a subject as of a given ISO date. * Returns the most-recent event whose asOf ≤ queryDate, or null. */ export function queryAsOf( events: ClaimEvent[], subject: string, queryDate: string, ): ClaimEvent | null { const candidates = events .filter((e) => e.subject === subject && e.asOf <= queryDate) .sort((a, b) => b.asOf.localeCompare(a.asOf) || b.ts.localeCompare(a.ts)); return candidates[0] ?? null; } /** Project current-state knowledge graph from the ledger. */ export function projectGraph(events: ClaimEvent[], app: string): KnowledgeGraph { const bySubject = new Map(); for (const e of events) { const list = bySubject.get(e.subject) ?? []; list.push(e); bySubject.set(e.subject, list); } const nodes: KnowledgeGraphNode[] = []; for (const [subject, evts] of bySubject) { const sorted = [...evts].sort((a, b) => b.ts.localeCompare(a.ts)); const current = sorted[0]; nodes.push({ subject, currentValue: current.value, provenance: current.provenance, asOf: current.asOf, sourceRef: current.sourceRef, latestEventId: current.id, historyCount: evts.length, }); } nodes.sort((a, b) => a.subject.localeCompare(b.subject)); return { generatedAt: new Date().toISOString(), app, nodes, }; } /** * Project temporal SEO metadata per page from the ledger. * `pageClaimsMap` maps a page route → array of claim subjects surfaced on that page. */ export function projectTemporalSeo( events: ClaimEvent[], pageClaimsMap: Map, ): TemporalSeo[] { const result: TemporalSeo[] = []; for (const [page, subjects] of pageClaimsMap) { const relevantEvents = events.filter((e) => subjects.includes(e.subject)); if (relevantEvents.length === 0) continue; const genesisDates = relevantEvents .filter((e) => e.event === "genesis") .map((e) => e.asOf) .sort(); const updateDates = relevantEvents .filter((e) => e.event === "verify-update" || e.event === "genesis") .map((e) => e.asOf) .sort(); const datePublished = genesisDates[0] ?? updateDates[0] ?? relevantEvents[0].asOf; const dateModified = updateDates[updateDates.length - 1] ?? datePublished; const temporalCoverage = datePublished !== dateModified ? `${datePublished}/${dateModified}` : undefined; result.push({ page, datePublished, dateModified, temporalCoverage }); } return result.sort((a, b) => a.page.localeCompare(b.page)); }