#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ ReportDocument v1 contract validator (stdlib only). Mirrors `contracts/personal-report/report-document.v1.schema.json` and `frontend/src/lib/personal-report-contract.ts` (Zod). This module deliberately uses only the Python standard library: the project does not vendor `jsonschema`, so the validator below implements the schema semantics explicitly so that the three sides (JSON Schema / Zod / Python) stay aligned and testable. Public surface: SCHEMA_VERSION, REPORT_CONTRACT_VERSION, MAX_SERIALIZED_BYTES CLAIM_STATUSES, REPORT_TYPES, PRESENTATION_MODES, BIRTH_TIME_STATUSES TECHNIQUE_STATUSES, CONFLICT_STATUSES, CHART_IDS FAILURE_CODES (shared stable enum used by the persistence layer) compute_evidence_hash(document) -> str validate_report_document(document) -> ValidationResult is_valid_report_document(document) -> bool load_report_document(path) -> dict parse_report_document_json(text) -> ValidationResult CLI: python3 scripts/personal_report_contract.py """ from __future__ import annotations import hashlib import json import re import sys from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple SCHEMA_VERSION = "report_document.v1" REPORT_CONTRACT_VERSION = "1" MAX_SERIALIZED_BYTES = 1_572_864 # 1.5 MiB hard cap on UTF-8 JSON serialization. CLAIM_STATUSES = ( "multi_system_consensus", "single_system_inference", "parameter_sensitive", "unclosed_divisional_chart", "user_history_verification_required", "blocked", ) REPORT_TYPES = ("personal_full", "personal_thematic") PRESENTATION_MODES = ("default", "research") BIRTH_TIME_STATUSES = ("reported", "candidate", "accepted", "confirmed") TECHNIQUE_STATUSES = ("verified", "partial", "blocked") CONFLICT_STATUSES = ("unresolved", "partial", "resolved") CHART_IDS = ("D1", "D9", "D10") HOUSE_NUMBERS = tuple(range(1, 13)) # Stable failure-code enum shared with the personal_reports table check # constraint and frontend/src/lib/personal-report-service.ts. FAILURE_CODES = ( "profile_incomplete", "birth_time_not_usable", "report_generation_in_progress", "report_rate_limited", "calculation_unavailable", "model_unavailable", "report_schema_invalid", "report_guard_rejected", "report_not_found", ) UUID_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") SHA1_PATTERN = re.compile(r"^[0-9a-f]{40}$") ISO8601_PATTERN = re.compile( r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$" ) EVIDENCE_ID_PATTERN = re.compile(r"^ev-[a-z0-9_-]{1,63}$") SECTION_ID_PATTERN = re.compile(r"^[a-z][a-z0-9_-]{0,63}$") TECHNIQUE_ID_PATTERN = re.compile(r"^[a-z0-9_.-]{1,80}$") # ──────────────────────────────────────────────────────────────────────────── # Semantic guard patterns. These must stay byte-for-byte equivalent to the # FORBIDDEN_PATTERNS list in frontend/src/lib/personal-report-contract.ts. # ──────────────────────────────────────────────────────────────────────────── FORBIDDEN_PATTERNS: Tuple[Tuple[str, str], ...] = ( ("html_tag_open", r"<\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\b"), ("html_tag_close", r""), ("event_handler", r"\bon(?:load|error|click|mouseover|mouseout|submit|focus|blur|change|dblclick|keydown|keyup|pointerdown|pointerup)\s*="), ("executable_url", r"\b(?:javascript|vbscript|data:text/html|data:text/javascript|file):"), ("processing_instruction", r"<\?"), ("template_literal", r"\$\{"), ("stack_trace", r"(?:Traceback \(most recent call last\)|node:internal/| at (?:Object|async|node)\.)"), ("dunder_path", r"__(?:dirname|filename)(?![A-Za-z0-9_])|__proto__"), ("process_env", r"\bprocess\.env\b"), ("unix_home_path", r"(?:^|[\\/:])(?:Users|home|opt|var|tmp|root|srv)[\\/]"), ("windows_drive_path", r"^[a-zA-Z]:[\\/]"), ("jwt_token", r"\beyJ[A-Za-z0-9_-]{20,}\b"), ("secret_marker", r"\b(?:SUPABASE_SERVICE_ROLE_KEY|AUTH_SECRET|BEGIN RSA PRIVATE KEY|BEGIN EC PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY)\b"), ("tool_trace", r"\b(?:tool_call_id|tool_result|assistant_tool_calls|system_prompt)\b"), ("chain_of_thought", r"\bchain[\s_-]?of[\s_-]?thought\b"), ) # Deterministic-prediction phrases that must never appear inside a section whose # claimStatus is "blocked". Must match the TS side exactly. DETERMINISTIC_PHRASES: Tuple[str, ...] = ( "必然", "必定", "一定会", "肯定会", "绝对会", "保证会", "无疑将", "百分之百", "确定无疑", "guaranteed", "definitely will", "certainly will", "will certainly", "is certain to", ) # Bounded text limits shared with the schema. TEXT_LIMITS: Dict[str, int] = { "displayName": 120, "birthPlaceLabel": 200, "headline": 200, "summary": 2000, "priority": 200, "chartTitle": 120, "sign": 40, "occupant": 40, "planetName": 40, "sectionTitle": 160, "narrative": 4000, "action": 400, "caveat": 400, "techniqueName": 160, "notes": 500, "conflictDescription": 1000, "conflictImpact": 500, "evidenceLabel": 160, "evidenceValue": 500, "evidenceSource": 200, "blockedTechnique": 120, "disclaimer": 2000, } ARRAY_LIMITS: Dict[str, int] = { "priorities": 8, "charts": 3, "houses": 12, "planets": 12, "occupants": 12, "thematicNarrative": 12, "actions": 12, "caveats": 12, "evidenceRefs": 24, "techniqueAudit": 100, "conflicts": 50, "calculationEvidence": 100, "blockedTechniques": 100, } _RE = re.compile def _compile(patterns: Tuple[Tuple[str, str], ...]) -> List[Tuple[str, "re.Pattern[str]"]]: return [(name, _RE(expression, re.IGNORECASE)) for name, expression in patterns] _FORBIDDEN_COMPILED = _compile(FORBIDDEN_PATTERNS) _DETERMINISTIC_COMPILED = [ (_RE(phrase, re.IGNORECASE), phrase) for phrase in DETERMINISTIC_PHRASES ] @dataclass class ValidationResult: valid: bool errors: List[str] = field(default_factory=list) def add(self, path: str, message: str) -> None: self.errors.append(f"{path}: {message}") # ──────────────────────────────────────────────────────────────────────────── # Evidence hash (deterministic, cross-language). # ──────────────────────────────────────────────────────────────────────────── def _canonical_evidence(document: Dict[str, Any]) -> Dict[str, Any]: """Canonical evidence object. Defensive: malformed rows are reduced to empty placeholders so validation never raises on arbitrary input; the canonical hash only matches the TS side for structurally valid documents.""" appendix = document.get("evidenceAppendix") appendix = appendix if isinstance(appendix, dict) else {} def rows(key: str) -> List[Any]: value = appendix.get(key) return value if isinstance(value, list) else [] def safe(row: Any, key: str) -> Any: return row.get(key) if isinstance(row, dict) else None canonical = { "techniqueAudit": [], "conflicts": [], "calculationEvidence": [], } for row in rows("techniqueAudit"): entry = { "id": safe(row, "id"), "techniqueId": safe(row, "techniqueId"), "techniqueName": safe(row, "techniqueName"), "status": safe(row, "status"), "used": safe(row, "used"), } if isinstance(row, dict) and "notes" in row: entry["notes"] = row["notes"] canonical["techniqueAudit"].append(entry) for row in rows("conflicts"): canonical["conflicts"].append({ "id": safe(row, "id"), "description": safe(row, "description"), "impact": safe(row, "impact"), "status": safe(row, "status"), }) for row in rows("calculationEvidence"): canonical["calculationEvidence"].append({ "id": safe(row, "id"), "label": safe(row, "label"), "value": safe(row, "value"), "source": safe(row, "source"), }) return canonical def _canonical_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=False) def compute_evidence_hash(document: Dict[str, Any]) -> str: """Deterministic SHA-256 over the evidence appendix, matching the TS side.""" canonical = _canonical_evidence(document) return hashlib.sha256(_canonical_json(canonical).encode("utf-8")).hexdigest() def serialized_bytes(document: Dict[str, Any]) -> int: return len(_canonical_json(document).encode("utf-8")) # ──────────────────────────────────────────────────────────────────────────── # Semantic guards. # ──────────────────────────────────────────────────────────────────────────── def forbidden_content_hits(value: str) -> List[str]: hits: List[str] = [] for name, pattern in _FORBIDDEN_COMPILED: if pattern.search(value): hits.append(name) return hits def _text_guard(result: ValidationResult, path: str, value: Any) -> None: if value is None: return if not isinstance(value, str): result.add(path, f"expected string, got {type(value).__name__}") return hits = forbidden_content_hits(value) if hits: result.add(path, "forbidden content: " + ", ".join(sorted(set(hits)))) def _blocked_determinism(result: ValidationResult, path: str, claim_status: str, texts: List[Tuple[str, str]]) -> None: if claim_status != "blocked": return for label, text in texts: if not isinstance(text, str): continue for pattern, phrase in _DETERMINISTIC_COMPILED: if pattern.search(text): result.add(path, f"blocked section contains deterministic prediction ({label!r} matches {phrase!r})") def _evidence_refs(result: ValidationResult, document: Dict[str, Any]) -> None: appendix = document["evidenceAppendix"] def ids(key: str) -> List[Any]: value = appendix.get(key) return value if isinstance(value, list) else [] known_ids: List[str] = [] seen: Dict[str, str] = {} for key in ("techniqueAudit", "conflicts", "calculationEvidence"): for index, row in enumerate(ids(key)): if not isinstance(row, dict) or not isinstance(row.get("id"), str): continue evidence_id = row["id"] if evidence_id in seen: result.add( f"evidenceAppendix.{key}[{index}].id", f"duplicate evidence id {evidence_id!r} (also used in {seen[evidence_id]})", ) else: seen[evidence_id] = f"{key}[{index}]" known_ids.append(evidence_id) for index, section in enumerate(document.get("thematicNarrative", [])): if not isinstance(section, dict): continue path = f"thematicNarrative[{index}].evidenceRefs" refs = section.get("evidenceRefs") if not isinstance(refs, list): continue for ref in refs: if ref not in known_ids: result.add(path, f"unknown evidence id {ref!r}") # ──────────────────────────────────────────────────────────────────────────── # Structural validation (explicit schema implementation). # ──────────────────────────────────────────────────────────────────────────── def _expect_object(result: ValidationResult, path: str, value: Any) -> Optional[Dict[str, Any]]: if not isinstance(value, dict): result.add(path, f"expected object, got {type(value).__name__}") return None return value def _check_enum(result: ValidationResult, path: str, value: Any, allowed: Tuple[str, ...]) -> None: if value not in allowed: result.add(path, f"invalid value {value!r}; allowed: {', '.join(allowed)}") def _check_text(result: ValidationResult, path: str, value: Any, max_length: int, min_length: int = 1) -> None: if not isinstance(value, str): result.add(path, f"expected string, got {type(value).__name__}") return if len(value) < min_length: result.add(path, f"shorter than minimum length {min_length}") if len(value) > max_length: result.add(path, f"longer than maximum length {max_length}") _text_guard(result, path, value) def _check_uuid(result: ValidationResult, path: str, value: Any) -> None: if not isinstance(value, str) or not UUID_PATTERN.match(value): result.add(path, f"invalid uuid {value!r}") def _check_hash(result: ValidationResult, path: str, value: Any, pattern: "re.Pattern[str]", length: int) -> None: if not isinstance(value, str) or not pattern.match(value) or len(value) != length: result.add(path, f"invalid hex hash {value!r}") def _check_array( result: ValidationResult, path: str, value: Any, max_items: int, min_items: int = 0, ) -> Optional[List[Any]]: if not isinstance(value, list): result.add(path, f"expected array, got {type(value).__name__}") return None if len(value) > max_items: result.add(path, f"longer than maximum items {max_items}") if len(value) < min_items: result.add(path, f"shorter than minimum items {min_items}") return value def _check_keys( result: ValidationResult, path: str, value: Dict[str, Any], required: Tuple[str, ...], allowed: Optional[Tuple[str, ...]] = None, ) -> None: permitted = required if allowed is None else allowed missing = [key for key in required if key not in value] if missing: result.add(path, "missing required keys: " + ", ".join(missing)) extra = [key for key in value if key not in permitted] if extra: result.add(path, "unexpected keys: " + ", ".join(extra)) def _validate_subject(result: ValidationResult, path: str, subject: Any) -> None: value = _expect_object(result, path, subject) if value is None: return _check_keys(result, path, value, ("displayName", "birthTimeStatus", "birthPlaceLabel")) _check_text(result, f"{path}.displayName", value.get("displayName"), TEXT_LIMITS["displayName"]) _check_enum(result, f"{path}.birthTimeStatus", value.get("birthTimeStatus"), BIRTH_TIME_STATUSES) _check_text(result, f"{path}.birthPlaceLabel", value.get("birthPlaceLabel"), TEXT_LIMITS["birthPlaceLabel"]) def _validate_provenance(result: ValidationResult, path: str, provenance: Any) -> None: value = _expect_object(result, path, provenance) if value is None: return _check_keys( result, path, value, ("skillSourceCommit", "skillSnapshotSha256", "calculationHash", "evidenceHash", "reportContractVersion"), ) commit = value.get("skillSourceCommit") if commit is not None: _check_hash(result, f"{path}.skillSourceCommit", commit, SHA1_PATTERN, 40) _check_hash(result, f"{path}.skillSnapshotSha256", value.get("skillSnapshotSha256"), SHA256_PATTERN, 64) _check_hash(result, f"{path}.calculationHash", value.get("calculationHash"), SHA256_PATTERN, 64) _check_hash(result, f"{path}.evidenceHash", value.get("evidenceHash"), SHA256_PATTERN, 64) if value.get("reportContractVersion") != REPORT_CONTRACT_VERSION: result.add(f"{path}.reportContractVersion", f"must be {REPORT_CONTRACT_VERSION!r}") def _validate_executive_summary(result: ValidationResult, path: str, summary: Any) -> None: value = _expect_object(result, path, summary) if value is None: return _check_keys(result, path, value, ("headline", "summary", "priorities", "overallClaimStatus")) _check_text(result, f"{path}.headline", value.get("headline"), TEXT_LIMITS["headline"]) _check_text(result, f"{path}.summary", value.get("summary"), TEXT_LIMITS["summary"]) priorities = _check_array(result, f"{path}.priorities", value.get("priorities"), ARRAY_LIMITS["priorities"]) if priorities is not None: for index, priority in enumerate(priorities): _check_text(result, f"{path}.priorities[{index}]", priority, TEXT_LIMITS["priority"]) _check_enum(result, f"{path}.overallClaimStatus", value.get("overallClaimStatus"), CLAIM_STATUSES) def _validate_chart(result: ValidationResult, path: str, chart: Any) -> None: value = _expect_object(result, path, chart) if value is None: return _check_keys(result, path, value, ("id", "title", "houses", "claimStatus"), ("id", "title", "houses", "claimStatus", "planets")) _check_enum(result, f"{path}.id", value.get("id"), CHART_IDS) _check_text(result, f"{path}.title", value.get("title"), TEXT_LIMITS["chartTitle"]) _check_enum(result, f"{path}.claimStatus", value.get("claimStatus"), CLAIM_STATUSES) houses = _check_array(result, f"{path}.houses", value.get("houses"), ARRAY_LIMITS["houses"]) seen_houses: List[int] = [] if houses is not None: for index, house in enumerate(houses): house_path = f"{path}.houses[{index}]" house_value = _expect_object(result, house_path, house) if house_value is None: continue _check_keys(result, house_path, house_value, ("houseNumber", "sign", "occupants")) house_number = house_value.get("houseNumber") if house_number in seen_houses: result.add(house_path, f"duplicate houseNumber {house_number!r}") if isinstance(house_number, int) and not isinstance(house_number, bool) and house_number in HOUSE_NUMBERS: seen_houses.append(house_number) elif not isinstance(house_number, bool): result.add(f"{house_path}.houseNumber", f"must be integer 1..12, got {house_number!r}") _check_text(result, f"{house_path}.sign", house_value.get("sign"), TEXT_LIMITS["sign"]) occupants = _check_array(result, f"{house_path}.occupants", house_value.get("occupants"), ARRAY_LIMITS["occupants"]) if occupants is not None: for occupant_index, occupant in enumerate(occupants): _check_text(result, f"{house_path}.occupants[{occupant_index}]", occupant, TEXT_LIMITS["occupant"]) planets = None if "planets" in value: planets = _check_array(result, f"{path}.planets", value.get("planets"), ARRAY_LIMITS["planets"]) if planets is not None: for index, planet in enumerate(planets): planet_path = f"{path}.planets[{index}]" planet_value = _expect_object(result, planet_path, planet) if planet_value is None: continue _check_keys(result, planet_path, planet_value, ("name", "sign", "longitudeDegrees", "houseNumber", "retrograde")) _check_text(result, f"{planet_path}.name", planet_value.get("name"), TEXT_LIMITS["planetName"]) _check_text(result, f"{planet_path}.sign", planet_value.get("sign"), TEXT_LIMITS["sign"]) longitude = planet_value.get("longitudeDegrees") if not isinstance(longitude, (int, float)) or isinstance(longitude, bool) or not (0 <= longitude < 360): result.add(f"{planet_path}.longitudeDegrees", f"must be number 0..360 (360 excluded), got {longitude!r}") house_number = planet_value.get("houseNumber") if not isinstance(house_number, int) or isinstance(house_number, bool) or house_number not in HOUSE_NUMBERS: result.add(f"{planet_path}.houseNumber", f"must be integer 1..12, got {house_number!r}") if not isinstance(planet_value.get("retrograde"), bool): result.add(f"{planet_path}.retrograde", "must be boolean") def _validate_thematic_section(result: ValidationResult, path: str, section: Any) -> None: value = _expect_object(result, path, section) if value is None: return _check_keys(result, path, value, ("id", "title", "narrative", "actions", "caveats", "claimStatus", "evidenceRefs")) section_id = value.get("id") if not isinstance(section_id, str) or not SECTION_ID_PATTERN.match(section_id): result.add(f"{path}.id", f"invalid section id {section_id!r}") _check_text(result, f"{path}.title", value.get("title"), TEXT_LIMITS["sectionTitle"]) _check_text(result, f"{path}.narrative", value.get("narrative"), TEXT_LIMITS["narrative"]) actions = _check_array(result, f"{path}.actions", value.get("actions"), ARRAY_LIMITS["actions"]) if actions is not None: for index, action in enumerate(actions): _check_text(result, f"{path}.actions[{index}]", action, TEXT_LIMITS["action"]) caveats = _check_array(result, f"{path}.caveats", value.get("caveats"), ARRAY_LIMITS["caveats"]) if caveats is not None: for index, caveat in enumerate(caveats): _check_text(result, f"{path}.caveats[{index}]", caveat, TEXT_LIMITS["caveat"]) _check_enum(result, f"{path}.claimStatus", value.get("claimStatus"), CLAIM_STATUSES) refs = _check_array(result, f"{path}.evidenceRefs", value.get("evidenceRefs"), ARRAY_LIMITS["evidenceRefs"]) if refs is not None: for index, ref in enumerate(refs): if not isinstance(ref, str) or not EVIDENCE_ID_PATTERN.match(ref): result.add(f"{path}.evidenceRefs[{index}]", f"invalid evidence id {ref!r}") def _validate_technique_row(result: ValidationResult, path: str, row: Any) -> None: value = _expect_object(result, path, row) if value is None: return _check_keys(result, path, value, ("id", "techniqueId", "techniqueName", "status", "used"), ("id", "techniqueId", "techniqueName", "status", "used", "notes")) evidence_id = value.get("id") if not isinstance(evidence_id, str) or not EVIDENCE_ID_PATTERN.match(evidence_id): result.add(f"{path}.id", f"invalid evidence id {evidence_id!r}") technique_id = value.get("techniqueId") if not isinstance(technique_id, str) or not TECHNIQUE_ID_PATTERN.match(technique_id): result.add(f"{path}.techniqueId", f"invalid technique id {technique_id!r}") _check_text(result, f"{path}.techniqueName", value.get("techniqueName"), TEXT_LIMITS["techniqueName"]) _check_enum(result, f"{path}.status", value.get("status"), TECHNIQUE_STATUSES) if not isinstance(value.get("used"), bool): result.add(f"{path}.used", "must be boolean") if "notes" in value: _check_text(result, f"{path}.notes", value.get("notes"), TEXT_LIMITS["notes"], min_length=0) def _validate_conflict_row(result: ValidationResult, path: str, row: Any) -> None: value = _expect_object(result, path, row) if value is None: return _check_keys(result, path, value, ("id", "description", "impact", "status")) evidence_id = value.get("id") if not isinstance(evidence_id, str) or not EVIDENCE_ID_PATTERN.match(evidence_id): result.add(f"{path}.id", f"invalid evidence id {evidence_id!r}") _check_text(result, f"{path}.description", value.get("description"), TEXT_LIMITS["conflictDescription"]) _check_text(result, f"{path}.impact", value.get("impact"), TEXT_LIMITS["conflictImpact"]) _check_enum(result, f"{path}.status", value.get("status"), CONFLICT_STATUSES) def _validate_calculation_row(result: ValidationResult, path: str, row: Any) -> None: value = _expect_object(result, path, row) if value is None: return _check_keys(result, path, value, ("id", "label", "value", "source")) evidence_id = value.get("id") if not isinstance(evidence_id, str) or not EVIDENCE_ID_PATTERN.match(evidence_id): result.add(f"{path}.id", f"invalid evidence id {evidence_id!r}") _check_text(result, f"{path}.label", value.get("label"), TEXT_LIMITS["evidenceLabel"]) _check_text(result, f"{path}.value", value.get("value"), TEXT_LIMITS["evidenceValue"]) _check_text(result, f"{path}.source", value.get("source"), TEXT_LIMITS["evidenceSource"]) def _validate_evidence_appendix(result: ValidationResult, path: str, appendix: Any) -> None: value = _expect_object(result, path, appendix) if value is None: return _check_keys( result, path, value, ("expandedByDefault", "techniqueAudit", "conflicts", "calculationEvidence", "blockedTechniques"), ) if not isinstance(value.get("expandedByDefault"), bool): result.add(f"{path}.expandedByDefault", "must be boolean") rows = _check_array(result, f"{path}.techniqueAudit", value.get("techniqueAudit"), ARRAY_LIMITS["techniqueAudit"]) if rows is not None: for index, row in enumerate(rows): _validate_technique_row(result, f"{path}.techniqueAudit[{index}]", row) rows = _check_array(result, f"{path}.conflicts", value.get("conflicts"), ARRAY_LIMITS["conflicts"]) if rows is not None: for index, row in enumerate(rows): _validate_conflict_row(result, f"{path}.conflicts[{index}]", row) rows = _check_array(result, f"{path}.calculationEvidence", value.get("calculationEvidence"), ARRAY_LIMITS["calculationEvidence"]) if rows is not None: for index, row in enumerate(rows): _validate_calculation_row(result, f"{path}.calculationEvidence[{index}]", row) blocked = _check_array(result, f"{path}.blockedTechniques", value.get("blockedTechniques"), ARRAY_LIMITS["blockedTechniques"]) if blocked is not None: for index, technique in enumerate(blocked): _check_text(result, f"{path}.blockedTechniques[{index}]", technique, TEXT_LIMITS["blockedTechnique"]) def _validate_chart_set(result: ValidationResult, charts: List[Any]) -> None: """Runtime chart-set semantics: exactly one D1, unique ids, D1 complete houses.""" seen_ids: List[str] = [] d1_chart: Optional[Dict[str, Any]] = None for index, chart in enumerate(charts): if not isinstance(chart, dict): continue chart_id = chart.get("id") if isinstance(chart_id, str): if chart_id in seen_ids: result.add(f"charts[{index}].id", f"duplicate chart id {chart_id!r}") seen_ids.append(chart_id) if chart_id == "D1": d1_chart = chart d1_count = sum(1 for chart_id in seen_ids if chart_id == "D1") if d1_count != 1: result.add("charts", f"must contain exactly one D1 chart, found {d1_count}") if d1_chart is not None: houses = d1_chart.get("houses") if not isinstance(houses, list): result.add("charts[D1].houses", "D1 chart must declare houses") return numbers = [] for house in houses: if isinstance(house, dict) and isinstance(house.get("houseNumber"), int) \ and not isinstance(house.get("houseNumber"), bool): numbers.append(house["houseNumber"]) if sorted(numbers) != list(range(1, 13)): result.add("charts[D1].houses", "D1 chart must contain all twelve house numbers 1..12 exactly once") elif len(set(numbers)) != 12: result.add("charts[D1].houses", "D1 chart contains duplicate house numbers") def validate_report_document(document: Any) -> ValidationResult: result = ValidationResult(valid=True) if not isinstance(document, dict): result.add("(root)", f"expected object, got {type(document).__name__}") result.valid = False return result _check_keys( result, "(root)", document, ( "schemaVersion", "reportId", "reportType", "presentationMode", "generatedAt", "subject", "provenance", "executiveSummary", "charts", "thematicNarrative", "evidenceAppendix", "disclaimer", ), ) if document.get("schemaVersion") != SCHEMA_VERSION: result.add("schemaVersion", f"must be {SCHEMA_VERSION!r}") _check_uuid(result, "reportId", document.get("reportId")) _check_enum(result, "reportType", document.get("reportType"), REPORT_TYPES) _check_enum(result, "presentationMode", document.get("presentationMode"), PRESENTATION_MODES) generated_at = document.get("generatedAt") if not isinstance(generated_at, str) or not ISO8601_PATTERN.match(generated_at): result.add("generatedAt", f"invalid ISO-8601 timestamp {generated_at!r}") _validate_subject(result, "subject", document.get("subject")) _validate_provenance(result, "provenance", document.get("provenance")) _validate_executive_summary(result, "executiveSummary", document.get("executiveSummary")) charts = _check_array(result, "charts", document.get("charts"), ARRAY_LIMITS["charts"], min_items=1) if charts is not None: for index, chart in enumerate(charts): _validate_chart(result, f"charts[{index}]", chart) _validate_chart_set(result, charts) sections = _check_array(result, "thematicNarrative", document.get("thematicNarrative"), ARRAY_LIMITS["thematicNarrative"]) seen_section_ids: List[str] = [] if sections is not None: for index, section in enumerate(sections): _validate_thematic_section(result, f"thematicNarrative[{index}]", section) section_id = section.get("id") if isinstance(section, dict) else None if isinstance(section_id, str): if section_id in seen_section_ids: result.add(f"thematicNarrative[{index}].id", f"duplicate section id {section_id!r}") seen_section_ids.append(section_id) _validate_evidence_appendix(result, "evidenceAppendix", document.get("evidenceAppendix")) _check_text(result, "disclaimer", document.get("disclaimer"), TEXT_LIMITS["disclaimer"]) # Semantic guards (only when the structural shape is usable; all access is # defensive so arbitrary/malformed JSON can never raise). if ( isinstance(document.get("evidenceAppendix"), dict) and all( isinstance(document["evidenceAppendix"].get(key), list) for key in ("techniqueAudit", "conflicts", "calculationEvidence") ) and isinstance(document.get("thematicNarrative"), list) and isinstance(document.get("provenance"), dict) and isinstance(document.get("charts"), list) and isinstance(document.get("executiveSummary"), dict) ): _evidence_refs(result, document) expected_hash = compute_evidence_hash(document) if document["provenance"].get("evidenceHash") != expected_hash: result.add("provenance.evidenceHash", f"does not match computed evidence hash {expected_hash}") blocked_texts: List[Tuple[str, str]] = [] summary = document["executiveSummary"] if summary.get("overallClaimStatus") == "blocked": blocked_texts.append(("headline", summary.get("headline"))) blocked_texts.append(("summary", summary.get("summary"))) for index, priority in enumerate(summary.get("priorities", [])): blocked_texts.append((f"priorities[{index}]", priority)) for index, chart in enumerate(document["charts"]): if isinstance(chart, dict) and chart.get("claimStatus") == "blocked": blocked_texts.append((f"charts[{index}].title", chart.get("title"))) for index, section in enumerate(document["thematicNarrative"]): if isinstance(section, dict) and section.get("claimStatus") == "blocked": blocked_texts.append((f"thematicNarrative[{index}].title", section.get("title"))) blocked_texts.append((f"thematicNarrative[{index}].narrative", section.get("narrative"))) for action_index, action in enumerate(section.get("actions", [])): blocked_texts.append((f"thematicNarrative[{index}].actions[{action_index}]", action)) for caveat_index, caveat in enumerate(section.get("caveats", [])): blocked_texts.append((f"thematicNarrative[{index}].caveats[{caveat_index}]", caveat)) for path, text in blocked_texts: _blocked_determinism(result, path, "blocked", [(path, text)]) size = serialized_bytes(document) if size > MAX_SERIALIZED_BYTES: result.add("(size)", f"serialized document is {size} bytes, exceeding {MAX_SERIALIZED_BYTES}") result.valid = not result.errors return result def is_valid_report_document(document: Any) -> bool: return validate_report_document(document).valid def load_report_document(path: str) -> Dict[str, Any]: with open(path, "r", encoding="utf-8") as handle: return json.load(handle) def parse_report_document_json(text: str) -> ValidationResult: try: document = json.loads(text) except json.JSONDecodeError as error: result = ValidationResult(valid=False) result.add("(json)", f"invalid JSON: {error}") return result return validate_report_document(document) def main(argv: Optional[List[str]] = None) -> int: args = list(sys.argv[1:] if argv is None else argv) if not args: print("usage: python3 scripts/personal_report_contract.py ", file=sys.stderr) return 2 path = args[0] try: with open(path, "r", encoding="utf-8") as handle: text = handle.read() except OSError as error: print(f"unable to read {path}: {error}", file=sys.stderr) return 2 result = parse_report_document_json(text) if result.valid: try: size = serialized_bytes(json.loads(text)) except ValueError: size = 0 print(f"valid: {path} ({size} bytes)") return 0 print(f"invalid: {path}", file=sys.stderr) for error in result.errors: print(f" - {error}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())