/** * ReportDocument v1/v2 contract (isomorphic Zod side). * * ReportDocument v2 is the canonical current contract. The v1 schema and * compatibility type alias remain readable while the existing producer and * renderer migrate independently. This file contains no node:crypto; evidence * hashes are recomputed only by the server entry and the Python validator. * * JSON Schema draft-07 cannot express every semantic rule. The runtime guards * below are mirrored by scripts/personal_report_contract.py: chart integrity, * requested-theme coverage, evidence-reference closure, evidence-id * uniqueness, blocked-claim safety, unsupported date rejection, content * safety, and the serialization cap. */ import { z } from "zod"; export const LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v1" as const; export const REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v2" as const; export const REPORT_DOCUMENT_V1_SCHEMA_VERSION = LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION; export const REPORT_DOCUMENT_V2_SCHEMA_VERSION = REPORT_DOCUMENT_SCHEMA_VERSION; export const CURRENT_REPORT_DOCUMENT_SCHEMA_VERSION = REPORT_DOCUMENT_SCHEMA_VERSION; export const REPORT_CONTRACT_V1_VERSION = "1" as const; export const REPORT_CONTRACT_V2_VERSION = "2" as const; /** @deprecated Existing v1 producer compatibility. */ export const REPORT_CONTRACT_VERSION = REPORT_CONTRACT_V1_VERSION; export const REPORT_DOCUMENT_MAX_BYTES = 1_572_864; // 1.5 MiB hard cap. export const CLAIM_STATUSES = [ "multi_system_consensus", "single_system_inference", "parameter_sensitive", "unclosed_divisional_chart", "user_history_verification_required", "blocked", ] as const; export type ClaimStatus = (typeof CLAIM_STATUSES)[number]; export const REPORT_TYPES = ["personal_full", "personal_thematic"] as const; export const PRESENTATION_MODES = ["default", "research"] as const; export const REPORT_DEPTHS = ["concise", "standard", "deep", "research"] as const; export type ReportDepth = (typeof REPORT_DEPTHS)[number]; export const BIRTH_TIME_STATUSES = ["reported", "candidate", "accepted", "confirmed"] as const; export const TECHNIQUE_STATUSES = ["verified", "partial", "blocked"] as const; export const CONFLICT_STATUSES = ["unresolved", "partial", "resolved"] as const; export const REPORT_ACTION_PRIORITIES = ["now", "next", "watch"] as const; export const REPORT_DOCUMENT_V1_CHART_IDS = ["D1", "D9", "D10"] as const; export const CHART_IDS = ["D1", "D2", "D9", "D10", "D11", "D24"] as const; const claimStatusSchema = z.enum(CLAIM_STATUSES); const themeIdSchema = z.string().regex(/^[a-z][a-z0-9_.-]{0,95}$/, "invalid theme id"); const sectionIdSchema = z.string().regex(/^[a-z][a-z0-9_-]{0,95}$/, "invalid section id"); const evidenceIdSchema = z.string().regex(/^ev-[a-z0-9_-]{1,63}$/, "invalid evidence id"); const sha256HexSchema = z.string().regex(/^[0-9a-f]{64}$/, "invalid sha256 hex"); const skillNameSchema = z.string() .max(120) .regex(/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/, "invalid skill name"); const skillVersionSchema = z.string() .max(80) .regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/, "invalid skill version"); const iso8601Schema = z.string() .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/, "invalid ISO-8601 timestamp"); const text = (maxLength: number, minLength = 1) => z.string().min(minLength).max(maxLength); const textArray = (maxItems: number, maxLength: number, minItems = 0) => z.array(text(maxLength)).min(minItems).max(maxItems); const evidenceRefsSchema = (minItems = 0) => z.array(evidenceIdSchema).min(minItems).max(24); const houseSchema = z.strictObject({ houseNumber: z.number().int().min(1).max(12), sign: text(40), occupants: textArray(12, 40), }); const planetSchema = z.strictObject({ name: text(40), sign: text(40), longitudeDegrees: z.number().min(0).lt(360), houseNumber: z.number().int().min(1).max(12), retrograde: z.boolean(), }); const reportDocumentV1ChartSchema = z.strictObject({ id: z.enum(REPORT_DOCUMENT_V1_CHART_IDS), title: text(120), houses: z.array(houseSchema).max(12), planets: z.array(planetSchema).max(12).optional(), claimStatus: claimStatusSchema, }); const reportDocumentV2ChartSchema = z.strictObject({ id: z.enum(CHART_IDS), title: text(120), houses: z.array(houseSchema).max(12), planets: z.array(planetSchema).max(12).optional(), claimStatus: claimStatusSchema, evidenceRefs: evidenceRefsSchema(1), }); const thematicSectionV1Schema = z.strictObject({ id: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/, "invalid section id"), title: text(160), narrative: text(4000), actions: textArray(12, 400), caveats: textArray(12, 400), claimStatus: claimStatusSchema, evidenceRefs: evidenceRefsSchema(), }); const thematicSectionV2Schema = z.strictObject({ id: sectionIdSchema, theme: themeIdSchema, title: text(160), narrative: text(4000), actions: textArray(12, 400), caveats: textArray(12, 400), claimStatus: claimStatusSchema, evidenceRefs: evidenceRefsSchema(1), }); const techniqueAuditRowSchema = z.strictObject({ id: evidenceIdSchema, techniqueId: z.string().regex(/^[a-z0-9_.-]{1,80}$/, "invalid technique id"), techniqueName: text(160), status: z.enum(TECHNIQUE_STATUSES), used: z.boolean(), notes: z.string().max(500).optional(), }); const conflictRowSchema = z.strictObject({ id: evidenceIdSchema, description: text(1000), impact: text(500), status: z.enum(CONFLICT_STATUSES), }); const calculationEvidenceRowSchema = z.strictObject({ id: evidenceIdSchema, label: text(160), value: text(500), source: text(200), }); const evidenceAppendixSchema = z.strictObject({ expandedByDefault: z.boolean(), techniqueAudit: z.array(techniqueAuditRowSchema).max(100), conflicts: z.array(conflictRowSchema).max(50), calculationEvidence: z.array(calculationEvidenceRowSchema).max(100), blockedTechniques: textArray(100, 120), }); const subjectSchema = z.strictObject({ displayName: text(120), birthTimeStatus: z.enum(BIRTH_TIME_STATUSES), birthPlaceLabel: text(200), }); const provenanceV1Schema = z.strictObject({ // Optional only so legacy v1 rows generated before named/versioned Skill // provenance remain readable. Current v2 generation requires both. skillName: skillNameSchema.optional(), skillVersion: skillVersionSchema.optional(), skillSourceCommit: z.string().regex(/^[0-9a-f]{40}$/, "invalid commit sha").nullable(), skillSnapshotSha256: sha256HexSchema, calculationHash: sha256HexSchema, evidenceHash: sha256HexSchema, reportContractVersion: z.literal(REPORT_CONTRACT_V1_VERSION), }); const provenanceV2Schema = z.strictObject({ skillName: skillNameSchema, skillVersion: skillVersionSchema, skillSourceCommit: z.string().regex(/^[0-9a-f]{40}$/, "invalid commit sha").nullable(), skillSnapshotSha256: sha256HexSchema, calculationHash: sha256HexSchema, evidenceHash: sha256HexSchema, reportContractVersion: z.literal(REPORT_CONTRACT_V2_VERSION), }); const executiveSummaryV1Schema = z.strictObject({ headline: text(200), summary: text(2000), priorities: textArray(8, 200), overallClaimStatus: claimStatusSchema, }); const executiveSummaryV2Schema = z.strictObject({ headline: text(200), summary: text(2000), priorities: textArray(8, 200), overallClaimStatus: claimStatusSchema, evidenceRefs: evidenceRefsSchema(1), }); const natalFoundationSchema = z.strictObject({ title: text(160), narrative: text(4000), keyFactors: textArray(12, 400), caveats: textArray(12, 400), claimStatus: claimStatusSchema, evidenceRefs: evidenceRefsSchema(1), }); const currentPhaseSchema = z.strictObject({ title: text(160), phaseLabel: text(200), narrative: text(4000), timingNotes: textArray(12, 400), caveats: textArray(12, 400), claimStatus: claimStatusSchema, evidenceRefs: evidenceRefsSchema(1), }); const actionNoteSchema = z.strictObject({ id: sectionIdSchema, title: text(160), note: text(1000), priority: z.enum(REPORT_ACTION_PRIORITIES), evidenceRefs: evidenceRefsSchema(1), }); const blockedConflictDisclosureSchema = z.strictObject({ theme: themeIdSchema, title: text(160), reason: text(2000), missingEvidence: textArray(24, 400, 1), conflictNotes: textArray(24, 500), evidenceRefs: evidenceRefsSchema(), claimStatus: z.literal("blocked"), }); export const reportDocumentV1Schema = z.strictObject({ schemaVersion: z.literal(REPORT_DOCUMENT_V1_SCHEMA_VERSION), reportId: z.string().uuid(), reportType: z.enum(REPORT_TYPES), presentationMode: z.enum(PRESENTATION_MODES), generatedAt: iso8601Schema, subject: subjectSchema, provenance: provenanceV1Schema, executiveSummary: executiveSummaryV1Schema, charts: z.array(reportDocumentV1ChartSchema).min(1).max(3), thematicNarrative: z.array(thematicSectionV1Schema).max(12), evidenceAppendix: evidenceAppendixSchema, disclaimer: text(2000), }); export const reportDocumentV2Schema = z.strictObject({ schemaVersion: z.literal(REPORT_DOCUMENT_V2_SCHEMA_VERSION), reportId: z.string().uuid(), reportType: z.enum(REPORT_TYPES), presentationMode: z.enum(PRESENTATION_MODES), depth: z.enum(REPORT_DEPTHS), requestedThemes: z.array(themeIdSchema).min(1).max(12), generatedAt: iso8601Schema, subject: subjectSchema, provenance: provenanceV2Schema, executiveSummary: executiveSummaryV2Schema, natalFoundation: natalFoundationSchema, currentPhase: currentPhaseSchema.nullable(), actionNotes: z.array(actionNoteSchema).min(1).max(24), charts: z.array(reportDocumentV2ChartSchema).min(1).max(6), thematicNarrative: z.array(thematicSectionV2Schema).max(12), blockedConflictDisclosure: z.array(blockedConflictDisclosureSchema).max(12), evidenceAppendix: evidenceAppendixSchema, disclaimer: text(2000), }); /** Canonical reader accepts current v2 plus stored v1 rows during migration. */ export const reportDocumentSchema = z.union([reportDocumentV2Schema, reportDocumentV1Schema]); export type LegacyReportDocumentV1 = z.infer; export type ReportDocumentV2 = z.infer; export type ReportDocument = LegacyReportDocumentV1 | ReportDocumentV2; /** @deprecated Compatibility name retained for existing producer/renderer imports. */ export type ReportDocumentV1 = LegacyReportDocumentV1; export type EvidenceAppendix = ReportDocument["evidenceAppendix"]; export type ChartV1 = LegacyReportDocumentV1["charts"][number]; export type ChartV2 = ReportDocumentV2["charts"][number]; export type ThematicSectionV1 = LegacyReportDocumentV1["thematicNarrative"][number]; export type ThematicSectionV2 = ReportDocumentV2["thematicNarrative"][number]; export type NatalFoundationV2 = ReportDocumentV2["natalFoundation"]; export type CurrentPhaseV2 = ReportDocumentV2["currentPhase"]; export type ActionNoteV2 = ReportDocumentV2["actionNotes"][number]; export type BlockedConflictDisclosureV2 = ReportDocumentV2["blockedConflictDisclosure"][number]; export type ReportDocumentParseError = Readonly<{ path: string; message: string; }>; export class ReportDocumentValidationError extends Error { readonly errors: readonly ReportDocumentParseError[]; constructor(errors: readonly ReportDocumentParseError[]) { super(errors.map((error) => `${error.path}: ${error.message}`).join("; ")); this.name = "ReportDocumentValidationError"; this.errors = errors; } } export function isReportDocumentV2(document: ReportDocument): document is ReportDocumentV2 { return document.schemaVersion === REPORT_DOCUMENT_V2_SCHEMA_VERSION; } /** Canonical evidence object used by the evidence hash on both language sides. */ export function canonicalEvidence(appendix: EvidenceAppendix): Record { return { techniqueAudit: appendix.techniqueAudit.map((row) => ({ id: row.id, techniqueId: row.techniqueId, techniqueName: row.techniqueName, status: row.status, used: row.used, ...(row.notes !== undefined ? { notes: row.notes } : {}), })), conflicts: appendix.conflicts.map((row) => ({ id: row.id, description: row.description, impact: row.impact, status: row.status, })), calculationEvidence: appendix.calculationEvidence.map((row) => ({ id: row.id, label: row.label, value: row.value, source: row.source, })), }; } export function serializedReportDocumentBytes(document: ReportDocument): number { return new TextEncoder().encode(JSON.stringify(document)).length; } /** Keep regex semantics equivalent to FORBIDDEN_PATTERNS on the Python side. */ export const FORBIDDEN_CONTENT_PATTERNS: readonly Readonly<{ name: string; pattern: RegExp }>[] = [ { name: "html_tag", pattern: /<\s*\/?\s*[a-z][^>]*>/i }, { name: "event_handler", pattern: /\bon(?:load|error|click|mouseover|mouseout|submit|focus|blur|change|dblclick|keydown|keyup|pointerdown|pointerup)\s*=/i }, { name: "style_attribute", pattern: /\bstyle\s*=/i }, { name: "css_at_rule", pattern: /@(?:import|media|supports|font-face|keyframes)\b/i }, { name: "css_rule", pattern: /(?:^|[}\s])(?:[.#]?[a-z][a-z0-9_-]*)(?:\s+[.#]?[a-z][a-z0-9_-]*)*\s*\{[^{}]*\}/i }, { name: "css_declaration", pattern: /(?:^|[;{\s])(?:color|background(?:-color)?|font(?:-family|-size|-weight)?|display|position|margin|padding|width|height|grid|flex|border|transform|animation)\s*:\s*[^;\n{}]+[;}]?/i }, { name: "executable_url", pattern: /\b(?:javascript|vbscript|data:text\/html|data:text\/javascript|file):/i }, { name: "processing_instruction", pattern: /<\?/i }, { name: "template_literal", pattern: /\$\{/i }, { name: "stack_trace", pattern: /(?:Traceback \(most recent call last\)|node:internal\/| at (?:Object|async|node)\.)/i }, { name: "dunder_path", pattern: /__(?:dirname|filename)(?![A-Za-z0-9_])|__proto__/i }, { name: "process_env", pattern: /\bprocess\.env\b/i }, { name: "unix_home_path", pattern: /(?:^|[\\/:])(?:Users|home|opt|var|tmp|root|srv)[\\/]/i }, { name: "windows_drive_path", pattern: /^[a-zA-Z]:[\\/]/i }, { name: "jwt_token", pattern: /\beyJ[A-Za-z0-9_-]{20,}\b/i }, { name: "secret_marker", pattern: /\b(?:SUPABASE_SERVICE_ROLE_KEY|AUTH_SECRET|BEGIN RSA PRIVATE KEY|BEGIN EC PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY)\b/i }, { name: "tool_trace", pattern: /\b(?:tool_call_id|tool_result|assistant_tool_calls|system_prompt)\b/i }, { name: "chain_of_thought", pattern: /\bchain[\s_-]?of[\s_-]?thought\b/i }, { name: "medical_diagnosis", pattern: /(?:你|命主)(?:已经|已|必将|一定会|确定)?(?:患有|罹患|得了)(?:癌症|糖尿病|抑郁症|双相情感障碍|心脏病|精神疾病)|(?:确诊为|诊断为)(?:癌症|糖尿病|抑郁症|双相情感障碍|心脏病|精神疾病)|\b(?:you|the native)\s+(?:definitely\s+)?(?:have|has|will develop)\s+(?:cancer|diabetes|depression|bipolar disorder|heart disease)\b|\bdiagnosed with\s+(?:cancer|diabetes|depression|bipolar disorder|heart disease)\b/i }, { name: "deterministic_financial_promise", pattern: /(?:保证收益|保本保收益|稳赚不赔|稳赚|必赚|确定(?:盈利|获利|回报)|一定会(?:赚钱|盈利|获利))|\b(?:guaranteed|certain|risk-free)\s+(?:profit|return|gain)s?\b|\bwill definitely\s+(?:profit|earn|make money)\b/i }, ]; export const BLOCKED_DETERMINISTIC_PHRASES: readonly string[] = [ "必然", "必定", "一定会", "肯定会", "绝对会", "保证会", "无疑将", "百分之百", "确定无疑", "guaranteed", "definitely will", "certainly will", "will certainly", "is certain to", ]; const DATE_CLAIM_PATTERN = /(?:^|[^0-9])(?:19|20)\d{2}(?:年(?:0?[1-9]|1[0-2])月(?:(?:0?[1-9]|[12]\d|3[01])日)?|[-/.](?:0?[1-9]|1[0-2])(?:[-/.](?:0?[1-9]|[12]\d|3[01]))?)(?:[^0-9]|$)/i; export function findForbiddenContent(value: string): readonly string[] { return FORBIDDEN_CONTENT_PATTERNS .filter(({ pattern }) => pattern.test(value)) .map(({ name }) => name); } function textLeaves(value: unknown, path = ""): readonly Readonly<{ path: string; text: string }>[] { if (typeof value === "string") return [{ path: path || "(root)", text: value }]; if (Array.isArray(value)) return value.flatMap((entry, index) => textLeaves(entry, `${path}[${index}]`)); if (value && typeof value === "object") { return Object.entries(value).flatMap(([key, entry]) => textLeaves(entry, path ? `${path}.${key}` : key)); } return []; } function blockedTexts(document: ReportDocument): readonly Readonly<{ path: string; text: string }>[] { const entries: { path: string; text: string }[] = []; if (document.executiveSummary.overallClaimStatus === "blocked") { entries.push(...textLeaves(document.executiveSummary, "executiveSummary")); } document.charts.forEach((chart, index) => { if (chart.claimStatus === "blocked") entries.push(...textLeaves(chart, `charts[${index}]`)); }); document.thematicNarrative.forEach((section, index) => { if (section.claimStatus === "blocked") entries.push(...textLeaves(section, `thematicNarrative[${index}]`)); }); if (isReportDocumentV2(document)) { if (document.natalFoundation.claimStatus === "blocked") { entries.push(...textLeaves(document.natalFoundation, "natalFoundation")); } if (document.currentPhase?.claimStatus === "blocked") { entries.push(...textLeaves(document.currentPhase, "currentPhase")); } document.blockedConflictDisclosure.forEach((section, index) => { entries.push(...textLeaves(section, `blockedConflictDisclosure[${index}]`)); }); } return entries; } export function findBlockedDeterministicClaims(document: ReportDocument): readonly string[] { const phrases = BLOCKED_DETERMINISTIC_PHRASES.map((phrase) => new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i")); return blockedTexts(document) .filter(({ text }) => phrases.some((pattern) => pattern.test(text))) .map(({ path }) => path); } function evidenceRefEntries(document: ReportDocument): readonly Readonly<{ path: string; refs: readonly string[] }>[] { const entries: { path: string; refs: readonly string[] }[] = document.thematicNarrative.map((section, index) => ({ path: document.schemaVersion === REPORT_DOCUMENT_V1_SCHEMA_VERSION ? section.id : `thematicNarrative[${index}].evidenceRefs`, refs: section.evidenceRefs, })); if (!isReportDocumentV2(document)) return entries; entries.push({ path: "executiveSummary.evidenceRefs", refs: document.executiveSummary.evidenceRefs }); entries.push({ path: "natalFoundation.evidenceRefs", refs: document.natalFoundation.evidenceRefs }); if (document.currentPhase) entries.push({ path: "currentPhase.evidenceRefs", refs: document.currentPhase.evidenceRefs }); document.actionNotes.forEach((note, index) => entries.push({ path: `actionNotes[${index}].evidenceRefs`, refs: note.evidenceRefs })); document.charts.forEach((chart, index) => entries.push({ path: `charts[${index}].evidenceRefs`, refs: chart.evidenceRefs })); document.blockedConflictDisclosure.forEach((section, index) => entries.push({ path: `blockedConflictDisclosure[${index}].evidenceRefs`, refs: section.evidenceRefs })); return entries; } export function findDanglingEvidenceRefs(document: ReportDocument): readonly string[] { const knownIds = new Set([ ...document.evidenceAppendix.techniqueAudit.map((row) => row.id), ...document.evidenceAppendix.conflicts.map((row) => row.id), ...document.evidenceAppendix.calculationEvidence.map((row) => row.id), ]); return evidenceRefEntries(document).flatMap(({ path, refs }) => refs.filter((ref) => !knownIds.has(ref)).map((ref) => `${path}:${ref}`), ); } /** Evidence ids must be globally unique across the whole appendix. */ export function findDuplicateEvidenceIds(document: ReportDocument): readonly string[] { const locations = new Map(); const duplicates: string[] = []; const rows: Readonly<{ key: string; index: number; id: string }>[] = [ ...document.evidenceAppendix.techniqueAudit.map((row, index) => ({ key: "techniqueAudit", index, id: row.id })), ...document.evidenceAppendix.conflicts.map((row, index) => ({ key: "conflicts", index, id: row.id })), ...document.evidenceAppendix.calculationEvidence.map((row, index) => ({ key: "calculationEvidence", index, id: row.id })), ]; for (const { key, index, id } of rows) { const location = `${key}[${index}]`; const first = locations.get(id); if (first !== undefined) duplicates.push(`evidence id ${id} used in both ${first} and ${location}`); else locations.set(id, location); } return duplicates; } export function findChartSetViolations(document: ReportDocument): readonly string[] { const violations: string[] = []; const ids = document.charts.map((chart) => chart.id); const d1Count = ids.filter((id) => id === "D1").length; if (d1Count !== 1) violations.push(`charts must contain exactly one D1 chart, found ${d1Count}`); const seen = new Set(); for (const id of ids) { if (seen.has(id)) violations.push(`duplicate chart id ${id}`); seen.add(id); } document.charts.forEach((chart) => { const numbers = chart.houses.map((house) => house.houseNumber); if (new Set(numbers).size !== numbers.length) violations.push(`${chart.id} chart contains duplicate house numbers`); }); const d1 = document.charts.find((chart) => chart.id === "D1"); if (d1) { const unique = new Set(d1.houses.map((house) => house.houseNumber)); const expected = Array.from({ length: 12 }, (_, index) => index + 1); if (d1.houses.length !== 12 || expected.some((number) => !unique.has(number))) { violations.push("D1 chart must contain all twelve house numbers 1..12 exactly once"); } } return violations; } const REQUIRED_THEME_CHARTS: Readonly> = { career: ["D10"], wealth: ["D2", "D11"], marriage: ["D9"], education: ["D24"], }; export function findThemeCoverageViolations(document: ReportDocumentV2): readonly string[] { const violations: string[] = []; const requestedCounts = new Map(); document.requestedThemes.forEach((theme) => requestedCounts.set(theme, (requestedCounts.get(theme) ?? 0) + 1)); requestedCounts.forEach((count, theme) => { if (count !== 1) violations.push(`requestedThemes must contain ${theme} exactly once, found ${count}`); }); const coverageCounts = new Map(); const requested = new Set(document.requestedThemes); for (const section of document.thematicNarrative) { coverageCounts.set(section.theme, (coverageCounts.get(section.theme) ?? 0) + 1); if (!requested.has(section.theme)) violations.push(`thematic section covers unrequested theme ${section.theme}`); } for (const section of document.blockedConflictDisclosure) { coverageCounts.set(section.theme, (coverageCounts.get(section.theme) ?? 0) + 1); if (!requested.has(section.theme)) violations.push(`blocked disclosure covers unrequested theme ${section.theme}`); } for (const theme of requested) { const count = coverageCounts.get(theme) ?? 0; if (count !== 1) violations.push(`requested theme ${theme} must have exactly one thematic section or blocked disclosure, found ${count}`); } const chartIds = new Set(document.charts.map((chart) => chart.id)); for (const section of document.thematicNarrative) { for (const requiredChart of REQUIRED_THEME_CHARTS[section.theme] ?? []) { if (!chartIds.has(requiredChart)) { violations.push(`thematic section ${section.theme} requires structured ${requiredChart} chart data or a blocked disclosure`); } } } return violations; } export function findUnsupportedDateClaims(document: ReportDocument): readonly string[] { if (!isReportDocumentV2(document)) return []; const sections: Readonly<{ path: string; value: unknown; refs: readonly string[] }>[] = [ { path: "executiveSummary", value: document.executiveSummary, refs: document.executiveSummary.evidenceRefs }, { path: "natalFoundation", value: document.natalFoundation, refs: document.natalFoundation.evidenceRefs }, ...(document.currentPhase ? [{ path: "currentPhase", value: document.currentPhase, refs: document.currentPhase.evidenceRefs }] : []), ...document.thematicNarrative.map((section, index) => ({ path: `thematicNarrative[${index}]`, value: section, refs: section.evidenceRefs })), ...document.actionNotes.map((note, index) => ({ path: `actionNotes[${index}]`, value: note, refs: note.evidenceRefs })), ...document.blockedConflictDisclosure.map((section, index) => ({ path: `blockedConflictDisclosure[${index}]`, value: section, refs: section.evidenceRefs })), ]; return sections .filter(({ value, refs }) => refs.length === 0 && textLeaves(value).some(({ text }) => DATE_CLAIM_PATTERN.test(text))) .map(({ path }) => `${path}: date claim requires evidenceRefs`); } export function validateReportDocumentGuards(document: ReportDocument): readonly string[] { const errors: string[] = []; errors.push(...findChartSetViolations(document)); errors.push(...findDuplicateEvidenceIds(document)); errors.push(...findBlockedDeterministicClaims(document).map((path) => `${path}: blocked section contains deterministic prediction`)); errors.push(...findDanglingEvidenceRefs(document).map((ref) => `evidenceRefs: unknown evidence id ${ref}`)); errors.push(...findUnsupportedDateClaims(document)); if (isReportDocumentV2(document)) errors.push(...findThemeCoverageViolations(document)); for (const { path, text: value } of textLeaves(document)) { const hits = findForbiddenContent(value); if (hits.length > 0) errors.push(`${path}: forbidden content ${hits.join(",")}`); } const size = serializedReportDocumentBytes(document); if (size > REPORT_DOCUMENT_MAX_BYTES) { errors.push(`serialized document is ${size} bytes, exceeding ${REPORT_DOCUMENT_MAX_BYTES}`); } return errors; } export type ReportDocumentParseResult = | Readonly<{ ok: true; document: ReportDocument }> | Readonly<{ ok: false; errors: readonly ReportDocumentParseError[] }>; export function safeParseReportDocument(input: unknown): ReportDocumentParseResult { const parsed = reportDocumentSchema.safeParse(input); if (!parsed.success) { return { ok: false, errors: parsed.error.issues.map((issue) => ({ path: issue.path.join(".") || "(root)", message: issue.message, })), }; } const document = parsed.data; const guardErrors = validateReportDocumentGuards(document); if (guardErrors.length > 0) { return { ok: false, errors: guardErrors.map((message) => ({ path: "(guard)", message })), }; } return { ok: true, document }; } export function parseReportDocument(input: unknown): ReportDocument { const result = safeParseReportDocument(input); if (!result.ok) throw new ReportDocumentValidationError(result.errors); return result.document; }