From 1e58b44fae16321e4e63d4bda7c8d708c7e26ef1 Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Wed, 15 Jul 2026 02:46:18 +0200 Subject: [PATCH 1/8] feat: stage source-linked ingestion proposals without canonical writes --- .../document-ingestion-v2.scenario.json | 52 + .../working-leo/document-ingestion-v2.txt | 5 + .../telegram-transcript-ingestion-v1.jsonl | 3 + ...gram-transcript-ingestion-v1.scenario.json | 99 ++ scripts/prepare_kb_source_manifest.py | 135 ++- ...run_leo_local_ingestion_proposal_canary.py | 979 ++++++++++++++---- scripts/stage_normalized_proposal.py | 6 + tests/test_prepare_kb_source_manifest.py | 78 +- ...run_leo_local_ingestion_proposal_canary.py | 395 ++++--- tests/test_stage_normalized_proposal.py | 31 + 10 files changed, 1442 insertions(+), 341 deletions(-) create mode 100644 fixtures/working-leo/document-ingestion-v2.scenario.json create mode 100644 fixtures/working-leo/document-ingestion-v2.txt create mode 100644 fixtures/working-leo/telegram-transcript-ingestion-v1.jsonl create mode 100644 fixtures/working-leo/telegram-transcript-ingestion-v1.scenario.json diff --git a/fixtures/working-leo/document-ingestion-v2.scenario.json b/fixtures/working-leo/document-ingestion-v2.scenario.json new file mode 100644 index 0000000..bf9a5f6 --- /dev/null +++ b/fixtures/working-leo/document-ingestion-v2.scenario.json @@ -0,0 +1,52 @@ +{ + "schema": "livingip.workingLeoSourceIngestionScenario.v2", + "artifact": { + "path": "document-ingestion-v2.txt", + "format": "plain_text" + }, + "source": { + "identity": "document:working-leo-review-gated-composition-v2", + "source_key": "working_leo_document_ingestion_v2", + "source_type": "article", + "title": "Working Leo review-gated composition note", + "locator": "fixture://working-leo/document-ingestion-v2", + "metadata": { + "author": "Working Leo synthetic canary fixture", + "captured_at": "2026-07-15T00:00:00Z", + "document_kind": "operating_note", + "language": "en", + "synthetic": true + } + }, + "extractor": { + "name": "deterministic_fixture_replay", + "version": "2" + }, + "extraction": { + "claims": [ + { + "claim_key": "staged_proposal_remains_noncanonical", + "type": "structural", + "confidence": 0.75, + "text": "A staged knowledge proposal remains non-canonical until review and guarded apply succeed.", + "body": "A staged proposal remains non-canonical until an operator reviews it and a separate guarded apply succeeds.", + "metadata": { + "needs_research": false + }, + "evidence": [ + { + "segment_id": "paragraph-2", + "role": "grounds", + "excerpt": "A staged proposal remains non-canonical until an operator reviews it and a separate guarded apply succeeds.", + "metadata": { + "evidence_kind": "exact_quote" + } + } + ], + "edges": [] + } + ], + "duplicates": [], + "conflicts": [] + } +} diff --git a/fixtures/working-leo/document-ingestion-v2.txt b/fixtures/working-leo/document-ingestion-v2.txt new file mode 100644 index 0000000..470fc38 --- /dev/null +++ b/fixtures/working-leo/document-ingestion-v2.txt @@ -0,0 +1,5 @@ +Working Leo composition note. A newly captured document may produce claim and evidence candidates, but those candidates are not canonical knowledge. + +A staged proposal remains non-canonical until an operator reviews it and a separate guarded apply succeeds. The proposal must retain the source content hash, exact evidence excerpt, and source identity so reviewers can trace every planned row back to the captured artifact. + +For a staging-only rehearsal, pending_review is the terminal state. No public knowledge-base row should be written. diff --git a/fixtures/working-leo/telegram-transcript-ingestion-v1.jsonl b/fixtures/working-leo/telegram-transcript-ingestion-v1.jsonl new file mode 100644 index 0000000..dd3c7cf --- /dev/null +++ b/fixtures/working-leo/telegram-transcript-ingestion-v1.jsonl @@ -0,0 +1,3 @@ +{"ts":"2026-07-15T09:00:01Z","chat_id":900001,"chat_title":"Synthetic review gate thread","message_id":7301,"type":"message","username":"fixture_analyst","display_name":"Fixture Analyst","user_id":910001,"message":"A pending review does not change canonical knowledge; only a separate guarded apply can do that.","reply_to":null,"synthetic":true} +{"ts":"2026-07-15T09:00:12Z","chat_id":900001,"chat_title":"Synthetic review gate thread","message_id":7302,"type":"message","username":"fixture_operator","display_name":"Fixture Operator","user_id":910002,"message":"Reviewer approval alone makes the proposal canonical immediately; no apply step is needed.","reply_to":7301,"synthetic":true} +{"ts":"2026-07-15T09:00:24Z","chat_id":900001,"chat_title":"Synthetic review gate thread","message_id":7303,"type":"message","username":"fixture_reviewer","display_name":"Fixture Reviewer","user_id":910003,"message":"A pending review does not change canonical knowledge; only a separate guarded apply can do that.","reply_to":7302,"synthetic":true} diff --git a/fixtures/working-leo/telegram-transcript-ingestion-v1.scenario.json b/fixtures/working-leo/telegram-transcript-ingestion-v1.scenario.json new file mode 100644 index 0000000..696ecbb --- /dev/null +++ b/fixtures/working-leo/telegram-transcript-ingestion-v1.scenario.json @@ -0,0 +1,99 @@ +{ + "schema": "livingip.workingLeoSourceIngestionScenario.v2", + "artifact": { + "path": "telegram-transcript-ingestion-v1.jsonl", + "format": "telegram_jsonl" + }, + "source": { + "identity": "fixture:telegram-chat-900001-export-20260715", + "source_key": "telegram_review_gate_exchange_20260715", + "source_type": "transcript", + "title": "Synthetic Telegram review-gate exchange", + "locator": "fixture://working-leo/telegram-transcript-ingestion-v1", + "metadata": { + "captured_at": "2026-07-15T09:01:00Z", + "export_format": "teleo_append_only_telegram_jsonl", + "language": "en", + "synthetic": true + } + }, + "extractor": { + "name": "deterministic_telegram_jsonl_replay", + "version": "1" + }, + "extraction": { + "claims": [ + { + "claim_key": "pending_review_does_not_change_canonical", + "type": "structural", + "confidence": 0.7, + "text": "Pending review alone does not change canonical knowledge.", + "body": "A pending review does not change canonical knowledge; only a separate guarded apply can do that.", + "metadata": { + "needs_research": false + }, + "evidence": [ + { + "segment_id": "message-900001-7301", + "role": "grounds", + "excerpt": "A pending review does not change canonical knowledge; only a separate guarded apply can do that.", + "metadata": { + "evidence_kind": "exact_message" + } + }, + { + "segment_id": "message-900001-7303", + "role": "illustrates", + "excerpt": "A pending review does not change canonical knowledge; only a separate guarded apply can do that.", + "metadata": { + "evidence_kind": "duplicate_exact_message" + } + } + ], + "edges": [ + { + "edge_type": "contradicts", + "target": "approval_alone_makes_canonical" + } + ] + }, + { + "claim_key": "approval_alone_makes_canonical", + "type": "empirical", + "confidence": 0.4, + "text": "Reviewer approval alone makes a proposal canonical without apply.", + "body": "Reviewer approval alone makes the proposal canonical immediately; no apply step is needed.", + "metadata": { + "needs_research": true + }, + "evidence": [ + { + "segment_id": "message-900001-7302", + "role": "grounds", + "excerpt": "Reviewer approval alone makes the proposal canonical immediately; no apply step is needed.", + "metadata": { + "evidence_kind": "exact_message" + } + } + ], + "edges": [] + } + ], + "duplicates": [ + { + "segment_id": "message-900001-7303", + "duplicate_of_claim_key": "pending_review_does_not_change_canonical", + "excerpt": "A pending review does not change canonical knowledge; only a separate guarded apply can do that.", + "reason": "Message 7303 repeats message 7301 exactly and is retained as explicit duplicate evidence." + } + ], + "conflicts": [ + { + "from_claim_key": "pending_review_does_not_change_canonical", + "to_claim_key": "approval_alone_makes_canonical", + "relationship": "contradicts", + "reason": "The two candidate propositions assert incompatible canonical-state transitions." + } + ] + } +} diff --git a/scripts/prepare_kb_source_manifest.py b/scripts/prepare_kb_source_manifest.py index 8a16403..6823e1d 100644 --- a/scripts/prepare_kb_source_manifest.py +++ b/scripts/prepare_kb_source_manifest.py @@ -34,7 +34,7 @@ DEFAULT_MODEL = "anthropic/claude-sonnet-4.5" EXTRACTOR_VERSION = "2" DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" DEFAULT_KEY_FILE = Path("/opt/teleo-eval/secrets/openrouter-key") -TEXT_SUFFIXES = {".csv", ".htm", ".html", ".json", ".md", ".rst", ".txt", ".xml"} +TEXT_SUFFIXES = {".csv", ".htm", ".html", ".json", ".jsonl", ".md", ".rst", ".txt", ".xml"} MAX_SOURCE_CHARS = 120_000 MAX_CANDIDATES = 20 MAX_CLAIMS = 3 @@ -124,20 +124,37 @@ def _load_context(path: Path) -> dict[str, Any]: return {"database_search_query": query.strip(), "candidate_claims": normalized} -def build_source_fragments(text: str) -> dict[str, str]: - """Label non-empty source lines so the model selects text instead of copying it.""" +def build_source_fragment_index(text: str) -> tuple[dict[str, str], dict[str, dict[str, Any]]]: + """Return selectable lines plus exact line/character provenance.""" fragments: dict[str, str] = {} - for line in text.splitlines(): - if line.strip(): - fragments[f"S{len(fragments) + 1:05d}"] = line + locations: dict[str, dict[str, Any]] = {} + offset = 0 + for line_number, raw_line in enumerate(text.splitlines(keepends=True), start=1): + line = raw_line.rstrip("\r\n") + quote = line.strip() + if quote: + reference = f"S{len(fragments) + 1:05d}" + start = offset + line.find(quote) + fragments[reference] = quote + locations[reference] = { + "reference": reference, + "line": line_number, + "char_start": start, + "char_end": start + len(quote), + "quote_sha256": sha256_bytes(quote.encode("utf-8")), + } + offset += len(raw_line) if not fragments: raise PreparationError("extracted text has no selectable source lines") - return fragments + return fragments, locations -def build_prompt( - fragments: dict[str, str], context: dict[str, Any], repair_error: str | None = None -) -> str: +def build_source_fragments(text: str) -> dict[str, str]: + """Label non-empty source lines so the model selects text instead of copying it.""" + return build_source_fragment_index(text)[0] + + +def build_prompt(fragments: dict[str, str], context: dict[str, Any], repair_error: str | None = None) -> str: candidates = json.dumps(context["candidate_claims"], indent=2, ensure_ascii=True) source = "\n".join(f"[{reference}] {line}" for reference, line in fragments.items()) repair = "" @@ -273,9 +290,7 @@ def parse_model_output(content: str) -> dict[str, Any]: if not isinstance(claim, dict): raise PreparationError(f"model extraction claims[{index}] must be an object") if set(claim) != MODEL_CLAIM_KEYS: - raise PreparationError( - f"model extraction claims[{index}] keys must equal {sorted(MODEL_CLAIM_KEYS)}" - ) + raise PreparationError(f"model extraction claims[{index}] keys must equal {sorted(MODEL_CLAIM_KEYS)}") confidence = claim.get("confidence") if confidence is not None and ( isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or not 0 <= confidence <= 0.75 @@ -304,27 +319,47 @@ def _resolve_reference(reference: Any, fragments: dict[str, str], label: str) -> return fragments[reference] -def resolve_model_output(selection: dict[str, Any], fragments: dict[str, str]) -> dict[str, Any]: +def resolve_model_output( + selection: dict[str, Any], + fragments: dict[str, str], + fragment_locations: dict[str, dict[str, Any]] | None = None, +) -> dict[str, Any]: + def selected(kind: str, reference: str, **identity: Any) -> dict[str, Any]: + result = {"kind": kind, "reference": reference, **identity} + if fragment_locations is not None: + result.update(fragment_locations[reference]) + return result + claims: list[dict[str, Any]] = [] - selected_references: list[dict[str, str]] = [] + selected_references: list[dict[str, Any]] = [] for claim_index, claim in enumerate(selection["claims"]): quote_ref = claim["quote_ref"] quote = _resolve_reference(quote_ref, fragments, f"claims[{claim_index}].quote_ref") - selected_references.append({"kind": "claim", "reference": quote_ref}) + selected_references.append(selected("claim", quote_ref, claim_key=claim["claim_key"], quote=quote)) evidence: list[dict[str, str]] = [] for evidence_index, row in enumerate(claim["evidence"]): evidence_ref = row["quote_ref"] + evidence_quote = _resolve_reference( + evidence_ref, + fragments, + f"claims[{claim_index}].evidence[{evidence_index}].quote_ref", + ) evidence.append( { - "quote": _resolve_reference( - evidence_ref, - fragments, - f"claims[{claim_index}].evidence[{evidence_index}].quote_ref", - ), + "quote": evidence_quote, "role": row["role"], } ) - selected_references.append({"kind": "evidence", "reference": evidence_ref}) + selected_references.append( + selected( + "evidence", + evidence_ref, + claim_key=claim["claim_key"], + evidence_index=evidence_index, + evidence_role=row["role"], + quote=evidence_quote, + ) + ) claims.append( { "claim_key": claim["claim_key"], @@ -351,7 +386,14 @@ def resolve_model_output(selection: dict[str, Any], fragments: dict[str, str]) - "reason": duplicate["reason"], } ) - selected_references.append({"kind": "duplicate", "reference": source_quote_ref}) + selected_references.append( + selected( + "duplicate", + source_quote_ref, + claim_id=duplicate["claim_id"], + quote=duplicates[-1]["source_quote"], + ) + ) return { "schema": RESOLVED_OUTPUT_SCHEMA, @@ -362,6 +404,37 @@ def resolve_model_output(selection: dict[str, Any], fragments: dict[str, str]) - } +def validate_bundle_source_locations(bundle: dict[str, Any], selected_references: list[dict[str, Any]]) -> None: + """Ensure compiler quote offsets match the exact selected fragment occurrence.""" + available = list(bundle.get("quote_bindings") or []) + for selected in selected_references: + if selected.get("kind") not in {"claim", "evidence"}: + continue + match_index = next( + ( + index + for index, binding in enumerate(available) + if binding.get("kind") == selected.get("kind") + and binding.get("claim_key") == selected.get("claim_key") + and binding.get("quote") == selected.get("quote") + and ( + selected.get("kind") != "evidence" or binding.get("evidence_role") == selected.get("evidence_role") + ) + ), + None, + ) + if match_index is None: + raise PreparationError("compiler dropped a selected claim/evidence source binding") + binding = available.pop(match_index) + if binding.get("first_start") != selected.get("char_start") or binding.get("first_end") != selected.get( + "char_end" + ): + raise PreparationError( + f"selected {selected['reference']} is an ambiguous repeated quote; " + "the current compiler cannot preserve that exact occurrence" + ) + + def build_manifest( *, args: argparse.Namespace, @@ -427,7 +500,7 @@ def prepare(args: argparse.Namespace) -> dict[str, Any]: artifact_bytes = _read_nonempty(args.artifact, "artifact") text_bytes = extract_utf8_text(args.artifact, args.text) text = text_bytes.decode("utf-8", errors="strict") - fragments = build_source_fragments(text) + fragments, fragment_locations = build_source_fragment_index(text) context = _load_context(args.kb_context) requested_output_dir = args.output_dir.expanduser() @@ -464,7 +537,7 @@ def prepare(args: argparse.Namespace) -> dict[str, Any]: attempts.append({"attempt": attempt, "response_sha256": sha256_bytes(raw.encode("utf-8")), "usage": usage}) try: selection = parse_model_output(raw) - extraction = resolve_model_output(selection, fragments) + extraction = resolve_model_output(selection, fragments, fragment_locations) compiler._validate_dedupe( { "database_search_query": context["database_search_query"], @@ -494,6 +567,7 @@ def prepare(args: argparse.Namespace) -> dict[str, Any]: with os.fdopen(fd, "wb") as handle: handle.write(_json_bytes(manifest)) bundle = compiler.compile_source_packet(args.artifact, temp_text, temp_manifest) + validate_bundle_source_locations(bundle, extraction["selected_source_references"]) finally: if temp_text.exists(): temp_text.unlink() @@ -518,6 +592,7 @@ def prepare(args: argparse.Namespace) -> dict[str, Any]: raise PreparationError("prepared extraction has no manifest") _write_private(manifest_path, _json_bytes(manifest)) bundle = compiler.compile_source_packet(args.artifact, text_path, manifest_path) + validate_bundle_source_locations(bundle, extraction["selected_source_references"]) receipt = { "schema": RECEIPT_SCHEMA, @@ -545,10 +620,20 @@ def prepare(args: argparse.Namespace) -> dict[str, Any]: "model": args.model, "attempts": attempts, "claim_count": len(extraction["claims"]), + "evidence_count": sum(len(claim["evidence"]) for claim in extraction["claims"]), + "duplicate_judgment_count": len(extraction["duplicates"]), "selectable_source_line_count": len(fragments), "selected_source_references": extraction["selected_source_references"], "notes": extraction["extraction_notes"], }, + "replay": { + "artifact_sha256": artifact_sha256, + "extracted_text_sha256": text_sha256, + "kb_context_sha256": sha256_bytes(_json_bytes(context)), + "fragment_index_sha256": sha256_bytes(_json_bytes(fragment_locations)), + "extractor": {"name": f"openrouter:{args.model}", "version": EXTRACTOR_VERSION}, + "exact_selected_locations_validated": bundle is not None, + }, "outputs": { "output_dir": str(output_dir), "extracted_text": str(text_path), diff --git a/scripts/run_leo_local_ingestion_proposal_canary.py b/scripts/run_leo_local_ingestion_proposal_canary.py index 89270dc..3cb9243 100644 --- a/scripts/run_leo_local_ingestion_proposal_canary.py +++ b/scripts/run_leo_local_ingestion_proposal_canary.py @@ -1,11 +1,20 @@ #!/usr/bin/env python3 -"""Run a document-ingestion-to-staged-proposal canary in disposable local Postgres.""" +"""Ingest source-only fixtures into review staging in disposable Postgres. + +The canary parses a raw document or repo-native Telegram JSONL transcript, +replays a versioned deterministic extraction sidecar, persists exact source +locations, normalizes the candidates, and stages one ``pending_review`` +proposal. Representative canonical ``public.*`` rows are fingerprinted before +and after. No review, apply, network access, production target, or durable +Docker volume is used. +""" from __future__ import annotations import argparse import hashlib import json +import re import shutil import subprocess import sys @@ -21,89 +30,497 @@ sys.path.insert(0, str(HERE)) import apply_proposal as ap # noqa: E402 import stage_normalized_proposal as normalized_stage # noqa: E402 -SCHEMA = "livingip.workingLeoLocalIngestionProposalCanary.v1" -FIXTURE_SCHEMA = "livingip.workingLeoDocumentIngestionFixture.v1" -DEFAULT_FIXTURE = REPO_ROOT / "fixtures" / "working-leo" / "document-ingestion-v1.json" +SCHEMA = "livingip.workingLeoLocalIngestionProposalCanary.v2" +SUITE_SCHEMA = "livingip.workingLeoLocalIngestionProposalSuite.v1" +FIXTURE_SCHEMA = "livingip.workingLeoSourceIngestionScenario.v2" +DEFAULT_DOCUMENT_FIXTURE = REPO_ROOT / "fixtures" / "working-leo" / "document-ingestion-v2.scenario.json" +DEFAULT_TELEGRAM_FIXTURE = REPO_ROOT / "fixtures" / "working-leo" / "telegram-transcript-ingestion-v1.scenario.json" +DEFAULT_FIXTURE = DEFAULT_DOCUMENT_FIXTURE +DEFAULT_FIXTURES = (DEFAULT_DOCUMENT_FIXTURE, DEFAULT_TELEGRAM_FIXTURE) CONTAINER_LABEL = "livingip.canary=working-leo-local-ingestion" +CANONICAL_TABLES = ("claims", "sources", "claim_evidence", "claim_edges") +SEED_CLAIM_A = "00000000-0000-4000-8000-000000000101" +SEED_CLAIM_B = "00000000-0000-4000-8000-000000000102" +SEED_SOURCE = "00000000-0000-4000-8000-000000000201" class CanaryError(RuntimeError): - pass + """Raised when a fixture or disposable runtime fails closed.""" def sha256_bytes(value: bytes) -> str: return hashlib.sha256(value).hexdigest() +def canonical_json_bytes(value: Any) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode() + + def canonical_sha256(value: Any) -> str: - return sha256_bytes(json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()) + return sha256_bytes(canonical_json_bytes(value)) -def stable_uuid(artifact_sha256: str, label: str) -> str: - return str(uuid.uuid5(uuid.NAMESPACE_URL, f"livingip:working-leo-ingestion:{artifact_sha256}:{label}")) +def canonical_snapshot_complete(snapshot: dict[str, Any]) -> bool: + return set(snapshot) == set(CANONICAL_TABLES) and all( + isinstance(snapshot.get(table), list) and bool(snapshot[table]) for table in CANONICAL_TABLES + ) + + +def stable_uuid(seed: str, label: str) -> str: + return str(uuid.uuid5(uuid.NAMESPACE_URL, f"livingip:working-leo-ingestion:{seed}:{label}")) + + +def _require_object(value: Any, label: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise CanaryError(f"{label} must be an object") + return value + + +def _require_list(value: Any, label: str) -> list[Any]: + if not isinstance(value, list): + raise CanaryError(f"{label} must be a list") + return value + + +def _require_text(value: Any, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise CanaryError(f"{label} must be a non-empty string") + return value.strip() + + +def _safe_source_key(value: str) -> str: + normalized = re.sub(r"[^a-z0-9._:-]+", "_", value.lower()).strip("_") + if not normalized: + raise CanaryError(f"could not derive a stable source key from {value!r}") + return normalized[:128] + + +def _load_plain_text_segments(raw: bytes, source: dict[str, Any]) -> list[dict[str, Any]]: + try: + text = raw.decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise CanaryError(f"plain_text artifact must be strict UTF-8: {exc}") from exc + locator = _require_text(source.get("locator"), "source.locator") + segments: list[dict[str, Any]] = [] + cursor = 0 + for part in re.split(r"\n[ \t]*\n", text): + part_start = text.find(part, cursor) + cursor = part_start + len(part) + body = part.strip() + if not body: + continue + start = part_start + part.find(body) + index = len(segments) + 1 + canonical_record = {"paragraph": index, "text": body} + segments.append( + { + "id": f"paragraph-{index}", + "body": body, + "locator": f"{locator}#paragraph-{index}", + "json_pointer": None, + "content_sha256": canonical_sha256(canonical_record), + "text_sha256": sha256_bytes(body.encode()), + "metadata": {"paragraph": index, "char_start": start, "char_end": start + len(body)}, + } + ) + if not segments: + raise CanaryError("plain_text artifact contains no non-empty paragraphs") + return segments + + +def _load_telegram_jsonl_segments(raw: bytes) -> list[dict[str, Any]]: + try: + text = raw.decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise CanaryError(f"telegram_jsonl artifact must be strict UTF-8: {exc}") from exc + segments: list[dict[str, Any]] = [] + seen: set[tuple[int, int]] = set() + for line_number, line in enumerate(text.splitlines(), start=1): + if not line.strip(): + continue + try: + record = json.loads(line) + except json.JSONDecodeError as exc: + raise CanaryError(f"telegram JSONL line {line_number} is invalid JSON: {exc}") from exc + record = _require_object(record, f"telegram JSONL line {line_number}") + chat_id = record.get("chat_id") + message_id = record.get("message_id") + message = record.get("message") + if isinstance(chat_id, bool) or not isinstance(chat_id, int): + raise CanaryError(f"telegram JSONL line {line_number}.chat_id must be an integer") + if isinstance(message_id, bool) or not isinstance(message_id, int): + raise CanaryError(f"telegram JSONL line {line_number}.message_id must be an integer") + if not isinstance(message, str) or not message.strip(): + raise CanaryError(f"telegram JSONL line {line_number}.message must be non-empty") + key = (chat_id, message_id) + if key in seen: + raise CanaryError(f"telegram JSONL repeats chat/message identity {key}") + seen.add(key) + body = message.strip() + segments.append( + { + "id": f"message-{chat_id}-{message_id}", + "body": body, + "locator": f"telegram://chat/{chat_id}/message/{message_id}", + "json_pointer": f"jsonl://line/{line_number}/message", + "content_sha256": canonical_sha256(record), + "text_sha256": sha256_bytes(body.encode()), + "metadata": { + "line_number": line_number, + "ts": record.get("ts"), + "chat_id": chat_id, + "chat_title": record.get("chat_title"), + "message_id": message_id, + "type": record.get("type"), + "username": record.get("username"), + "display_name": record.get("display_name"), + "user_id": record.get("user_id"), + "reply_to": record.get("reply_to"), + "synthetic": record.get("synthetic") is True, + }, + } + ) + if not segments: + raise CanaryError("telegram_jsonl artifact contains no messages") + return segments + + +def _load_segments(raw: bytes, artifact_format: str, source: dict[str, Any]) -> list[dict[str, Any]]: + if artifact_format == "plain_text": + return _load_plain_text_segments(raw, source) + if artifact_format == "telegram_jsonl": + return _load_telegram_jsonl_segments(raw) + raise CanaryError("artifact.format must be plain_text or telegram_jsonl") def load_fixture(path: Path) -> tuple[dict[str, Any], dict[str, Any]]: - raw = path.read_bytes() + scenario_path = path.resolve() try: - fixture = json.loads(raw) - except json.JSONDecodeError as exc: - raise CanaryError(f"fixture is not valid JSON: {exc}") from exc - if not isinstance(fixture, dict) or fixture.get("schema") != FIXTURE_SCHEMA: - raise CanaryError(f"fixture schema must be {FIXTURE_SCHEMA}") - source = fixture.get("source") - extraction = fixture.get("extraction") - claim = extraction.get("claim") if isinstance(extraction, dict) else None - evidence = extraction.get("evidence") if isinstance(extraction, dict) else None - if not all(isinstance(item, dict) for item in (source, claim, evidence)): - raise CanaryError("fixture requires source, extraction.claim, and extraction.evidence objects") - required_source = ("identity", "source_key", "source_type", "title", "locator", "body", "metadata") - required_claim = ("claim_key", "type", "confidence", "text", "body", "metadata") - required_evidence = ("role", "body", "metadata") - for label, value, required in ( - ("source", source, required_source), - ("claim", claim, required_claim), - ("evidence", evidence, required_evidence), - ): - missing = [key for key in required if value.get(key) in (None, "")] - if missing: - raise CanaryError(f"fixture {label} is missing: {', '.join(missing)}") - if not isinstance(value.get("metadata"), dict): - raise CanaryError(f"fixture {label}.metadata must be an object") - if claim["body"] not in source["body"]: - raise CanaryError("extracted claim body must be an exact substring of source.body") - if evidence["body"] not in source["body"]: - raise CanaryError("extracted evidence body must be an exact substring of source.body") + scenario_raw = scenario_path.read_bytes() + scenario = json.loads(scenario_raw) + except (OSError, json.JSONDecodeError) as exc: + raise CanaryError(f"scenario is not readable JSON: {exc}") from exc + if not isinstance(scenario, dict) or scenario.get("schema") != FIXTURE_SCHEMA: + raise CanaryError(f"scenario schema must be {FIXTURE_SCHEMA}") + artifact = _require_object(scenario.get("artifact"), "artifact") + source = _require_object(scenario.get("source"), "source") + extractor = _require_object(scenario.get("extractor"), "extractor") + extraction = _require_object(scenario.get("extraction"), "extraction") + artifact_rel = Path(_require_text(artifact.get("path"), "artifact.path")) + if artifact_rel.is_absolute(): + raise CanaryError("artifact.path must be relative to the scenario") + artifact_path = (scenario_path.parent / artifact_rel).resolve() + try: + artifact_path.relative_to(scenario_path.parent.resolve()) + except ValueError as exc: + raise CanaryError("artifact.path must remain inside the scenario directory") from exc + try: + raw = artifact_path.read_bytes() + except OSError as exc: + raise CanaryError(f"source artifact is unreadable: {exc}") from exc + if not raw: + raise CanaryError("source artifact must not be empty") + artifact_format = _require_text(artifact.get("format"), "artifact.format") + extractor_name = _require_text(extractor.get("name"), "extractor.name") + extractor_version = _require_text(extractor.get("version"), "extractor.version") + required_source = ("identity", "source_key", "source_type", "title", "locator", "metadata") + missing_source = [key for key in required_source if source.get(key) in (None, "")] + if missing_source: + raise CanaryError(f"source is missing: {', '.join(missing_source)}") + if source["source_type"] not in ap.SOURCE_TYPES: + raise CanaryError(f"source.source_type must be one of {sorted(ap.SOURCE_TYPES)}") + if not isinstance(source.get("metadata"), dict): + raise CanaryError("source.metadata must be an object") + segments = _load_segments(raw, artifact_format, source) + segment_by_id = {segment["id"]: segment for segment in segments} + + claims = _require_list(extraction.get("claims"), "extraction.claims") + duplicates = _require_list(extraction.get("duplicates"), "extraction.duplicates") + conflicts = _require_list(extraction.get("conflicts"), "extraction.conflicts") + if not claims: + raise CanaryError("extraction.claims must contain at least one review candidate") + claim_keys: set[str] = set() + normalized_claims: list[dict[str, Any]] = [] + evidence_count = 0 + for claim_index, raw_claim in enumerate(claims): + claim = _require_object(raw_claim, f"extraction.claims[{claim_index}]") + claim_key = _require_text(claim.get("claim_key"), f"extraction.claims[{claim_index}].claim_key") + if claim_key in claim_keys: + raise CanaryError(f"duplicate claim_key {claim_key}") + claim_keys.add(claim_key) + claim_type = _require_text(claim.get("type"), f"extraction.claims[{claim_index}].type") + if claim_type not in ap.CLAIM_TYPES: + raise CanaryError(f"claim {claim_key} has unsupported type {claim_type}") + confidence = claim.get("confidence") + if isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or not 0 <= confidence <= 1: + raise CanaryError(f"claim {claim_key} confidence must be between 0 and 1") + evidence_rows = _require_list(claim.get("evidence"), f"claim {claim_key}.evidence") + if not evidence_rows: + raise CanaryError(f"claim {claim_key} requires evidence") + normalized_evidence: list[dict[str, Any]] = [] + for evidence_index, raw_evidence in enumerate(evidence_rows): + evidence = _require_object(raw_evidence, f"claim {claim_key}.evidence[{evidence_index}]") + segment_id = _require_text(evidence.get("segment_id"), f"claim {claim_key} evidence segment_id") + segment = segment_by_id.get(segment_id) + if segment is None: + raise CanaryError(f"claim {claim_key} evidence references unknown segment {segment_id}") + excerpt = _require_text(evidence.get("excerpt"), f"claim {claim_key} evidence excerpt") + if excerpt not in segment["body"]: + raise CanaryError(f"claim {claim_key} evidence excerpt is not an exact segment substring") + role = _require_text(evidence.get("role"), f"claim {claim_key} evidence role") + if role not in ap.EVIDENCE_ROLES: + raise CanaryError(f"claim {claim_key} evidence has unsupported role {role}") + normalized_evidence.append( + { + **evidence, + "segment_id": segment_id, + "excerpt": excerpt, + "role": role, + "source_locator": segment["locator"], + "source_json_pointer": segment["json_pointer"], + "source_content_sha256": segment["content_sha256"], + "source_text_sha256": segment["text_sha256"], + } + ) + evidence_count += 1 + body = _require_text(claim.get("body"), f"claim {claim_key}.body") + if not any(body in segment_by_id[row["segment_id"]]["body"] for row in normalized_evidence): + raise CanaryError(f"claim {claim_key} body must be an exact substring of its evidence segments") + edges = _require_list(claim.get("edges", []), f"claim {claim_key}.edges") + normalized_claims.append( + { + **claim, + "claim_key": claim_key, + "type": claim_type, + "confidence": confidence, + "text": _require_text(claim.get("text"), f"claim {claim_key}.text"), + "body": body, + "metadata": _require_object(claim.get("metadata", {}), f"claim {claim_key}.metadata"), + "evidence": normalized_evidence, + "edges": edges, + } + ) + + normalized_duplicates: list[dict[str, Any]] = [] + for duplicate_index, raw_duplicate in enumerate(duplicates): + duplicate = _require_object(raw_duplicate, f"extraction.duplicates[{duplicate_index}]") + segment_id = _require_text(duplicate.get("segment_id"), f"duplicate {duplicate_index}.segment_id") + target = _require_text( + duplicate.get("duplicate_of_claim_key"), f"duplicate {duplicate_index}.duplicate_of_claim_key" + ) + if target not in claim_keys: + raise CanaryError(f"duplicate {duplicate_index} references unknown claim {target}") + segment = segment_by_id.get(segment_id) + if segment is None: + raise CanaryError(f"duplicate {duplicate_index} references unknown segment {segment_id}") + excerpt = _require_text(duplicate.get("excerpt"), f"duplicate {duplicate_index}.excerpt") + if excerpt not in segment["body"]: + raise CanaryError(f"duplicate {duplicate_index} excerpt is not an exact segment substring") + normalized_duplicates.append( + { + **duplicate, + "segment_id": segment_id, + "duplicate_of_claim_key": target, + "excerpt": excerpt, + "source_locator": segment["locator"], + "source_json_pointer": segment["json_pointer"], + "source_content_sha256": segment["content_sha256"], + "source_text_sha256": segment["text_sha256"], + } + ) + + normalized_conflicts: list[dict[str, Any]] = [] + for conflict_index, raw_conflict in enumerate(conflicts): + conflict = _require_object(raw_conflict, f"extraction.conflicts[{conflict_index}]") + from_key = _require_text(conflict.get("from_claim_key"), f"conflict {conflict_index}.from_claim_key") + to_key = _require_text(conflict.get("to_claim_key"), f"conflict {conflict_index}.to_claim_key") + relationship = _require_text(conflict.get("relationship"), f"conflict {conflict_index}.relationship") + if from_key not in claim_keys or to_key not in claim_keys: + raise CanaryError(f"conflict {conflict_index} must reference produced claim keys") + if relationship != "contradicts": + raise CanaryError("fixture conflicts must use relationship=contradicts") + matching_edge = any( + claim["claim_key"] == from_key + and any(edge.get("edge_type") == relationship and edge.get("target") == to_key for edge in claim["edges"]) + for claim in normalized_claims + ) + if not matching_edge: + raise CanaryError(f"conflict {from_key}->{to_key} has no matching candidate edge") + normalized_conflicts.append({**conflict, "from_claim_key": from_key, "to_claim_key": to_key}) + artifact_sha256 = sha256_bytes(raw) - source_content_sha256 = sha256_bytes(source["body"].encode()) - return fixture, { - "path": str(path.resolve()), + source_content_sha256 = canonical_sha256( + [ + { + "id": segment["id"], + "locator": segment["locator"], + "content_sha256": segment["content_sha256"], + } + for segment in segments + ] + ) + extraction_spec_sha256 = canonical_sha256(extraction) + replay_identity_sha256 = canonical_sha256( + { + "artifact_sha256": artifact_sha256, + "extractor": {"name": extractor_name, "version": extractor_version}, + "extraction_spec_sha256": extraction_spec_sha256, + } + ) + fixture = { + **scenario, + "artifact": {**artifact, "resolved_path": str(artifact_path)}, + "extractor": {"name": extractor_name, "version": extractor_version}, + "segments": segments, + "extraction": { + "claims": normalized_claims, + "duplicates": normalized_duplicates, + "conflicts": normalized_conflicts, + }, + } + receipt = { + "scenario_path": str(scenario_path), + "scenario_sha256": sha256_bytes(scenario_raw), + "artifact_path": str(artifact_path), + "artifact_format": artifact_format, "artifact_sha256": artifact_sha256, "source_content_sha256": source_content_sha256, "source_identity": source["identity"], + "source_locator": source["locator"], + "extractor": {"name": extractor_name, "version": extractor_version}, + "extraction_spec_sha256": extraction_spec_sha256, + "replay_identity_sha256": replay_identity_sha256, + "counts": { + "source_segments": len(segments), + "claim_candidates": len(normalized_claims), + "evidence_candidates": evidence_count, + "duplicate_judgments": len(normalized_duplicates), + "conflict_relationships": len(normalized_conflicts), + }, } + return fixture, receipt -def build_row_ids(artifact_sha256: str) -> dict[str, str]: +def build_row_ids(input_receipt: dict[str, Any], fixture: dict[str, Any]) -> dict[str, Any]: + replay_seed = input_receipt["replay_identity_sha256"] + claim_ids = { + claim["claim_key"]: stable_uuid(replay_seed, f"claim-extraction:{claim['claim_key']}") + for claim in fixture["extraction"]["claims"] + } + evidence_ids: dict[str, str] = {} + for claim in fixture["extraction"]["claims"]: + for index, _evidence in enumerate(claim["evidence"]): + key = f"{claim['claim_key']}:{index}" + evidence_ids[key] = stable_uuid(replay_seed, f"evidence-extraction:{key}") + duplicate_ids = { + str(index): stable_uuid(replay_seed, f"duplicate-assessment:{index}") + for index, _duplicate in enumerate(fixture["extraction"]["duplicates"]) + } + conflict_ids = { + str(index): stable_uuid(replay_seed, f"conflict-assessment:{index}") + for index, _conflict in enumerate(fixture["extraction"]["conflicts"]) + } return { - "source_capture_id": stable_uuid(artifact_sha256, "source-capture"), - "claim_extraction_id": stable_uuid(artifact_sha256, "claim-extraction"), - "evidence_extraction_id": stable_uuid(artifact_sha256, "evidence-extraction"), - "parent_proposal_id": stable_uuid(artifact_sha256, "rich-parent-proposal"), + "source_capture_id": stable_uuid(input_receipt["artifact_sha256"], "source-capture"), + "claim_extraction_ids": claim_ids, + "evidence_extraction_ids": evidence_ids, + "duplicate_assessment_ids": duplicate_ids, + "conflict_assessment_ids": conflict_ids, + "parent_proposal_id": stable_uuid(replay_seed, "rich-parent-proposal"), } +def _segment_source_key(source_key: str, segment_id: str) -> str: + return _safe_source_key(f"{source_key}__{segment_id}") + + def build_parent_packet( - fixture: dict[str, Any], input_receipt: dict[str, Any], row_ids: dict[str, str] + fixture: dict[str, Any], input_receipt: dict[str, Any], row_ids: dict[str, Any] ) -> dict[str, Any]: source = fixture["source"] - claim = fixture["extraction"]["claim"] - evidence = fixture["extraction"]["evidence"] - parent_source_ref = ( - f"local-ingestion:{input_receipt['artifact_sha256']}:" - f"source={row_ids['source_capture_id']}:claim={row_ids['claim_extraction_id']}:" - f"evidence={row_ids['evidence_extraction_id']}" - ) + claims = fixture["extraction"]["claims"] + segment_by_id = {segment["id"]: segment for segment in fixture["segments"]} + referenced_segment_ids: list[str] = [] + for claim in claims: + for evidence in claim["evidence"]: + if evidence["segment_id"] not in referenced_segment_ids: + referenced_segment_ids.append(evidence["segment_id"]) + for duplicate in fixture["extraction"]["duplicates"]: + if duplicate["segment_id"] not in referenced_segment_ids: + referenced_segment_ids.append(duplicate["segment_id"]) + + source_candidates = [] + for segment_id in referenced_segment_ids: + segment = segment_by_id[segment_id] + segment_source_key = _segment_source_key(source["source_key"], segment_id) + provenance = { + "artifact_sha256": input_receipt["artifact_sha256"], + "segment_id": segment_id, + "locator": segment["locator"], + "json_pointer": segment["json_pointer"], + "content_sha256": segment["content_sha256"], + "text_sha256": segment["text_sha256"], + } + source_candidates.append( + { + "source_key": segment_source_key, + "source_type": "dm" if input_receipt["artifact_format"] == "telegram_jsonl" else source["source_type"], + "title": f"{source['title']} - {segment_id}", + "storage_path": segment["locator"], + "url": None, + "source_quality": json.dumps(provenance, sort_keys=True, separators=(",", ":")), + "usage_limits": "Review-only exact excerpt; no canonical apply is authorized.", + "key_excerpt": segment["body"], + "content_sha256": segment["content_sha256"], + } + ) + + claim_candidates = [] + for claim in claims: + claim_candidates.append( + { + "claim_key": claim["claim_key"], + "type": claim["type"], + "confidence": claim["confidence"], + "text": claim["text"], + "body": claim["body"], + "evidence_tier": "source_artifact_exact_location", + "needs_research": claim["metadata"].get("needs_research", False), + "evidence": [ + { + "source_key": _segment_source_key(source["source_key"], evidence["segment_id"]), + "role": evidence["role"], + } + for evidence in claim["evidence"] + ], + "edges": claim["edges"], + } + ) + + ingestion_manifest = { + "scenario_schema": fixture["schema"], + "scenario_sha256": input_receipt["scenario_sha256"], + "artifact_format": input_receipt["artifact_format"], + "artifact_sha256": input_receipt["artifact_sha256"], + "source_content_sha256": input_receipt["source_content_sha256"], + "source_identity": input_receipt["source_identity"], + "source_locator": input_receipt["source_locator"], + "extractor": input_receipt["extractor"], + "extraction_spec_sha256": input_receipt["extraction_spec_sha256"], + "replay_identity_sha256": input_receipt["replay_identity_sha256"], + "segments": [ + { + "id": segment["id"], + "locator": segment["locator"], + "json_pointer": segment["json_pointer"], + "content_sha256": segment["content_sha256"], + "text_sha256": segment["text_sha256"], + } + for segment in fixture["segments"] + ], + "row_ids": row_ids, + "candidate_counts": input_receipt["counts"], + } return { "id": row_ids["parent_proposal_id"], "proposal_type": "attach_evidence", @@ -111,47 +528,55 @@ def build_parent_packet( "proposed_by_handle": "leo", "proposed_by_agent_id": None, "channel": "local_ingestion_canary", - "source_ref": parent_source_ref, - "rationale": "Stage one hash-bound claim/evidence extraction from a captured local document fixture.", + "source_ref": ( + f"local-ingestion:{input_receipt['artifact_sha256']}:" + f"replay={input_receipt['replay_identity_sha256']}:capture={row_ids['source_capture_id']}" + ), + "rationale": "Stage source-linked candidate claims for review without canonical apply.", "payload": { - "claim_candidates": [ - { - "claim_key": claim["claim_key"], - "type": claim["type"], - "confidence": claim["confidence"], - "text": claim["text"], - "body": claim["body"], - "evidence_tier": "isolated_fixture", - "needs_research": claim["metadata"].get("needs_research", False), - "evidence": [{"source_key": source["source_key"], "role": evidence["role"]}], - "edges": [], - } - ], - "source_candidates": [ - { - "source_key": source["source_key"], - "source_type": source["source_type"], - "title": source["title"], - "storage_path": input_receipt["path"], - "url": None, - "source_quality": "local realistic ingestion fixture", - "key_excerpt": evidence["body"], - "content_sha256": input_receipt["source_content_sha256"], - } - ], + "ingestion_manifest": ingestion_manifest, + "claim_candidates": claim_candidates, + "source_candidates": source_candidates, "dedupe_conflict_assessment": { - "database_search_query": claim["claim_key"].replace("_", " "), - "potential_existing_claim_ids": [], - "conflicts": [], + "database_search_query": " ".join(claim["claim_key"].replace("_", " ") for claim in claims), + "duplicates": fixture["extraction"]["duplicates"], + "conflicts": fixture["extraction"]["conflicts"], + "review_required": True, }, }, } def build_schema_sql() -> str: - return r"""\set ON_ERROR_STOP on + return rf"""\set ON_ERROR_STOP on create schema kb_canary; create schema kb_stage; +create table public.claims ( + id uuid primary key, type text not null, text text not null, status text not null, + confidence double precision, tags text[] not null default '{{}}' +); +create table public.sources ( + id uuid primary key, source_type text not null, url text, storage_path text, + excerpt text, hash text not null +); +create table public.claim_evidence ( + claim_id uuid not null, source_id uuid not null, role text not null, weight double precision, + primary key (claim_id, source_id, role) +); +create table public.claim_edges ( + from_claim uuid not null, to_claim uuid not null, edge_type text not null, weight double precision, + primary key (from_claim, to_claim, edge_type) +); +insert into public.claims(id, type, text, status, confidence, tags) values + ('{SEED_CLAIM_A}', 'structural', 'A staged proposal is non-canonical until guarded apply succeeds.', 'open', 0.99, array['seed']), + ('{SEED_CLAIM_B}', 'meta', 'Review and canonical application are separate state transitions.', 'open', 0.98, array['seed']); +insert into public.sources(id, source_type, storage_path, excerpt, hash) values + ('{SEED_SOURCE}', 'observation', 'fixture://canonical/seed', 'Representative canonical seed.', + '{"a" * 64}'); +insert into public.claim_evidence(claim_id, source_id, role, weight) values + ('{SEED_CLAIM_A}', '{SEED_SOURCE}', 'grounds', 1.0); +insert into public.claim_edges(from_claim, to_claim, edge_type, weight) values + ('{SEED_CLAIM_A}', '{SEED_CLAIM_B}', 'supports', 1.0); create table kb_canary.source_captures ( id uuid primary key, source_identity text not null unique, @@ -159,15 +584,15 @@ create table kb_canary.source_captures ( title text not null, locator text not null, artifact_path text not null, - artifact_sha256 text not null check (artifact_sha256 ~ '^[0-9a-f]{64}$'), - content_sha256 text not null check (content_sha256 ~ '^[0-9a-f]{64}$'), - body text not null, + artifact_sha256 text not null check (artifact_sha256 ~ '^[0-9a-f]{{64}}$'), + content_sha256 text not null check (content_sha256 ~ '^[0-9a-f]{{64}}$'), + replay_identity_sha256 text not null check (replay_identity_sha256 ~ '^[0-9a-f]{{64}}$'), metadata jsonb not null ); create table kb_canary.claim_extractions ( id uuid primary key, source_capture_id uuid not null references kb_canary.source_captures(id), - claim_key text not null, + claim_key text not null unique, body text not null, metadata jsonb not null ); @@ -175,8 +600,32 @@ create table kb_canary.evidence_extractions ( id uuid primary key, claim_extraction_id uuid not null references kb_canary.claim_extractions(id), source_capture_id uuid not null references kb_canary.source_captures(id), + source_segment_id text not null, + source_locator text not null, + source_json_pointer text, role text not null, body text not null, + body_sha256 text not null, + source_content_sha256 text not null, + metadata jsonb not null +); +create table kb_canary.duplicate_assessments ( + id uuid primary key, + source_capture_id uuid not null references kb_canary.source_captures(id), + source_segment_id text not null, + source_locator text not null, + duplicate_of_claim_key text not null, + excerpt text not null, + excerpt_sha256 text not null, + reason text not null, + metadata jsonb not null +); +create table kb_canary.conflict_assessments ( + id uuid primary key, + source_capture_id uuid not null references kb_canary.source_captures(id), + from_claim_key text not null, + to_claim_key text not null, + relationship text not null, metadata jsonb not null ); create table kb_stage.kb_proposals ( @@ -200,59 +649,111 @@ create table kb_stage.kb_proposals ( """ -def build_capture_sql(fixture: dict[str, Any], input_receipt: dict[str, Any], row_ids: dict[str, str]) -> str: - source = fixture["source"] - claim = fixture["extraction"]["claim"] - evidence = fixture["extraction"]["evidence"] - q = ap.sql_literal - claim_metadata = { - **claim["metadata"], - "claim_type": claim["type"], - "confidence": claim["confidence"], - "text": claim["text"], - } - return rf"""\set ON_ERROR_STOP on -begin; -insert into kb_canary.source_captures - (id, source_identity, source_type, title, locator, artifact_path, - artifact_sha256, content_sha256, body, metadata) -values - ({q(row_ids["source_capture_id"])}::uuid, {q(source["identity"])}, {q(source["source_type"])}, - {q(source["title"])}, {q(source["locator"])}, {q(input_receipt["path"])}, - {q(input_receipt["artifact_sha256"])}, {q(input_receipt["source_content_sha256"])}, - {q(source["body"])}, {q(json.dumps(source["metadata"], sort_keys=True))}::jsonb); -insert into kb_canary.claim_extractions - (id, source_capture_id, claim_key, body, metadata) -values - ({q(row_ids["claim_extraction_id"])}::uuid, {q(row_ids["source_capture_id"])}::uuid, - {q(claim["claim_key"])}, {q(claim["body"])}, {q(json.dumps(claim_metadata, sort_keys=True))}::jsonb); -insert into kb_canary.evidence_extractions - (id, claim_extraction_id, source_capture_id, role, body, metadata) -values - ({q(row_ids["evidence_extraction_id"])}::uuid, {q(row_ids["claim_extraction_id"])}::uuid, - {q(row_ids["source_capture_id"])}::uuid, {q(evidence["role"])}, {q(evidence["body"])}, - {q(json.dumps(evidence["metadata"], sort_keys=True))}::jsonb); -commit; +def build_canonical_snapshot_sql() -> str: + return r"""\set ON_ERROR_STOP on +begin transaction read only; +select jsonb_build_object( + 'claims', coalesce((select jsonb_agg(to_jsonb(t) order by t.id) from public.claims t), '[]'::jsonb), + 'sources', coalesce((select jsonb_agg(to_jsonb(t) order by t.id) from public.sources t), '[]'::jsonb), + 'claim_evidence', coalesce((select jsonb_agg(to_jsonb(t) order by t.claim_id, t.source_id, t.role) + from public.claim_evidence t), '[]'::jsonb), + 'claim_edges', coalesce((select jsonb_agg(to_jsonb(t) order by t.from_claim, t.to_claim, t.edge_type) + from public.claim_edges t), '[]'::jsonb) +)::text; +rollback; """ -def build_readback_sql(row_ids: dict[str, str], proposal_id: str) -> str: +def build_capture_sql(fixture: dict[str, Any], input_receipt: dict[str, Any], row_ids: dict[str, Any]) -> str: + source = fixture["source"] + q = ap.sql_literal + statements = [ + r"\set ON_ERROR_STOP on", + "begin;", + "insert into kb_canary.source_captures " + "(id, source_identity, source_type, title, locator, artifact_path, artifact_sha256, content_sha256, " + "replay_identity_sha256, metadata) values " + f"({q(row_ids['source_capture_id'])}::uuid, {q(source['identity'])}, {q(source['source_type'])}, " + f"{q(source['title'])}, {q(source['locator'])}, {q(input_receipt['artifact_path'])}, " + f"{q(input_receipt['artifact_sha256'])}, {q(input_receipt['source_content_sha256'])}, " + f"{q(input_receipt['replay_identity_sha256'])}, " + f"{q(json.dumps({**source['metadata'], 'extractor': input_receipt['extractor']}, sort_keys=True))}::jsonb);", + ] + segment_by_id = {segment["id"]: segment for segment in fixture["segments"]} + for claim in fixture["extraction"]["claims"]: + claim_id = row_ids["claim_extraction_ids"][claim["claim_key"]] + claim_metadata = { + **claim["metadata"], + "claim_type": claim["type"], + "confidence": claim["confidence"], + "text": claim["text"], + "extractor": input_receipt["extractor"], + "replay_identity_sha256": input_receipt["replay_identity_sha256"], + } + statements.append( + "insert into kb_canary.claim_extractions " + "(id, source_capture_id, claim_key, body, metadata) values " + f"({q(claim_id)}::uuid, {q(row_ids['source_capture_id'])}::uuid, {q(claim['claim_key'])}, " + f"{q(claim['body'])}, {q(json.dumps(claim_metadata, sort_keys=True))}::jsonb);" + ) + for index, evidence in enumerate(claim["evidence"]): + evidence_key = f"{claim['claim_key']}:{index}" + segment = segment_by_id[evidence["segment_id"]] + evidence_metadata = { + **_require_object(evidence.get("metadata", {}), f"evidence {evidence_key}.metadata"), + "source_text_sha256": segment["text_sha256"], + } + statements.append( + "insert into kb_canary.evidence_extractions " + "(id, claim_extraction_id, source_capture_id, source_segment_id, source_locator, " + "source_json_pointer, role, body, body_sha256, source_content_sha256, metadata) values " + f"({q(row_ids['evidence_extraction_ids'][evidence_key])}::uuid, {q(claim_id)}::uuid, " + f"{q(row_ids['source_capture_id'])}::uuid, {q(segment['id'])}, {q(segment['locator'])}, " + f"{q(segment['json_pointer'])}, {q(evidence['role'])}, {q(evidence['excerpt'])}, " + f"{q(sha256_bytes(evidence['excerpt'].encode()))}, {q(segment['content_sha256'])}, " + f"{q(json.dumps(evidence_metadata, sort_keys=True))}::jsonb);" + ) + for index, duplicate in enumerate(fixture["extraction"]["duplicates"]): + statements.append( + "insert into kb_canary.duplicate_assessments " + "(id, source_capture_id, source_segment_id, source_locator, duplicate_of_claim_key, excerpt, " + "excerpt_sha256, reason, metadata) values " + f"({q(row_ids['duplicate_assessment_ids'][str(index)])}::uuid, " + f"{q(row_ids['source_capture_id'])}::uuid, {q(duplicate['segment_id'])}, " + f"{q(duplicate['source_locator'])}, {q(duplicate['duplicate_of_claim_key'])}, " + f"{q(duplicate['excerpt'])}, {q(sha256_bytes(duplicate['excerpt'].encode()))}, " + f"{q(duplicate['reason'])}, {q(json.dumps({'json_pointer': duplicate['source_json_pointer'], 'source_content_sha256': duplicate['source_content_sha256'], 'source_text_sha256': duplicate['source_text_sha256']}, sort_keys=True))}::jsonb);" + ) + for index, conflict in enumerate(fixture["extraction"]["conflicts"]): + statements.append( + "insert into kb_canary.conflict_assessments " + "(id, source_capture_id, from_claim_key, to_claim_key, relationship, metadata) values " + f"({q(row_ids['conflict_assessment_ids'][str(index)])}::uuid, " + f"{q(row_ids['source_capture_id'])}::uuid, {q(conflict['from_claim_key'])}, " + f"{q(conflict['to_claim_key'])}, {q(conflict['relationship'])}, " + f"{q(json.dumps({key: value for key, value in conflict.items() if key not in {'from_claim_key', 'to_claim_key', 'relationship'}}, sort_keys=True))}::jsonb);" + ) + statements.append("commit;") + return "\n".join(statements) + "\n" + + +def build_readback_sql(proposal_id: str) -> str: q = ap.sql_literal return rf"""\set ON_ERROR_STOP on begin transaction read only; select jsonb_build_object( - 'source_capture', (select to_jsonb(s) from kb_canary.source_captures s - where id = {q(row_ids["source_capture_id"])}::uuid), - 'claim_extraction', (select to_jsonb(c) from kb_canary.claim_extractions c - where id = {q(row_ids["claim_extraction_id"])}::uuid), - 'evidence_extraction', (select to_jsonb(e) from kb_canary.evidence_extractions e - where id = {q(row_ids["evidence_extraction_id"])}::uuid), - 'staged_proposal', (select to_jsonb(p) from kb_stage.kb_proposals p - where id = {q(proposal_id)}::uuid), + 'source_captures', coalesce((select jsonb_agg(to_jsonb(t) order by t.id) from kb_canary.source_captures t), '[]'::jsonb), + 'claim_extractions', coalesce((select jsonb_agg(to_jsonb(t) order by t.claim_key) from kb_canary.claim_extractions t), '[]'::jsonb), + 'evidence_extractions', coalesce((select jsonb_agg(to_jsonb(t) order by t.id) from kb_canary.evidence_extractions t), '[]'::jsonb), + 'duplicate_assessments', coalesce((select jsonb_agg(to_jsonb(t) order by t.id) from kb_canary.duplicate_assessments t), '[]'::jsonb), + 'conflict_assessments', coalesce((select jsonb_agg(to_jsonb(t) order by t.id) from kb_canary.conflict_assessments t), '[]'::jsonb), + 'staged_proposal', (select to_jsonb(p) from kb_stage.kb_proposals p where id = {q(proposal_id)}::uuid), 'counts', jsonb_build_object( 'source_captures', (select count(*) from kb_canary.source_captures), 'claim_extractions', (select count(*) from kb_canary.claim_extractions), 'evidence_extractions', (select count(*) from kb_canary.evidence_extractions), + 'duplicate_assessments', (select count(*) from kb_canary.duplicate_assessments), + 'conflict_assessments', (select count(*) from kb_canary.conflict_assessments), 'staged_proposals', (select count(*) from kb_stage.kb_proposals) ) )::text; @@ -320,11 +821,7 @@ class DockerPostgres: if inspect["Config"]["Labels"].get("livingip.canary") != "working-leo-local-ingestion": raise CanaryError("disposable Postgres is missing its canary label") self.volume_mounts = [ - { - "type": mount.get("Type"), - "name": mount.get("Name"), - "destination": mount.get("Destination"), - } + {"type": mount.get("Type"), "name": mount.get("Name"), "destination": mount.get("Destination")} for mount in inspect.get("Mounts", []) if mount.get("Type") == "volume" ] @@ -386,60 +883,130 @@ def proposal_links(readback: dict[str, Any], input_receipt: dict[str, Any]) -> d "planned_evidence_keys": [ {"claim_id": row["claim_id"], "source_id": row["source_id"], "role": row["role"]} for row in evidence_rows ], - "source_content_hash_matches": any( - row.get("hash") == input_receipt["source_content_sha256"] for row in source_rows + "planned_edge_keys": [ + { + "from_claim": row["from_claim"], + "to_claim": row["to_claim"], + "edge_type": row["edge_type"], + } + for row in apply_payload["edges"] + ], + "planned_counts": { + "claims": len(apply_payload["claims"]), + "sources": len(source_rows), + "evidence": len(evidence_rows), + "edges": len(apply_payload["edges"]), + }, + "input_hash_in_manifest": ( + apply_payload["normalization_manifest"].get("ingestion_manifest", {}).get("artifact_sha256") + == input_receipt["artifact_sha256"] ), - "parent_source_ref_embedded": any( - readback["source_capture"]["artifact_sha256"] in str(row.get("excerpt")) - and readback["source_capture"]["id"] in str(row.get("excerpt")) - and readback["claim_extraction"]["id"] in str(row.get("excerpt")) - and readback["evidence_extraction"]["id"] in str(row.get("excerpt")) - for row in source_rows + "replay_identity_in_manifest": ( + apply_payload["normalization_manifest"].get("ingestion_manifest", {}).get("replay_identity_sha256") + == input_receipt["replay_identity_sha256"] ), } def evaluate_checks( - fixture: dict[str, Any], input_receipt: dict[str, Any], row_ids: dict[str, str], readback: dict[str, Any] + fixture: dict[str, Any], + input_receipt: dict[str, Any], + row_ids: dict[str, Any], + readback: dict[str, Any], + canonical_before: dict[str, Any], + canonical_after: dict[str, Any], ) -> dict[str, bool]: - source = readback.get("source_capture") or {} - claim = readback.get("claim_extraction") or {} - evidence = readback.get("evidence_extraction") or {} + source_rows = readback.get("source_captures") or [] + claim_rows = readback.get("claim_extractions") or [] + evidence_rows = readback.get("evidence_extractions") or [] + duplicate_rows = readback.get("duplicate_assessments") or [] + conflict_rows = readback.get("conflict_assessments") or [] proposal = readback.get("staged_proposal") or {} + counts = input_receipt["counts"] links = proposal_links(readback, input_receipt) + manifest = ((proposal.get("payload") or {}).get("apply_payload") or {}).get("normalization_manifest") or {} + ingestion_manifest = manifest.get("ingestion_manifest") or {} + assessment = manifest.get("dedupe_conflict_assessment") or {} + expected_db_counts = { + "source_captures": 1, + "claim_extractions": counts["claim_candidates"], + "evidence_extractions": counts["evidence_candidates"], + "duplicate_assessments": counts["duplicate_judgments"], + "conflict_assessments": counts["conflict_relationships"], + "staged_proposals": 1, + } + segment_by_id = {segment["id"]: segment for segment in fixture["segments"]} + evidence_locations_exact = all( + row.get("source_locator") == segment_by_id.get(row.get("source_segment_id"), {}).get("locator") + and row.get("source_content_sha256") + == segment_by_id.get(row.get("source_segment_id"), {}).get("content_sha256") + and row.get("body") in segment_by_id.get(row.get("source_segment_id"), {}).get("body", "") + for row in evidence_rows + ) return { - "input_artifact_hash_persisted": source.get("artifact_sha256") == input_receipt["artifact_sha256"], - "source_content_hash_persisted": source.get("content_sha256") == input_receipt["source_content_sha256"], - "source_identity_persisted": source.get("source_identity") == input_receipt["source_identity"], - "claim_links_to_source_capture": claim.get("source_capture_id") == row_ids["source_capture_id"], - "claim_body_and_metadata_persisted": ( - claim.get("body") == fixture["extraction"]["claim"]["body"] - and claim.get("metadata", {}).get("text") == fixture["extraction"]["claim"]["text"] + "source_only_artifact_hash_persisted": ( + len(source_rows) == 1 and source_rows[0].get("artifact_sha256") == input_receipt["artifact_sha256"] ), - "evidence_links_to_claim_and_source": ( - evidence.get("claim_extraction_id") == row_ids["claim_extraction_id"] - and evidence.get("source_capture_id") == row_ids["source_capture_id"] + "replay_identity_persisted": ( + len(source_rows) == 1 + and source_rows[0].get("replay_identity_sha256") == input_receipt["replay_identity_sha256"] ), - "evidence_body_and_metadata_persisted": ( - evidence.get("body") == fixture["extraction"]["evidence"]["body"] - and evidence.get("metadata") == fixture["extraction"]["evidence"]["metadata"] + "extractor_name_and_version_persisted": ( + ingestion_manifest.get("extractor") == input_receipt["extractor"] + and all(row.get("metadata", {}).get("extractor") == input_receipt["extractor"] for row in claim_rows) ), - "one_row_per_ingestion_stage": readback.get("counts") - == {"source_captures": 1, "claim_extractions": 1, "evidence_extractions": 1, "staged_proposals": 1}, + "all_claim_candidates_persisted_once": ( + {row.get("claim_key") for row in claim_rows} + == {claim["claim_key"] for claim in fixture["extraction"]["claims"]} + and len(claim_rows) == counts["claim_candidates"] + ), + "all_evidence_has_exact_source_location": ( + len(evidence_rows) == counts["evidence_candidates"] and evidence_locations_exact + ), + "duplicate_judgments_persisted": ( + len(duplicate_rows) == counts["duplicate_judgments"] + and assessment.get("duplicates") == fixture["extraction"]["duplicates"] + ), + "conflicts_and_relationships_persisted": ( + len(conflict_rows) == counts["conflict_relationships"] + and assessment.get("conflicts") == fixture["extraction"]["conflicts"] + and links["planned_counts"]["edges"] == counts["conflict_relationships"] + ), + "database_candidate_counts_exact": readback.get("counts") == expected_db_counts, "proposal_is_pending_review": proposal.get("status") == "pending_review", - "proposal_has_source_ref": bool(proposal.get("source_ref")), - "proposal_payload_contains_input_hash": links["source_content_hash_matches"], - "proposal_payload_contains_capture_row_linkage": links["parent_source_ref_embedded"], + "proposal_has_replayable_ingestion_manifest": ( + links["input_hash_in_manifest"] + and links["replay_identity_in_manifest"] + and ingestion_manifest.get("segments") + == [ + { + "id": segment["id"], + "locator": segment["locator"], + "json_pointer": segment["json_pointer"], + "content_sha256": segment["content_sha256"], + "text_sha256": segment["text_sha256"], + } + for segment in fixture["segments"] + ] + ), "no_review_or_apply_metadata": all( proposal.get(key) is None for key in ("reviewed_by_handle", "reviewed_at", "applied_by_handle", "applied_at") ), + "canonical_snapshot_nonempty": ( + canonical_snapshot_complete(canonical_before) and canonical_snapshot_complete(canonical_after) + ), + "canonical_fingerprint_unchanged": ( + canonical_snapshot_complete(canonical_before) + and canonical_snapshot_complete(canonical_after) + and canonical_sha256(canonical_before) == canonical_sha256(canonical_after) + ), } def run_canary(fixture_path: Path, output: Path | None = None) -> dict[str, Any]: fixture, input_receipt = load_fixture(fixture_path) - row_ids = build_row_ids(input_receipt["artifact_sha256"]) + row_ids = build_row_ids(input_receipt, fixture) parent = build_parent_packet(fixture, input_receipt, row_ids) child = normalized_stage.prepare_normalized_child(parent) container_name = f"working-leo-ingest-{uuid.uuid4().hex[:12]}" @@ -447,7 +1014,7 @@ def run_canary(fixture_path: Path, output: Path | None = None) -> dict[str, Any] receipt: dict[str, Any] = { "schema": SCHEMA, "status": "fail", - "required_tier": "isolated_realistic_ingestion_to_proposal", + "required_tier": "T2_runtime_to_review_queue_staging", "input": input_receipt, "row_ids": row_ids, "production_mutation_attempted": False, @@ -456,44 +1023,49 @@ def run_canary(fixture_path: Path, output: Path | None = None) -> dict[str, Any] try: receipt["environment"] = postgres.start() postgres.psql(build_schema_sql()) + canonical_before = postgres.psql_json(build_canonical_snapshot_sql()) postgres.psql(build_capture_sql(fixture, input_receipt, row_ids)) stage_result = postgres.psql_json(normalized_stage.build_stage_sql(child)) - readback = postgres.psql_json(build_readback_sql(row_ids, child["id"])) - checks = evaluate_checks(fixture, input_receipt, row_ids, readback) + readback = postgres.psql_json(build_readback_sql(child["id"])) + canonical_after = postgres.psql_json(build_canonical_snapshot_sql()) + canonical_unchanged = ( + canonical_snapshot_complete(canonical_before) + and canonical_snapshot_complete(canonical_after) + and canonical_sha256(canonical_before) == canonical_sha256(canonical_after) + ) + checks = evaluate_checks(fixture, input_receipt, row_ids, readback, canonical_before, canonical_after) receipt.update( { "stage_command_result": stage_result, "rows": readback, + "candidate_counts": input_receipt["counts"], "row_link_fields_proven": proposal_links(readback, input_receipt), + "canonical": { + "fingerprint_before": canonical_sha256(canonical_before), + "fingerprint_after": canonical_sha256(canonical_after), + "table_counts": {key: len(canonical_before[key]) for key in CANONICAL_TABLES}, + "unchanged": canonical_unchanged, + }, "checks": checks, "status": "pass" if all(checks.values()) else "fail", } ) - except (CanaryError, OSError, ValueError, KeyError) as exc: + except (CanaryError, OSError, ValueError, KeyError, normalized_stage.bound.CheckpointError) as exc: receipt["error"] = {"type": type(exc).__name__, "message": str(exc)} finally: receipt["cleanup"] = postgres.cleanup() - receipt["cleanup"]["network_mode_none"] = ( - receipt.get("environment", {}).get("network_mode") == "none" - ) - receipt["cleanup"]["no_production_target_referenced"] = receipt[ - "cleanup" - ]["network_mode_none"] - if not all( - ( - receipt["cleanup"]["container_absent"], - receipt["cleanup"]["network_mode_none"], - ) - ): + receipt["cleanup"]["network_mode_none"] = receipt.get("environment", {}).get("network_mode") == "none" + receipt["cleanup"]["no_production_target_referenced"] = receipt["cleanup"]["network_mode_none"] + if not all((receipt["cleanup"]["container_absent"], receipt["cleanup"]["network_mode_none"])): receipt["status"] = "fail" receipt["strongest_claim"] = ( - "A realistic local document fixture was hash-captured, replayed through deterministic claim/evidence " - "extraction, normalized by the existing rich-proposal normalizer, and staged/read back as one " - "pending_review proposal in disposable networkless Postgres." + "One source-only artifact was parsed into exact-location candidate claims, duplicate/conflict " + "assessments, and a replay-bound pending_review proposal in disposable networkless Postgres; " + "representative canonical row contents remained fingerprint-identical." ) receipt["residual_gap"] = ( - "This does not prove live generative extraction, arbitrary document coverage, canonical apply, or any " - "hosted/production runtime." + "This proves the deterministic local fixture extractor and review queue only; it does not prove " + "arbitrary generative extraction, approval/apply, hosted runtime, or production mutation." ) if output: output.parent.mkdir(parents=True, exist_ok=True) @@ -501,16 +1073,45 @@ def run_canary(fixture_path: Path, output: Path | None = None) -> dict[str, Any] return receipt +def run_suite(fixture_paths: list[Path], output: Path | None = None) -> dict[str, Any]: + receipts = [run_canary(path.resolve()) for path in fixture_paths] + suite = { + "schema": SUITE_SCHEMA, + "status": "pass" if receipts and all(item["status"] == "pass" for item in receipts) else "fail", + "required_tier": "T2_runtime_to_review_queue_staging", + "fixture_count": len(receipts), + "document_fixture_passed": any( + item["status"] == "pass" and item["input"]["artifact_format"] == "plain_text" for item in receipts + ), + "social_conversation_fixture_passed": any( + item["status"] == "pass" and item["input"]["artifact_format"] == "telegram_jsonl" for item in receipts + ), + "canonical_fingerprint_invariant_all": bool(receipts) + and all(item.get("canonical", {}).get("unchanged") is True for item in receipts), + "receipts": receipts, + } + if output: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(suite, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return suite + + def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--fixture", type=Path, default=DEFAULT_FIXTURE) + parser.add_argument( + "--fixture", + type=Path, + action="append", + help="repeatable scenario path; defaults to the document and Telegram scenarios", + ) parser.add_argument("--output", type=Path) return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = parse_args(argv) - receipt = run_canary(args.fixture.resolve(), args.output.resolve() if args.output else None) + fixtures = args.fixture or list(DEFAULT_FIXTURES) + receipt = run_suite([path.resolve() for path in fixtures], args.output.resolve() if args.output else None) print(json.dumps(receipt, sort_keys=True, separators=(",", ":"))) return 0 if receipt["status"] == "pass" else 1 diff --git a/scripts/stage_normalized_proposal.py b/scripts/stage_normalized_proposal.py index 9750929..9fe9a56 100644 --- a/scripts/stage_normalized_proposal.py +++ b/scripts/stage_normalized_proposal.py @@ -57,6 +57,12 @@ def prepare_normalized_child(parent: dict[str, Any]) -> dict[str, Any]: manifest = apply_payload.setdefault("normalization_manifest", {}) if not isinstance(manifest, dict): raise bound.CheckpointError("normalized child normalization_manifest must be an object") + parent_payload = parent.get("payload") + ingestion_manifest = parent_payload.get("ingestion_manifest") if isinstance(parent_payload, dict) else None + if ingestion_manifest is not None: + if not isinstance(ingestion_manifest, dict): + raise bound.CheckpointError("parent payload.ingestion_manifest must be an object") + manifest["ingestion_manifest"] = json.loads(json.dumps(ingestion_manifest, sort_keys=True)) parent_packet_sha256 = canonical_sha256(parent) manifest["parent_packet_sha256"] = parent_packet_sha256 child_payload_sha256 = canonical_sha256(payload) diff --git a/tests/test_prepare_kb_source_manifest.py b/tests/test_prepare_kb_source_manifest.py index ec41f52..d85a0c2 100644 --- a/tests/test_prepare_kb_source_manifest.py +++ b/tests/test_prepare_kb_source_manifest.py @@ -123,11 +123,17 @@ def test_prepare_builds_private_compiler_validated_manifest_without_db_write( assert manifest["dedupe"]["duplicates"][0]["claim_id"] == CANDIDATE_ID assert manifest["claims"][0]["quote"] == CLAIM_QUOTE assert manifest["dedupe"]["duplicates"][0]["source_quote"] == DUPLICATE_QUOTE - assert receipt["extraction"]["selected_source_references"] == [ - {"kind": "claim", "reference": "S00003"}, - {"kind": "evidence", "reference": "S00003"}, - {"kind": "duplicate", "reference": "S00002"}, + selected = receipt["extraction"]["selected_source_references"] + assert [(row["kind"], row["reference"]) for row in selected] == [ + ("claim", "S00003"), + ("evidence", "S00003"), + ("duplicate", "S00002"), ] + assert all(row["char_end"] > row["char_start"] for row in selected) + assert all(len(row["quote_sha256"]) == 64 for row in selected) + assert receipt["replay"]["exact_selected_locations_validated"] is True + assert receipt["extraction"]["evidence_count"] == 1 + assert receipt["extraction"]["duplicate_judgment_count"] == 1 assert stat.S_IMODE(output_dir.stat().st_mode) == 0o700 for path in output_dir.iterdir(): assert stat.S_IMODE(path.stat().st_mode) == 0o600 @@ -173,6 +179,63 @@ def test_source_fragment_ids_are_dense_across_blank_lines() -> None: assert fragments == {"S00001": "first", "S00002": "second"} +def test_source_fragment_index_trims_indentation_but_preserves_exact_offsets() -> None: + text = " indented Unicode: \u201creviewed\u201d \nnext\n" + + fragments, locations = preparer.build_source_fragment_index(text) + + assert fragments["S00001"] == "indented Unicode: \u201creviewed\u201d" + selected = locations["S00001"] + assert selected["line"] == 1 + assert text[selected["char_start"] : selected["char_end"]] == fragments["S00001"] + + +def test_bundle_location_validation_rejects_ambiguous_rebound() -> None: + text = "same line\nsame line\n" + fragments, locations = preparer.build_source_fragment_index(text) + extraction = preparer.resolve_model_output( + { + "claims": [ + { + "claim_key": "second_occurrence", + "type": "structural", + "text": "The second occurrence was selected.", + "quote_ref": "S00002", + "confidence": 0.5, + "tags": [], + "evidence": [{"quote_ref": "S00002", "role": "grounds"}], + } + ], + "duplicates": [], + "extraction_notes": "Select the second repeated line.", + }, + fragments, + locations, + ) + fake_bundle = { + "quote_bindings": [ + { + "kind": "claim", + "claim_key": "second_occurrence", + "quote": "same line", + "first_start": 0, + "first_end": 9, + }, + { + "kind": "evidence", + "claim_key": "second_occurrence", + "evidence_role": "grounds", + "quote": "same line", + "first_start": 0, + "first_end": 9, + }, + ] + } + + with pytest.raises(preparer.PreparationError, match="ambiguous repeated quote"): + preparer.validate_bundle_source_locations(fake_bundle, extraction["selected_source_references"]) + + def test_prepare_returns_no_novel_claims_without_manifest_or_stageable_packet( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -195,6 +258,13 @@ def test_binary_artifact_requires_explicit_utf8_extraction(tmp_path: Path) -> No preparer.extract_utf8_text(artifact, None) +def test_repo_native_jsonl_transcript_is_accepted_as_strict_utf8(tmp_path: Path) -> None: + artifact = tmp_path / "telegram.jsonl" + artifact.write_text('{"chat_id":1,"message_id":2,"message":"hello"}\n', encoding="utf-8") + + assert preparer.extract_utf8_text(artifact, None) == artifact.read_bytes() + + def test_openrouter_key_cannot_be_redirected_to_an_unapproved_endpoint(tmp_path: Path) -> None: with pytest.raises(preparer.PreparationError, match=r"must equal https://openrouter\.ai"): preparer.call_openrouter( diff --git a/tests/test_run_leo_local_ingestion_proposal_canary.py b/tests/test_run_leo_local_ingestion_proposal_canary.py index ec57f0b..cfae09e 100644 --- a/tests/test_run_leo_local_ingestion_proposal_canary.py +++ b/tests/test_run_leo_local_ingestion_proposal_canary.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +import hashlib import json import sys from pathlib import Path @@ -13,90 +14,247 @@ sys.path.insert(0, str(REPO_ROOT / "scripts")) import run_leo_local_ingestion_proposal_canary as canary # noqa: E402 -def _loaded() -> tuple[dict, dict, dict]: - fixture, input_receipt = canary.load_fixture(canary.DEFAULT_FIXTURE) - row_ids = canary.build_row_ids(input_receipt["artifact_sha256"]) - return fixture, input_receipt, row_ids - - -def test_fixture_is_realistic_hash_bound_and_exactly_evidenced() -> None: - fixture, input_receipt, row_ids = _loaded() - - assert fixture["extraction"]["evidence"]["body"] in fixture["source"]["body"] - assert len(input_receipt["artifact_sha256"]) == 64 - assert len(input_receipt["source_content_sha256"]) == 64 - assert input_receipt["artifact_sha256"] != input_receipt["source_content_sha256"] - assert len(set(row_ids.values())) == 4 - - -def test_fixture_validation_fails_closed_when_evidence_is_not_in_source(tmp_path: Path) -> None: - fixture = json.loads(canary.DEFAULT_FIXTURE.read_text(encoding="utf-8")) - fixture["extraction"]["evidence"]["body"] = "invented evidence" - path = tmp_path / "bad-fixture.json" - path.write_text(json.dumps(fixture), encoding="utf-8") - - with pytest.raises(canary.CanaryError, match="exact substring"): - canary.load_fixture(path) - - -def test_fixture_validation_fails_closed_when_claim_body_is_not_in_source( - tmp_path: Path, -) -> None: - fixture = json.loads(canary.DEFAULT_FIXTURE.read_text(encoding="utf-8")) - fixture["extraction"]["claim"]["body"] = "invented claim body" - path = tmp_path / "bad-claim-fixture.json" - path.write_text(json.dumps(fixture), encoding="utf-8") - - with pytest.raises(canary.CanaryError, match="claim body must be an exact substring"): - canary.load_fixture(path) - - -def test_capture_sql_escapes_quotes_and_backslashes_from_fixture( - tmp_path: Path, -) -> None: - fixture = json.loads(canary.DEFAULT_FIXTURE.read_text(encoding="utf-8")) - fixture["source"]["title"] = "O'Brien\\review" - fixture["source"]["metadata"]["note"] = "quoted ' value \\ path" - path = tmp_path / "quoted-fixture.json" - path.write_text(json.dumps(fixture), encoding="utf-8") - loaded, input_receipt = canary.load_fixture(path) - row_ids = canary.build_row_ids(input_receipt["artifact_sha256"]) - - sql = canary.build_capture_sql(loaded, input_receipt, row_ids) - - assert canary.ap.sql_literal(fixture["source"]["title"]) in sql - assert canary.ap.sql_literal( - json.dumps(fixture["source"]["metadata"], sort_keys=True) - ) in sql - - -def test_parent_normalizes_to_hash_bound_pending_proposal_with_embedded_row_links() -> None: - fixture, input_receipt, row_ids = _loaded() +def _loaded(path: Path) -> tuple[dict, dict, dict, dict, dict]: + fixture, input_receipt = canary.load_fixture(path) + row_ids = canary.build_row_ids(input_receipt, fixture) parent = canary.build_parent_packet(fixture, input_receipt, row_ids) child = canary.normalized_stage.prepare_normalized_child(parent) - payload = child["payload"]["apply_payload"] - - assert child["status"] == "pending_review" - assert child["proposal_type"] == "approve_claim" - assert len(payload["claims"]) == 1 - assert len(payload["sources"]) == 2 - assert len(payload["evidence"]) == 2 - assert input_receipt["source_content_sha256"] in {row["hash"] for row in payload["sources"]} - excerpts = "\n".join(row["excerpt"] for row in payload["sources"]) - assert input_receipt["artifact_sha256"] in excerpts - assert row_ids["source_capture_id"] in excerpts - assert row_ids["claim_extraction_id"] in excerpts - assert row_ids["evidence_extraction_id"] in excerpts + return fixture, input_receipt, row_ids, parent, child -def test_capture_and_stage_sql_never_mutate_canonical_public_tables() -> None: - fixture, input_receipt, row_ids = _loaded() - child = canary.normalized_stage.prepare_normalized_child( - canary.build_parent_packet(fixture, input_receipt, row_ids) +def _copy_scenario(tmp_path: Path, scenario_path: Path) -> Path: + scenario = json.loads(scenario_path.read_text(encoding="utf-8")) + artifact_source = scenario_path.parent / scenario["artifact"]["path"] + artifact_target = tmp_path / artifact_source.name + artifact_target.write_bytes(artifact_source.read_bytes()) + scenario_target = tmp_path / scenario_path.name + scenario_target.write_text(json.dumps(scenario), encoding="utf-8") + return scenario_target + + +def _synthetic_readback(fixture: dict, input_receipt: dict, row_ids: dict, child: dict) -> dict: + claim_rows = [] + evidence_rows = [] + segment_by_id = {segment["id"]: segment for segment in fixture["segments"]} + for claim in fixture["extraction"]["claims"]: + claim_id = row_ids["claim_extraction_ids"][claim["claim_key"]] + claim_rows.append( + { + "id": claim_id, + "source_capture_id": row_ids["source_capture_id"], + "claim_key": claim["claim_key"], + "body": claim["body"], + "metadata": {"extractor": input_receipt["extractor"]}, + } + ) + for index, evidence in enumerate(claim["evidence"]): + segment = segment_by_id[evidence["segment_id"]] + evidence_rows.append( + { + "id": row_ids["evidence_extraction_ids"][f"{claim['claim_key']}:{index}"], + "claim_extraction_id": claim_id, + "source_capture_id": row_ids["source_capture_id"], + "source_segment_id": segment["id"], + "source_locator": segment["locator"], + "source_content_sha256": segment["content_sha256"], + "body": evidence["excerpt"], + } + ) + proposal = { + **child, + "reviewed_by_handle": None, + "reviewed_at": None, + "applied_by_handle": None, + "applied_at": None, + } + counts = input_receipt["counts"] + return { + "source_captures": [ + { + "artifact_sha256": input_receipt["artifact_sha256"], + "replay_identity_sha256": input_receipt["replay_identity_sha256"], + } + ], + "claim_extractions": claim_rows, + "evidence_extractions": evidence_rows, + "duplicate_assessments": [{} for _item in fixture["extraction"]["duplicates"]], + "conflict_assessments": [{} for _item in fixture["extraction"]["conflicts"]], + "staged_proposal": proposal, + "counts": { + "source_captures": 1, + "claim_extractions": counts["claim_candidates"], + "evidence_extractions": counts["evidence_candidates"], + "duplicate_assessments": counts["duplicate_judgments"], + "conflict_assessments": counts["conflict_relationships"], + "staged_proposals": 1, + }, + } + + +@pytest.mark.parametrize("scenario_path", canary.DEFAULT_FIXTURES) +def test_source_artifact_is_separate_hash_bound_and_replayable(scenario_path: Path) -> None: + fixture, input_receipt, row_ids, parent, child = _loaded(scenario_path) + artifact_path = Path(input_receipt["artifact_path"]) + + assert input_receipt["artifact_sha256"] == hashlib.sha256(artifact_path.read_bytes()).hexdigest() + assert input_receipt["artifact_sha256"] != input_receipt["scenario_sha256"] + assert len(input_receipt["replay_identity_sha256"]) == 64 + assert row_ids == canary.build_row_ids(input_receipt, fixture) + ingestion = child["payload"]["apply_payload"]["normalization_manifest"]["ingestion_manifest"] + assert ingestion["artifact_sha256"] == input_receipt["artifact_sha256"] + assert ingestion["extractor"] == input_receipt["extractor"] + assert ingestion["replay_identity_sha256"] == input_receipt["replay_identity_sha256"] + assert ingestion["row_ids"] == row_ids + assert parent["status"] == child["status"] == "pending_review" + + +def test_document_and_telegram_candidate_accounting_is_exact() -> None: + _doc, doc_input, _doc_ids, _doc_parent, doc_child = _loaded(canary.DEFAULT_DOCUMENT_FIXTURE) + telegram, telegram_input, _telegram_ids, _telegram_parent, telegram_child = _loaded(canary.DEFAULT_TELEGRAM_FIXTURE) + + assert doc_input["counts"] == { + "source_segments": 3, + "claim_candidates": 1, + "evidence_candidates": 1, + "duplicate_judgments": 0, + "conflict_relationships": 0, + } + assert telegram_input["counts"] == { + "source_segments": 3, + "claim_candidates": 2, + "evidence_candidates": 3, + "duplicate_judgments": 1, + "conflict_relationships": 1, + } + doc_payload = doc_child["payload"]["apply_payload"] + telegram_payload = telegram_child["payload"]["apply_payload"] + assert {key: len(doc_payload[key]) for key in ("claims", "sources", "evidence", "edges")} == { + "claims": 1, + "sources": 2, + "evidence": 2, + "edges": 0, + } + assert {key: len(telegram_payload[key]) for key in ("claims", "sources", "evidence", "edges")} == { + "claims": 2, + "sources": 5, + "evidence": 5, + "edges": 1, + } + assessment = telegram_payload["normalization_manifest"]["dedupe_conflict_assessment"] + assert assessment["duplicates"] == telegram["extraction"]["duplicates"] + assert assessment["conflicts"] == telegram["extraction"]["conflicts"] + assert telegram_payload["edges"][0]["edge_type"] == "contradicts" + + +def test_telegram_duplicate_text_keeps_distinct_exact_message_provenance() -> None: + fixture, _input, _row_ids, _parent, _child = _loaded(canary.DEFAULT_TELEGRAM_FIXTURE) + first, _second, repeated = fixture["segments"] + + assert first["body"] == repeated["body"] + assert first["text_sha256"] == repeated["text_sha256"] + assert first["content_sha256"] != repeated["content_sha256"] + assert first["locator"] == "telegram://chat/900001/message/7301" + assert repeated["locator"] == "telegram://chat/900001/message/7303" + duplicate = fixture["extraction"]["duplicates"][0] + assert duplicate["source_locator"] == repeated["locator"] + assert duplicate["source_json_pointer"] == "jsonl://line/3/message" + + +def test_fixture_validation_fails_closed_on_invented_evidence(tmp_path: Path) -> None: + scenario_path = _copy_scenario(tmp_path, canary.DEFAULT_TELEGRAM_FIXTURE) + scenario = json.loads(scenario_path.read_text(encoding="utf-8")) + scenario["extraction"]["claims"][0]["evidence"][0]["excerpt"] = "invented evidence" + scenario_path.write_text(json.dumps(scenario), encoding="utf-8") + + with pytest.raises(canary.CanaryError, match="not an exact segment substring"): + canary.load_fixture(scenario_path) + + +def test_fixture_artifact_path_cannot_escape_scenario_directory(tmp_path: Path) -> None: + scenario = json.loads(canary.DEFAULT_DOCUMENT_FIXTURE.read_text(encoding="utf-8")) + scenario["artifact"]["path"] = "../outside.txt" + path = tmp_path / "scenario.json" + path.write_text(json.dumps(scenario), encoding="utf-8") + + with pytest.raises(canary.CanaryError, match="remain inside"): + canary.load_fixture(path) + + +def test_capture_sql_escapes_quotes_and_backslashes(tmp_path: Path) -> None: + scenario_path = _copy_scenario(tmp_path, canary.DEFAULT_DOCUMENT_FIXTURE) + scenario = json.loads(scenario_path.read_text(encoding="utf-8")) + scenario["source"]["title"] = "O'Brien\\review" + scenario["source"]["metadata"]["note"] = "quoted ' value \\ path" + scenario_path.write_text(json.dumps(scenario), encoding="utf-8") + fixture, input_receipt, row_ids, _parent, _child = _loaded(scenario_path) + + sql = canary.build_capture_sql(fixture, input_receipt, row_ids) + + assert canary.ap.sql_literal(scenario["source"]["title"]) in sql + expected_metadata = { + **scenario["source"]["metadata"], + "extractor": input_receipt["extractor"], + } + assert canary.ap.sql_literal(json.dumps(expected_metadata, sort_keys=True)) in sql + + +def test_replay_identity_and_ids_change_with_extractor_version(tmp_path: Path) -> None: + scenario_path = _copy_scenario(tmp_path, canary.DEFAULT_DOCUMENT_FIXTURE) + fixture, original, original_ids, _parent, original_child = _loaded(scenario_path) + scenario = json.loads(scenario_path.read_text(encoding="utf-8")) + scenario["extractor"]["version"] = "3" + scenario_path.write_text(json.dumps(scenario), encoding="utf-8") + changed_fixture, changed, changed_ids, _changed_parent, changed_child = _loaded(scenario_path) + + assert original["artifact_sha256"] == changed["artifact_sha256"] + assert original["replay_identity_sha256"] != changed["replay_identity_sha256"] + assert original_ids["source_capture_id"] == changed_ids["source_capture_id"] + assert original_ids["claim_extraction_ids"] != changed_ids["claim_extraction_ids"] + assert original_ids["parent_proposal_id"] != changed_ids["parent_proposal_id"] + assert original_child["payload_sha256"] != changed_child["payload_sha256"] + assert fixture["segments"] == changed_fixture["segments"] + + +def test_source_byte_tamper_changes_hashes_and_replay_ids(tmp_path: Path) -> None: + scenario_path = _copy_scenario(tmp_path, canary.DEFAULT_DOCUMENT_FIXTURE) + fixture, before, before_ids, _parent, _child = _loaded(scenario_path) + artifact_path = Path(before["artifact_path"]) + artifact_path.write_text( + artifact_path.read_text(encoding="utf-8") + "\n\nA new source paragraph.", encoding="utf-8" ) + changed_fixture, after, after_ids, _changed_parent, _changed_child = _loaded(scenario_path) + + assert before["artifact_sha256"] != after["artifact_sha256"] + assert before["source_content_sha256"] != after["source_content_sha256"] + assert before["replay_identity_sha256"] != after["replay_identity_sha256"] + assert before_ids["source_capture_id"] != after_ids["source_capture_id"] + assert before_ids["claim_extraction_ids"] != after_ids["claim_extraction_ids"] + assert len(changed_fixture["segments"]) == len(fixture["segments"]) + 1 + + +def test_replay_properties_hold_across_many_version_labels(tmp_path: Path) -> None: + scenario_path = _copy_scenario(tmp_path, canary.DEFAULT_DOCUMENT_FIXTURE) + base = json.loads(scenario_path.read_text(encoding="utf-8")) + observed: set[str] = set() + for index in range(40): + scenario = copy.deepcopy(base) + scenario["extractor"]["version"] = f"property-{index}" + scenario_path.write_text(json.dumps(scenario), encoding="utf-8") + fixture_a, receipt_a = canary.load_fixture(scenario_path) + fixture_b, receipt_b = canary.load_fixture(scenario_path) + ids_a = canary.build_row_ids(receipt_a, fixture_a) + ids_b = canary.build_row_ids(receipt_b, fixture_b) + assert receipt_a == receipt_b + assert ids_a == ids_b + observed.add(receipt_a["replay_identity_sha256"]) + assert len(observed) == 40 + + +@pytest.mark.parametrize("scenario_path", canary.DEFAULT_FIXTURES) +def test_capture_and_stage_sql_do_not_mutate_canonical_tables(scenario_path: Path) -> None: + fixture, input_receipt, row_ids, parent, child = _loaded(scenario_path) sql = "\n".join( ( - canary.build_schema_sql(), canary.build_capture_sql(fixture, input_receipt, row_ids), canary.normalized_stage.build_stage_sql(child), ) @@ -106,59 +264,50 @@ def test_capture_and_stage_sql_never_mutate_canonical_public_tables() -> None: assert "update public." not in sql assert "delete from public." not in sql assert "insert into kb_canary.source_captures" in sql - assert "insert into kb_canary.claim_extractions" in sql - assert "insert into kb_canary.evidence_extractions" in sql assert "insert into kb_stage.kb_proposals" in sql + assert parent["payload"]["ingestion_manifest"]["row_ids"] == row_ids -def test_check_evaluation_requires_every_row_link_and_staging_boundary() -> None: - fixture, input_receipt, row_ids = _loaded() - child = canary.normalized_stage.prepare_normalized_child( - canary.build_parent_packet(fixture, input_receipt, row_ids) +def test_canonical_snapshot_covers_all_nonempty_canonical_tables() -> None: + schema_sql = canary.build_schema_sql().lower() + snapshot_sql = canary.build_canonical_snapshot_sql().lower() + + for table in ("claims", "sources", "claim_evidence", "claim_edges"): + assert f"insert into public.{table}" in schema_sql + assert f"from public.{table}" in snapshot_sql + assert "order by" in snapshot_sql + assert "begin transaction read only" in snapshot_sql + assert canary.canonical_snapshot_complete({}) is False + assert ( + canary.canonical_snapshot_complete( + { + "claims": [{"id": "1"}], + "sources": [{"id": "2"}], + "claim_evidence": [{"claim_id": "1"}], + "claim_edges": [{"from_claim": "1"}], + } + ) + is True ) - apply_payload = child["payload"]["apply_payload"] - readback = { - "source_capture": { - "id": row_ids["source_capture_id"], - "artifact_sha256": input_receipt["artifact_sha256"], - "content_sha256": input_receipt["source_content_sha256"], - "source_identity": input_receipt["source_identity"], - }, - "claim_extraction": { - "id": row_ids["claim_extraction_id"], - "source_capture_id": row_ids["source_capture_id"], - "body": fixture["extraction"]["claim"]["body"], - "metadata": {"text": fixture["extraction"]["claim"]["text"]}, - }, - "evidence_extraction": { - "id": row_ids["evidence_extraction_id"], - "claim_extraction_id": row_ids["claim_extraction_id"], - "source_capture_id": row_ids["source_capture_id"], - "body": fixture["extraction"]["evidence"]["body"], - "metadata": fixture["extraction"]["evidence"]["metadata"], - }, - "staged_proposal": { - "id": child["id"], - "status": "pending_review", - "source_ref": child["source_ref"], - "payload": {"apply_payload": apply_payload}, - "reviewed_by_handle": None, - "reviewed_at": None, - "applied_by_handle": None, - "applied_at": None, - }, - "counts": { - "source_captures": 1, - "claim_extractions": 1, - "evidence_extractions": 1, - "staged_proposals": 1, - }, + + +def test_evaluation_fails_when_exact_evidence_locator_is_changed() -> None: + fixture, input_receipt, row_ids, _parent, child = _loaded(canary.DEFAULT_TELEGRAM_FIXTURE) + readback = _synthetic_readback(fixture, input_receipt, row_ids, child) + canonical = { + "claims": [{"id": "1"}], + "sources": [{"id": "2"}], + "claim_evidence": [{"claim_id": "1"}], + "claim_edges": [{"from_claim": "1"}], } - checks = canary.evaluate_checks(fixture, input_receipt, row_ids, readback) + checks = canary.evaluate_checks(fixture, input_receipt, row_ids, readback, canonical, copy.deepcopy(canonical)) assert all(checks.values()) broken = copy.deepcopy(readback) - broken["evidence_extraction"]["claim_extraction_id"] = canary.stable_uuid("0" * 64, "wrong") + broken["evidence_extractions"][0]["source_locator"] = "telegram://chat/900001/message/9999" assert ( - canary.evaluate_checks(fixture, input_receipt, row_ids, broken)["evidence_links_to_claim_and_source"] is False + canary.evaluate_checks(fixture, input_receipt, row_ids, broken, canonical, copy.deepcopy(canonical))[ + "all_evidence_has_exact_source_location" + ] + is False ) diff --git a/tests/test_stage_normalized_proposal.py b/tests/test_stage_normalized_proposal.py index 09f86d7..a4309a8 100644 --- a/tests/test_stage_normalized_proposal.py +++ b/tests/test_stage_normalized_proposal.py @@ -116,6 +116,37 @@ def test_invalid_source_hash_refuses_to_prepare_a_child() -> None: stage.prepare_normalized_child(parent) +def test_prepare_normalized_child_preserves_replayable_ingestion_manifest() -> None: + parent = _parent() + parent["payload"]["ingestion_manifest"] = { + "artifact_sha256": "a" * 64, + "extractor": {"name": "deterministic_fixture_replay", "version": "2"}, + "replay_identity_sha256": "b" * 64, + "segments": [ + { + "id": "message-1", + "locator": "telegram://chat/1/message/1", + "content_sha256": "c" * 64, + } + ], + } + + child = stage.prepare_normalized_child(parent) + + assert ( + child["payload"]["apply_payload"]["normalization_manifest"]["ingestion_manifest"] + == parent["payload"]["ingestion_manifest"] + ) + + +def test_prepare_normalized_child_rejects_non_object_ingestion_manifest() -> None: + parent = _parent() + parent["payload"]["ingestion_manifest"] = "not-an-object" + + with pytest.raises(stage.bound.CheckpointError, match="ingestion_manifest must be an object"): + stage.prepare_normalized_child(parent) + + def test_stage_sql_validates_exact_pending_row_before_commit_and_never_writes_public() -> None: child = stage.prepare_normalized_child(_parent()) sql = stage.build_stage_sql(child) From a6947a3f8bb74323a452c3b20a4060d19e1f94f3 Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Wed, 15 Jul 2026 03:19:49 +0200 Subject: [PATCH 2/8] replay exact revise strategy database transitions --- docs/kb-rebuild-and-recompile.md | 67 +- ops/run_local_genesis_ledger_rebuild.py | 543 ++++++++++++++- scripts/kb_apply_prereqs.sql | 5 +- tests/test_kb_apply_prereqs.py | 9 + .../test_run_local_genesis_ledger_rebuild.py | 659 +++++++++++++++--- 5 files changed, 1153 insertions(+), 130 deletions(-) diff --git a/docs/kb-rebuild-and-recompile.md b/docs/kb-rebuild-and-recompile.md index 2baf932..00996dc 100644 --- a/docs/kb-rebuild-and-recompile.md +++ b/docs/kb-rebuild-and-recompile.md @@ -148,7 +148,7 @@ not retain every column of the proposal ledger, so exact reconstruction also needs the full approved proposal row, immutable approval snapshot, and final applied proposal row. -## Genesis Plus Strict Ledger: Working Insert-Only Slice +## Genesis Plus Strict Ledger: Working Deterministic Slice `ops/run_local_genesis_ledger_rebuild.py` now executes the first exact genesis-plus-ledger slice in one command: @@ -206,27 +206,65 @@ Each referenced material object has exact top-level keys must contain every current `kb_stage.kb_proposals` column; partial envelopes are rejected. -The command verifies every hash before starting Docker, restores a tmpfs-backed -Postgres with `--network none`, reapplies the current guarded prerequisites, -and proves genesis parity. For each entry it seeds the receipt's exact row IDs -and timestamps in the disposable clone, executes the existing `kb_apply` -payload-bound guard and ledger transition, checks exact proposal and canonical -row readbacks, then verifies the complete final parity manifest. Its public +The command verifies every hash before starting Docker, requires a +SHA-256-pinned Postgres image (and defaults to a pinned PostgreSQL 16 Alpine +multi-platform digest), restores it on tmpfs with `--network none`, reapplies +the current guarded prerequisites, and proves genesis parity. Insert-only +entries seed the receipt's exact canonical row IDs and timestamps before the +existing `kb_apply` payload-bound guard executes. For `revise_strategy`, the +runner seeds only the proposal and approval, captures the target agent's +prestate, executes the real guarded transition, validates the generated delta, +and then replaces only those generated rows with the receipt-pinned IDs and +timestamps. Every path checks exact proposal and canonical row readbacks before +verifying the complete final parity manifest. Its public mode-`0600` receipt contains hashes, IDs, types, counts, parity summaries, and cleanup proof, but no payloads, rows, SQL, source paths, or command errors. +The legacy `seed_exact` summary is the insert-only aggregate of +`proposal_seed_exact` and `canonical_seed_exact`; it is intentionally false for +successful mutating entries, which instead report +`mutating_delta_validated` and `mutating_poststate_normalized`. +Fresh guard bootstrap rows use a fixed baseline timestamp rather than wall +clock time, so repeated clean restores have identical row hashes. -The exact v1 claim ceiling is intentionally narrow: +The exact v1 claim ceiling is intentionally bounded: -- `add_edge`, `attach_evidence`, and `approve_claim` strict receipts execute; +- `add_edge`, `attach_evidence`, `approve_claim`, and `revise_strategy` strict + receipts execute; - sequence gaps, hash drift, engine drift, duplicate proposals, and legacy or freeform payloads fail before container start; -- `revise_strategy` fails closed because its current receipt omits the prior - strategies and nodes that the apply engine deactivates or retires; +- `revise_strategy` is accepted only when the receipt pins the exact SQL that + matches the current guarded apply engine. Immediately before each entry, the + runner captures the target agent's strategy/node IDs, active strategy, + non-retired nodes, and maximum version. It then validates the generated + post-minus-pre delta, requires `version = previous maximum + 1`, and replaces + only the generated strategy/node rows with the exact receipt rows; +- the original transaction timestamp is derived from the fresh strategy + `created_at` and must equal every fresh node's `created_at` and `updated_at`. + Only node IDs observed as non-retired before apply receive that timestamp; + already-retired, unrelated-agent, and shared NULL-agent rows stay untouched; +- generated nodes must have no anchors before normalization, preventing a + delete-and-reinsert step from silently cascading future trigger-created rows; - full proposal before/after rows are mandatory because the current receipt envelope does not retain proposal origin fields or exact `updated_at`; - this proves only an isolated local reconstruction. It does not touch or prove VPS, GCP, Telegram, a live database, or blank-schema source recompilation. +The transition contract for `revise_strategy` is final-state deterministic, not +a claim that the v1 receipt independently contains a historical before-image: + +```text +exact genesis/pre-entry state + + exact current/original guarded SQL + + receipt-pinned fresh strategy and nodes + -> prior active strategy inactive + -> exactly the prior non-retired nodes retired at the original transaction time + -> one receipt-exact active strategy and receipt-exact node set +``` + +The genesis and final manifests remain mandatory oracles. Any incorrect +prestate, unrelated-row mutation, missing/extra generated row, semantic drift, +version drift, hash drift, or final rowset difference fails the reconstruction. + The source compiler now turns one raw artifact, its strict UTF-8 extraction, and a reviewed extraction manifest into a deterministic, hash-bound `pending_review` proposal bundle: @@ -272,10 +310,9 @@ neither command applies canonical rows. The existing full-data clone canary separately proves that a reviewed packet can create source, claim, evidence, and edge rows and affect later reasoning. -The remaining reconstruction work is to retain complete deltas for mutating -contracts such as `revise_strategy`, backfill or explicitly reject legacy -freeform applies, and extend beyond genesis recovery to a blank-schema source -compiler. The insert-only ledger runner does not prove that every historical +The remaining reconstruction work is to backfill or explicitly reject legacy +freeform applies and extend beyond genesis recovery to a blank-schema source +compiler. The strict ledger runner does not prove that every historical canonical row can be rebuilt semantically from retained sources. ## Definition Of Working diff --git a/ops/run_local_genesis_ledger_rebuild.py b/ops/run_local_genesis_ledger_rebuild.py index ef871a3..a3361e1 100644 --- a/ops/run_local_genesis_ledger_rebuild.py +++ b/ops/run_local_genesis_ledger_rebuild.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 """Deterministically rebuild a disposable Postgres from genesis plus a strict ledger. -The v1 replay boundary is intentionally narrow. It supports strict, insert-only -``add_edge``, ``attach_evidence``, and ``approve_claim`` receipts. Each ledger -entry must also retain the exact approved and applied proposal rows plus the -immutable approval snapshot. Legacy/freeform entries and ``revise_strategy`` -fail before Docker starts because their retained material is not a complete -database delta. +The v1 replay boundary supports strict ``add_edge``, ``attach_evidence``, +``approve_claim``, and ``revise_strategy`` receipts. Insert-only actions seed +their receipt-pinned rows before the guarded transition. ``revise_strategy`` +instead captures the exact prestate identities, executes the exact hash-matched +guarded SQL, validates the generated delta, and normalizes only that delta to the +receipt-pinned transaction timestamp, IDs, and rows. Every entry must retain the +full approved and applied proposal rows plus its immutable approval snapshot. +Legacy and freeform entries still fail before Docker starts. """ from __future__ import annotations @@ -46,15 +48,21 @@ LEDGER_ARTIFACT = "teleo_genesis_plus_ledger" MATERIAL_ARTIFACT = "teleo_strict_proposal_replay_material" LEDGER_CONTRACT_VERSION = 1 MATERIAL_CONTRACT_VERSION = 1 -SUPPORTED_PROPOSAL_TYPES = frozenset({"add_edge", "attach_evidence", "approve_claim"}) +DEFAULT_REBUILD_IMAGE = ( + "docker.io/library/postgres:16-alpine@" + "sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777" +) +SUPPORTED_PROPOSAL_TYPES = frozenset( + {"add_edge", "attach_evidence", "approve_claim", "revise_strategy"} +) CLAIM_CEILING = ( - "exact genesis plus ordered strict insert-only proposal replay; supported types are " - "add_edge, attach_evidence, and approve_claim; full approved/applied proposal rows " - "and immutable approval snapshots are required" + "exact genesis plus ordered strict proposal replay; supported types are add_edge, " + "attach_evidence, approve_claim, and revise_strategy; mutating strategy replay " + "captures prestate identities and normalizes the exact receipt-pinned poststate; " + "full approved/applied proposal rows and immutable approval snapshots are required" ) NOT_PROVEN = ( "legacy or freeform proposal replay", - "revise_strategy replay because prior-row mutations are absent from v1 receipts", "source-corpus recompilation from a blank schema", "VPS, GCP, Telegram, or any live database mutation", ) @@ -77,6 +85,7 @@ FIXED_ENGINE_PATHS = frozenset( ) SHA256_RE = re.compile(r"[0-9a-f]{64}\Z") +IMAGE_DIGEST_RE = re.compile(r"\S+@sha256:[0-9a-f]{64}\Z") PROPOSAL_TRANSITION_FIELDS = frozenset( {"status", "applied_by_handle", "applied_by_agent_id", "applied_at", "updated_at"} ) @@ -121,6 +130,35 @@ CANONICAL_TABLE_ORDER = ( "claim_evidence", "claim_edges", ) +STRATEGY_REQUIRED_FIELDS = frozenset( + { + "id", + "agent_id", + "diagnosis", + "guiding_policy", + "proximate_objectives", + "version", + "active", + "created_at", + } +) +STRATEGY_NODE_REQUIRED_FIELDS = frozenset( + { + "id", + "agent_id", + "node_type", + "title", + "body", + "rank", + "status", + "horizon", + "measure", + "source_ref", + "metadata", + "created_at", + "updated_at", + } +) class ReconstructionError(RuntimeError): @@ -308,6 +346,119 @@ def _validate_proposal_rows( ) +def _parse_aware_timestamp( + value: Any, field: str, *, code: str = "invalid_mutating_receipt" +) -> datetime: + if not isinstance(value, str) or not value: + raise ReconstructionError(code, f"{field} must be an ISO-8601 timestamp") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ReconstructionError(code, f"{field} must be an ISO-8601 timestamp") from exc + if parsed.tzinfo is None: + raise ReconstructionError(code, f"{field} must include a timezone") + return parsed + + +def _validated_uuid_list( + value: Any, field: str, *, code: str = "invalid_mutating_state" +) -> list[str]: + if not isinstance(value, list): + raise ReconstructionError(code, f"{field} must be a UUID list") + normalized: list[str] = [] + for item in value: + try: + normalized.append(str(uuid.UUID(str(item)))) + except (TypeError, ValueError, AttributeError) as exc: + raise ReconstructionError(code, f"{field} must contain only UUIDs") from exc + if len(normalized) != len(set(normalized)): + raise ReconstructionError(code, f"{field} contains duplicate UUIDs") + return normalized + + +def _validate_revise_strategy_receipt_rows( + proposal: dict[str, Any], canonical_rows: dict[str, Any] +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + payload = proposal.get("payload") + apply_payload = payload.get("apply_payload") if isinstance(payload, dict) else None + if not isinstance(apply_payload, dict): + raise ReconstructionError( + "invalid_mutating_receipt", "revise_strategy receipt requires a strict apply payload" + ) + try: + agent_id = str(uuid.UUID(str(apply_payload.get("agent_id")))) + except (TypeError, ValueError, AttributeError) as exc: + raise ReconstructionError( + "invalid_mutating_receipt", "revise_strategy receipt requires one agent UUID" + ) from exc + + strategies = canonical_rows.get("strategies") + nodes = canonical_rows.get("strategy_nodes") + if not isinstance(strategies, list) or len(strategies) != 1 or not isinstance(strategies[0], dict): + raise ReconstructionError( + "invalid_mutating_receipt", "revise_strategy receipt must pin one fresh strategy row" + ) + expected_nodes = apply_payload.get("strategy_nodes") + if ( + not isinstance(expected_nodes, list) + or not isinstance(nodes, list) + or len(nodes) != len(expected_nodes) + or any(not isinstance(row, dict) for row in nodes) + ): + raise ReconstructionError( + "invalid_mutating_receipt", "revise_strategy receipt must pin every fresh strategy node row" + ) + + strategy = strategies[0] + if frozenset(strategy) != STRATEGY_REQUIRED_FIELDS: + raise ReconstructionError( + "invalid_mutating_receipt", "revise_strategy receipt strategy row fields are not canonical" + ) + if any(frozenset(row) != STRATEGY_NODE_REQUIRED_FIELDS for row in nodes): + raise ReconstructionError( + "invalid_mutating_receipt", "revise_strategy receipt strategy node row fields are not canonical" + ) + strategy_id = _validated_uuid_list( + [strategy.get("id")], "receipt strategy id", code="invalid_mutating_receipt" + )[0] + node_ids = _validated_uuid_list( + [row.get("id") for row in nodes], + "receipt strategy node ids", + code="invalid_mutating_receipt", + ) + if strategy.get("agent_id") != agent_id or any(row.get("agent_id") != agent_id for row in nodes): + raise ReconstructionError( + "invalid_mutating_receipt", "revise_strategy receipt rows do not belong to the payload agent" + ) + if strategy.get("active") is not True or any(row.get("status") != "active" for row in nodes): + raise ReconstructionError( + "invalid_mutating_receipt", "revise_strategy receipt must pin the fresh active poststate" + ) + version = strategy.get("version") + if isinstance(version, bool) or not isinstance(version, int) or version < 1: + raise ReconstructionError( + "invalid_mutating_receipt", "revise_strategy receipt strategy version must be positive" + ) + + transaction_timestamp = strategy.get("created_at") + transaction_time = _parse_aware_timestamp(transaction_timestamp, "receipt strategy created_at") + for row in nodes: + if row.get("created_at") != transaction_timestamp or row.get("updated_at") != transaction_timestamp: + raise ReconstructionError( + "invalid_mutating_receipt", + "revise_strategy fresh strategy and node timestamps must share one transaction timestamp", + ) + applied_time = _parse_aware_timestamp(proposal.get("applied_at"), "receipt proposal applied_at") + if transaction_time > applied_time: + raise ReconstructionError( + "invalid_mutating_receipt", "revise_strategy transaction timestamp is after proposal apply time" + ) + + return {**strategy, "id": strategy_id}, [ + {**row, "id": normalized_id} for row, normalized_id in zip(nodes, node_ids, strict=True) + ] + + def _validate_entry_material( material: dict[str, Any], *, @@ -340,11 +491,6 @@ def _validate_entry_material( if not isinstance(proposal, dict): raise ReconstructionError("invalid_material", "replay receipt has no proposal object") proposal_type = proposal.get("proposal_type") - if proposal_type == "revise_strategy": - raise ReconstructionError( - "unsupported_mutating_receipt", - "revise_strategy receipts omit prior strategy and node mutations and cannot replay exactly", - ) if proposal_type not in SUPPORTED_PROPOSAL_TYPES: raise ReconstructionError( "unsupported_proposal_type", "ledger entry proposal type is not supported by v1 reconstruction" @@ -358,6 +504,8 @@ def _validate_entry_material( canonical_rows = receipt.get("canonical_rows") if not isinstance(apply_metadata, dict) or not isinstance(canonical_rows, dict): raise ReconstructionError("invalid_replay_receipt", "replay receipt is missing apply or row material") + if proposal_type == "revise_strategy": + _validate_revise_strategy_receipt_rows(proposal, canonical_rows) try: rebuilt = replay_receipt.build_replay_receipt( proposal, @@ -382,6 +530,7 @@ def _validate_entry_material( raise ReconstructionError( "replay_material_hash_mismatch", "ledger replay-material pin does not match the private receipt" ) + receipt = {**receipt, "canonical_rows": rebuilt["canonical_rows"]} approved = material["approved_proposal"] approval = material["approval_snapshot"] @@ -394,6 +543,15 @@ def _validate_entry_material( "current_apply_engine_rejected_material", "current guarded apply engine rejected the strict replay material", ) from exc + current_apply_sql_sha256 = _sha256_text(current_apply_sql) + if proposal_type == "revise_strategy" and ( + apply_metadata.get("source") != "exact_executed_sql" + or apply_metadata.get("apply_sql_sha256") != current_apply_sql_sha256 + ): + raise ReconstructionError( + "mutating_apply_engine_mismatch", + "revise_strategy replay requires the exact executed SQL to match the current guarded apply engine", + ) return LedgerEntry( sequence=expected_sequence, @@ -405,7 +563,7 @@ def _validate_entry_material( applied_proposal=applied, receipt=receipt, current_apply_sql=current_apply_sql, - current_apply_sql_sha256=_sha256_text(current_apply_sql), + current_apply_sql_sha256=current_apply_sql_sha256, ) @@ -550,6 +708,280 @@ def build_seed_sql(entry: LedgerEntry) -> str: return "\n".join(statements) + "\n" +def _uuid_membership_sql(column: str, values: list[str], *, negate: bool = False) -> str: + if not values: + return "true" if negate else "false" + rendered = ", ".join(f"{apply_engine.sql_literal(value)}::uuid" for value in values) + operator = "not in" if negate else "in" + return f"{column} {operator} ({rendered})" + + +def _uuid_array_sql(values: list[str]) -> str: + if not values: + return "array[]::uuid[]" + rendered = ", ".join(f"{apply_engine.sql_literal(value)}::uuid" for value in values) + return f"array[{rendered}]::uuid[]" + + +def build_revise_strategy_prestate_sql(entry: LedgerEntry) -> str: + apply_payload = entry.receipt["proposal"]["payload"]["apply_payload"] + agent = apply_engine.sql_literal(apply_payload["agent_id"]) + return f"""with strategy_state as ( + select coalesce(jsonb_agg(s.id::text order by s.id::text), '[]'::jsonb) as strategy_ids, + coalesce( + jsonb_agg(s.id::text order by s.id::text) filter (where s.active), + '[]'::jsonb + ) as active_strategy_ids, + coalesce(max(s.version), 0) as max_strategy_version + from public.strategies s + where s.agent_id = {agent}::uuid +), node_state as ( + select coalesce(jsonb_agg(n.id::text order by n.id::text), '[]'::jsonb) as strategy_node_ids, + coalesce( + jsonb_agg(n.id::text order by n.id::text) filter (where n.status <> 'retired'), + '[]'::jsonb + ) as non_retired_strategy_node_ids + from public.strategy_nodes n + where n.agent_id = {agent}::uuid +) +select jsonb_build_object( + 'strategy_ids', s.strategy_ids, + 'active_strategy_ids', s.active_strategy_ids, + 'max_strategy_version', s.max_strategy_version, + 'strategy_node_ids', n.strategy_node_ids, + 'non_retired_strategy_node_ids', n.non_retired_strategy_node_ids +)::text +from strategy_state s cross join node_state n; +""" + + +def _validate_revise_strategy_prestate(value: Any) -> dict[str, Any]: + expected = frozenset( + { + "strategy_ids", + "active_strategy_ids", + "max_strategy_version", + "strategy_node_ids", + "non_retired_strategy_node_ids", + } + ) + state = _require_exact_keys(value, expected, "revise_strategy prestate") + strategy_ids = _validated_uuid_list(state["strategy_ids"], "prestate strategy ids") + active_strategy_ids = _validated_uuid_list( + state["active_strategy_ids"], "prestate active strategy ids" + ) + strategy_node_ids = _validated_uuid_list(state["strategy_node_ids"], "prestate strategy node ids") + non_retired_node_ids = _validated_uuid_list( + state["non_retired_strategy_node_ids"], "prestate non-retired strategy node ids" + ) + max_version = state["max_strategy_version"] + if isinstance(max_version, bool) or not isinstance(max_version, int) or max_version < 0: + raise ReconstructionError( + "invalid_mutating_state", "prestate maximum strategy version must be a non-negative integer" + ) + if len(active_strategy_ids) > 1 or not set(active_strategy_ids).issubset(strategy_ids): + raise ReconstructionError( + "invalid_mutating_state", "prestate active strategies violate the one-active invariant" + ) + if not set(non_retired_node_ids).issubset(strategy_node_ids): + raise ReconstructionError( + "invalid_mutating_state", "prestate non-retired strategy nodes are not a subset of all nodes" + ) + return { + "strategy_ids": strategy_ids, + "active_strategy_ids": active_strategy_ids, + "max_strategy_version": max_version, + "strategy_node_ids": strategy_node_ids, + "non_retired_strategy_node_ids": non_retired_node_ids, + } + + +def build_revise_strategy_generated_delta_sql(entry: LedgerEntry, prestate: dict[str, Any]) -> str: + state = _validate_revise_strategy_prestate(prestate) + apply_payload = entry.receipt["proposal"]["payload"]["apply_payload"] + agent = apply_engine.sql_literal(apply_payload["agent_id"]) + strategy_filter = _uuid_membership_sql("s.id", state["strategy_ids"], negate=True) + node_filter = _uuid_membership_sql("n.id", state["strategy_node_ids"], negate=True) + return f"""select jsonb_build_object( + 'strategies', coalesce(( + select jsonb_agg(to_jsonb(s) order by s.id::text) + from public.strategies s + where s.agent_id = {agent}::uuid and {strategy_filter} + ), '[]'::jsonb), + 'strategy_nodes', coalesce(( + select jsonb_agg(to_jsonb(n) order by n.id::text) + from public.strategy_nodes n + where n.agent_id = {agent}::uuid and {node_filter} + ), '[]'::jsonb) +)::text; +""" + + +def _validate_revise_strategy_generated_delta( + entry: LedgerEntry, + prestate: dict[str, Any], + generated_rows: Any, +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + state = _validate_revise_strategy_prestate(prestate) + if not isinstance(generated_rows, dict): + raise ReconstructionError( + "invalid_mutating_delta", "revise_strategy generated delta must be a row object" + ) + try: + replay_receipt.build_replay_receipt( + entry.receipt["proposal"], + generated_rows, + apply_sql_sha256=entry.current_apply_sql_sha256, + apply_sql_source="exact_executed_sql", + exported_at_utc=entry.receipt.get("exported_at_utc"), + ) + except (KeyError, TypeError, ValueError) as exc: + raise ReconstructionError( + "invalid_mutating_delta", + "revise_strategy generated rows do not match the strict payload", + ) from exc + + strategies = generated_rows.get("strategies") + nodes = generated_rows.get("strategy_nodes") + if not isinstance(strategies, list) or len(strategies) != 1 or not isinstance(strategies[0], dict): + raise ReconstructionError( + "invalid_mutating_delta", "revise_strategy apply did not generate exactly one strategy" + ) + if not isinstance(nodes, list) or any(not isinstance(row, dict) for row in nodes): + raise ReconstructionError( + "invalid_mutating_delta", "revise_strategy apply generated invalid strategy nodes" + ) + strategy = strategies[0] + if strategy.get("version") != state["max_strategy_version"] + 1: + raise ReconstructionError( + "invalid_mutating_delta", "revise_strategy generated an unexpected strategy version" + ) + transaction_timestamp = strategy.get("created_at") + _parse_aware_timestamp( + transaction_timestamp, "generated strategy created_at", code="invalid_mutating_delta" + ) + if any( + row.get("created_at") != transaction_timestamp or row.get("updated_at") != transaction_timestamp + for row in nodes + ): + raise ReconstructionError( + "invalid_mutating_delta", + "revise_strategy generated rows do not share one transaction timestamp", + ) + _validated_uuid_list( + [strategy.get("id")], "generated strategy id", code="invalid_mutating_delta" + ) + _validated_uuid_list( + [row.get("id") for row in nodes], + "generated strategy node ids", + code="invalid_mutating_delta", + ) + return strategy, nodes + + +def build_revise_strategy_normalization_sql( + entry: LedgerEntry, + prestate: dict[str, Any], + generated_rows: dict[str, Any], +) -> str: + state = _validate_revise_strategy_prestate(prestate) + generated_strategy, generated_nodes = _validate_revise_strategy_generated_delta( + entry, state, generated_rows + ) + receipt_strategy, receipt_nodes = _validate_revise_strategy_receipt_rows( + entry.receipt["proposal"], entry.receipt["canonical_rows"] + ) + if receipt_strategy["version"] != state["max_strategy_version"] + 1: + raise ReconstructionError( + "mutating_receipt_version_mismatch", + "revise_strategy receipt version does not follow the observed prestate", + ) + if receipt_strategy["id"] in state["strategy_ids"] or set( + row["id"] for row in receipt_nodes + ).intersection(state["strategy_node_ids"]): + raise ReconstructionError( + "mutating_receipt_id_collision", "revise_strategy receipt IDs collide with the observed prestate" + ) + + apply_payload = entry.receipt["proposal"]["payload"]["apply_payload"] + agent = apply_engine.sql_literal(apply_payload["agent_id"]) + generated_strategy_id = str(uuid.UUID(str(generated_strategy["id"]))) + generated_node_ids = [str(uuid.UUID(str(row["id"]))) for row in generated_nodes] + previous_active = state["active_strategy_ids"] + changed_node_ids = state["non_retired_strategy_node_ids"] + transaction_timestamp = apply_engine.sql_literal(receipt_strategy["created_at"]) + generated_node_array = _uuid_array_sql(generated_node_ids) + previous_active_array = _uuid_array_sql(previous_active) + changed_node_array = _uuid_array_sql(changed_node_ids) + + prior_strategy_assertion = "" + if previous_active: + prior_strategy_assertion = f""" + if (select count(*) from public.strategies + where agent_id = {agent}::uuid and id = any(previous_active_ids) and not active) <> {len(previous_active)} then + raise exception 'revise_strategy prior active strategy transition mismatch'; + end if;""" + prior_node_normalization = "" + if changed_node_ids: + prior_node_normalization = f""" + if (select count(*) from public.strategy_nodes + where agent_id = {agent}::uuid and id = any(changed_node_ids) and status = 'retired') <> {len(changed_node_ids)} then + raise exception 'revise_strategy prior node transition mismatch'; + end if; + update public.strategy_nodes + set status = 'retired', updated_at = {transaction_timestamp}::timestamptz + where agent_id = {agent}::uuid and id = any(changed_node_ids); + get diagnostics changed_rows = row_count; + if changed_rows <> {len(changed_node_ids)} then + raise exception 'revise_strategy prior node normalization mismatch'; + end if;""" + + return f"""begin; +set local standard_conforming_strings = on; +do $reconstruction$ +declare + changed_rows integer; + generated_strategy_id constant uuid := {apply_engine.sql_literal(generated_strategy_id)}::uuid; + generated_node_ids constant uuid[] := {generated_node_array}; + previous_active_ids constant uuid[] := {previous_active_array}; + changed_node_ids constant uuid[] := {changed_node_array}; +begin{prior_strategy_assertion} + if exists ( + select 1 from public.strategy_node_anchors + where from_node_id = any(generated_node_ids) or to_node_id = any(generated_node_ids) + ) then + raise exception 'revise_strategy generated nodes unexpectedly have anchors'; + end if; + delete from public.strategy_nodes + where agent_id = {agent}::uuid and id = any(generated_node_ids); + get diagnostics changed_rows = row_count; + if changed_rows <> {len(generated_node_ids)} then + raise exception 'revise_strategy generated node delta mismatch'; + end if; + delete from public.strategies + where agent_id = {agent}::uuid and id = generated_strategy_id; + get diagnostics changed_rows = row_count; + if changed_rows <> 1 then + raise exception 'revise_strategy generated strategy delta mismatch'; + end if;{prior_node_normalization} + insert into public.strategies + select seeded.* from jsonb_populate_record( + null::public.strategies, {_jsonb_literal(receipt_strategy)} + ) seeded; + insert into public.strategy_nodes + select seeded.* from jsonb_populate_recordset( + null::public.strategy_nodes, {_jsonb_literal(receipt_nodes)} + ) seeded; + if (select count(*) from public.strategies + where agent_id = {agent}::uuid and active) <> 1 then + raise exception 'revise_strategy normalized one-active invariant mismatch'; + end if; +end +$reconstruction$; +commit; +""" + + def build_proposal_readback_sql(proposal_id: str) -> str: literal = apply_engine.sql_literal(proposal_id) return f"""select jsonb_build_object( @@ -694,7 +1126,12 @@ def _entry_summary(entry: LedgerEntry) -> dict[str, Any]: "current_apply_sql_sha256": entry.current_apply_sql_sha256, "expected_row_counts": entry.receipt["expected_row_counts"], "seed_exact": False, + "proposal_seed_exact": False, + "canonical_seed_exact": False, "guarded_apply_executed": False, + "mutating_prestate_captured": False, + "mutating_delta_validated": False, + "mutating_poststate_normalized": False, "proposal_exact": False, "canonical_rows_exact": False, "status": "pending", @@ -707,6 +1144,7 @@ def apply_entry( entry: LedgerEntry, summary: dict[str, Any], ) -> None: + mutating_prestate: dict[str, Any] | None = None try: _psql( args, @@ -733,20 +1171,34 @@ def apply_entry( code="entry_seed_mismatch", action=f"ledger entry {entry.sequence} proposal seed", ) - seeded_rows = _read_json( - args, - container, - replay_receipt.build_postflight_sql(entry.receipt["proposal"]), - code="entry_seed_readback_failed", - action=f"ledger entry {entry.sequence} canonical seed readback", - ) - _assert_exact_json( - seeded_rows, - entry.receipt["canonical_rows"], - code="entry_seed_mismatch", - action=f"ledger entry {entry.sequence} canonical seed", - ) - summary["seed_exact"] = True + summary["proposal_seed_exact"] = True + if entry.applied_proposal["proposal_type"] == "revise_strategy": + mutating_prestate = _validate_revise_strategy_prestate( + _read_json( + args, + container, + build_revise_strategy_prestate_sql(entry), + code="mutating_prestate_readback_failed", + action=f"ledger entry {entry.sequence} revise_strategy prestate readback", + ) + ) + summary["mutating_prestate_captured"] = True + else: + seeded_rows = _read_json( + args, + container, + replay_receipt.build_postflight_sql(entry.receipt["proposal"]), + code="entry_seed_readback_failed", + action=f"ledger entry {entry.sequence} canonical seed readback", + ) + _assert_exact_json( + seeded_rows, + entry.receipt["canonical_rows"], + code="entry_seed_mismatch", + action=f"ledger entry {entry.sequence} canonical seed", + ) + summary["canonical_seed_exact"] = True + summary["seed_exact"] = summary["proposal_seed_exact"] and summary["canonical_seed_exact"] _psql( args, @@ -759,6 +1211,29 @@ def apply_entry( ) summary["guarded_apply_executed"] = True + if mutating_prestate is not None: + generated_rows = _read_json( + args, + container, + build_revise_strategy_generated_delta_sql(entry, mutating_prestate), + code="mutating_delta_readback_failed", + action=f"ledger entry {entry.sequence} revise_strategy generated delta readback", + ) + normalization_sql = build_revise_strategy_normalization_sql( + entry, mutating_prestate, generated_rows + ) + summary["mutating_delta_validated"] = True + _psql( + args, + container, + normalization_sql, + role="postgres", + timeout=args.apply_timeout, + code="mutating_poststate_normalization_failed", + action=f"ledger entry {entry.sequence} revise_strategy exact poststate normalization", + ) + summary["mutating_poststate_normalized"] = True + _psql( args, container, @@ -1199,7 +1674,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument("--ledger", required=True, type=Path) parser.add_argument("--ledger-sha256", required=True) parser.add_argument("--database", default="teleo") - parser.add_argument("--image", default=canonical_rebuild.DEFAULT_IMAGE) + parser.add_argument("--image", default=DEFAULT_REBUILD_IMAGE) parser.add_argument("--container-prefix", default="teleo-genesis-ledger-rebuild") parser.add_argument("--tmpfs-mb", default=1024, type=int) parser.add_argument("--startup-timeout", default=60.0, type=float) @@ -1218,8 +1693,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.error("--database must be a safe PostgreSQL identifier up to 63 characters") if not canonical_rebuild.CONTAINER_PREFIX_RE.fullmatch(args.container_prefix): parser.error("--container-prefix must be 1-40 Docker-safe characters") - if not args.image or any(character.isspace() for character in args.image): - parser.error("--image must be a non-empty Docker image reference without whitespace") + if not IMAGE_DIGEST_RE.fullmatch(args.image): + parser.error("--image must be pinned by a lowercase sha256 digest") if not args.docker_bin or any(character.isspace() for character in args.docker_bin): parser.error("--docker-bin must be one executable name or path without whitespace") if not 64 <= args.tmpfs_mb <= 65536: diff --git a/scripts/kb_apply_prereqs.sql b/scripts/kb_apply_prereqs.sql index 9779c5a..839f6b5 100644 --- a/scripts/kb_apply_prereqs.sql +++ b/scripts/kb_apply_prereqs.sql @@ -315,8 +315,9 @@ alter table kb_stage.kb_review_principals owner to kb_gate_owner; alter table kb_stage.kb_proposal_approvals owner to kb_gate_owner; insert into kb_stage.kb_review_principals - (db_role, reviewed_by_handle, reviewed_by_agent_id, active) -select 'kb_review'::name, a.handle, a.id, true + (db_role, reviewed_by_handle, reviewed_by_agent_id, active, created_at) +select 'kb_review'::name, a.handle, a.id, true, + '1970-01-01 00:00:00+00'::timestamptz from public.agents a where a.handle = 'm3ta' on conflict (db_role) do nothing; diff --git a/tests/test_kb_apply_prereqs.py b/tests/test_kb_apply_prereqs.py index c4dbd2e..e66395c 100644 --- a/tests/test_kb_apply_prereqs.py +++ b/tests/test_kb_apply_prereqs.py @@ -203,6 +203,15 @@ def _lines(completed: subprocess.CompletedProcess[str]) -> set[str]: return {line for line in completed.stdout.splitlines() if line} +def test_reviewer_principal_seed_timestamp_is_deterministic(migrated_postgres: str) -> None: + created_at = _psql( + migrated_postgres, + "select created_at::text from kb_stage.kb_review_principals where db_role = 'kb_review';", + ).stdout.strip() + + assert created_at == "1970-01-01 00:00:00+00" + + def _catalog_snapshot(container: str) -> str: return _psql( container, diff --git a/tests/test_run_local_genesis_ledger_rebuild.py b/tests/test_run_local_genesis_ledger_rebuild.py index 4919b1c..721772c 100644 --- a/tests/test_run_local_genesis_ledger_rebuild.py +++ b/tests/test_run_local_genesis_ledger_rebuild.py @@ -23,6 +23,18 @@ REVIEWER_ID = "11111111-1111-4111-8111-111111111111" SERVICE_ID = "44444444-4444-4444-4444-444444444444" PROPOSAL_ID = "99999999-9999-4999-8999-999999999999" EDGE_ID = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" +OTHER_AGENT_ID = "22222222-2222-4222-8222-222222222222" +REVISE_PROPOSAL_ID = "88888888-8888-4888-8888-888888888888" +PRIOR_STRATEGY_ID = "33333333-3333-4333-8333-333333333333" +PRIOR_ACTIVE_NODE_ID = "55555555-5555-4555-8555-555555555555" +PRIOR_RETIRED_NODE_ID = "66666666-6666-4666-8666-666666666666" +OTHER_STRATEGY_ID = "77777777-7777-4777-8777-777777777777" +OTHER_ACTIVE_NODE_ID = "12121212-1212-4212-8212-121212121212" +NULL_AGENT_NODE_ID = "13131313-1313-4313-8313-131313131313" +PRIOR_NODE_ANCHOR_ID = "19191919-1919-4919-8919-191919191919" +RECEIPT_STRATEGY_ID = "14141414-1414-4414-8414-141414141414" +RECEIPT_NODE_A_ID = "15151515-1515-4515-8515-151515151515" +RECEIPT_NODE_B_ID = "16161616-1616-4616-8616-161616161616" PRIVATE_MARKER = "PRIVATE-REPLAY-MATERIAL-MUST-NOT-LEAK" @@ -155,6 +167,58 @@ def proposal_rows() -> tuple[dict, dict, dict]: return approved, applied, approval +def revise_strategy_proposal_rows() -> tuple[dict, dict, dict]: + approved, _, approval = proposal_rows() + payload = { + "apply_payload": { + "agent_id": REVIEWER_ID, + "strategy": { + "diagnosis": "Deterministic reconstruction needs exact mutation deltas.", + "guiding_policy": "Replay every guarded transition and fail closed on drift.", + "proximate_objectives": ["exact parity", "zero orphan containers"], + }, + "strategy_nodes": [ + { + "node_type": "policy", + "title": "Replay exactly", + "body": "Bind the guarded transition to its canonical receipt.", + "rank": 1, + }, + { + "node_type": "policy", + "title": "Replay exactly", + "body": "Bind the guarded transition to its canonical receipt.", + "rank": 1, + }, + ], + }, + "private_title": PRIVATE_MARKER, + } + approved = { + **approved, + "id": REVISE_PROPOSAL_ID, + "proposal_type": "revise_strategy", + "payload": payload, + "created_at": "2026-07-14T00:58:00+00:00", + "updated_at": "2026-07-14T01:00:00+00:00", + } + applied = { + **approved, + "status": "applied", + "applied_by_handle": "kb-apply", + "applied_by_agent_id": SERVICE_ID, + "applied_at": "2026-07-14T01:01:00+00:00", + "updated_at": "2026-07-14T01:01:00.000001+00:00", + } + approval = { + **approval, + "proposal_id": REVISE_PROPOSAL_ID, + "proposal_type": "revise_strategy", + "payload": payload, + } + return approved, applied, approval + + def applied_envelope(applied: dict) -> dict: fields = ( "id", @@ -202,6 +266,62 @@ def replay_material() -> dict: } +def revise_strategy_replay_material( + *, apply_sql_source: str = "exact_executed_sql", version: int = 2 +) -> dict: + approved, applied, approval = revise_strategy_proposal_rows() + proposal = applied_envelope(applied) + transaction_timestamp = "2026-07-14T01:00:30+00:00" + canonical_rows = { + "strategies": [ + { + "id": RECEIPT_STRATEGY_ID, + "agent_id": REVIEWER_ID, + **proposal["payload"]["apply_payload"]["strategy"], + "version": version, + "active": True, + "created_at": transaction_timestamp, + } + ], + "strategy_nodes": [ + { + "id": node_id, + "agent_id": REVIEWER_ID, + **node, + "status": "active", + "horizon": None, + "measure": None, + "source_ref": None, + "metadata": {}, + "created_at": transaction_timestamp, + "updated_at": transaction_timestamp, + } + for node_id, node in zip( + (RECEIPT_NODE_A_ID, RECEIPT_NODE_B_ID), + proposal["payload"]["apply_payload"]["strategy_nodes"], + strict=True, + ) + ], + } + apply_sql = rebuild.apply_engine.build_apply_sql(proposal, applied["applied_by_handle"]) + receipt = rebuild.replay_receipt.build_replay_receipt( + proposal, + canonical_rows, + apply_sql_sha256=rebuild.replay_receipt.sha256_text(apply_sql), + apply_sql_source=apply_sql_source, + exported_at_utc="2026-07-14T01:02:00+00:00", + ) + return { + "artifact": rebuild.MATERIAL_ARTIFACT, + "contract_version": rebuild.MATERIAL_CONTRACT_VERSION, + "sequence": 1, + "approved_proposal": approved, + "approval_snapshot": approval, + "applied_proposal": applied, + "replay_receipt": receipt, + } + + def write_bundle(tmp_path: Path, *, material: dict | None = None) -> argparse.Namespace: dump = tmp_path / "genesis.dump" dump.write_bytes(b"PGDMPfixture") @@ -238,7 +358,7 @@ def write_bundle(tmp_path: Path, *, material: dict | None = None) -> argparse.Na ledger=ledger_path, ledger_sha256=sha256_file(ledger_path), database="teleo", - image=rebuild.canonical_rebuild.DEFAULT_IMAGE, + image=rebuild.DEFAULT_REBUILD_IMAGE, container_prefix="teleo-genesis-ledger-test", tmpfs_mb=256, startup_timeout=30.0, @@ -302,18 +422,115 @@ def test_dump_hash_mismatch_fails_before_container_start(tmp_path: Path, monkeyp assert not any(len(command) > 1 and command[1] == "run" for command in commands) -def test_revise_strategy_receipt_fails_closed_before_receipt_rows_are_used(tmp_path: Path) -> None: - material = replay_material() - material["replay_receipt"]["proposal"]["proposal_type"] = "revise_strategy" - material["applied_proposal"]["proposal_type"] = "revise_strategy" - material["approved_proposal"]["proposal_type"] = "revise_strategy" - material["approval_snapshot"]["proposal_type"] = "revise_strategy" +def test_revise_strategy_material_validates_exact_engine_and_canonicalizes_row_order( + tmp_path: Path, +) -> None: + material = revise_strategy_replay_material() + material["replay_receipt"]["canonical_rows"]["strategy_nodes"].reverse() args = write_bundle(tmp_path, material=material) - with pytest.raises(rebuild.ReconstructionError, match="prior strategy") as exc_info: + entry = rebuild.load_bundle(args).entries[0] + + assert entry.applied_proposal["proposal_type"] == "revise_strategy" + assert entry.receipt["apply_engine"]["source"] == "exact_executed_sql" + assert entry.receipt["apply_engine"]["apply_sql_sha256"] == entry.current_apply_sql_sha256 + assert entry.receipt["canonical_rows"]["strategy_nodes"] == sorted( + entry.receipt["canonical_rows"]["strategy_nodes"], key=rebuild._canonical_json + ) + + +def test_revise_strategy_material_rejects_reconstructed_apply_engine(tmp_path: Path) -> None: + args = write_bundle( + tmp_path, + material=revise_strategy_replay_material(apply_sql_source="reconstructed_current_engine"), + ) + + with pytest.raises(rebuild.ReconstructionError) as exc_info: rebuild.load_bundle(args) - assert exc_info.value.code == "unsupported_mutating_receipt" + assert exc_info.value.code == "mutating_apply_engine_mismatch" + + +@pytest.mark.parametrize( + "invalid_case", + ("extra_strategy_field", "duplicate_node_id", "node_timestamp_drift", "transaction_after_apply"), +) +def test_revise_strategy_receipt_rejects_impossible_poststate( + tmp_path: Path, invalid_case: str +) -> None: + material = revise_strategy_replay_material() + rows = material["replay_receipt"]["canonical_rows"] + if invalid_case == "extra_strategy_field": + rows["strategies"][0]["unexpected"] = "ignored by jsonb_populate_record" + elif invalid_case == "duplicate_node_id": + rows["strategy_nodes"][1]["id"] = rows["strategy_nodes"][0]["id"] + elif invalid_case == "node_timestamp_drift": + rows["strategy_nodes"][0]["updated_at"] = "2026-07-14T01:00:31+00:00" + else: + rows["strategies"][0]["created_at"] = "2026-07-14T01:01:01+00:00" + for row in rows["strategy_nodes"]: + row["created_at"] = "2026-07-14T01:01:01+00:00" + row["updated_at"] = "2026-07-14T01:01:01+00:00" + args = write_bundle(tmp_path, material=material) + + with pytest.raises(rebuild.ReconstructionError) as exc_info: + rebuild.load_bundle(args) + + assert exc_info.value.code == "invalid_mutating_receipt" + + +def test_revise_strategy_transition_builders_preserve_historical_prestate(tmp_path: Path) -> None: + entry = rebuild.load_bundle( + write_bundle(tmp_path, material=revise_strategy_replay_material()) + ).entries[0] + prestate = { + "strategy_ids": [PRIOR_STRATEGY_ID], + "active_strategy_ids": [PRIOR_STRATEGY_ID], + "max_strategy_version": 1, + "strategy_node_ids": [PRIOR_ACTIVE_NODE_ID, PRIOR_RETIRED_NODE_ID], + "non_retired_strategy_node_ids": [PRIOR_ACTIVE_NODE_ID], + } + generated_rows = copy.deepcopy(entry.receipt["canonical_rows"]) + generated_rows["strategies"][0]["id"] = "17171717-1717-4717-8717-171717171717" + generated_rows["strategies"][0]["created_at"] = "2026-07-15T01:00:30+00:00" + for index, row in enumerate(generated_rows["strategy_nodes"], start=1): + row["id"] = f"18181818-1818-4818-8818-18181818181{index}" + row["created_at"] = "2026-07-15T01:00:30+00:00" + row["updated_at"] = "2026-07-15T01:00:30+00:00" + + sql = rebuild.build_revise_strategy_normalization_sql(entry, prestate, generated_rows) + + assert PRIOR_ACTIVE_NODE_ID in sql + assert PRIOR_RETIRED_NODE_ID not in sql + assert PRIOR_STRATEGY_ID in sql + assert RECEIPT_STRATEGY_ID in sql + assert "updated_at = E'2026-07-14T01:00:30+00:00'::timestamptz" in sql + + +def test_revise_strategy_transition_builders_allow_empty_prestate(tmp_path: Path) -> None: + entry = rebuild.load_bundle( + write_bundle(tmp_path, material=revise_strategy_replay_material(version=1)) + ).entries[0] + prestate = { + "strategy_ids": [], + "active_strategy_ids": [], + "max_strategy_version": 0, + "strategy_node_ids": [], + "non_retired_strategy_node_ids": [], + } + generated_rows = copy.deepcopy(entry.receipt["canonical_rows"]) + generated_rows["strategies"][0]["id"] = "17171717-1717-4717-8717-171717171717" + generated_rows["strategies"][0]["created_at"] = "2026-07-15T01:00:30+00:00" + for index, row in enumerate(generated_rows["strategy_nodes"], start=1): + row["id"] = f"18181818-1818-4818-8818-18181818181{index}" + row["created_at"] = "2026-07-15T01:00:30+00:00" + row["updated_at"] = "2026-07-15T01:00:30+00:00" + + sql = rebuild.build_revise_strategy_normalization_sql(entry, prestate, generated_rows) + + assert "prior active strategy transition mismatch" not in sql + assert "prior node normalization mismatch" not in sql + assert RECEIPT_STRATEGY_ID in sql def test_legacy_freeform_receipt_fails_closed(tmp_path: Path) -> None: @@ -331,6 +548,17 @@ def test_legacy_freeform_receipt_fails_closed(tmp_path: Path) -> None: assert PRIVATE_MARKER not in exc_info.value.safe_message +def test_unknown_proposal_operation_fails_closed_before_replay(tmp_path: Path) -> None: + material = replay_material() + material["replay_receipt"]["proposal"]["proposal_type"] = "delete_claim" + args = write_bundle(tmp_path, material=material) + + with pytest.raises(rebuild.ReconstructionError) as exc_info: + rebuild.load_bundle(args) + + assert exc_info.value.code == "unsupported_proposal_type" + + def test_proposal_metadata_outside_apply_transition_is_rejected(tmp_path: Path) -> None: material = replay_material() material["applied_proposal"]["rationale"] = "silently changed after review" @@ -354,6 +582,28 @@ def test_output_cannot_overwrite_a_fixed_guard_or_engine_file(tmp_path: Path) -> assert exc_info.value.code == "unsafe_output_path" +def test_cli_defaults_to_pinned_image_and_rejects_moving_tag(tmp_path: Path) -> None: + bundle = write_bundle(tmp_path) + argv = [ + "--genesis-dump", + str(bundle.genesis_dump), + "--genesis-manifest", + str(bundle.genesis_manifest), + "--ledger", + str(bundle.ledger), + "--ledger-sha256", + bundle.ledger_sha256, + "--output", + str(tmp_path / "receipt.json"), + ] + + assert rebuild.parse_args(argv).image == rebuild.DEFAULT_REBUILD_IMAGE + with pytest.raises(SystemExit) as exc_info: + rebuild.parse_args([*argv, "--image", "postgres:16-alpine"]) + + assert exc_info.value.code == 2 + + def test_unknown_private_field_name_is_not_echoed_in_public_error(tmp_path: Path) -> None: material = replay_material() material[PRIVATE_MARKER] = "private value" @@ -484,17 +734,18 @@ create table public.reasoning_tools ( created_at timestamptz not null default now() ); create table public.strategies ( - id uuid primary key, + id uuid primary key default gen_random_uuid(), agent_id uuid not null references public.agents(id), diagnosis text, guiding_policy text, proximate_objectives jsonb, version integer not null default 1, active boolean not null default true, - created_at timestamptz not null default now() + created_at timestamptz not null default now(), + unique (agent_id, version) ); create table public.strategy_nodes ( - id uuid primary key, + id uuid primary key default gen_random_uuid(), agent_id uuid references public.agents(id), node_type text, title text, @@ -508,6 +759,20 @@ create table public.strategy_nodes ( created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); +create table public.strategy_node_anchors ( + id uuid primary key default gen_random_uuid(), + from_node_id uuid not null references public.strategy_nodes(id) on delete cascade, + anchor_role text not null, + to_node_id uuid references public.strategy_nodes(id) on delete cascade, + to_shared_root_id uuid, + belief_id uuid, + claim_id uuid, + source_id uuid, + weight numeric, + note text, + created_at timestamptz not null default now(), + check (num_nonnulls(to_node_id, to_shared_root_id, belief_id, claim_id, source_id) = 1) +); create table kb_stage.kb_proposals ( id uuid primary key, proposal_type text not null, @@ -530,10 +795,34 @@ create table kb_stage.kb_proposals ( ); insert into public.agents (id, handle, kind) values - ('{REVIEWER_ID}', 'm3ta', 'human'); + ('{REVIEWER_ID}', 'm3ta', 'human'), + ('{OTHER_AGENT_ID}', 'other-agent', 'system'); insert into public.claims (id, type, text, status, confidence, tags, created_by) values ('{CLAIM_A}', 'structural', 'Fixture claim A', 'open', 0.8, array['fixture'], '{REVIEWER_ID}'), ('{CLAIM_B}', 'structural', 'Fixture claim B', 'open', 0.8, array['fixture'], '{REVIEWER_ID}'); +insert into public.strategies + (id, agent_id, diagnosis, guiding_policy, proximate_objectives, version, active, created_at) +values + ('{PRIOR_STRATEGY_ID}', '{REVIEWER_ID}', 'Prior diagnosis', 'Prior policy', '["prior"]', 1, true, + '2026-07-01T00:00:00+00:00'), + ('{OTHER_STRATEGY_ID}', '{OTHER_AGENT_ID}', 'Other diagnosis', 'Other policy', '["other"]', 1, true, + '2026-07-01T00:00:00+00:00'); +insert into public.strategy_nodes + (id, agent_id, node_type, title, body, rank, status, created_at, updated_at) +values + ('{PRIOR_ACTIVE_NODE_ID}', '{REVIEWER_ID}', 'policy', 'Prior active', 'Retire exactly once', 1, + 'active', '2026-07-01T00:00:00+00:00', '2026-07-01T00:00:00+00:00'), + ('{PRIOR_RETIRED_NODE_ID}', '{REVIEWER_ID}', 'policy', 'Prior retired', 'Leave timestamp unchanged', 2, + 'retired', '2026-06-01T00:00:00+00:00', '2026-06-02T00:00:00+00:00'), + ('{OTHER_ACTIVE_NODE_ID}', '{OTHER_AGENT_ID}', 'policy', 'Other active', 'Leave other agent unchanged', 1, + 'active', '2026-07-01T00:00:00+00:00', '2026-07-01T00:00:00+00:00'), + ('{NULL_AGENT_NODE_ID}', null, 'policy', 'Shared active', 'Leave shared node unchanged', 1, + 'active', '2026-07-01T00:00:00+00:00', '2026-07-01T00:00:00+00:00'); +insert into public.strategy_node_anchors + (id, from_node_id, anchor_role, to_node_id, weight, note, created_at) +values + ('{PRIOR_NODE_ANCHOR_ID}', '{PRIOR_ACTIVE_NODE_ID}', 'serves', '{PRIOR_RETIRED_NODE_ID}', 1.0, + 'Existing anchor must survive strategy revision replay', '2026-07-01T00:00:00+00:00'); """ @@ -631,13 +920,17 @@ def source_postgres() -> Iterator[tuple[str, str]]: "run", "--rm", "--detach", + "--network", + "none", "--name", container, + "--label", + f"{rebuild.canonical_rebuild.CANARY_LABEL}={container}", "--env", f"POSTGRES_DB={database}", "--env", "POSTGRES_HOST_AUTH_METHOD=trust", - rebuild.canonical_rebuild.DEFAULT_IMAGE, + rebuild.DEFAULT_REBUILD_IMAGE, ], text=True, capture_output=True, @@ -692,14 +985,24 @@ def source_postgres() -> Iterator[tuple[str, str]]: capture_output=True, check=False, ) + inspected = subprocess.run( + ["docker", "container", "inspect", container], + text=True, + capture_output=True, + check=False, + ) + if inspected.returncode == 0: + pytest.fail(f"fixture PostgreSQL container was not removed: {container}") -def test_live_genesis_plus_ledger_command_rebuilds_exactly_and_proves_cleanup( +def capture_source_snapshot( + source_container: str, + database: str, tmp_path: Path, - source_postgres: tuple[str, str], -) -> None: - source_container, database = source_postgres - genesis_dump = tmp_path / "genesis.dump" + *, + stem: str, +) -> tuple[Path, Path]: + dump = tmp_path / f"{stem}.dump" dump_result = subprocess.run( [ "docker", @@ -711,7 +1014,7 @@ def test_live_genesis_plus_ledger_command_rebuilds_exactly_and_proves_cleanup( "-d", database, "--format=custom", - "--file=/tmp/genesis.dump", + f"--file=/tmp/{stem}.dump", ], text=True, capture_output=True, @@ -719,15 +1022,130 @@ def test_live_genesis_plus_ledger_command_rebuilds_exactly_and_proves_cleanup( ) assert dump_result.returncode == 0, dump_result.stderr copy_result = subprocess.run( - ["docker", "cp", f"{source_container}:/tmp/genesis.dump", str(genesis_dump)], + ["docker", "cp", f"{source_container}:/tmp/{stem}.dump", str(dump)], text=True, capture_output=True, check=False, ) assert copy_result.returncode == 0, copy_result.stderr + manifest = tmp_path / f"{stem}-manifest.jsonl" + capture_manifest(source_container, database, manifest) + return dump, manifest - genesis_manifest = tmp_path / "genesis-manifest.jsonl" - capture_manifest(source_container, database, genesis_manifest) + +def seed_proposal_and_approval( + source_container: str, + database: str, + approved: dict, + approval: dict, +) -> dict: + sql = f"""begin; +set local standard_conforming_strings = on; +insert into kb_stage.kb_proposals +select seeded.* from jsonb_populate_record( + null::kb_stage.kb_proposals, {rebuild._jsonb_literal(approved)} +) seeded; +insert into kb_stage.kb_proposal_approvals +select seeded.* from jsonb_populate_record( + null::kb_stage.kb_proposal_approvals, {rebuild._jsonb_literal(approval)} +) seeded; +commit; +""" + docker_psql(source_container, database, sql) + raw = docker_psql( + source_container, + database, + rebuild.build_proposal_readback_sql(approved["id"]), + ).stdout.strip() + return json.loads(raw) + + +def run_genesis_ledger_command( + *, + tmp_path: Path, + database: str, + genesis_dump: Path, + genesis_manifest: Path, + material_path: Path, + final_manifest: Path, + artifact_stem: str, + container_prefix: str, + run_number: int, +) -> tuple[subprocess.CompletedProcess[str], dict, Path]: + ledger = { + "artifact": rebuild.LEDGER_ARTIFACT, + "contract_version": rebuild.LEDGER_CONTRACT_VERSION, + "engine": rebuild._engine_hashes(), + "genesis": { + "dump_sha256": sha256_file(genesis_dump), + "parity_manifest_sha256": sha256_file(genesis_manifest), + }, + "entries": [ + { + "sequence": 1, + "material": material_path.name, + "sha256": sha256_file(material_path), + "replay_material_sha256": json.loads(material_path.read_text(encoding="utf-8"))[ + "replay_receipt" + ]["hashes"]["replay_material_sha256"], + } + ], + "final_parity": { + "manifest": final_manifest.name, + "sha256": sha256_file(final_manifest), + }, + } + ledger_path = write_json(tmp_path / f"{artifact_stem}-ledger-run-{run_number}.json", ledger) + output = tmp_path / f"{artifact_stem}-reconstruction-receipt-run-{run_number}.json" + command = [ + sys.executable, + str(Path(rebuild.__file__).resolve()), + "--genesis-dump", + str(genesis_dump), + "--genesis-manifest", + str(genesis_manifest), + "--ledger", + str(ledger_path), + "--ledger-sha256", + sha256_file(ledger_path), + "--database", + database, + "--container-prefix", + container_prefix, + "--tmpfs-mb", + "256", + "--max-target-query-ms", + "5000", + "--max-target-source-ratio", + "100", + "--output", + str(output), + ] + completed = subprocess.run( + command, + cwd=rebuild.REPO_ROOT, + text=True, + capture_output=True, + check=False, + timeout=180, + ) + assert completed.returncode == 0, completed.stderr + completed.stdout + assert output.is_file(), "rebuild command succeeded without writing its receipt" + receipt = json.loads(output.read_text(encoding="utf-8")) + return completed, receipt, output + + +def test_live_genesis_plus_ledger_command_rebuilds_exactly_and_proves_cleanup( + tmp_path: Path, + source_postgres: tuple[str, str], +) -> None: + source_container, database = source_postgres + genesis_dump, genesis_manifest = capture_source_snapshot( + source_container, + database, + tmp_path, + stem="insert-only-genesis", + ) material = replay_material() material_path = write_json(tmp_path / "entry-0001.json", material) @@ -753,64 +1171,18 @@ def test_live_genesis_plus_ledger_command_rebuilds_exactly_and_proves_cleanup( final_manifest = tmp_path / "final-manifest.jsonl" capture_manifest(source_container, database, final_manifest) - ledger = { - "artifact": rebuild.LEDGER_ARTIFACT, - "contract_version": rebuild.LEDGER_CONTRACT_VERSION, - "engine": rebuild._engine_hashes(), - "genesis": { - "dump_sha256": sha256_file(genesis_dump), - "parity_manifest_sha256": sha256_file(genesis_manifest), - }, - "entries": [ - { - "sequence": 1, - "material": material_path.name, - "sha256": sha256_file(material_path), - "replay_material_sha256": entry.replay_material_sha256, - } - ], - "final_parity": { - "manifest": final_manifest.name, - "sha256": sha256_file(final_manifest), - }, - } - ledger_path = write_json(tmp_path / "ledger.json", ledger) - output = tmp_path / "reconstruction-receipt.json" - command = [ - sys.executable, - str(Path(rebuild.__file__).resolve()), - "--genesis-dump", - str(genesis_dump), - "--genesis-manifest", - str(genesis_manifest), - "--ledger", - str(ledger_path), - "--ledger-sha256", - sha256_file(ledger_path), - "--database", - database, - "--container-prefix", - "teleo-genesis-ledger-live-test", - "--tmpfs-mb", - "256", - "--max-target-query-ms", - "5000", - "--max-target-source-ratio", - "100", - "--output", - str(output), - ] - completed = subprocess.run( - command, - cwd=rebuild.REPO_ROOT, - text=True, - capture_output=True, - check=False, - timeout=180, + completed, receipt, output = run_genesis_ledger_command( + tmp_path=tmp_path, + database=database, + genesis_dump=genesis_dump, + genesis_manifest=genesis_manifest, + material_path=material_path, + final_manifest=final_manifest, + artifact_stem="insert-only", + container_prefix="teleo-genesis-ledger-live-test", + run_number=1, ) - assert completed.returncode == 0, completed.stderr + completed.stdout - receipt = json.loads(output.read_text(encoding="utf-8")) assert receipt["status"] == "pass" assert receipt["genesis_parity"]["status"] == "pass" assert receipt["ledger"]["entries"][0]["status"] == "pass" @@ -831,3 +1203,132 @@ def test_live_genesis_plus_ledger_command_rebuilds_exactly_and_proves_cleanup( check=False, ) assert inspect.returncode != 0 + + +def test_live_revise_strategy_rebuild_is_exact_repeatable_and_leaves_no_container( + tmp_path: Path, + source_postgres: tuple[str, str], +) -> None: + source_container, database = source_postgres + genesis_dump, genesis_manifest = capture_source_snapshot( + source_container, + database, + tmp_path, + stem="revise-strategy-genesis", + ) + + approved, _, approval = revise_strategy_proposal_rows() + pre_apply = seed_proposal_and_approval( + source_container, + database, + approved, + approval, + ) + apply_sql = rebuild.apply_engine.build_apply_sql(pre_apply["proposal"], "kb-apply") + docker_psql(source_container, database, apply_sql, role="kb_apply") + post_apply = json.loads( + docker_psql( + source_container, + database, + rebuild.build_proposal_readback_sql(REVISE_PROPOSAL_ID), + ).stdout.strip() + ) + applied_proposal = post_apply["proposal"] + proposal_envelope = applied_envelope(applied_proposal) + source_receipt_path, receipt = rebuild.apply_engine.capture_replay_receipt( + argparse.Namespace( + container=source_container, + role="kb_apply", + host="127.0.0.1", + db=database, + receipt_out=str(tmp_path / "source-revise-strategy-private-receipt.json"), + receipt_dir=None, + ), + proposal_envelope, + "", + apply_sql=apply_sql, + ) + assert source_receipt_path.is_file() + assert stat.S_IMODE(source_receipt_path.stat().st_mode) == 0o600 + material = { + "artifact": rebuild.MATERIAL_ARTIFACT, + "contract_version": rebuild.MATERIAL_CONTRACT_VERSION, + "sequence": 1, + "approved_proposal": pre_apply["proposal"], + "approval_snapshot": pre_apply["approval_snapshot"], + "applied_proposal": applied_proposal, + "replay_receipt": receipt, + } + material_path = write_json(tmp_path / "revise-strategy-entry-0001.json", material) + final_manifest = tmp_path / "revise-strategy-final-manifest.jsonl" + capture_manifest(source_container, database, final_manifest) + + runs = [ + run_genesis_ledger_command( + tmp_path=tmp_path, + database=database, + genesis_dump=genesis_dump, + genesis_manifest=genesis_manifest, + material_path=material_path, + final_manifest=final_manifest, + artifact_stem="revise-strategy", + container_prefix="teleo-genesis-ledger-revise", + run_number=run_number, + ) + for run_number in (1, 2) + ] + + for completed, reconstruction, output in runs: + assert reconstruction["status"] == "pass" + assert reconstruction["genesis_parity"]["status"] == "pass" + assert reconstruction["final_parity"]["status"] == "pass" + entry_summary = reconstruction["ledger"]["entries"][0] + assert entry_summary["proposal_type"] == "revise_strategy" + assert entry_summary["proposal_seed_exact"] is True + assert entry_summary["canonical_seed_exact"] is False + assert entry_summary["seed_exact"] is False + assert entry_summary["guarded_apply_executed"] is True + assert entry_summary["mutating_prestate_captured"] is True + assert entry_summary["mutating_delta_validated"] is True + assert entry_summary["mutating_poststate_normalized"] is True + assert entry_summary["proposal_exact"] is True + assert entry_summary["canonical_rows_exact"] is True + assert reconstruction["cleanup"]["container_absent"] is True + assert reconstruction["safety"]["network_access_available_to_container"] is False + assert reconstruction["safety"]["private_replay_material_emitted"] is False + assert PRIVATE_MARKER not in completed.stdout + assert PRIVATE_MARKER not in output.read_text(encoding="utf-8") + assert stat.S_IMODE(output.stat().st_mode) == 0o600 + + container_name = reconstruction["container"]["name"] + inspect = subprocess.run( + ["docker", "container", "inspect", container_name], + text=True, + capture_output=True, + check=False, + ) + assert inspect.returncode != 0 + labeled = subprocess.run( + [ + "docker", + "ps", + "-aq", + "--filter", + f"label={rebuild.canonical_rebuild.CANARY_LABEL}={container_name}", + ], + text=True, + capture_output=True, + check=False, + ) + assert labeled.returncode == 0 + assert labeled.stdout.strip() == "" + + first = runs[0][1] + second = runs[1][1] + assert first["inputs"] == second["inputs"] + assert first["ledger"]["entries"][0]["material_sha256"] == second["ledger"]["entries"][0][ + "material_sha256" + ] + assert first["ledger"]["entries"][0]["replay_material_sha256"] == second["ledger"]["entries"][0][ + "replay_material_sha256" + ] From 3b138fd2e1117244405fe19c856c81e8aafe4131 Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Wed, 15 Jul 2026 04:00:14 +0200 Subject: [PATCH 3/8] fix: bind ingestion acceptance to exact provenance --- ...run_leo_local_ingestion_proposal_canary.py | 661 ++++++++++++++---- ...run_leo_local_ingestion_proposal_canary.py | 477 ++++++++++++- 2 files changed, 1011 insertions(+), 127 deletions(-) diff --git a/scripts/run_leo_local_ingestion_proposal_canary.py b/scripts/run_leo_local_ingestion_proposal_canary.py index 3cb9243..12854b2 100644 --- a/scripts/run_leo_local_ingestion_proposal_canary.py +++ b/scripts/run_leo_local_ingestion_proposal_canary.py @@ -31,7 +31,7 @@ import apply_proposal as ap # noqa: E402 import stage_normalized_proposal as normalized_stage # noqa: E402 SCHEMA = "livingip.workingLeoLocalIngestionProposalCanary.v2" -SUITE_SCHEMA = "livingip.workingLeoLocalIngestionProposalSuite.v1" +SUITE_SCHEMA = "livingip.workingLeoLocalIngestionProposalSuite.v2" FIXTURE_SCHEMA = "livingip.workingLeoSourceIngestionScenario.v2" DEFAULT_DOCUMENT_FIXTURE = REPO_ROOT / "fixtures" / "working-leo" / "document-ingestion-v2.scenario.json" DEFAULT_TELEGRAM_FIXTURE = REPO_ROOT / "fixtures" / "working-leo" / "telegram-transcript-ingestion-v1.scenario.json" @@ -60,6 +60,42 @@ def canonical_sha256(value: Any) -> str: return sha256_bytes(canonical_json_bytes(value)) +def normalized_segment_manifest(segments: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return the complete normalized provenance identity for replay hashing.""" + return [ + { + "id": segment["id"], + "locator": segment["locator"], + "json_pointer": segment["json_pointer"], + "content_sha256": segment["content_sha256"], + "text_sha256": segment["text_sha256"], + } + for segment in segments + ] + + +def replay_identity_payload( + *, + artifact_sha256: str, + artifact_format: str, + source_identity: str, + source_locator: str, + normalized_segments_sha256: str, + extractor: dict[str, str], + extraction_spec_sha256: str, +) -> dict[str, Any]: + """Build the explicit source-to-extraction identity used by every row ID.""" + return { + "artifact_sha256": artifact_sha256, + "artifact_format": artifact_format, + "source_identity": source_identity, + "source_locator": source_locator, + "normalized_segments_sha256": normalized_segments_sha256, + "extractor": extractor, + "extraction_spec_sha256": extraction_spec_sha256, + } + + def canonical_snapshot_complete(snapshot: dict[str, Any]) -> bool: return set(snapshot) == set(CANONICAL_TABLES) and all( isinstance(snapshot.get(table), list) and bool(snapshot[table]) for table in CANONICAL_TABLES @@ -231,6 +267,9 @@ def load_fixture(path: Path) -> tuple[dict[str, Any], dict[str, Any]]: raise CanaryError(f"source.source_type must be one of {sorted(ap.SOURCE_TYPES)}") if not isinstance(source.get("metadata"), dict): raise CanaryError("source.metadata must be an object") + source_identity = _require_text(source.get("identity"), "source.identity") + source_locator = _require_text(source.get("locator"), "source.locator") + source = {**source, "identity": source_identity, "locator": source_locator} segments = _load_segments(raw, artifact_format, source) segment_by_id = {segment["id"]: segment for segment in segments} @@ -349,28 +388,25 @@ def load_fixture(path: Path) -> tuple[dict[str, Any], dict[str, Any]]: normalized_conflicts.append({**conflict, "from_claim_key": from_key, "to_claim_key": to_key}) artifact_sha256 = sha256_bytes(raw) - source_content_sha256 = canonical_sha256( - [ - { - "id": segment["id"], - "locator": segment["locator"], - "content_sha256": segment["content_sha256"], - } - for segment in segments - ] - ) + segment_manifest = normalized_segment_manifest(segments) + source_content_sha256 = canonical_sha256(segment_manifest) extraction_spec_sha256 = canonical_sha256(extraction) - replay_identity_sha256 = canonical_sha256( - { - "artifact_sha256": artifact_sha256, - "extractor": {"name": extractor_name, "version": extractor_version}, - "extraction_spec_sha256": extraction_spec_sha256, - } + extractor_identity = {"name": extractor_name, "version": extractor_version} + replay_components = replay_identity_payload( + artifact_sha256=artifact_sha256, + artifact_format=artifact_format, + source_identity=source_identity, + source_locator=source_locator, + normalized_segments_sha256=source_content_sha256, + extractor=extractor_identity, + extraction_spec_sha256=extraction_spec_sha256, ) + replay_identity_sha256 = canonical_sha256(replay_components) fixture = { **scenario, "artifact": {**artifact, "resolved_path": str(artifact_path)}, - "extractor": {"name": extractor_name, "version": extractor_version}, + "source": source, + "extractor": extractor_identity, "segments": segments, "extraction": { "claims": normalized_claims, @@ -385,10 +421,11 @@ def load_fixture(path: Path) -> tuple[dict[str, Any], dict[str, Any]]: "artifact_format": artifact_format, "artifact_sha256": artifact_sha256, "source_content_sha256": source_content_sha256, - "source_identity": source["identity"], - "source_locator": source["locator"], - "extractor": {"name": extractor_name, "version": extractor_version}, + "source_identity": source_identity, + "source_locator": source_locator, + "extractor": extractor_identity, "extraction_spec_sha256": extraction_spec_sha256, + "replay_identity_components": replay_components, "replay_identity_sha256": replay_identity_sha256, "counts": { "source_segments": len(segments), @@ -421,7 +458,7 @@ def build_row_ids(input_receipt: dict[str, Any], fixture: dict[str, Any]) -> dic for index, _conflict in enumerate(fixture["extraction"]["conflicts"]) } return { - "source_capture_id": stable_uuid(input_receipt["artifact_sha256"], "source-capture"), + "source_capture_id": stable_uuid(replay_seed, "source-capture"), "claim_extraction_ids": claim_ids, "evidence_extraction_ids": evidence_ids, "duplicate_assessment_ids": duplicate_ids, @@ -507,17 +544,9 @@ def build_parent_packet( "source_locator": input_receipt["source_locator"], "extractor": input_receipt["extractor"], "extraction_spec_sha256": input_receipt["extraction_spec_sha256"], + "replay_identity_components": input_receipt["replay_identity_components"], "replay_identity_sha256": input_receipt["replay_identity_sha256"], - "segments": [ - { - "id": segment["id"], - "locator": segment["locator"], - "json_pointer": segment["json_pointer"], - "content_sha256": segment["content_sha256"], - "text_sha256": segment["text_sha256"], - } - for segment in fixture["segments"] - ], + "segments": normalized_segment_manifest(fixture["segments"]), "row_ids": row_ids, "candidate_counts": input_receipt["counts"], } @@ -547,6 +576,100 @@ def build_parent_packet( } +def _independent_planned_uuid(parent_proposal_id: str, kind: str, key: str) -> str: + """Reproduce the public deterministic-ID contract without calling the planner.""" + return str(uuid.uuid5(uuid.NAMESPACE_URL, f"teleo:kb-rich-contract:{parent_proposal_id}:{kind}:{key}")) + + +def expected_planned_graph_identity(fixture: dict[str, Any], row_ids: dict[str, Any]) -> dict[str, Any]: + """Derive planned row IDs and FK identities directly from normalized source candidates.""" + parent_id = row_ids["parent_proposal_id"] + claims = fixture["extraction"]["claims"] + claim_ids = { + claim["claim_key"]: _independent_planned_uuid(parent_id, "claim", claim["claim_key"]) for claim in claims + } + referenced_segment_ids: list[str] = [] + for claim in claims: + for evidence in claim["evidence"]: + if evidence["segment_id"] not in referenced_segment_ids: + referenced_segment_ids.append(evidence["segment_id"]) + for duplicate in fixture["extraction"]["duplicates"]: + if duplicate["segment_id"] not in referenced_segment_ids: + referenced_segment_ids.append(duplicate["segment_id"]) + + segment_source_ids = { + segment_id: _independent_planned_uuid( + parent_id, + "source", + _segment_source_key(fixture["source"]["source_key"], segment_id), + ) + for segment_id in referenced_segment_ids + } + body_source_ids = { + claim["claim_key"]: _independent_planned_uuid(parent_id, "claim-body-source", claim["claim_key"]) + for claim in claims + } + + evidence_rows: list[dict[str, Any]] = [] + seen_evidence: set[tuple[str, str, str]] = set() + + def append_evidence(claim_id: str, source_id: str, role: str) -> None: + key = (claim_id, source_id, role) + if key in seen_evidence: + return + seen_evidence.add(key) + evidence_rows.append( + { + "claim_id": claim_id, + "source_id": source_id, + "role": role, + "weight": None, + "created_by": None, + } + ) + + for claim in claims: + claim_key = claim["claim_key"] + claim_id = claim_ids[claim_key] + append_evidence(claim_id, body_source_ids[claim_key], "illustrates") + for evidence in claim["evidence"]: + append_evidence(claim_id, segment_source_ids[evidence["segment_id"]], evidence["role"]) + + edge_rows: list[dict[str, Any]] = [] + seen_edges: set[tuple[str, str, str]] = set() + for claim in claims: + from_claim = claim_ids[claim["claim_key"]] + for edge in claim["edges"]: + target_key = _require_text(edge.get("target"), f"claim {claim['claim_key']} edge target") + to_claim = claim_ids.get(target_key) + if to_claim is None: + raise CanaryError(f"claim {claim['claim_key']} edge references unknown claim {target_key}") + edge_type = _require_text(edge.get("edge_type"), f"claim {claim['claim_key']} edge type") + identity = (from_claim, to_claim, edge_type) + if identity in seen_edges: + continue + seen_edges.add(identity) + edge_rows.append( + { + "from_claim": from_claim, + "to_claim": to_claim, + "edge_type": edge_type, + "weight": edge.get("weight"), + "created_by": None, + } + ) + + return { + "planned_claim_ids": [claim_ids[claim["claim_key"]] for claim in claims], + "planned_source_ids": [ + *(segment_source_ids[segment_id] for segment_id in referenced_segment_ids), + *(body_source_ids[claim["claim_key"]] for claim in claims), + ], + "planned_evidence_rows": evidence_rows, + "planned_edge_rows": edge_rows, + } + + def build_schema_sql() -> str: return rf"""\set ON_ERROR_STOP on create schema kb_canary; @@ -664,6 +787,116 @@ rollback; """ +def expected_persisted_rows( + fixture: dict[str, Any], input_receipt: dict[str, Any], row_ids: dict[str, Any] +) -> dict[str, list[dict[str, Any]]]: + """Project the complete relational identity expected from one fixture.""" + source = fixture["source"] + expected: dict[str, list[dict[str, Any]]] = { + "source_captures": [ + { + "id": row_ids["source_capture_id"], + "source_identity": source["identity"], + "source_type": source["source_type"], + "title": source["title"], + "locator": source["locator"], + "artifact_path": input_receipt["artifact_path"], + "artifact_sha256": input_receipt["artifact_sha256"], + "content_sha256": input_receipt["source_content_sha256"], + "replay_identity_sha256": input_receipt["replay_identity_sha256"], + "metadata": {**source["metadata"], "extractor": input_receipt["extractor"]}, + } + ], + "claim_extractions": [], + "evidence_extractions": [], + "duplicate_assessments": [], + "conflict_assessments": [], + } + segment_by_id = {segment["id"]: segment for segment in fixture["segments"]} + for claim in fixture["extraction"]["claims"]: + claim_id = row_ids["claim_extraction_ids"][claim["claim_key"]] + expected["claim_extractions"].append( + { + "id": claim_id, + "source_capture_id": row_ids["source_capture_id"], + "claim_key": claim["claim_key"], + "body": claim["body"], + "metadata": { + **claim["metadata"], + "claim_type": claim["type"], + "confidence": claim["confidence"], + "text": claim["text"], + "extractor": input_receipt["extractor"], + "replay_identity_sha256": input_receipt["replay_identity_sha256"], + }, + } + ) + for index, evidence in enumerate(claim["evidence"]): + evidence_key = f"{claim['claim_key']}:{index}" + segment = segment_by_id[evidence["segment_id"]] + expected["evidence_extractions"].append( + { + "id": row_ids["evidence_extraction_ids"][evidence_key], + "claim_extraction_id": claim_id, + "source_capture_id": row_ids["source_capture_id"], + "source_segment_id": segment["id"], + "source_locator": segment["locator"], + "source_json_pointer": segment["json_pointer"], + "role": evidence["role"], + "body": evidence["excerpt"], + "body_sha256": sha256_bytes(evidence["excerpt"].encode()), + "source_content_sha256": segment["content_sha256"], + "metadata": { + **_require_object(evidence.get("metadata", {}), f"evidence {evidence_key}.metadata"), + "source_text_sha256": segment["text_sha256"], + }, + } + ) + for index, duplicate in enumerate(fixture["extraction"]["duplicates"]): + expected["duplicate_assessments"].append( + { + "id": row_ids["duplicate_assessment_ids"][str(index)], + "source_capture_id": row_ids["source_capture_id"], + "source_segment_id": duplicate["segment_id"], + "source_locator": duplicate["source_locator"], + "duplicate_of_claim_key": duplicate["duplicate_of_claim_key"], + "excerpt": duplicate["excerpt"], + "excerpt_sha256": sha256_bytes(duplicate["excerpt"].encode()), + "reason": duplicate["reason"], + "metadata": { + "json_pointer": duplicate["source_json_pointer"], + "source_content_sha256": duplicate["source_content_sha256"], + "source_text_sha256": duplicate["source_text_sha256"], + }, + } + ) + for index, conflict in enumerate(fixture["extraction"]["conflicts"]): + expected["conflict_assessments"].append( + { + "id": row_ids["conflict_assessment_ids"][str(index)], + "source_capture_id": row_ids["source_capture_id"], + "from_claim_key": conflict["from_claim_key"], + "to_claim_key": conflict["to_claim_key"], + "relationship": conflict["relationship"], + "metadata": { + key: value + for key, value in conflict.items() + if key not in {"from_claim_key", "to_claim_key", "relationship"} + }, + } + ) + return expected + + +def rows_exact(actual: Any, expected: list[dict[str, Any]]) -> bool: + """Compare complete row identities independent of database result order.""" + return ( + isinstance(actual, list) + and all(isinstance(row, dict) for row in actual) + and sorted(actual, key=canonical_json_bytes) == sorted(expected, key=canonical_json_bytes) + ) + + def build_capture_sql(fixture: dict[str, Any], input_receipt: dict[str, Any], row_ids: dict[str, Any]) -> str: source = fixture["source"] q = ap.sql_literal @@ -869,41 +1102,88 @@ class DockerPostgres: } -def proposal_links(readback: dict[str, Any], input_receipt: dict[str, Any]) -> dict[str, Any]: - proposal = readback["staged_proposal"] - apply_payload = proposal["payload"]["apply_payload"] - source_rows = apply_payload["sources"] - evidence_rows = apply_payload["evidence"] +def proposal_readback_parts( + readback: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]: + """Extract typed proposal layers so every verifier path fails closed consistently.""" + proposal_value = readback.get("staged_proposal") + proposal = proposal_value if isinstance(proposal_value, dict) else {} + payload_value = proposal.get("payload") + payload = payload_value if isinstance(payload_value, dict) else {} + apply_value = payload.get("apply_payload") + apply_payload = apply_value if isinstance(apply_value, dict) else {} + manifest_value = apply_payload.get("normalization_manifest") + manifest = manifest_value if isinstance(manifest_value, dict) else {} + ingestion_value = manifest.get("ingestion_manifest") + ingestion_manifest = ingestion_value if isinstance(ingestion_value, dict) else {} + assessment_value = manifest.get("dedupe_conflict_assessment") + assessment = assessment_value if isinstance(assessment_value, dict) else {} + return proposal, apply_payload, manifest, ingestion_manifest, assessment + + +def proposal_links(readback: dict[str, Any]) -> dict[str, Any]: + """Project observed proposal row identities and links without judging them.""" + proposal, apply_payload, _manifest, ingestion_manifest, _assessment = proposal_readback_parts(readback) + claim_rows = apply_payload.get("claims") if isinstance(apply_payload.get("claims"), list) else [] + source_rows = apply_payload.get("sources") if isinstance(apply_payload.get("sources"), list) else [] + evidence_rows = apply_payload.get("evidence") if isinstance(apply_payload.get("evidence"), list) else [] + edge_rows = apply_payload.get("edges") if isinstance(apply_payload.get("edges"), list) else [] + planned_evidence_rows = [ + { + "claim_id": row.get("claim_id"), + "source_id": row.get("source_id"), + "role": row.get("role"), + "weight": row.get("weight"), + "created_by": row.get("created_by"), + } + for row in evidence_rows + if isinstance(row, dict) + ] + planned_edge_rows = [ + { + "from_claim": row.get("from_claim"), + "to_claim": row.get("to_claim"), + "edge_type": row.get("edge_type"), + "weight": row.get("weight"), + "created_by": row.get("created_by"), + } + for row in edge_rows + if isinstance(row, dict) + ] return { - "proposal_id": proposal["id"], - "proposal_status": proposal["status"], - "proposal_source_ref": proposal["source_ref"], - "planned_claim_ids": [row["id"] for row in apply_payload["claims"]], - "planned_source_ids": [row["id"] for row in source_rows], + "proposal_id": proposal.get("id"), + "proposal_status": proposal.get("status"), + "proposal_source_ref": proposal.get("source_ref"), + "planned_claim_ids": [row.get("id") for row in claim_rows if isinstance(row, dict)], + "planned_source_ids": [row.get("id") for row in source_rows if isinstance(row, dict)], "planned_evidence_keys": [ - {"claim_id": row["claim_id"], "source_id": row["source_id"], "role": row["role"]} for row in evidence_rows + {key: row[key] for key in ("claim_id", "source_id", "role")} for row in planned_evidence_rows ], "planned_edge_keys": [ - { - "from_claim": row["from_claim"], - "to_claim": row["to_claim"], - "edge_type": row["edge_type"], - } - for row in apply_payload["edges"] + {key: row[key] for key in ("from_claim", "to_claim", "edge_type")} for row in planned_edge_rows ], + "planned_evidence_rows": planned_evidence_rows, + "planned_edge_rows": planned_edge_rows, "planned_counts": { - "claims": len(apply_payload["claims"]), + "claims": len(claim_rows), "sources": len(source_rows), "evidence": len(evidence_rows), - "edges": len(apply_payload["edges"]), + "edges": len(edge_rows), }, - "input_hash_in_manifest": ( - apply_payload["normalization_manifest"].get("ingestion_manifest", {}).get("artifact_sha256") - == input_receipt["artifact_sha256"] - ), + "manifest_artifact_sha256": ingestion_manifest.get("artifact_sha256"), + "manifest_replay_identity_sha256": ingestion_manifest.get("replay_identity_sha256"), + "manifest_row_ids": ingestion_manifest.get("row_ids"), + } + + +def proposal_link_proof(readback: dict[str, Any], input_receipt: dict[str, Any]) -> dict[str, Any]: + """Attach explicit comparisons to the pure observed link projection for receipts.""" + links = proposal_links(readback) + return { + **links, + "input_hash_in_manifest": links["manifest_artifact_sha256"] == input_receipt["artifact_sha256"], "replay_identity_in_manifest": ( - apply_payload["normalization_manifest"].get("ingestion_manifest", {}).get("replay_identity_sha256") - == input_receipt["replay_identity_sha256"] + links["manifest_replay_identity_sha256"] == input_receipt["replay_identity_sha256"] ), } @@ -916,17 +1196,43 @@ def evaluate_checks( canonical_before: dict[str, Any], canonical_after: dict[str, Any], ) -> dict[str, bool]: - source_rows = readback.get("source_captures") or [] - claim_rows = readback.get("claim_extractions") or [] - evidence_rows = readback.get("evidence_extractions") or [] - duplicate_rows = readback.get("duplicate_assessments") or [] - conflict_rows = readback.get("conflict_assessments") or [] - proposal = readback.get("staged_proposal") or {} + source_value = readback.get("source_captures") + claim_value = readback.get("claim_extractions") + evidence_value = readback.get("evidence_extractions") + duplicate_value = readback.get("duplicate_assessments") + conflict_value = readback.get("conflict_assessments") + source_rows = source_value if isinstance(source_value, list) else [] + claim_rows = claim_value if isinstance(claim_value, list) else [] + evidence_rows = evidence_value if isinstance(evidence_value, list) else [] + duplicate_rows = duplicate_value if isinstance(duplicate_value, list) else [] + conflict_rows = conflict_value if isinstance(conflict_value, list) else [] + proposal, apply_payload, _manifest, ingestion_manifest, assessment = proposal_readback_parts(readback) counts = input_receipt["counts"] - links = proposal_links(readback, input_receipt) - manifest = ((proposal.get("payload") or {}).get("apply_payload") or {}).get("normalization_manifest") or {} - ingestion_manifest = manifest.get("ingestion_manifest") or {} - assessment = manifest.get("dedupe_conflict_assessment") or {} + links = proposal_links(readback) + deterministic_row_ids = build_row_ids(input_receipt, fixture) + expected_rows = expected_persisted_rows(fixture, input_receipt, deterministic_row_ids) + expected_parent = build_parent_packet(fixture, input_receipt, deterministic_row_ids) + expected_child = normalized_stage.prepare_normalized_child(expected_parent) + expected_apply_payload = expected_child["payload"]["apply_payload"] + persisted_proposal_envelope_fields = ( + "id", + "proposal_type", + "status", + "proposed_by_handle", + "proposed_by_agent_id", + "channel", + "source_ref", + "rationale", + "reviewed_by_handle", + "reviewed_by_agent_id", + "reviewed_at", + "review_note", + "applied_by_handle", + "applied_by_agent_id", + "applied_at", + ) + expected_proposal_envelope = {key: expected_child.get(key) for key in persisted_proposal_envelope_fields} + actual_proposal_envelope = {key: proposal.get(key) for key in persisted_proposal_envelope_fields} expected_db_counts = { "source_captures": 1, "claim_extractions": counts["claim_candidates"], @@ -935,63 +1241,91 @@ def evaluate_checks( "conflict_assessments": counts["conflict_relationships"], "staged_proposals": 1, } - segment_by_id = {segment["id"]: segment for segment in fixture["segments"]} - evidence_locations_exact = all( - row.get("source_locator") == segment_by_id.get(row.get("source_segment_id"), {}).get("locator") - and row.get("source_content_sha256") - == segment_by_id.get(row.get("source_segment_id"), {}).get("content_sha256") - and row.get("body") in segment_by_id.get(row.get("source_segment_id"), {}).get("body", "") - for row in evidence_rows + source_rows_exact = rows_exact(source_rows, expected_rows["source_captures"]) + claim_rows_exact = rows_exact(claim_rows, expected_rows["claim_extractions"]) + evidence_rows_exact = rows_exact(evidence_rows, expected_rows["evidence_extractions"]) + duplicate_rows_exact = rows_exact(duplicate_rows, expected_rows["duplicate_assessments"]) + conflict_rows_exact = rows_exact(conflict_rows, expected_rows["conflict_assessments"]) + proposal_envelope_exact = actual_proposal_envelope == expected_proposal_envelope + planned_rows_exact = all( + apply_payload.get(collection) == expected_apply_payload.get(collection) + for collection in ("claims", "sources", "evidence", "edges") ) + graph_collections = {"claims", "sources", "evidence", "edges"} + apply_auxiliary_exact = {key: value for key, value in apply_payload.items() if key not in graph_collections} == { + key: value for key, value in expected_apply_payload.items() if key not in graph_collections + } + expected_graph_identity = expected_planned_graph_identity(fixture, deterministic_row_ids) + actual_graph_identity = { + key: links[key] + for key in ("planned_claim_ids", "planned_source_ids", "planned_evidence_rows", "planned_edge_rows") + } + independent_graph_identity_exact = actual_graph_identity == expected_graph_identity + pending_state = normalized_stage.bound._state_semantics(proposal, "pending_review") + source_row = source_rows[0] if len(source_rows) == 1 and isinstance(source_rows[0], dict) else {} + claim_rows_are_objects = all(isinstance(row, dict) for row in claim_rows) return { "source_only_artifact_hash_persisted": ( - len(source_rows) == 1 and source_rows[0].get("artifact_sha256") == input_receipt["artifact_sha256"] + len(source_rows) == 1 and source_row.get("artifact_sha256") == input_receipt["artifact_sha256"] ), + "source_capture_identity_exact": source_rows_exact, + "deterministic_row_ids_recomputed_exact": row_ids == deterministic_row_ids, "replay_identity_persisted": ( len(source_rows) == 1 - and source_rows[0].get("replay_identity_sha256") == input_receipt["replay_identity_sha256"] + and source_row.get("replay_identity_sha256") == input_receipt["replay_identity_sha256"] ), "extractor_name_and_version_persisted": ( ingestion_manifest.get("extractor") == input_receipt["extractor"] - and all(row.get("metadata", {}).get("extractor") == input_receipt["extractor"] for row in claim_rows) + and claim_rows_are_objects + and all( + isinstance(row.get("metadata"), dict) and row["metadata"].get("extractor") == input_receipt["extractor"] + for row in claim_rows + ) ), "all_claim_candidates_persisted_once": ( - {row.get("claim_key") for row in claim_rows} + claim_rows_are_objects + and {row.get("claim_key") for row in claim_rows} == {claim["claim_key"] for claim in fixture["extraction"]["claims"]} and len(claim_rows) == counts["claim_candidates"] ), + "claim_extraction_identities_and_fks_exact": claim_rows_exact, "all_evidence_has_exact_source_location": ( - len(evidence_rows) == counts["evidence_candidates"] and evidence_locations_exact + len(evidence_rows) == counts["evidence_candidates"] and evidence_rows_exact ), + "evidence_extraction_identities_and_fks_exact": evidence_rows_exact, "duplicate_judgments_persisted": ( len(duplicate_rows) == counts["duplicate_judgments"] + and duplicate_rows_exact and assessment.get("duplicates") == fixture["extraction"]["duplicates"] ), "conflicts_and_relationships_persisted": ( len(conflict_rows) == counts["conflict_relationships"] + and conflict_rows_exact and assessment.get("conflicts") == fixture["extraction"]["conflicts"] - and links["planned_counts"]["edges"] == counts["conflict_relationships"] + and links["planned_edge_rows"] == expected_graph_identity["planned_edge_rows"] + ), + "planned_graph_matches_independent_source_oracle": independent_graph_identity_exact, + "planned_proposal_identities_and_linkages_exact": ( + proposal_envelope_exact + and planned_rows_exact + and apply_auxiliary_exact + and independent_graph_identity_exact + and links["manifest_row_ids"] == deterministic_row_ids + and links["proposal_id"] == expected_child["id"] + and links["proposal_status"] == expected_child["status"] + and links["proposal_source_ref"] == expected_child["source_ref"] ), "database_candidate_counts_exact": readback.get("counts") == expected_db_counts, - "proposal_is_pending_review": proposal.get("status") == "pending_review", + "proposal_is_pending_review": pending_state["status_matches"], "proposal_has_replayable_ingestion_manifest": ( - links["input_hash_in_manifest"] - and links["replay_identity_in_manifest"] - and ingestion_manifest.get("segments") - == [ - { - "id": segment["id"], - "locator": segment["locator"], - "json_pointer": segment["json_pointer"], - "content_sha256": segment["content_sha256"], - "text_sha256": segment["text_sha256"], - } - for segment in fixture["segments"] - ] + links["manifest_artifact_sha256"] == input_receipt["artifact_sha256"] + and links["manifest_replay_identity_sha256"] == input_receipt["replay_identity_sha256"] + and ingestion_manifest == expected_apply_payload["normalization_manifest"]["ingestion_manifest"] ), - "no_review_or_apply_metadata": all( - proposal.get(key) is None - for key in ("reviewed_by_handle", "reviewed_at", "applied_by_handle", "applied_at") + "no_review_or_apply_metadata": ( + pending_state["review_fields_match"] + and pending_state["apply_fields_match"] + and proposal.get("review_note") is None ), "canonical_snapshot_nonempty": ( canonical_snapshot_complete(canonical_before) and canonical_snapshot_complete(canonical_after) @@ -1005,22 +1339,24 @@ def evaluate_checks( def run_canary(fixture_path: Path, output: Path | None = None) -> dict[str, Any]: - fixture, input_receipt = load_fixture(fixture_path) - row_ids = build_row_ids(input_receipt, fixture) - parent = build_parent_packet(fixture, input_receipt, row_ids) - child = normalized_stage.prepare_normalized_child(parent) - container_name = f"working-leo-ingest-{uuid.uuid4().hex[:12]}" - postgres = DockerPostgres(container_name) receipt: dict[str, Any] = { "schema": SCHEMA, "status": "fail", "required_tier": "T2_runtime_to_review_queue_staging", - "input": input_receipt, - "row_ids": row_ids, + "input": {"scenario_path": str(fixture_path.resolve())}, + "row_ids": {}, "production_mutation_attempted": False, "canonical_apply_attempted": False, } + postgres: DockerPostgres | None = None try: + fixture, input_receipt = load_fixture(fixture_path) + row_ids = build_row_ids(input_receipt, fixture) + parent = build_parent_packet(fixture, input_receipt, row_ids) + child = normalized_stage.prepare_normalized_child(parent) + receipt["input"] = input_receipt + receipt["row_ids"] = row_ids + postgres = DockerPostgres(f"working-leo-ingest-{uuid.uuid4().hex[:12]}") receipt["environment"] = postgres.start() postgres.psql(build_schema_sql()) canonical_before = postgres.psql_json(build_canonical_snapshot_sql()) @@ -1039,7 +1375,7 @@ def run_canary(fixture_path: Path, output: Path | None = None) -> dict[str, Any] "stage_command_result": stage_result, "rows": readback, "candidate_counts": input_receipt["counts"], - "row_link_fields_proven": proposal_links(readback, input_receipt), + "row_link_fields_proven": proposal_link_proof(readback, input_receipt), "canonical": { "fingerprint_before": canonical_sha256(canonical_before), "fingerprint_after": canonical_sha256(canonical_after), @@ -1050,19 +1386,45 @@ def run_canary(fixture_path: Path, output: Path | None = None) -> dict[str, Any] "status": "pass" if all(checks.values()) else "fail", } ) - except (CanaryError, OSError, ValueError, KeyError, normalized_stage.bound.CheckpointError) as exc: + except ( + CanaryError, + OSError, + ValueError, + KeyError, + TypeError, + AttributeError, + IndexError, + normalized_stage.bound.CheckpointError, + ) as exc: receipt["error"] = {"type": type(exc).__name__, "message": str(exc)} finally: - receipt["cleanup"] = postgres.cleanup() - receipt["cleanup"]["network_mode_none"] = receipt.get("environment", {}).get("network_mode") == "none" + if postgres is None: + receipt["cleanup"] = { + "remove_attempted": False, + "remove_returncode": None, + "container_absent": True, + "docker_volume_mounts_observed": [], + "anonymous_or_named_volume_created": False, + "runtime_not_started": True, + "network_mode_none": True, + } + else: + receipt["cleanup"] = postgres.cleanup() + receipt["cleanup"]["runtime_not_started"] = not postgres.started + receipt["cleanup"]["network_mode_none"] = receipt.get("environment", {}).get("network_mode") == "none" receipt["cleanup"]["no_production_target_referenced"] = receipt["cleanup"]["network_mode_none"] if not all((receipt["cleanup"]["container_absent"], receipt["cleanup"]["network_mode_none"])): receipt["status"] = "fail" - receipt["strongest_claim"] = ( - "One source-only artifact was parsed into exact-location candidate claims, duplicate/conflict " - "assessments, and a replay-bound pending_review proposal in disposable networkless Postgres; " - "representative canonical row contents remained fingerprint-identical." - ) + if receipt["status"] == "pass": + receipt["strongest_claim"] = ( + "One source-only artifact was parsed into exact-location candidate claims, duplicate/conflict " + "assessments, and a replay-bound pending_review proposal in disposable networkless Postgres; " + "representative canonical row contents remained fingerprint-identical." + ) + else: + receipt["strongest_claim"] = ( + "No source-to-review-staging capability claim is made because acceptance failed closed." + ) receipt["residual_gap"] = ( "This proves the deterministic local fixture extractor and review queue only; it does not prove " "arbitrary generative extraction, approval/apply, hosted runtime, or production mutation." @@ -1073,21 +1435,74 @@ def run_canary(fixture_path: Path, output: Path | None = None) -> dict[str, Any] return receipt +def receipt_proves_canonical_invariance(receipt: dict[str, Any]) -> bool: + """Recompute the suite-level canonical verdict from receipt evidence.""" + canonical = receipt.get("canonical") + checks = receipt.get("checks") + if not isinstance(canonical, dict) or not isinstance(checks, dict): + return False + before = canonical.get("fingerprint_before") + after = canonical.get("fingerprint_after") + return ( + isinstance(before, str) + and isinstance(after, str) + and re.fullmatch(r"[0-9a-f]{64}", before) is not None + and re.fullmatch(r"[0-9a-f]{64}", after) is not None + and before == after + and canonical.get("unchanged") is True + and checks.get("canonical_fingerprint_unchanged") is True + ) + + def run_suite(fixture_paths: list[Path], output: Path | None = None) -> dict[str, Any]: receipts = [run_canary(path.resolve()) for path in fixture_paths] + all_supplied_pass = bool(receipts) and all(item.get("status") == "pass" for item in receipts) + document_fixture_passed = any( + item.get("status") == "pass" and item.get("input", {}).get("artifact_format") == "plain_text" + for item in receipts + ) + social_conversation_fixture_passed = any( + item.get("status") == "pass" and item.get("input", {}).get("artifact_format") == "telegram_jsonl" + for item in receipts + ) + canonical_fingerprint_invariant_all = bool(receipts) and all( + receipt_proves_canonical_invariance(item) for item in receipts + ) + required_shape_coverage = document_fixture_passed and social_conversation_fixture_passed + full_suite_passed = all_supplied_pass and required_shape_coverage and canonical_fingerprint_invariant_all + if not all_supplied_pass or not canonical_fingerprint_invariant_all: + status = "fail" + elif full_suite_passed: + status = "pass" + else: + status = "partial" + missing_required_coverage = [] + if not document_fixture_passed: + missing_required_coverage.append("document") + if not social_conversation_fixture_passed: + missing_required_coverage.append("social_conversation") + if not canonical_fingerprint_invariant_all: + missing_required_coverage.append("canonical_fingerprint_invariance") suite = { "schema": SUITE_SCHEMA, - "status": "pass" if receipts and all(item["status"] == "pass" for item in receipts) else "fail", + "status": status, "required_tier": "T2_runtime_to_review_queue_staging", + "current_tier": ( + "T2_runtime_to_review_queue_staging" + if full_suite_passed + else "T2_runtime_single_fixture" + if status == "partial" + else "runtime_verification_failed" + ), "fixture_count": len(receipts), - "document_fixture_passed": any( - item["status"] == "pass" and item["input"]["artifact_format"] == "plain_text" for item in receipts - ), - "social_conversation_fixture_passed": any( - item["status"] == "pass" and item["input"]["artifact_format"] == "telegram_jsonl" for item in receipts - ), - "canonical_fingerprint_invariant_all": bool(receipts) - and all(item.get("canonical", {}).get("unchanged") is True for item in receipts), + "all_supplied_fixtures_passed": all_supplied_pass, + "document_fixture_passed": document_fixture_passed, + "social_conversation_fixture_passed": social_conversation_fixture_passed, + "canonical_fingerprint_invariant_all": canonical_fingerprint_invariant_all, + "required_shape_coverage_met": required_shape_coverage, + "coverage_status": "full" if required_shape_coverage else "partial", + "missing_required_coverage": missing_required_coverage, + "full_suite_passed": full_suite_passed, "receipts": receipts, } if output: diff --git a/tests/test_run_leo_local_ingestion_proposal_canary.py b/tests/test_run_leo_local_ingestion_proposal_canary.py index cfae09e..1290dc4 100644 --- a/tests/test_run_leo_local_ingestion_proposal_canary.py +++ b/tests/test_run_leo_local_ingestion_proposal_canary.py @@ -32,6 +32,37 @@ def _copy_scenario(tmp_path: Path, scenario_path: Path) -> Path: return scenario_target +def _canonical_snapshot() -> dict: + return { + "claims": [{"id": "1"}], + "sources": [{"id": "2"}], + "claim_evidence": [{"claim_id": "1"}], + "claim_edges": [{"from_claim": "1"}], + } + + +def _row_id_values(row_ids: dict) -> set[str]: + values: set[str] = set() + for value in row_ids.values(): + if isinstance(value, dict): + values.update(_row_id_values(value)) + else: + values.add(value) + return values + + +def _planned_uuid_values(child: dict) -> set[str]: + apply_payload = child["payload"]["apply_payload"] + values = {child["id"]} + for row in apply_payload["claims"] + apply_payload["sources"]: + values.add(row["id"]) + for row in apply_payload["evidence"]: + values.update((row["claim_id"], row["source_id"])) + for row in apply_payload["edges"]: + values.update((row["from_claim"], row["to_claim"])) + return values + + def _synthetic_readback(fixture: dict, input_receipt: dict, row_ids: dict, child: dict) -> dict: claim_rows = [] evidence_rows = [] @@ -44,7 +75,14 @@ def _synthetic_readback(fixture: dict, input_receipt: dict, row_ids: dict, child "source_capture_id": row_ids["source_capture_id"], "claim_key": claim["claim_key"], "body": claim["body"], - "metadata": {"extractor": input_receipt["extractor"]}, + "metadata": { + **claim["metadata"], + "claim_type": claim["type"], + "confidence": claim["confidence"], + "text": claim["text"], + "extractor": input_receipt["extractor"], + "replay_identity_sha256": input_receipt["replay_identity_sha256"], + }, } ) for index, evidence in enumerate(claim["evidence"]): @@ -56,10 +94,52 @@ def _synthetic_readback(fixture: dict, input_receipt: dict, row_ids: dict, child "source_capture_id": row_ids["source_capture_id"], "source_segment_id": segment["id"], "source_locator": segment["locator"], + "source_json_pointer": segment["json_pointer"], + "role": evidence["role"], "source_content_sha256": segment["content_sha256"], "body": evidence["excerpt"], + "body_sha256": canary.sha256_bytes(evidence["excerpt"].encode()), + "metadata": { + **evidence.get("metadata", {}), + "source_text_sha256": segment["text_sha256"], + }, } ) + duplicate_rows = [] + for index, duplicate in enumerate(fixture["extraction"]["duplicates"]): + duplicate_rows.append( + { + "id": row_ids["duplicate_assessment_ids"][str(index)], + "source_capture_id": row_ids["source_capture_id"], + "source_segment_id": duplicate["segment_id"], + "source_locator": duplicate["source_locator"], + "duplicate_of_claim_key": duplicate["duplicate_of_claim_key"], + "excerpt": duplicate["excerpt"], + "excerpt_sha256": canary.sha256_bytes(duplicate["excerpt"].encode()), + "reason": duplicate["reason"], + "metadata": { + "json_pointer": duplicate["source_json_pointer"], + "source_content_sha256": duplicate["source_content_sha256"], + "source_text_sha256": duplicate["source_text_sha256"], + }, + } + ) + conflict_rows = [] + for index, conflict in enumerate(fixture["extraction"]["conflicts"]): + conflict_rows.append( + { + "id": row_ids["conflict_assessment_ids"][str(index)], + "source_capture_id": row_ids["source_capture_id"], + "from_claim_key": conflict["from_claim_key"], + "to_claim_key": conflict["to_claim_key"], + "relationship": conflict["relationship"], + "metadata": { + key: value + for key, value in conflict.items() + if key not in {"from_claim_key", "to_claim_key", "relationship"} + }, + } + ) proposal = { **child, "reviewed_by_handle": None, @@ -71,14 +151,22 @@ def _synthetic_readback(fixture: dict, input_receipt: dict, row_ids: dict, child return { "source_captures": [ { + "id": row_ids["source_capture_id"], + "source_identity": fixture["source"]["identity"], + "source_type": fixture["source"]["source_type"], + "title": fixture["source"]["title"], + "locator": fixture["source"]["locator"], + "artifact_path": input_receipt["artifact_path"], "artifact_sha256": input_receipt["artifact_sha256"], + "content_sha256": input_receipt["source_content_sha256"], "replay_identity_sha256": input_receipt["replay_identity_sha256"], + "metadata": {**fixture["source"]["metadata"], "extractor": input_receipt["extractor"]}, } ], "claim_extractions": claim_rows, "evidence_extractions": evidence_rows, - "duplicate_assessments": [{} for _item in fixture["extraction"]["duplicates"]], - "conflict_assessments": [{} for _item in fixture["extraction"]["conflicts"]], + "duplicate_assessments": duplicate_rows, + "conflict_assessments": conflict_rows, "staged_proposal": proposal, "counts": { "source_captures": 1, @@ -208,13 +296,98 @@ def test_replay_identity_and_ids_change_with_extractor_version(tmp_path: Path) - assert original["artifact_sha256"] == changed["artifact_sha256"] assert original["replay_identity_sha256"] != changed["replay_identity_sha256"] - assert original_ids["source_capture_id"] == changed_ids["source_capture_id"] + assert original_ids["source_capture_id"] != changed_ids["source_capture_id"] assert original_ids["claim_extraction_ids"] != changed_ids["claim_extraction_ids"] assert original_ids["parent_proposal_id"] != changed_ids["parent_proposal_id"] assert original_child["payload_sha256"] != changed_child["payload_sha256"] assert fixture["segments"] == changed_fixture["segments"] +@pytest.mark.parametrize( + ("source_field", "replacement"), + ( + ("identity", "document:mutated-exact-source-identity"), + ("locator", "fixture://working-leo/mutated-exact-source-locator"), + ), +) +def test_replay_identity_and_every_row_id_bind_exact_source_identity( + tmp_path: Path, source_field: str, replacement: str +) -> None: + scenario_path = _copy_scenario(tmp_path, canary.DEFAULT_DOCUMENT_FIXTURE) + fixture, before, before_ids, _parent, before_child = _loaded(scenario_path) + scenario = json.loads(scenario_path.read_text(encoding="utf-8")) + scenario["source"][source_field] = replacement + scenario_path.write_text(json.dumps(scenario), encoding="utf-8") + + changed_fixture, after, after_ids, _changed_parent, after_child = _loaded(scenario_path) + + assert before["artifact_sha256"] == after["artifact_sha256"] + assert before["extraction_spec_sha256"] == after["extraction_spec_sha256"] + assert before["replay_identity_sha256"] != after["replay_identity_sha256"] + assert _row_id_values(before_ids).isdisjoint(_row_id_values(after_ids)) + assert _planned_uuid_values(before_child).isdisjoint(_planned_uuid_values(after_child)) + assert fixture["source"][source_field] != changed_fixture["source"][source_field] + + +@pytest.mark.parametrize( + ("segment_field", "replacement"), + ( + ("id", "message-900001-mutated"), + ("locator", "telegram://chat/900001/message/999999"), + ("json_pointer", "jsonl://line/999/message"), + ("content_sha256", "a" * 64), + ("text_sha256", "b" * 64), + ), +) +def test_replay_identity_and_every_row_id_bind_complete_normalized_segment( + segment_field: str, replacement: str +) -> None: + fixture, before, before_ids, _parent, before_child = _loaded(canary.DEFAULT_TELEGRAM_FIXTURE) + changed_fixture = copy.deepcopy(fixture) + after = copy.deepcopy(before) + segment = changed_fixture["segments"][-1] + original_id = segment["id"] + segment[segment_field] = replacement + for claim in changed_fixture["extraction"]["claims"]: + for evidence in claim["evidence"]: + if evidence["segment_id"] == original_id: + evidence["segment_id"] = segment["id"] + evidence["source_locator"] = segment["locator"] + evidence["source_json_pointer"] = segment["json_pointer"] + evidence["source_content_sha256"] = segment["content_sha256"] + evidence["source_text_sha256"] = segment["text_sha256"] + for duplicate in changed_fixture["extraction"]["duplicates"]: + if duplicate["segment_id"] == original_id: + duplicate["segment_id"] = segment["id"] + duplicate["source_locator"] = segment["locator"] + duplicate["source_json_pointer"] = segment["json_pointer"] + duplicate["source_content_sha256"] = segment["content_sha256"] + duplicate["source_text_sha256"] = segment["text_sha256"] + after["source_content_sha256"] = canary.canonical_sha256( + canary.normalized_segment_manifest(changed_fixture["segments"]) + ) + after["replay_identity_components"] = canary.replay_identity_payload( + artifact_sha256=after["artifact_sha256"], + artifact_format=after["artifact_format"], + source_identity=after["source_identity"], + source_locator=after["source_locator"], + normalized_segments_sha256=after["source_content_sha256"], + extractor=after["extractor"], + extraction_spec_sha256=after["extraction_spec_sha256"], + ) + after["replay_identity_sha256"] = canary.canonical_sha256(after["replay_identity_components"]) + after_ids = canary.build_row_ids(after, changed_fixture) + after_parent = canary.build_parent_packet(changed_fixture, after, after_ids) + after_child = canary.normalized_stage.prepare_normalized_child(after_parent) + + assert before["artifact_sha256"] == after["artifact_sha256"] + assert before["scenario_sha256"] == after["scenario_sha256"] + assert before["extraction_spec_sha256"] == after["extraction_spec_sha256"] + assert before["replay_identity_sha256"] != after["replay_identity_sha256"] + assert _row_id_values(before_ids).isdisjoint(_row_id_values(after_ids)) + assert _planned_uuid_values(before_child).isdisjoint(_planned_uuid_values(after_child)) + + def test_source_byte_tamper_changes_hashes_and_replay_ids(tmp_path: Path) -> None: scenario_path = _copy_scenario(tmp_path, canary.DEFAULT_DOCUMENT_FIXTURE) fixture, before, before_ids, _parent, _child = _loaded(scenario_path) @@ -311,3 +484,299 @@ def test_evaluation_fails_when_exact_evidence_locator_is_changed() -> None: ] is False ) + + +def test_evaluation_recomputes_instead_of_trusting_supplied_row_ids() -> None: + fixture, input_receipt, row_ids, _parent, _child = _loaded(canary.DEFAULT_TELEGRAM_FIXTURE) + tampered_ids = copy.deepcopy(row_ids) + tampered_ids["source_capture_id"] = "00000000-0000-4000-8000-000000009990" + tampered_parent = canary.build_parent_packet(fixture, input_receipt, tampered_ids) + tampered_child = canary.normalized_stage.prepare_normalized_child(tampered_parent) + readback = _synthetic_readback(fixture, input_receipt, tampered_ids, tampered_child) + + checks = canary.evaluate_checks( + fixture, input_receipt, tampered_ids, readback, _canonical_snapshot(), _canonical_snapshot() + ) + + assert checks["deterministic_row_ids_recomputed_exact"] is False + assert checks["source_capture_identity_exact"] is False + assert checks["planned_proposal_identities_and_linkages_exact"] is False + + +def test_independent_graph_oracle_rejects_consistent_generator_edge_corruption( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fixture, input_receipt = canary.load_fixture(canary.DEFAULT_TELEGRAM_FIXTURE) + row_ids = canary.build_row_ids(input_receipt, fixture) + parent = canary.build_parent_packet(fixture, input_receipt, row_ids) + original_prepare = canary.normalized_stage.prepare_normalized_child + + def corrupted_prepare(parent_packet: dict) -> dict: + child = copy.deepcopy(original_prepare(parent_packet)) + child["payload"]["apply_payload"]["edges"][0]["to_claim"] = "00000000-0000-4000-8000-000000009996" + return child + + monkeypatch.setattr(canary.normalized_stage, "prepare_normalized_child", corrupted_prepare) + corrupted_child = corrupted_prepare(parent) + readback = _synthetic_readback(fixture, input_receipt, row_ids, corrupted_child) + + checks = canary.evaluate_checks( + fixture, input_receipt, row_ids, readback, _canonical_snapshot(), _canonical_snapshot() + ) + + assert checks["planned_graph_matches_independent_source_oracle"] is False + assert checks["planned_proposal_identities_and_linkages_exact"] is False + assert checks["conflicts_and_relationships_persisted"] is False + + +@pytest.mark.parametrize( + ("mutation", "failed_check"), + ( + ("source_id", "source_capture_identity_exact"), + ("source_identity", "source_capture_identity_exact"), + ("source_locator", "source_capture_identity_exact"), + ("claim_id", "claim_extraction_identities_and_fks_exact"), + ("claim_source_fk", "claim_extraction_identities_and_fks_exact"), + ("evidence_id", "evidence_extraction_identities_and_fks_exact"), + ("evidence_claim_fk", "evidence_extraction_identities_and_fks_exact"), + ("evidence_source_fk", "evidence_extraction_identities_and_fks_exact"), + ("evidence_segment_id", "evidence_extraction_identities_and_fks_exact"), + ("evidence_json_pointer", "evidence_extraction_identities_and_fks_exact"), + ("evidence_role", "evidence_extraction_identities_and_fks_exact"), + ("evidence_body_sha256", "evidence_extraction_identities_and_fks_exact"), + ("proposal_id", "planned_proposal_identities_and_linkages_exact"), + ("planned_claim_id", "planned_proposal_identities_and_linkages_exact"), + ("planned_source_id", "planned_proposal_identities_and_linkages_exact"), + ("planned_evidence_claim_fk", "planned_proposal_identities_and_linkages_exact"), + ("planned_evidence_source_fk", "planned_proposal_identities_and_linkages_exact"), + ("planned_evidence_role", "planned_proposal_identities_and_linkages_exact"), + ("manifest_row_id", "planned_proposal_identities_and_linkages_exact"), + ), +) +def test_evaluation_rejects_wrong_deterministic_ids_and_every_fk_linkage(mutation: str, failed_check: str) -> None: + fixture, input_receipt, row_ids, _parent, child = _loaded(canary.DEFAULT_TELEGRAM_FIXTURE) + readback = _synthetic_readback(fixture, input_receipt, row_ids, child) + broken = copy.deepcopy(readback) + wrong_uuid = "00000000-0000-4000-8000-000000009999" + apply_payload = broken["staged_proposal"]["payload"]["apply_payload"] + if mutation == "source_id": + broken["source_captures"][0]["id"] = wrong_uuid + elif mutation == "source_identity": + broken["source_captures"][0]["source_identity"] = "fixture:wrong-source-identity" + elif mutation == "source_locator": + broken["source_captures"][0]["locator"] = "fixture://working-leo/wrong-source-locator" + elif mutation == "claim_id": + broken["claim_extractions"][0]["id"] = broken["claim_extractions"][1]["id"] + elif mutation == "claim_source_fk": + broken["claim_extractions"][0]["source_capture_id"] = wrong_uuid + elif mutation == "evidence_id": + broken["evidence_extractions"][0]["id"] = broken["evidence_extractions"][1]["id"] + elif mutation == "evidence_claim_fk": + broken["evidence_extractions"][0]["claim_extraction_id"] = broken["claim_extractions"][1]["id"] + elif mutation == "evidence_source_fk": + broken["evidence_extractions"][0]["source_capture_id"] = wrong_uuid + elif mutation == "evidence_segment_id": + broken["evidence_extractions"][0]["source_segment_id"] = "message-900001-9999" + elif mutation == "evidence_json_pointer": + broken["evidence_extractions"][0]["source_json_pointer"] = "jsonl://line/999/message" + elif mutation == "evidence_role": + broken["evidence_extractions"][0]["role"] = "supports" + elif mutation == "evidence_body_sha256": + broken["evidence_extractions"][0]["body_sha256"] = "d" * 64 + elif mutation == "proposal_id": + broken["staged_proposal"]["id"] = wrong_uuid + elif mutation == "planned_claim_id": + apply_payload["claims"][0]["id"] = apply_payload["claims"][1]["id"] + elif mutation == "planned_source_id": + apply_payload["sources"][0]["id"] = apply_payload["sources"][1]["id"] + elif mutation == "planned_evidence_claim_fk": + apply_payload["evidence"][0]["claim_id"] = apply_payload["claims"][1]["id"] + elif mutation == "planned_evidence_source_fk": + apply_payload["evidence"][0]["source_id"] = apply_payload["sources"][1]["id"] + elif mutation == "planned_evidence_role": + apply_payload["evidence"][0]["role"] = "supports" + elif mutation == "manifest_row_id": + apply_payload["normalization_manifest"]["ingestion_manifest"]["row_ids"]["source_capture_id"] = wrong_uuid + + checks = canary.evaluate_checks( + fixture, input_receipt, row_ids, broken, _canonical_snapshot(), _canonical_snapshot() + ) + + assert checks[failed_check] is False + assert not all(checks.values()) + + +@pytest.mark.parametrize( + ("surface", "field", "replacement", "failed_check"), + ( + ("duplicate", "id", "00000000-0000-4000-8000-000000009991", "duplicate_judgments_persisted"), + ( + "duplicate", + "source_capture_id", + "00000000-0000-4000-8000-000000009992", + "duplicate_judgments_persisted", + ), + ("duplicate", "source_segment_id", "message-900001-9999", "duplicate_judgments_persisted"), + ( + "duplicate", + "source_locator", + "telegram://chat/900001/message/9999", + "duplicate_judgments_persisted", + ), + ("duplicate", "duplicate_of_claim_key", "approval_alone_makes_canonical", "duplicate_judgments_persisted"), + ("duplicate", "excerpt", "fabricated duplicate excerpt", "duplicate_judgments_persisted"), + ("duplicate", "excerpt_sha256", "c" * 64, "duplicate_judgments_persisted"), + ("duplicate", "reason", "fabricated duplicate reason", "duplicate_judgments_persisted"), + ("duplicate", "metadata", {"json_pointer": "fabricated"}, "duplicate_judgments_persisted"), + ("conflict", "id", "00000000-0000-4000-8000-000000009993", "conflicts_and_relationships_persisted"), + ( + "conflict", + "source_capture_id", + "00000000-0000-4000-8000-000000009994", + "conflicts_and_relationships_persisted", + ), + ("conflict", "from_claim_key", "approval_alone_makes_canonical", "conflicts_and_relationships_persisted"), + ( + "conflict", + "to_claim_key", + "pending_review_does_not_change_canonical", + "conflicts_and_relationships_persisted", + ), + ("conflict", "relationship", "supports", "conflicts_and_relationships_persisted"), + ("conflict", "metadata", {"reason": "fabricated"}, "conflicts_and_relationships_persisted"), + ("edge", "from_claim", "00000000-0000-4000-8000-000000009995", "conflicts_and_relationships_persisted"), + ("edge", "to_claim", "00000000-0000-4000-8000-000000009996", "conflicts_and_relationships_persisted"), + ("edge", "edge_type", "supports", "conflicts_and_relationships_persisted"), + ("edge", "weight", 0.5, "conflicts_and_relationships_persisted"), + ("edge", "created_by", "00000000-0000-4000-8000-000000009997", "conflicts_and_relationships_persisted"), + ), +) +def test_evaluation_rejects_mutated_duplicate_conflict_and_edge_identities( + surface: str, field: str, replacement: object, failed_check: str +) -> None: + fixture, input_receipt, row_ids, _parent, child = _loaded(canary.DEFAULT_TELEGRAM_FIXTURE) + broken = _synthetic_readback(fixture, input_receipt, row_ids, child) + if surface == "duplicate": + broken["duplicate_assessments"][0][field] = replacement + elif surface == "conflict": + broken["conflict_assessments"][0][field] = replacement + else: + broken["staged_proposal"]["payload"]["apply_payload"]["edges"][0][field] = replacement + + checks = canary.evaluate_checks( + fixture, input_receipt, row_ids, broken, _canonical_snapshot(), _canonical_snapshot() + ) + + assert checks[failed_check] is False + assert not all(checks.values()) + + +@pytest.mark.parametrize( + ("field", "replacement"), + ( + ("reviewed_by_handle", "reviewer"), + ("reviewed_by_agent_id", "00000000-0000-4000-8000-000000009981"), + ("reviewed_at", "2026-07-15T00:00:00+00:00"), + ("review_note", "fabricated review note"), + ("applied_by_handle", "applier"), + ("applied_by_agent_id", "00000000-0000-4000-8000-000000009982"), + ("applied_at", "2026-07-15T00:00:01+00:00"), + ), +) +def test_evaluation_rejects_every_review_and_apply_metadata_mutation(field: str, replacement: str) -> None: + fixture, input_receipt, row_ids, _parent, child = _loaded(canary.DEFAULT_TELEGRAM_FIXTURE) + broken = _synthetic_readback(fixture, input_receipt, row_ids, child) + broken["staged_proposal"][field] = replacement + + checks = canary.evaluate_checks( + fixture, input_receipt, row_ids, broken, _canonical_snapshot(), _canonical_snapshot() + ) + + assert checks["no_review_or_apply_metadata"] is False + assert checks["planned_proposal_identities_and_linkages_exact"] is False + + +def _suite_child(artifact_format: str, *, passed: bool = True, canonical_unchanged: bool = True) -> dict: + before = "a" * 64 + after = before if canonical_unchanged else "b" * 64 + return { + "status": "pass" if passed else "fail", + "input": {"artifact_format": artifact_format}, + "canonical": { + "fingerprint_before": before, + "fingerprint_after": after, + "unchanged": canonical_unchanged, + }, + "checks": {"canonical_fingerprint_unchanged": canonical_unchanged}, + } + + +def test_single_fixture_suite_is_truthfully_partial(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(canary, "run_canary", lambda _path: _suite_child("plain_text")) + + suite = canary.run_suite([tmp_path / "document.scenario.json"]) + + assert suite["status"] == "partial" + assert suite["full_suite_passed"] is False + assert suite["coverage_status"] == "partial" + assert suite["missing_required_coverage"] == ["social_conversation"] + + +def test_malformed_fixture_fails_closed_with_receipt_and_without_runtime(tmp_path: Path) -> None: + malformed = tmp_path / "malformed.scenario.json" + malformed.write_text("{not-json", encoding="utf-8") + + receipt = canary.run_canary(malformed) + suite = canary.run_suite([malformed]) + + assert receipt["status"] == "fail" + assert receipt["error"]["type"] == "CanaryError" + assert receipt["cleanup"]["runtime_not_started"] is True + assert receipt["cleanup"]["container_absent"] is True + assert suite["status"] == "fail" + assert suite["receipts"][0]["error"]["type"] == "CanaryError" + + +@pytest.mark.parametrize( + ("children", "expected_status"), + ( + ( + [_suite_child("plain_text"), _suite_child("telegram_jsonl")], + "pass", + ), + ( + [_suite_child("plain_text", canonical_unchanged=False), _suite_child("telegram_jsonl")], + "fail", + ), + ( + [_suite_child("plain_text"), _suite_child("telegram_jsonl", passed=False)], + "fail", + ), + ), +) +def test_full_suite_status_requires_both_shapes_all_children_and_canonical_invariance( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, children: list[dict], expected_status: str +) -> None: + queue = iter(children) + monkeypatch.setattr(canary, "run_canary", lambda _path: next(queue)) + + suite = canary.run_suite([tmp_path / "document.scenario.json", tmp_path / "telegram.scenario.json"]) + + assert suite["status"] == expected_status + assert suite["full_suite_passed"] is (expected_status == "pass") + + +def test_suite_recomputes_canonical_invariance_instead_of_trusting_stale_boolean( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + children = [_suite_child("plain_text"), _suite_child("telegram_jsonl")] + children[0]["canonical"]["fingerprint_after"] = "c" * 64 + queue = iter(children) + monkeypatch.setattr(canary, "run_canary", lambda _path: next(queue)) + + suite = canary.run_suite([tmp_path / "document.scenario.json", tmp_path / "telegram.scenario.json"]) + + assert suite["status"] == "fail" + assert suite["canonical_fingerprint_invariant_all"] is False + assert suite["full_suite_passed"] is False + assert "canonical_fingerprint_invariance" in suite["missing_required_coverage"] From 40344c91d7c141c6f9993eb3d79ea29c6b33db9d Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Wed, 15 Jul 2026 04:00:53 +0200 Subject: [PATCH 4/8] enforce revise strategy approval timestamp provenance --- docs/kb-rebuild-and-recompile.md | 2 + ops/run_local_genesis_ledger_rebuild.py | 72 ++++------ .../test_run_local_genesis_ledger_rebuild.py | 129 +++++++++++++++--- 3 files changed, 135 insertions(+), 68 deletions(-) diff --git a/docs/kb-rebuild-and-recompile.md b/docs/kb-rebuild-and-recompile.md index 00996dc..b21eb56 100644 --- a/docs/kb-rebuild-and-recompile.md +++ b/docs/kb-rebuild-and-recompile.md @@ -240,6 +240,8 @@ The exact v1 claim ceiling is intentionally bounded: only the generated strategy/node rows with the exact receipt rows; - the original transaction timestamp is derived from the fresh strategy `created_at` and must equal every fresh node's `created_at` and `updated_at`. + It must also fall within the immutable, timezone-aware interval + `reviewed_at <= transaction timestamp <= applied_at`. Only node IDs observed as non-retired before apply receive that timestamp; already-retired, unrelated-agent, and shared NULL-agent rows stay untouched; - generated nodes must have no anchors before normalization, preventing a diff --git a/ops/run_local_genesis_ledger_rebuild.py b/ops/run_local_genesis_ledger_rebuild.py index a3361e1..a500fc6 100644 --- a/ops/run_local_genesis_ledger_rebuild.py +++ b/ops/run_local_genesis_ledger_rebuild.py @@ -49,12 +49,9 @@ MATERIAL_ARTIFACT = "teleo_strict_proposal_replay_material" LEDGER_CONTRACT_VERSION = 1 MATERIAL_CONTRACT_VERSION = 1 DEFAULT_REBUILD_IMAGE = ( - "docker.io/library/postgres:16-alpine@" - "sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777" -) -SUPPORTED_PROPOSAL_TYPES = frozenset( - {"add_edge", "attach_evidence", "approve_claim", "revise_strategy"} + "docker.io/library/postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777" ) +SUPPORTED_PROPOSAL_TYPES = frozenset({"add_edge", "attach_evidence", "approve_claim", "revise_strategy"}) CLAIM_CEILING = ( "exact genesis plus ordered strict proposal replay; supported types are add_edge, " "attach_evidence, approve_claim, and revise_strategy; mutating strategy replay " @@ -346,9 +343,7 @@ def _validate_proposal_rows( ) -def _parse_aware_timestamp( - value: Any, field: str, *, code: str = "invalid_mutating_receipt" -) -> datetime: +def _parse_aware_timestamp(value: Any, field: str, *, code: str = "invalid_mutating_receipt") -> datetime: if not isinstance(value, str) or not value: raise ReconstructionError(code, f"{field} must be an ISO-8601 timestamp") try: @@ -360,9 +355,7 @@ def _parse_aware_timestamp( return parsed -def _validated_uuid_list( - value: Any, field: str, *, code: str = "invalid_mutating_state" -) -> list[str]: +def _validated_uuid_list(value: Any, field: str, *, code: str = "invalid_mutating_state") -> list[str]: if not isinstance(value, list): raise ReconstructionError(code, f"{field} must be a UUID list") normalized: list[str] = [] @@ -382,9 +375,7 @@ def _validate_revise_strategy_receipt_rows( payload = proposal.get("payload") apply_payload = payload.get("apply_payload") if isinstance(payload, dict) else None if not isinstance(apply_payload, dict): - raise ReconstructionError( - "invalid_mutating_receipt", "revise_strategy receipt requires a strict apply payload" - ) + raise ReconstructionError("invalid_mutating_receipt", "revise_strategy receipt requires a strict apply payload") try: agent_id = str(uuid.UUID(str(apply_payload.get("agent_id")))) except (TypeError, ValueError, AttributeError) as exc: @@ -395,9 +386,7 @@ def _validate_revise_strategy_receipt_rows( strategies = canonical_rows.get("strategies") nodes = canonical_rows.get("strategy_nodes") if not isinstance(strategies, list) or len(strategies) != 1 or not isinstance(strategies[0], dict): - raise ReconstructionError( - "invalid_mutating_receipt", "revise_strategy receipt must pin one fresh strategy row" - ) + raise ReconstructionError("invalid_mutating_receipt", "revise_strategy receipt must pin one fresh strategy row") expected_nodes = apply_payload.get("strategy_nodes") if ( not isinstance(expected_nodes, list) @@ -418,9 +407,7 @@ def _validate_revise_strategy_receipt_rows( raise ReconstructionError( "invalid_mutating_receipt", "revise_strategy receipt strategy node row fields are not canonical" ) - strategy_id = _validated_uuid_list( - [strategy.get("id")], "receipt strategy id", code="invalid_mutating_receipt" - )[0] + strategy_id = _validated_uuid_list([strategy.get("id")], "receipt strategy id", code="invalid_mutating_receipt")[0] node_ids = _validated_uuid_list( [row.get("id") for row in nodes], "receipt strategy node ids", @@ -448,7 +435,14 @@ def _validate_revise_strategy_receipt_rows( "invalid_mutating_receipt", "revise_strategy fresh strategy and node timestamps must share one transaction timestamp", ) + reviewed_time = _parse_aware_timestamp(proposal.get("reviewed_at"), "receipt proposal reviewed_at") applied_time = _parse_aware_timestamp(proposal.get("applied_at"), "receipt proposal applied_at") + if reviewed_time > applied_time: + raise ReconstructionError("invalid_mutating_receipt", "revise_strategy proposal approval is after apply time") + if transaction_time < reviewed_time: + raise ReconstructionError( + "invalid_mutating_receipt", "revise_strategy transaction timestamp is before proposal approval" + ) if transaction_time > applied_time: raise ReconstructionError( "invalid_mutating_receipt", "revise_strategy transaction timestamp is after proposal apply time" @@ -767,9 +761,7 @@ def _validate_revise_strategy_prestate(value: Any) -> dict[str, Any]: ) state = _require_exact_keys(value, expected, "revise_strategy prestate") strategy_ids = _validated_uuid_list(state["strategy_ids"], "prestate strategy ids") - active_strategy_ids = _validated_uuid_list( - state["active_strategy_ids"], "prestate active strategy ids" - ) + active_strategy_ids = _validated_uuid_list(state["active_strategy_ids"], "prestate active strategy ids") strategy_node_ids = _validated_uuid_list(state["strategy_node_ids"], "prestate strategy node ids") non_retired_node_ids = _validated_uuid_list( state["non_retired_strategy_node_ids"], "prestate non-retired strategy node ids" @@ -824,9 +816,7 @@ def _validate_revise_strategy_generated_delta( ) -> tuple[dict[str, Any], list[dict[str, Any]]]: state = _validate_revise_strategy_prestate(prestate) if not isinstance(generated_rows, dict): - raise ReconstructionError( - "invalid_mutating_delta", "revise_strategy generated delta must be a row object" - ) + raise ReconstructionError("invalid_mutating_delta", "revise_strategy generated delta must be a row object") try: replay_receipt.build_replay_receipt( entry.receipt["proposal"], @@ -848,18 +838,12 @@ def _validate_revise_strategy_generated_delta( "invalid_mutating_delta", "revise_strategy apply did not generate exactly one strategy" ) if not isinstance(nodes, list) or any(not isinstance(row, dict) for row in nodes): - raise ReconstructionError( - "invalid_mutating_delta", "revise_strategy apply generated invalid strategy nodes" - ) + raise ReconstructionError("invalid_mutating_delta", "revise_strategy apply generated invalid strategy nodes") strategy = strategies[0] if strategy.get("version") != state["max_strategy_version"] + 1: - raise ReconstructionError( - "invalid_mutating_delta", "revise_strategy generated an unexpected strategy version" - ) + raise ReconstructionError("invalid_mutating_delta", "revise_strategy generated an unexpected strategy version") transaction_timestamp = strategy.get("created_at") - _parse_aware_timestamp( - transaction_timestamp, "generated strategy created_at", code="invalid_mutating_delta" - ) + _parse_aware_timestamp(transaction_timestamp, "generated strategy created_at", code="invalid_mutating_delta") if any( row.get("created_at") != transaction_timestamp or row.get("updated_at") != transaction_timestamp for row in nodes @@ -868,9 +852,7 @@ def _validate_revise_strategy_generated_delta( "invalid_mutating_delta", "revise_strategy generated rows do not share one transaction timestamp", ) - _validated_uuid_list( - [strategy.get("id")], "generated strategy id", code="invalid_mutating_delta" - ) + _validated_uuid_list([strategy.get("id")], "generated strategy id", code="invalid_mutating_delta") _validated_uuid_list( [row.get("id") for row in nodes], "generated strategy node ids", @@ -885,9 +867,7 @@ def build_revise_strategy_normalization_sql( generated_rows: dict[str, Any], ) -> str: state = _validate_revise_strategy_prestate(prestate) - generated_strategy, generated_nodes = _validate_revise_strategy_generated_delta( - entry, state, generated_rows - ) + generated_strategy, generated_nodes = _validate_revise_strategy_generated_delta(entry, state, generated_rows) receipt_strategy, receipt_nodes = _validate_revise_strategy_receipt_rows( entry.receipt["proposal"], entry.receipt["canonical_rows"] ) @@ -896,9 +876,9 @@ def build_revise_strategy_normalization_sql( "mutating_receipt_version_mismatch", "revise_strategy receipt version does not follow the observed prestate", ) - if receipt_strategy["id"] in state["strategy_ids"] or set( - row["id"] for row in receipt_nodes - ).intersection(state["strategy_node_ids"]): + if receipt_strategy["id"] in state["strategy_ids"] or set(row["id"] for row in receipt_nodes).intersection( + state["strategy_node_ids"] + ): raise ReconstructionError( "mutating_receipt_id_collision", "revise_strategy receipt IDs collide with the observed prestate" ) @@ -1219,9 +1199,7 @@ def apply_entry( code="mutating_delta_readback_failed", action=f"ledger entry {entry.sequence} revise_strategy generated delta readback", ) - normalization_sql = build_revise_strategy_normalization_sql( - entry, mutating_prestate, generated_rows - ) + normalization_sql = build_revise_strategy_normalization_sql(entry, mutating_prestate, generated_rows) summary["mutating_delta_validated"] = True _psql( args, diff --git a/tests/test_run_local_genesis_ledger_rebuild.py b/tests/test_run_local_genesis_ledger_rebuild.py index 721772c..1eb8ca0 100644 --- a/tests/test_run_local_genesis_ledger_rebuild.py +++ b/tests/test_run_local_genesis_ledger_rebuild.py @@ -266,9 +266,7 @@ def replay_material() -> dict: } -def revise_strategy_replay_material( - *, apply_sql_source: str = "exact_executed_sql", version: int = 2 -) -> dict: +def revise_strategy_replay_material(*, apply_sql_source: str = "exact_executed_sql", version: int = 2) -> dict: approved, applied, approval = revise_strategy_proposal_rows() proposal = applied_envelope(applied) transaction_timestamp = "2026-07-14T01:00:30+00:00" @@ -322,6 +320,19 @@ def revise_strategy_replay_material( } +def rehash_revise_strategy_receipt(material: dict) -> None: + prior_receipt = material["replay_receipt"] + proposal = applied_envelope(material["applied_proposal"]) + apply_sql = rebuild.apply_engine.build_apply_sql(proposal, material["applied_proposal"]["applied_by_handle"]) + material["replay_receipt"] = rebuild.replay_receipt.build_replay_receipt( + proposal, + prior_receipt["canonical_rows"], + apply_sql_sha256=rebuild.replay_receipt.sha256_text(apply_sql), + apply_sql_source=prior_receipt["apply_engine"]["source"], + exported_at_utc=prior_receipt["exported_at_utc"], + ) + + def write_bundle(tmp_path: Path, *, material: dict | None = None) -> argparse.Namespace: dump = tmp_path / "genesis.dump" dump.write_bytes(b"PGDMPfixture") @@ -451,13 +462,94 @@ def test_revise_strategy_material_rejects_reconstructed_apply_engine(tmp_path: P assert exc_info.value.code == "mutating_apply_engine_mismatch" +def test_revise_strategy_rejects_fully_rehashed_transaction_before_approval( + tmp_path: Path, +) -> None: + material = revise_strategy_replay_material() + original_receipt = material["replay_receipt"] + forged_timestamp = "2026-07-13T01:00:30+00:00" + canonical_rows = copy.deepcopy(original_receipt["canonical_rows"]) + canonical_rows["strategies"][0]["created_at"] = forged_timestamp + for row in canonical_rows["strategy_nodes"]: + row["created_at"] = forged_timestamp + row["updated_at"] = forged_timestamp + material["replay_receipt"]["canonical_rows"] = canonical_rows + rehash_revise_strategy_receipt(material) + args = write_bundle(tmp_path, material=material) + ledger = json.loads(args.ledger.read_text(encoding="utf-8")) + material_path = args.ledger.parent / ledger["entries"][0]["material"] + + assert material["replay_receipt"]["hashes"] != original_receipt["hashes"] + assert ledger["entries"][0]["sha256"] == sha256_file(material_path) + assert ( + ledger["entries"][0]["replay_material_sha256"] == material["replay_receipt"]["hashes"]["replay_material_sha256"] + ) + assert args.ledger_sha256 == sha256_file(args.ledger) + with pytest.raises(rebuild.ReconstructionError) as exc_info: + rebuild.load_bundle(args) + + assert exc_info.value.code == "invalid_mutating_receipt" + assert "before proposal approval" in exc_info.value.safe_message + + +@pytest.mark.parametrize( + ("reviewed_at", "transaction_timestamp", "applied_at"), + ( + ( + "2026-07-14T03:00:00+02:00", + "2026-07-14T01:00:00+00:00", + "2026-07-14T01:01:00+00:00", + ), + ( + "2026-07-14T01:00:00+00:00", + "2026-07-14T01:01:00+00:00", + "2026-07-14T03:01:00+02:00", + ), + ), +) +def test_revise_strategy_accepts_timezone_aware_closed_timestamp_boundaries( + tmp_path: Path, + reviewed_at: str, + transaction_timestamp: str, + applied_at: str, +) -> None: + material = revise_strategy_replay_material() + for proposal_row in (material["approved_proposal"], material["applied_proposal"]): + proposal_row["reviewed_at"] = reviewed_at + material["approval_snapshot"]["reviewed_at"] = reviewed_at + material["applied_proposal"]["applied_at"] = applied_at + rows = material["replay_receipt"]["canonical_rows"] + rows["strategies"][0]["created_at"] = transaction_timestamp + for row in rows["strategy_nodes"]: + row["created_at"] = transaction_timestamp + row["updated_at"] = transaction_timestamp + rehash_revise_strategy_receipt(material) + + entry = rebuild.load_bundle(write_bundle(tmp_path, material=material)).entries[0] + + assert entry.receipt["canonical_rows"]["strategies"][0]["created_at"] == transaction_timestamp + + +def test_revise_strategy_rejects_reviewed_at_without_timezone(tmp_path: Path) -> None: + material = revise_strategy_replay_material() + reviewed_at = "2026-07-14T01:00:00" + for proposal_row in (material["approved_proposal"], material["applied_proposal"]): + proposal_row["reviewed_at"] = reviewed_at + material["approval_snapshot"]["reviewed_at"] = reviewed_at + rehash_revise_strategy_receipt(material) + + with pytest.raises(rebuild.ReconstructionError) as exc_info: + rebuild.load_bundle(write_bundle(tmp_path, material=material)) + + assert exc_info.value.code == "invalid_mutating_receipt" + assert "reviewed_at must include a timezone" in exc_info.value.safe_message + + @pytest.mark.parametrize( "invalid_case", ("extra_strategy_field", "duplicate_node_id", "node_timestamp_drift", "transaction_after_apply"), ) -def test_revise_strategy_receipt_rejects_impossible_poststate( - tmp_path: Path, invalid_case: str -) -> None: +def test_revise_strategy_receipt_rejects_impossible_poststate(tmp_path: Path, invalid_case: str) -> None: material = revise_strategy_replay_material() rows = material["replay_receipt"]["canonical_rows"] if invalid_case == "extra_strategy_field": @@ -480,9 +572,7 @@ def test_revise_strategy_receipt_rejects_impossible_poststate( def test_revise_strategy_transition_builders_preserve_historical_prestate(tmp_path: Path) -> None: - entry = rebuild.load_bundle( - write_bundle(tmp_path, material=revise_strategy_replay_material()) - ).entries[0] + entry = rebuild.load_bundle(write_bundle(tmp_path, material=revise_strategy_replay_material())).entries[0] prestate = { "strategy_ids": [PRIOR_STRATEGY_ID], "active_strategy_ids": [PRIOR_STRATEGY_ID], @@ -508,9 +598,7 @@ def test_revise_strategy_transition_builders_preserve_historical_prestate(tmp_pa def test_revise_strategy_transition_builders_allow_empty_prestate(tmp_path: Path) -> None: - entry = rebuild.load_bundle( - write_bundle(tmp_path, material=revise_strategy_replay_material(version=1)) - ).entries[0] + entry = rebuild.load_bundle(write_bundle(tmp_path, material=revise_strategy_replay_material(version=1))).entries[0] prestate = { "strategy_ids": [], "active_strategy_ids": [], @@ -1085,9 +1173,9 @@ def run_genesis_ledger_command( "sequence": 1, "material": material_path.name, "sha256": sha256_file(material_path), - "replay_material_sha256": json.loads(material_path.read_text(encoding="utf-8"))[ - "replay_receipt" - ]["hashes"]["replay_material_sha256"], + "replay_material_sha256": json.loads(material_path.read_text(encoding="utf-8"))["replay_receipt"][ + "hashes" + ]["replay_material_sha256"], } ], "final_parity": { @@ -1326,9 +1414,8 @@ def test_live_revise_strategy_rebuild_is_exact_repeatable_and_leaves_no_containe first = runs[0][1] second = runs[1][1] assert first["inputs"] == second["inputs"] - assert first["ledger"]["entries"][0]["material_sha256"] == second["ledger"]["entries"][0][ - "material_sha256" - ] - assert first["ledger"]["entries"][0]["replay_material_sha256"] == second["ledger"]["entries"][0][ - "replay_material_sha256" - ] + assert first["ledger"]["entries"][0]["material_sha256"] == second["ledger"]["entries"][0]["material_sha256"] + assert ( + first["ledger"]["entries"][0]["replay_material_sha256"] + == second["ledger"]["entries"][0]["replay_material_sha256"] + ) From 3afd1dbb5ea5cbfd04f92d4cdcb8e8cfa1ee6718 Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Wed, 15 Jul 2026 03:17:38 +0200 Subject: [PATCH 5/8] feat: reproduce Leo identity across disposable restarts --- docs/leo-reproducible-identity.md | 206 ++++ .../hermes-runtime/agent/prompt_builder.py | 3 + .../hermes-runtime/gateway/run.py | 3 + .../hermes-runtime/hermes_cli/tools_config.py | 3 + .../hermes-runtime/run_agent.py | 3 + .../hermes-runtime/tools/registry.py | 3 + .../leo-identity-v1/leo-constitution-v1.json | 18 + .../leo-database-fingerprint-v1.json | 25 + .../leo-database-identity-v1.json | 58 ++ .../profile-template/bin/identity_readback.py | 5 + .../profile-template/config.yaml | 24 + .../plugins/identity-boundary/__init__.py | 4 + .../plugins/identity-boundary/plugin.yaml | 3 + .../skills/identity-readback/SKILL.md | 8 + scripts/leo_behavior_manifest.py | 309 +++++- scripts/leo_identity_manifest.py | 878 ++++++++++++++++++ scripts/leo_identity_profile.py | 495 ++++++++++ .../run_leo_identity_reconstruction_canary.py | 439 +++++++++ tests/test_leo_behavior_manifest.py | 68 ++ tests/test_leo_identity_manifest.py | 721 ++++++++++++++ 20 files changed, 3259 insertions(+), 17 deletions(-) create mode 100644 docs/leo-reproducible-identity.md create mode 100644 fixtures/working-leo/leo-identity-v1/hermes-runtime/agent/prompt_builder.py create mode 100644 fixtures/working-leo/leo-identity-v1/hermes-runtime/gateway/run.py create mode 100644 fixtures/working-leo/leo-identity-v1/hermes-runtime/hermes_cli/tools_config.py create mode 100644 fixtures/working-leo/leo-identity-v1/hermes-runtime/run_agent.py create mode 100644 fixtures/working-leo/leo-identity-v1/hermes-runtime/tools/registry.py create mode 100644 fixtures/working-leo/leo-identity-v1/leo-constitution-v1.json create mode 100644 fixtures/working-leo/leo-identity-v1/leo-database-fingerprint-v1.json create mode 100644 fixtures/working-leo/leo-identity-v1/leo-database-identity-v1.json create mode 100644 fixtures/working-leo/leo-identity-v1/profile-template/bin/identity_readback.py create mode 100644 fixtures/working-leo/leo-identity-v1/profile-template/config.yaml create mode 100644 fixtures/working-leo/leo-identity-v1/profile-template/plugins/identity-boundary/__init__.py create mode 100644 fixtures/working-leo/leo-identity-v1/profile-template/plugins/identity-boundary/plugin.yaml create mode 100644 fixtures/working-leo/leo-identity-v1/profile-template/skills/identity-readback/SKILL.md create mode 100644 scripts/leo_identity_manifest.py create mode 100644 scripts/leo_identity_profile.py create mode 100644 scripts/run_leo_identity_reconstruction_canary.py create mode 100644 tests/test_leo_identity_manifest.py diff --git a/docs/leo-reproducible-identity.md b/docs/leo-reproducible-identity.md new file mode 100644 index 0000000..ff49a67 --- /dev/null +++ b/docs/leo-reproducible-identity.md @@ -0,0 +1,206 @@ +# Reproducible Leo identity + +## Definition of Working + +- **Working target:** generate one pinned Leo identity manifest, compile a + disposable profile, answer the fixed identity query in a fresh process, + restart in a second process, and reject any drift before an answer. +- **Operator path:** run + `python -m scripts.run_leo_identity_reconstruction_canary` with the committed + identity fixtures from a clean checkout. +- **Done means:** the T2 receipt reports two distinct stopped processes with the + same manifest, identity inputs, query, answer hash, and output provenance; + the second process starts from a newly compiled empty profile; the drift + attempt exits before answering; every process group and temporary profile is + removed. +- **Not done:** a hand-edited `SOUL.md`, a manifest self-hash, unit tests without + process restart, or prior VPS restart evidence that did not reconstruct from + this manifest. +- **Required tier:** `T2_runtime`. T3 VPS restart/readback is a post-merge + graduation target. + +## Identity input inventory + +`GatewayRunner` and the surrounding Hermes profile can change an answer through +the following surfaces. The manifest either pins each surface or explicitly +keeps it outside identity authority. + +| Surface | Binding | Authority | +| --- | --- | --- | +| Model provider, route, limits, and reasoning settings | secret-free semantic config hash | runtime input; the actual model remains a per-turn receipt | +| Hosted model weights | provider-managed, explicitly not locally hashable | never claimed as a local hash | +| Hermes and Teleo code | clean Git commits plus executable source-tree hashes | runtime input | +| Python ABI and imported runtime packages | exact implementation/version and curated package versions | runtime input | +| Skills, plugins, and database tools | content hashes | runtime input | +| Gateway/tool permissions | strict allowlisted config hash plus exact no-send/read-only policy | runtime input; this local compiler does not claim an authorizer readback | +| Static constitutional rules | committed source path, byte hash, and semantic hash | static authority | +| Database snapshot version | database/user/system identity plus content, row, structure, and capture-provenance hashes; only WAL position is excluded | T2 pins a noncanonical fixture; it cannot grant canonical authority | +| Database-derived identity rows | typed table/row/kind/rank/evidence bindings plus source mode | synthetic fixtures are explicitly noncanonical; T3 requires independently verified database-exporter output | +| `SOUL.md` and `identity.json` | deterministic compiled-view hashes | generated views, never authorities | +| Sessions, state, and memory | excluded from the identity input hash and labeled temporary | continuity only; never evidence | + +The disposable profile is compiled from a strict non-secret configuration +allowlist; unrecognized transport/credential fields are not copied. Credential +values are omitted rather than hashed. Rotating a bot token changes the broad +operational behavior observation but does not change Leo's identity. +Changing a non-secret permission, model setting, skill, code file, database +fingerprint, constitutional rule, database identity row, or compiled view does +change a pinned input and fails closed. + +### Current `GatewayRunner` consumption map + +The T2 compiler contract was checked against the current live runtime source. +This inventory defines what the post-merge T3 receipt must observe rather than +silently assuming that a profile file is the whole prompt: + +- Startup resolves `-p leoclean` to `HERMES_HOME`, then loads profile and project + environment files before `config.yaml`; environment expansion, legacy + `gateway.json`, and defaults can change the effective configuration. The T2 + compiler therefore enforces an exact top-level profile allowlist (including + rejection of dangling symlinks) rather than relying on a filename denylist. +- Routing consumes the requested default model, the top-level + `smart_model_routing` switch, provider endpoints/defaults, auth pool choice, + reasoning settings, token/turn limits, and fallbacks. The pinned top-level + routing switch must be a boolean; arbitrary nested routing data is rejected. + Credentials are runtime capability, not identity, and their values are never + retained. +- Prompt construction consumes generated `SOUL.md`, the gateway/agent system + message, persistent `MEMORY.md`/`USER.md`, compiled skills, project-context + files, current time/timezone, and the effective model/provider. T2 requires + memory and project context absent; T3 records the effective system-prompt and + compiled-skills-prompt hashes. +- Plugin discovery can include profile plugins, optional project plugins, and + installed entry points. The live database-context hook can inject a + query-bound contract before the model and can reject or replace the reply + afterward, so T3 retains plugin registry, contract, retrieval, and delivered + response hashes. +- Effective tools come from platform toolsets and the runtime registry, not a + descriptive fixture field. T3 must read back exact tool schemas, prove the + send tool absent, and keep terminal restricted to the clone-bound read-only + wrapper. +- Authorization consumes chat/user allowlists and pairing-store state. T2 starts + with pairing absent; T3 records the fixed synthetic source and the actual + authorization decision without exposing IDs or tokens. +- Session keys consume platform/chat/user/thread/topic fields. Auto-skill, + reply/media/file context, persisted transcripts, and a reused system prompt + can all change a turn. Verification therefore happens before every child + starts, and T3 binds session key, persisted session ID, message-context + absence, and prompt hashes. + +The committed T2 database/identity inputs are synthetic fixtures and the +compiled view labels them `synthetic_noncanonical_fixture`; they do not stand +in for approved production rows. This T2 schema never grants canonical +authority from caller-supplied or merely self-hashed JSON. T3 must use output +that is independently verified by the database-owning same-transaction exporter +and bound to the database fingerprint, database user, system identifier, and +row digest. + +Every pinned source tree and profile component is structurally revalidated: +the verifier recomputes its file count, total bytes, sorted unique paths, and +canonical content hash, and rejects missing files, unreadable entries, or +symlinks. Rehashing forged structural metadata cannot make it authoritative. + +The current live Hermes checkout is not clean, so its Git SHA alone is not a +reproducible source bundle. This T2 receipt intentionally uses the committed +clean local runtime and the committed database/identity snapshot fixture. It +does not claim to reconstruct the current dirty VPS runtime. T3 must run only +after the merged identity code is deployed and must bind the deployed clean +content (or a content-addressed dirty-source bundle if the live checkout has not +yet been repaired). + +## Commands + +The complete canary is the preferred operator command: + +```bash +python -m scripts.run_leo_identity_reconstruction_canary \ + --profile-template fixtures/working-leo/leo-identity-v1/profile-template \ + --hermes-root fixtures/working-leo/leo-identity-v1/hermes-runtime \ + --deployment-root . \ + --source-root . \ + --database-fingerprint fixtures/working-leo/leo-identity-v1/leo-database-fingerprint-v1.json \ + --constitution fixtures/working-leo/leo-identity-v1/leo-constitution-v1.json \ + --database-identity fixtures/working-leo/leo-identity-v1/leo-database-identity-v1.json \ + --output docs/reports/leo-working-state-20260709/leo-reproducible-identity-t2-current.json +``` + +The negative lifecycle is separately runnable. It proves drift is caught before +the second process starts: + +```bash +python -m scripts.run_leo_identity_reconstruction_canary \ + --profile-template fixtures/working-leo/leo-identity-v1/profile-template \ + --hermes-root fixtures/working-leo/leo-identity-v1/hermes-runtime \ + --deployment-root . \ + --source-root . \ + --database-fingerprint fixtures/working-leo/leo-identity-v1/leo-database-fingerprint-v1.json \ + --constitution fixtures/working-leo/leo-identity-v1/leo-constitution-v1.json \ + --database-identity fixtures/working-leo/leo-identity-v1/leo-database-identity-v1.json \ + --inject-drift-before-restart \ + --output /tmp/leo-identity-drift-rejection.json +``` + +For inspection, the lifecycle can be decomposed into explicit manifest and +compile commands: + +```bash +python -m scripts.leo_behavior_manifest \ + --profile fixtures/working-leo/leo-identity-v1/profile-template \ + --hermes-root fixtures/working-leo/leo-identity-v1/hermes-runtime \ + --deployment-root . \ + --output /tmp/leo-behavior.json + +python -m scripts.leo_identity_manifest generate \ + --behavior-manifest /tmp/leo-behavior.json \ + --database-fingerprint fixtures/working-leo/leo-identity-v1/leo-database-fingerprint-v1.json \ + --constitution fixtures/working-leo/leo-identity-v1/leo-constitution-v1.json \ + --database-identity fixtures/working-leo/leo-identity-v1/leo-database-identity-v1.json \ + --source-root . \ + --output /tmp/leo-identity-manifest.json + +python -m scripts.leo_identity_profile compile \ + --manifest /tmp/leo-identity-manifest.json \ + --source-root . \ + --profile-template fixtures/working-leo/leo-identity-v1/profile-template \ + --profile /tmp/leo-disposable-profile \ + --hermes-root fixtures/working-leo/leo-identity-v1/hermes-runtime \ + --deployment-root . +``` + +All commands require a clean Git worktree because a commit plus an unretained +dirty source tree is not reproducible. + +## State inventory and transitions + +| State | Meaning | Next valid transition | +| --- | --- | --- | +| `inputs_unverified` | source paths exist but are not yet bound | validate clean Git, runtime, DB, rules, rows, and permissions | +| `manifest_pinned` | every material identity input has a stable hash | compile deterministic views | +| `profile_compiled` | static profile copied and generated views match the manifest | verify immediately before start | +| `runtime_verified` | runtime/code/view hashes match and session state is non-authoritative | answer fixed query | +| `stopped_cleanly` | child and process group are absent | compile a new empty profile from the manifest | +| `restart_reproduced` | child from the newly compiled profile has the same identity/query/provenance inputs | retain receipt and clean every profile | +| `drift_detected` | any input or generated view differs | fail closed; no answer and no next start | + +The irreversible boundary is not a database or production mutation: this T2 +canary is a local compiler/process runtime, no-send, database-read-only, and +disposable. The successful-restart receipt reaches `T2_runtime`; the standalone +pre-restart drift receipt is a passing negative component but reports +`T1_model` and `goal_tier_satisfied=false`. Neither proves the live VPS +`GatewayRunner`, Telegram delivery, hosted-model behavior, production database +state, or T3 restart recovery. + +Here `T2_runtime` uses the required Capability Tier Proof vocabulary: local +runtime behavior with restart. It does not imply Hermes/GatewayRunner or a +hosted model; those exclusions are explicit in the receipt's `runtime_variant`, +`tier_basis`, and `claim_ceiling`. + +## T3 graduation + +After merge and rollback proof, extend the schema with independent verification +of the database-owning same-transaction exporter, generate the manifest from +that live read-only canonical capture and deployed clean commits, compile a +disposable VPS profile, invoke the real no-send `GatewayRunner`, terminate that +child, open a new child from the same manifest, and retain model-call, DB-read, +service, cleanup, and drift-negative readbacks. Do not replace the production +profile during that graduation. diff --git a/fixtures/working-leo/leo-identity-v1/hermes-runtime/agent/prompt_builder.py b/fixtures/working-leo/leo-identity-v1/hermes-runtime/agent/prompt_builder.py new file mode 100644 index 0000000..b8aeeed --- /dev/null +++ b/fixtures/working-leo/leo-identity-v1/hermes-runtime/agent/prompt_builder.py @@ -0,0 +1,3 @@ +"""Pinned fixture prompt boundary.""" + +GENERATED_SOUL_IS_AUTHORITY = False diff --git a/fixtures/working-leo/leo-identity-v1/hermes-runtime/gateway/run.py b/fixtures/working-leo/leo-identity-v1/hermes-runtime/gateway/run.py new file mode 100644 index 0000000..ef70232 --- /dev/null +++ b/fixtures/working-leo/leo-identity-v1/hermes-runtime/gateway/run.py @@ -0,0 +1,3 @@ +"""Pinned fixture process boundary; the canary uses the real profile verifier.""" + +SESSION_STATE_IS_IDENTITY = False diff --git a/fixtures/working-leo/leo-identity-v1/hermes-runtime/hermes_cli/tools_config.py b/fixtures/working-leo/leo-identity-v1/hermes-runtime/hermes_cli/tools_config.py new file mode 100644 index 0000000..63d3877 --- /dev/null +++ b/fixtures/working-leo/leo-identity-v1/hermes-runtime/hermes_cli/tools_config.py @@ -0,0 +1,3 @@ +"""Pinned fixture tool configuration.""" + +ALLOWED_TOOLS = ("identity_readback",) diff --git a/fixtures/working-leo/leo-identity-v1/hermes-runtime/run_agent.py b/fixtures/working-leo/leo-identity-v1/hermes-runtime/run_agent.py new file mode 100644 index 0000000..774693b --- /dev/null +++ b/fixtures/working-leo/leo-identity-v1/hermes-runtime/run_agent.py @@ -0,0 +1,3 @@ +"""Pinned fixture entrypoint for the disposable identity runtime.""" + +RUNTIME_NAME = "leo-identity-readback-v1" diff --git a/fixtures/working-leo/leo-identity-v1/hermes-runtime/tools/registry.py b/fixtures/working-leo/leo-identity-v1/hermes-runtime/tools/registry.py new file mode 100644 index 0000000..e2ff210 --- /dev/null +++ b/fixtures/working-leo/leo-identity-v1/hermes-runtime/tools/registry.py @@ -0,0 +1,3 @@ +"""Pinned fixture registry for the no-send identity readback tool.""" + +REGISTERED_TOOLS = ("identity_readback",) diff --git a/fixtures/working-leo/leo-identity-v1/leo-constitution-v1.json b/fixtures/working-leo/leo-identity-v1/leo-constitution-v1.json new file mode 100644 index 0000000..f116f1e --- /dev/null +++ b/fixtures/working-leo/leo-identity-v1/leo-constitution-v1.json @@ -0,0 +1,18 @@ +{ + "identity_name": "Leo", + "rules": [ + { + "id": "canonical-evidence", + "text": "Treat canonical database rows and their bound evidence as durable knowledge; never promote session memory to evidence." + }, + { + "id": "review-before-apply", + "text": "Proposals may be prepared, but review and guarded apply remain separate authorized actions." + }, + { + "id": "truthful-proof-tier", + "text": "State exactly what was observed, separate inference from proof, and fail closed when required provenance drifts." + } + ], + "schema": "livingip.leoConstitutionSource.v1" +} diff --git a/fixtures/working-leo/leo-identity-v1/leo-database-fingerprint-v1.json b/fixtures/working-leo/leo-identity-v1/leo-database-fingerprint-v1.json new file mode 100644 index 0000000..7fc20f1 --- /dev/null +++ b/fixtures/working-leo/leo-identity-v1/leo-database-fingerprint-v1.json @@ -0,0 +1,25 @@ +{ + "environment": "disposable_fixture", + "fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111", + "read_consistency": { + "after": { + "database": "leo_identity_fixture", + "database_user": "leo_identity_reader", + "system_identifier": "7649789040005668902", + "wal_lsn": "0/16B6C50" + }, + "before": { + "database": "leo_identity_fixture", + "database_user": "leo_identity_reader", + "system_identifier": "7649789040005668902", + "wal_lsn": "0/16B6C50" + }, + "status": "stable_wal_marker" + }, + "schema": "livingip.teleoCanonicalDatabaseFingerprint.v1", + "status": "ok", + "structure_sha256": "3333333333333333333333333333333333333333333333333333333333333333", + "table_count": 7, + "table_rows_sha256": "2222222222222222222222222222222222222222222222222222222222222222", + "total_rows": 7 +} diff --git a/fixtures/working-leo/leo-identity-v1/leo-database-identity-v1.json b/fixtures/working-leo/leo-identity-v1/leo-database-identity-v1.json new file mode 100644 index 0000000..b5659e2 --- /dev/null +++ b/fixtures/working-leo/leo-identity-v1/leo-database-identity-v1.json @@ -0,0 +1,58 @@ +{ + "database_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111", + "identity_name": "Leo", + "source_provenance": { + "canonical_authority_granted": false, + "export_receipt_sha256": null, + "mode": "synthetic_noncanonical_fixture", + "same_transaction_as_fingerprint": false + }, + "records": [ + { + "evidence_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "kind": "persona", + "rank": 0, + "row_id": "11111111-1111-4111-8111-111111111111", + "status": "active", + "table": "public.personas", + "text": "Leo is the evidence-bound reasoning interface for the Teleo collective." + }, + { + "evidence_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kind": "strategy", + "rank": 0, + "row_id": "22222222-2222-4222-8222-222222222222", + "status": "active", + "table": "public.strategies", + "text": "Prefer source-grounded synthesis that exposes uncertainty and review boundaries." + }, + { + "evidence_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "kind": "belief", + "rank": 0, + "row_id": "33333333-3333-4333-8333-333333333333", + "status": "active", + "table": "public.beliefs", + "text": "A plausible answer without provenance is weaker than a bounded answer with an exact receipt." + }, + { + "evidence_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "kind": "shared_root", + "rank": 0, + "row_id": "44444444-4444-4444-8444-444444444444", + "status": "active", + "table": "public.shared_root", + "text": "Collective intelligence must remain inspectable, reversible, and accountable to evidence." + }, + { + "evidence_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "kind": "strategy_node", + "rank": 1, + "row_id": "55555555-5555-4555-8555-555555555555", + "status": "active", + "table": "public.strategy_nodes", + "text": "Compile identity from exact canonical inputs and reject drift before runtime startup." + } + ], + "schema": "livingip.leoDatabaseIdentitySource.v1" +} diff --git a/fixtures/working-leo/leo-identity-v1/profile-template/bin/identity_readback.py b/fixtures/working-leo/leo-identity-v1/profile-template/bin/identity_readback.py new file mode 100644 index 0000000..4ab40f9 --- /dev/null +++ b/fixtures/working-leo/leo-identity-v1/profile-template/bin/identity_readback.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +"""Fixture marker for the no-send identity readback tool.""" + +ACCESS_MODE = "read_only" +NETWORK_SEND_ENABLED = False diff --git a/fixtures/working-leo/leo-identity-v1/profile-template/config.yaml b/fixtures/working-leo/leo-identity-v1/profile-template/config.yaml new file mode 100644 index 0000000..8086613 --- /dev/null +++ b/fixtures/working-leo/leo-identity-v1/profile-template/config.yaml @@ -0,0 +1,24 @@ +model: + provider: fixture-local + default: leo-identity-readback-v1 + smart_routing: false + max_tokens: 512 +agent: + reasoning_effort: deterministic +memory: + enabled: false + search: disabled +gateway: + execution_mode: local_no_send + telegram: + posting_enabled: false + bot_token: fixture-value-is-never-emitted-or-identity-hashed +tools: + allowed: + - identity_readback + write_enabled: false +identity_runtime: + network_send_enabled: false + database_write_enabled: false + allowed_tools: + - identity_readback diff --git a/fixtures/working-leo/leo-identity-v1/profile-template/plugins/identity-boundary/__init__.py b/fixtures/working-leo/leo-identity-v1/profile-template/plugins/identity-boundary/__init__.py new file mode 100644 index 0000000..19f0f68 --- /dev/null +++ b/fixtures/working-leo/leo-identity-v1/profile-template/plugins/identity-boundary/__init__.py @@ -0,0 +1,4 @@ +"""Fixture marker for the pinned identity-boundary middleware.""" + +IDENTITY_VIEW_IS_AUTHORITY = False +SESSION_MEMORY_IS_EVIDENCE = False diff --git a/fixtures/working-leo/leo-identity-v1/profile-template/plugins/identity-boundary/plugin.yaml b/fixtures/working-leo/leo-identity-v1/profile-template/plugins/identity-boundary/plugin.yaml new file mode 100644 index 0000000..07cb632 --- /dev/null +++ b/fixtures/working-leo/leo-identity-v1/profile-template/plugins/identity-boundary/plugin.yaml @@ -0,0 +1,3 @@ +name: identity-boundary +version: 1 +description: Reject startup when a compiled identity view drifts. diff --git a/fixtures/working-leo/leo-identity-v1/profile-template/skills/identity-readback/SKILL.md b/fixtures/working-leo/leo-identity-v1/profile-template/skills/identity-readback/SKILL.md new file mode 100644 index 0000000..58c3a4b --- /dev/null +++ b/fixtures/working-leo/leo-identity-v1/profile-template/skills/identity-readback/SKILL.md @@ -0,0 +1,8 @@ +--- +name: identity-readback +description: Explain the pinned Leo identity and its noncanonical session boundary. +--- + +Read only the compiled identity view and its manifest receipt. Never treat a +session transcript, generated SOUL file, or unverified runtime memory as a +canonical source. diff --git a/scripts/leo_behavior_manifest.py b/scripts/leo_behavior_manifest.py index d8f6cad..39755a6 100644 --- a/scripts/leo_behavior_manifest.py +++ b/scripts/leo_behavior_manifest.py @@ -11,9 +11,13 @@ from __future__ import annotations import argparse import hashlib +import importlib.metadata import json import os +import platform +import re import subprocess +import sys from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -24,6 +28,54 @@ SCHEMA = "livingip.leoBehaviorManifest.v1" DEFAULT_PROFILE = Path("/home/teleo/.hermes/profiles/leoclean") DEFAULT_HERMES_ROOT = Path("/home/teleo/.hermes/hermes-agent") DEFAULT_DEPLOYMENT_ROOT = Path("/opt/teleo-eval/workspaces/deploy-infra") +STATIC_RUNTIME_COMPONENTS = ( + "profile_config", + "procedural_skills", + "runtime_middleware", + "database_tools", +) +IDENTITY_RUNTIME_COMPONENTS = ( + "procedural_skills", + "runtime_middleware", + "database_tools", +) +STABLE_MANIFEST_FIELDS = ( + "schema", + "model_runtime", + "hermes_runtime", + "teleo_infrastructure_runtime", + "components", + "python_runtime", + "canonical_database", +) +SECRET_KEYS = frozenset( + { + "access_key", + "access_token", + "api_key", + "authorization", + "bot_token", + "client_secret", + "credential", + "credentials", + "password", + "private_key", + "refresh_token", + "secret", + "token", + } +) +SECRET_KEY_SUFFIXES = ( + "api_key", + "access_key", + "private_key", + "password", + "secret", + "token", + "credential", +) +RUNTIME_DISTRIBUTIONS = ("PyYAML",) +SAFE_CONFIG_SCALAR = re.compile(r"^[A-Za-z0-9._:/-]+$") def sha256_bytes(value: bytes) -> str: @@ -50,7 +102,9 @@ def _safe_relative(path: Path, root: Path) -> str: return str(path) -def _resolved_node_fingerprint(path: Path, seen_directories: frozenset[tuple[int, int]] = frozenset()) -> dict[str, Any]: +def _resolved_node_fingerprint( + path: Path, seen_directories: frozenset[tuple[int, int]] = frozenset() +) -> dict[str, Any]: """Hash a symlink target, following nested links while stopping directory cycles.""" try: @@ -141,21 +195,139 @@ def _nested(config: dict[str, Any], *path: str) -> Any: return value +def secret_free_config(value: Any) -> Any: + """Return a semantic config projection with secret-bearing fields omitted.""" + + if isinstance(value, dict): + return { + str(key): secret_free_config(item) + for key, item in sorted(value.items(), key=lambda pair: str(pair[0])) + if not _is_secret_key(str(key)) + } + if isinstance(value, list): + return [secret_free_config(item) for item in value] + return value + + +def _validate_identity_config_value(value: Any, *, path: str) -> None: + if value is None or isinstance(value, (bool, int)): + return + if isinstance(value, str): + if not SAFE_CONFIG_SCALAR.fullmatch(value) or "://" in value or "@" in value: + raise ValueError(f"unsafe identity config scalar: {path}") + return + if isinstance(value, list): + for index, item in enumerate(value): + _validate_identity_config_value(item, path=f"{path}[{index}]") + return + if isinstance(value, dict): + for key, item in value.items(): + if _is_secret_key(str(key)): + raise ValueError(f"secret-bearing identity config key: {path}.{key}") + _validate_identity_config_value(item, path=f"{path}.{key}") + return + raise ValueError(f"unsupported identity config value: {path}") + + +def identity_config_projection(raw: dict[str, Any]) -> dict[str, Any]: + """Build the only configuration surface copied into a disposable identity profile.""" + + section_fields = { + "model": ("provider", "default", "smart_routing", "smart_routing_model", "max_tokens"), + "agent": ("reasoning_effort",), + "memory": ("enabled", "search"), + "tools": ("allowed", "write_enabled"), + "identity_runtime": ("network_send_enabled", "database_write_enabled", "allowed_tools"), + } + projection: dict[str, Any] = {} + for section, fields in section_fields.items(): + source = raw.get(section) + if source is None: + continue + if not isinstance(source, dict): + raise ValueError(f"identity config section must be a mapping: {section}") + selected = {field: source[field] for field in fields if field in source} + _validate_identity_config_value(selected, path=section) + projection[section] = selected + + gateway = raw.get("gateway") + if gateway is not None: + if not isinstance(gateway, dict): + raise ValueError("identity config section must be a mapping: gateway") + selected_gateway = {key: gateway[key] for key in ("execution_mode",) if key in gateway} + telegram = gateway.get("telegram") + if telegram is not None: + if not isinstance(telegram, dict): + raise ValueError("identity config section must be a mapping: gateway.telegram") + selected_telegram = {key: telegram[key] for key in ("posting_enabled",) if key in telegram} + if selected_telegram: + selected_gateway["telegram"] = selected_telegram + _validate_identity_config_value(selected_gateway, path="gateway") + projection["gateway"] = selected_gateway + + if "smart_model_routing" in raw: + routing = raw["smart_model_routing"] + if not isinstance(routing, bool): + raise ValueError("smart_model_routing must be boolean") + projection["smart_model_routing"] = routing + return projection + + +def _is_secret_key(key: str) -> bool: + snake_case = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", key) + normalized = re.sub(r"[^a-z0-9]+", "_", snake_case.casefold()).strip("_") + return normalized in SECRET_KEYS or any(normalized.endswith(f"_{suffix}") for suffix in SECRET_KEY_SUFFIXES) + + +def python_runtime_manifest() -> dict[str, Any]: + distributions: dict[str, str | None] = {} + for name in RUNTIME_DISTRIBUTIONS: + try: + distributions[name] = importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + distributions[name] = None + return { + "implementation": platform.python_implementation(), + "python_version": platform.python_version(), + "abi_tag": sys.implementation.cache_tag, + "runtime_distributions": distributions, + "runtime_distributions_sha256": canonical_sha256(distributions), + } + + def safe_model_config(config_path: Path) -> dict[str, Any]: try: raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} except (OSError, TypeError, yaml.YAMLError) as exc: return {"status": "unavailable", "error": type(exc).__name__} + if not isinstance(raw, dict): + return {"status": "unavailable", "error": "ConfigNotMapping"} + semantic = secret_free_config(raw) + try: + identity_config = identity_config_projection(raw) + except ValueError: + return {"status": "unavailable", "error": "UnsafeIdentityConfig"} + model_semantic = identity_config.get("model") or {} return { "status": "observed", - "provider": _nested(raw, "model", "provider"), - "default_model": _nested(raw, "model", "default"), - "smart_routing": _nested(raw, "model", "smart_routing"), - "smart_routing_model": _nested(raw, "model", "smart_routing_model"), - "max_tokens": _nested(raw, "model", "max_tokens"), - "reasoning_effort": _nested(raw, "agent", "reasoning_effort"), - "memory_enabled": _nested(raw, "memory", "enabled"), - "memory_search": _nested(raw, "memory", "search"), + "config_sha256": file_sha256(config_path), + "semantic_config_sha256": canonical_sha256(semantic), + "identity_config_sha256": canonical_sha256(identity_config), + "model_section_sha256": canonical_sha256(model_semantic), + "gateway_permissions_sha256": canonical_sha256(identity_config.get("gateway")), + "tool_permissions_sha256": canonical_sha256(identity_config.get("tools")), + "memory_section_sha256": canonical_sha256(identity_config.get("memory")), + "agent_runtime_sha256": canonical_sha256(identity_config.get("agent")), + "identity_runtime_policy": identity_config.get("identity_runtime"), + "provider": _nested(identity_config, "model", "provider"), + "default_model": _nested(identity_config, "model", "default"), + "smart_routing": _nested(identity_config, "model", "smart_routing"), + "smart_routing_model": _nested(identity_config, "model", "smart_routing_model"), + "gateway_smart_model_routing": identity_config.get("smart_model_routing"), + "max_tokens": _nested(identity_config, "model", "max_tokens"), + "reasoning_effort": _nested(identity_config, "agent", "reasoning_effort"), + "memory_enabled": _nested(identity_config, "memory", "enabled"), + "memory_search": _nested(identity_config, "memory", "search"), } @@ -196,7 +368,13 @@ def git_state(repo: Path) -> dict[str, Any]: } -def source_code_manifest(profile: Path, paths: tuple[Path, ...]) -> dict[str, Any]: +def source_code_manifest( + profile: Path, + paths: tuple[Path, ...], + *, + source_root: Path | None = None, + namespace: str = "source", +) -> dict[str, Any]: """Hash executable source while excluding generated caches and environments.""" allowed_suffixes = { @@ -216,12 +394,21 @@ def source_code_manifest(profile: Path, paths: tuple[Path, ...]) -> dict[str, An excluded_parts = {".git", ".pytest_cache", "__pycache__", "node_modules", "venv", ".venv"} files: list[dict[str, Any]] = [] missing: list[str] = [] + symlinks: list[str] = [] + source_root = (source_root or profile).resolve() + + def source_label(path: Path) -> str: + return f"{namespace}:{_safe_relative(path, source_root)}" + for requested in paths: if not requested.exists() and not requested.is_symlink(): - missing.append(_safe_relative(requested, profile)) + missing.append(source_label(requested)) continue candidates = [requested] if requested.is_file() else sorted(requested.rglob("*")) for path in candidates: + if path.is_symlink(): + symlinks.append(source_label(path)) + continue if not path.is_file() or excluded_parts & set(path.parts): continue stat = path.stat() @@ -229,14 +416,14 @@ def source_code_manifest(profile: Path, paths: tuple[Path, ...]) -> dict[str, An continue files.append( { - "path": _safe_relative(path, profile), + "path": source_label(path), "bytes": stat.st_size, "mode": oct(stat.st_mode & 0o777), "sha256": file_sha256(path), } ) files.sort(key=lambda item: item["path"]) - stable = {"files": files, "missing": sorted(missing)} + stable = {"files": files, "missing": sorted(missing), "symlinks": sorted(symlinks)} return { **stable, "file_count": len(files), @@ -261,6 +448,72 @@ def component( } +def stable_manifest_payload(manifest: dict[str, Any]) -> dict[str, Any]: + """Return the canonical, path-independent behavior payload.""" + + return {field: manifest.get(field) for field in STABLE_MANIFEST_FIELDS} + + +def static_runtime_payload(manifest: dict[str, Any]) -> dict[str, Any]: + """Return the exact payload committed by ``static_runtime_sha256``.""" + + components = manifest.get("components") or {} + return { + "schema": manifest.get("schema"), + "model_runtime": manifest.get("model_runtime"), + "hermes_runtime": manifest.get("hermes_runtime"), + "teleo_infrastructure_runtime": manifest.get("teleo_infrastructure_runtime"), + "components": {name: components.get(name) for name in STATIC_RUNTIME_COMPONENTS}, + "python_runtime": manifest.get("python_runtime"), + "canonical_database": manifest.get("canonical_database"), + } + + +def identity_runtime_payload(manifest: dict[str, Any]) -> dict[str, Any]: + """Return the exact payload committed by ``identity_runtime_sha256``.""" + + model = manifest.get("model_runtime") or {} + components = manifest.get("components") or {} + teleo = manifest.get("teleo_infrastructure_runtime") or {} + identity_model_runtime = { + key: model.get(key) + for key in ( + "status", + "identity_config_sha256", + "model_section_sha256", + "gateway_permissions_sha256", + "tool_permissions_sha256", + "memory_section_sha256", + "agent_runtime_sha256", + "identity_runtime_policy", + "provider", + "default_model", + "smart_routing", + "smart_routing_model", + "gateway_smart_model_routing", + "max_tokens", + "reasoning_effort", + "memory_enabled", + "memory_search", + "base_model_weights", + "actual_per_turn_model_must_be_recorded_by_the_call_receipt", + ) + } + return { + "schema": manifest.get("schema"), + "model_runtime": identity_model_runtime, + "hermes_runtime": manifest.get("hermes_runtime"), + "teleo_infrastructure_runtime": { + "git_head": teleo.get("git_head"), + "git_state": teleo.get("git_state"), + "source_tree": teleo.get("identity_source_tree"), + }, + "components": {name: components.get(name) for name in IDENTITY_RUNTIME_COMPONENTS}, + "python_runtime": manifest.get("python_runtime"), + "canonical_database": manifest.get("canonical_database"), + } + + def build_manifest( profile: Path = DEFAULT_PROFILE, hermes_root: Path = DEFAULT_HERMES_ROOT, @@ -270,6 +523,12 @@ def build_manifest( hermes_root = hermes_root.resolve() deployment_root = deployment_root.resolve() config = safe_model_config(profile / "config.yaml") + identity_runtime_sources = ( + deployment_root / "scripts" / "leo_behavior_manifest.py", + deployment_root / "scripts" / "leo_identity_manifest.py", + deployment_root / "scripts" / "leo_identity_profile.py", + deployment_root / "scripts" / "run_leo_identity_reconstruction_canary.py", + ) components = { "profile_config": component( profile, @@ -294,10 +553,10 @@ def build_manifest( ), "runtime_middleware": component( profile, - role="Pre-LLM context injection and post-LLM validation or response replacement.", + role="Profile plugins for pre-LLM context injection and post-LLM validation or response replacement.", mutability="deployment_static", replayability="fully_hashable", - paths=(profile / "plugins", hermes_root / "run_agent.py"), + paths=(profile / "plugins",), ), "database_tools": component( profile, @@ -353,14 +612,28 @@ def build_manifest( hermes_root / "hermes_cli", hermes_root / "tools", ), + source_root=hermes_root, + namespace="hermes", ), }, "teleo_infrastructure_runtime": { "git_head": git_head(deployment_root), "git_state": git_state(deployment_root), - "source_tree": source_code_manifest(profile, (deployment_root,)), + "source_tree": source_code_manifest( + profile, + (deployment_root,), + source_root=deployment_root, + namespace="teleo", + ), + "identity_source_tree": source_code_manifest( + profile, + identity_runtime_sources, + source_root=deployment_root, + namespace="teleo-identity", + ), }, "components": components, + "python_runtime": python_runtime_manifest(), "canonical_database": { "included": False, "reason": "Fingerprint through a read-only Postgres manifest in the database-owning harness.", @@ -372,7 +645,9 @@ def build_manifest( "profile_root": str(profile), "hermes_root": str(hermes_root), "deployment_root": str(deployment_root), - "behavior_sha256": canonical_sha256(stable), + "static_runtime_sha256": canonical_sha256(static_runtime_payload(stable)), + "identity_runtime_sha256": canonical_sha256(identity_runtime_payload(stable)), + "behavior_sha256": canonical_sha256(stable_manifest_payload(stable)), } diff --git a/scripts/leo_identity_manifest.py b/scripts/leo_identity_manifest.py new file mode 100644 index 0000000..d9d2943 --- /dev/null +++ b/scripts/leo_identity_manifest.py @@ -0,0 +1,878 @@ +#!/usr/bin/env python3 +"""Build and verify Leo's pinned, reproducible identity manifest.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + +if __package__ in {None, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from scripts import leo_behavior_manifest as behavior + +SCHEMA = "livingip.leoIdentityManifest.v1" +CONSTITUTION_SCHEMA = "livingip.leoConstitutionSource.v1" +DATABASE_IDENTITY_SCHEMA = "livingip.leoDatabaseIdentitySource.v1" +STRUCTURED_VIEW_SCHEMA = "livingip.leoCompiledIdentityView.v1" +DATABASE_FINGERPRINT_SCHEMA = "livingip.teleoCanonicalDatabaseFingerprint.v1" +SYNTHETIC_DATABASE_MODE = "synthetic_noncanonical_fixture" +CANONICAL_DATABASE_MODE = "canonical_database_same_transaction_export" +HEX_64 = re.compile(r"^[0-9a-f]{64}$") +HEX_40 = re.compile(r"^[0-9a-f]{40}$") +IDENTITY_TABLES = frozenset( + { + "public.personas", + "public.strategies", + "public.beliefs", + "public.shared_root", + "public.strategy_nodes", + "public.strategy_node_anchors", + "public.claims", + } +) + + +def expected_runtime_policy() -> dict[str, Any]: + return { + "network_send_enabled": False, + "database_write_enabled": False, + "allowed_tools": ["identity_readback"], + } + + +def expected_session_boundary() -> dict[str, Any]: + return { + "classification": "temporary_noncanonical", + "included_in_identity_inputs": False, + "may_be_used_as_evidence": False, + "restart_policy": "fresh_process_with_session_state_outside_identity_authority", + } + + +def expected_gatewayrunner_contract() -> dict[str, Any]: + return { + "profile_surfaces": ["config.yaml", "generated SOUL.md", "skills", "plugins", "bin tools"], + "required_absent_or_empty": { + "persistent_memory": behavior.canonical_sha256([]), + "pairing_store": behavior.canonical_sha256([]), + "project_context": behavior.canonical_sha256([]), + "generated_skill_prompt_cache": behavior.canonical_sha256([]), + "prior_sessions_at_first_start": behavior.canonical_sha256([]), + }, + "fixed_message_context": { + "auto_skill": None, + "reply_context": None, + "media_or_file_context": None, + }, + "required_t3_turn_receipt_fields": [ + "runner_class", + "authorization_decision", + "effective_system_prompt_sha256", + "compiled_skills_prompt_sha256", + "tool_registry_schema_sha256", + "plugin_registry_sha256", + "selected_model", + "selected_provider", + "api_mode", + "base_url_host", + "database_retrieval_receipt", + "session_key_sha256", + "persisted_session_id_sha256", + ], + } + + +class IdentityManifestError(ValueError): + """An identity input is incomplete, inconsistent, or drifted.""" + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise IdentityManifestError(message) + + +def _is_sha256(value: Any) -> bool: + return isinstance(value, str) and bool(HEX_64.fullmatch(value)) + + +def load_json(path: Path, label: str) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise IdentityManifestError(f"cannot read {label}: {type(exc).__name__}") from exc + if not isinstance(value, dict): + raise IdentityManifestError(f"{label} must be a JSON object") + return value + + +def canonical_rules(value: dict[str, Any]) -> list[dict[str, str]]: + _require(value.get("schema") == CONSTITUTION_SCHEMA, "unsupported constitution schema") + _require(value.get("identity_name") == "Leo", "constitution identity_name must be Leo") + raw_rules = value.get("rules") + _require(isinstance(raw_rules, list) and bool(raw_rules), "constitution rules must be non-empty") + rules: list[dict[str, str]] = [] + for raw in raw_rules: + _require(isinstance(raw, dict), "each constitutional rule must be an object") + rule_id = str(raw.get("id") or "").strip() + text = str(raw.get("text") or "").strip() + _require(bool(rule_id and text), "each constitutional rule needs id and text") + rules.append({"id": rule_id, "text": text}) + _require(len({item["id"] for item in rules}) == len(rules), "constitutional rule ids must be unique") + return sorted(rules, key=lambda item: item["id"]) + + +def canonical_database_records(value: dict[str, Any], *, fingerprint_sha256: str) -> list[dict[str, str | int]]: + _require(value.get("schema") == DATABASE_IDENTITY_SCHEMA, "unsupported database identity schema") + _require(value.get("identity_name") == "Leo", "database identity_name must be Leo") + _require( + value.get("database_fingerprint_sha256") == fingerprint_sha256, + "database identity export is not bound to the supplied database fingerprint", + ) + raw_records = value.get("records") + _require(isinstance(raw_records, list) and bool(raw_records), "database identity records must be non-empty") + records: list[dict[str, str | int]] = [] + for raw in raw_records: + _require(isinstance(raw, dict), "each database identity record must be an object") + rank = raw.get("rank", 0) + record: dict[str, str | int] = { + "table": str(raw.get("table") or "").strip(), + "row_id": str(raw.get("row_id") or "").strip(), + "kind": str(raw.get("kind") or "").strip(), + "rank": rank if isinstance(rank, int) and not isinstance(rank, bool) else -1, + "text": str(raw.get("text") or "").strip(), + "status": str(raw.get("status") or "").strip(), + "evidence_sha256": str(raw.get("evidence_sha256") or "").strip(), + } + _require(record["table"] in IDENTITY_TABLES, "database identity table is outside the allowlist") + _require(bool(record["row_id"] and record["kind"] and record["text"]), "database record fields are incomplete") + _require(isinstance(record["rank"], int) and record["rank"] >= 0, "database record rank is invalid") + _require(record["status"] == "active", "database identity records must be active selected rows") + _require(_is_sha256(record["evidence_sha256"]), "database record evidence_sha256 is invalid") + records.append(record) + keys = {(item["table"], item["row_id"]) for item in records} + _require(len(keys) == len(records), "database identity row bindings must be unique") + return sorted(records, key=lambda item: (str(item["table"]), int(item["rank"]), str(item["row_id"]))) + + +def database_fingerprint_semantic(value: dict[str, Any]) -> dict[str, Any]: + """Bind immutable capture provenance while excluding only the volatile WAL position.""" + + result = {key: value[key] for key in sorted(value) if key != "read_consistency"} + consistency = value["read_consistency"] + result["read_consistency"] = { + "status": consistency["status"], + "before": {key: item for key, item in consistency["before"].items() if key != "wal_lsn"}, + "after": {key: item for key, item in consistency["after"].items() if key != "wal_lsn"}, + } + return result + + +def database_identity_provenance( + value: dict[str, Any], + *, + fingerprint: dict[str, Any], +) -> dict[str, Any]: + raw = value.get("source_provenance") + _require(isinstance(raw, dict), "database identity source provenance is missing") + mode = raw.get("mode") + if mode == SYNTHETIC_DATABASE_MODE: + _require( + raw + == { + "mode": SYNTHETIC_DATABASE_MODE, + "canonical_authority_granted": False, + "same_transaction_as_fingerprint": False, + "export_receipt_sha256": None, + }, + "synthetic database provenance contract is invalid", + ) + _require( + fingerprint.get("environment") == "disposable_fixture", + "synthetic rows require a fixture fingerprint", + ) + _require("export_receipt" not in value, "synthetic database identity must not claim an export receipt") + return {**raw, "authority": "synthetic_noncanonical_fixture"} + + if mode == CANONICAL_DATABASE_MODE: + raise IdentityManifestError( + "canonical authority requires independently verified output from the database-owning same-transaction " + "exporter; caller-supplied T2 JSON cannot grant it" + ) + raise IdentityManifestError("unsupported database identity provenance mode") + + +def validate_database_fingerprint(value: dict[str, Any]) -> None: + _require(value.get("schema") == DATABASE_FINGERPRINT_SCHEMA, "unsupported database fingerprint schema") + _require(value.get("status") == "ok", "database fingerprint status must be ok") + for field in ("fingerprint_sha256", "table_rows_sha256", "structure_sha256"): + _require(_is_sha256(value.get(field)), f"database fingerprint {field} is invalid") + _require(isinstance(value.get("table_count"), int) and value["table_count"] > 0, "table_count must be positive") + _require(isinstance(value.get("total_rows"), int) and value["total_rows"] >= 0, "total_rows is invalid") + consistency = value.get("read_consistency") + _require(isinstance(consistency, dict), "database read_consistency is missing") + before = consistency.get("before") + after = consistency.get("after") + _require(consistency.get("status") == "stable_wal_marker", "database fingerprint was not read consistently") + _require(isinstance(before, dict) and before == after and bool(before), "database read markers differ") + for field in ("database", "database_user", "system_identifier", "wal_lsn"): + _require(bool(before.get(field)), f"database read marker {field} is missing") + + +def validate_content_manifest(value: Any, *, label: str) -> None: + """Recompute the structural metadata of a replayable content manifest.""" + + _require(isinstance(value, dict), f"{label} is invalid") + _require( + set(value) == {"files", "missing", "symlinks", "file_count", "total_bytes", "sha256"}, + f"{label} fields are invalid", + ) + files = value.get("files") + missing = value.get("missing") + symlinks = value.get("symlinks") + _require(isinstance(files, list) and bool(files), f"{label} is empty") + _require(missing == [], f"{label} has missing inputs") + _require(symlinks == [], f"{label} contains unsupported symlinks") + + paths: list[str] = [] + total_bytes = 0 + for item in files: + _require(isinstance(item, dict), f"{label} contains unreadable inputs") + _require(set(item) == {"path", "bytes", "mode", "sha256"}, f"{label} contains unreadable inputs") + path = item.get("path") + size = item.get("bytes") + mode = item.get("mode") + _require(isinstance(path, str) and bool(path), f"{label} contains an invalid path") + _require(isinstance(size, int) and not isinstance(size, bool) and size >= 0, f"{label} byte size is invalid") + _require(isinstance(mode, str) and bool(re.fullmatch(r"0o[0-7]{3}", mode)), f"{label} mode is invalid") + _require(_is_sha256(item.get("sha256")), f"{label} contains unreadable inputs") + paths.append(path) + total_bytes += size + + _require(paths == sorted(paths) and len(paths) == len(set(paths)), f"{label} paths are not unique and sorted") + _require(value.get("file_count") == len(files), f"{label} file count is invalid") + _require(value.get("total_bytes") == total_bytes, f"{label} total bytes is invalid") + stable = {"files": files, "missing": missing, "symlinks": symlinks} + _require(behavior.canonical_sha256(stable) == value.get("sha256"), f"{label} content hash is invalid") + + +def validate_behavior_manifest(value: dict[str, Any]) -> None: + _require(value.get("schema") == behavior.SCHEMA, "unsupported behavior manifest schema") + for field in ("behavior_sha256", "static_runtime_sha256", "identity_runtime_sha256"): + _require(_is_sha256(value.get(field)), f"behavior manifest {field} is invalid") + _require( + behavior.canonical_sha256(behavior.identity_runtime_payload(value)) == value["identity_runtime_sha256"], + "behavior identity runtime hash drift detected", + ) + _require( + behavior.canonical_sha256(behavior.static_runtime_payload(value)) == value["static_runtime_sha256"], + "behavior static runtime hash drift detected", + ) + _require( + behavior.canonical_sha256(behavior.stable_manifest_payload(value)) == value["behavior_sha256"], + "behavior manifest stable payload hash drift detected", + ) + model = value.get("model_runtime") + _require(isinstance(model, dict) and model.get("status") == "observed", "model runtime was not observed") + _require(bool(model.get("provider") and model.get("default_model")), "provider and default model must be pinned") + for field in ( + "semantic_config_sha256", + "identity_config_sha256", + "model_section_sha256", + "gateway_permissions_sha256", + "tool_permissions_sha256", + "memory_section_sha256", + "agent_runtime_sha256", + ): + _require(_is_sha256(model.get(field)), f"model runtime {field} is invalid") + _require( + model.get("base_model_weights") == "external_provider_managed_not_locally_hashable", + "hosted model weight boundary is missing", + ) + _require( + model.get("actual_per_turn_model_must_be_recorded_by_the_call_receipt") is True, "model receipt gate missing" + ) + policy = model.get("identity_runtime_policy") + _require(policy == expected_runtime_policy(), "identity runtime policy must be the exact local readback policy") + for field in ("hermes_runtime", "teleo_infrastructure_runtime"): + runtime = value.get(field) + _require(isinstance(runtime, dict), f"{field} is missing") + _require(bool(HEX_40.fullmatch(str(runtime.get("git_head") or ""))), f"{field} git_head is invalid") + git_state = runtime.get("git_state") + _require( + isinstance(git_state, dict) and git_state.get("worktree_clean") is True, + f"{field} is not reconstructible from clean Git", + ) + tree = runtime.get("identity_source_tree" if field == "teleo_infrastructure_runtime" else "source_tree") + validate_content_manifest(tree, label=f"{field} source tree") + components = value.get("components") + _require(isinstance(components, dict), "behavior components are missing") + for name in behavior.IDENTITY_RUNTIME_COMPONENTS: + content = (components.get(name) or {}).get("content") + validate_content_manifest(content, label=f"component {name}") + python_runtime = value.get("python_runtime") + _require(isinstance(python_runtime, dict), "Python runtime binding is missing") + _require( + bool(python_runtime.get("implementation") and python_runtime.get("python_version")), + "Python runtime is incomplete", + ) + _require(_is_sha256(python_runtime.get("runtime_distributions_sha256")), "runtime package binding is invalid") + _require( + all(python_runtime.get("runtime_distributions", {}).values()), "a required runtime distribution is missing" + ) + + +def _repo_path(path: Path, source_root: Path) -> str: + try: + return path.resolve(strict=True).relative_to(source_root.resolve(strict=True)).as_posix() + except (OSError, ValueError) as exc: + raise IdentityManifestError(f"identity source is outside the pinned source root: {path}") from exc + + +def source_binding( + path: Path, + *, + source_root: Path, + semantic_value: Any, + count: int, + pin_content: bool = True, +) -> dict[str, Any]: + result = { + "repo_path": _repo_path(path, source_root), + "semantic_sha256": behavior.canonical_sha256(semantic_value), + "record_count": count, + } + if pin_content: + result["content_sha256"] = behavior.file_sha256(path) + return result + + +def render_identity_views( + *, + identity_inputs_sha256: str, + database_fingerprint_sha256: str, + database_provenance: dict[str, Any], + rules: list[dict[str, str]], + records: list[dict[str, str | int]], +) -> dict[str, str]: + soul = [ + "", + "# Leo", + "", + f"Identity inputs: `{identity_inputs_sha256}`", + "", + "## Static constitutional rules", + "", + "These rules are static constitutional inputs, not database claims.", + "", + ] + soul.extend(f"- **{item['id']}**: {item['text']}" for item in rules) + database_description = ( + "canonical database capture" + if database_provenance["canonical_authority_granted"] + else "synthetic noncanonical fixture" + ) + soul.extend( + [ + "", + "## Database-derived identity", + "", + f"Generated from {database_description} fingerprint `{database_fingerprint_sha256}`.", + "", + ] + ) + soul.extend( + f"- `{item['table']}/{item['row_id']}` [{item['kind']}, rank {item['rank']}]: {item['text']} " + f"(evidence `{item['evidence_sha256']}`)" + for item in records + ) + soul.extend( + [ + "", + "## Authority and session boundary", + "", + "- This `SOUL.md` is a generated view and is never an authority by itself.", + "- Generated database identity is authoritative only when its same-transaction export receipt grants canonical authority.", + "- Runtime/session memory is temporary, noncanonical, and cannot serve as evidence.", + "- Hosted model weights are provider-managed and must be attributed by each turn receipt.", + "", + ] + ) + structured = { + "schema": STRUCTURED_VIEW_SCHEMA, + "identity_name": "Leo", + "identity_inputs_sha256": identity_inputs_sha256, + "static_constitution": {"authority": "pinned_static_source", "rules": rules}, + "database_identity": { + "authority": database_provenance["authority"], + "canonical_authority_granted": database_provenance["canonical_authority_granted"], + "source_mode": database_provenance["mode"], + "database_fingerprint_sha256": database_fingerprint_sha256, + "records": records, + }, + "session_boundary": { + "authority": "none", + "classification": "temporary_noncanonical", + "may_be_used_as_evidence": False, + }, + } + return { + "SOUL.md": "\n".join(soul), + "identity.json": json.dumps(structured, indent=2, sort_keys=True) + "\n", + } + + +def build_identity_manifest( + *, + behavior_manifest: dict[str, Any], + database_fingerprint_path: Path, + constitution_path: Path, + database_identity_path: Path, + source_root: Path, + source_commit: str | None = None, +) -> dict[str, Any]: + validate_behavior_manifest(behavior_manifest) + database_fingerprint = load_json(database_fingerprint_path, "database fingerprint") + validate_database_fingerprint(database_fingerprint) + constitution = load_json(constitution_path, "constitution source") + database_identity = load_json(database_identity_path, "database identity source") + rules = canonical_rules(constitution) + records = canonical_database_records( + database_identity, fingerprint_sha256=database_fingerprint["fingerprint_sha256"] + ) + database_provenance = database_identity_provenance( + database_identity, + fingerprint=database_fingerprint, + ) + runtime_commit = behavior_manifest["teleo_infrastructure_runtime"]["git_head"] + source_commit = source_commit or runtime_commit + _require(bool(HEX_40.fullmatch(str(source_commit))), "source commit is invalid") + _require(source_commit == runtime_commit, "source commit does not match the behavior manifest") + + model = behavior_manifest["model_runtime"] + components = behavior_manifest["components"] + marker = database_fingerprint["read_consistency"]["before"] + core = { + "identity_name": "Leo", + "source_commit": source_commit, + "runtime": { + "identity_runtime_sha256": behavior_manifest["identity_runtime_sha256"], + "hermes_git_head": behavior_manifest["hermes_runtime"]["git_head"], + "hermes_source_sha256": behavior_manifest["hermes_runtime"]["source_tree"]["sha256"], + "teleo_git_head": runtime_commit, + "teleo_source_sha256": behavior_manifest["teleo_infrastructure_runtime"]["identity_source_tree"]["sha256"], + "python": behavior_manifest["python_runtime"], + }, + "model": { + "provider": model["provider"], + "default_model": model["default_model"], + "smart_routing": model.get("smart_routing"), + "smart_routing_model": model.get("smart_routing_model"), + "gateway_smart_model_routing": model.get("gateway_smart_model_routing"), + "model_section_sha256": model["model_section_sha256"], + "hosted_weights": "external_provider_managed_not_locally_hashable", + "actual_model_required_in_turn_receipt": True, + }, + "skills": { + "content_sha256": components["procedural_skills"]["content"]["sha256"], + "file_count": components["procedural_skills"]["content"]["file_count"], + }, + "permissions": { + "identity_config_sha256": model["identity_config_sha256"], + "gateway_permissions_sha256": model["gateway_permissions_sha256"], + "tool_permissions_sha256": model["tool_permissions_sha256"], + "runtime_policy": model["identity_runtime_policy"], + "authorization_decision_required_in_runtime_receipt": True, + }, + "canonical_database": { + "database": marker["database"], + "database_user": marker["database_user"], + "system_identifier": marker["system_identifier"], + "authority": database_provenance["authority"], + "canonical_authority_granted": database_provenance["canonical_authority_granted"], + **{ + key: database_fingerprint[key] + for key in ("fingerprint_sha256", "table_rows_sha256", "structure_sha256", "table_count", "total_rows") + }, + "source": source_binding( + database_fingerprint_path, + source_root=source_root, + semantic_value=database_fingerprint_semantic(database_fingerprint), + count=database_fingerprint["table_count"], + pin_content=False, + ), + }, + "identity_sources": { + "static_constitution": { + "authority": "pinned_static_source", + **source_binding(constitution_path, source_root=source_root, semantic_value=rules, count=len(rules)), + }, + "database_derived_identity": { + "authority": database_provenance["authority"], + "provenance": database_provenance, + "database_fingerprint_sha256": database_fingerprint["fingerprint_sha256"], + **source_binding( + database_identity_path, + source_root=source_root, + semantic_value={"provenance": database_provenance, "records": records}, + count=len(records), + ), + }, + }, + "session_boundary": expected_session_boundary(), + "gatewayrunner_execution_contract": expected_gatewayrunner_contract(), + } + identity_inputs_sha256 = behavior.canonical_sha256(core) + views = render_identity_views( + identity_inputs_sha256=identity_inputs_sha256, + database_fingerprint_sha256=database_fingerprint["fingerprint_sha256"], + database_provenance=database_provenance, + rules=rules, + records=records, + ) + stable = { + "schema": SCHEMA, + "identity_inputs": core, + "identity_inputs_sha256": identity_inputs_sha256, + "compiled_views": { + name: { + "role": "generated_view_not_authority", + "sha256": behavior.sha256_bytes(content.encode("utf-8")), + "generated_from_identity_inputs_sha256": identity_inputs_sha256, + } + for name, content in sorted(views.items()) + }, + } + return {**stable, "manifest_sha256": behavior.canonical_sha256(stable)} + + +def validate_identity_manifest(value: dict[str, Any]) -> None: + _require(value.get("schema") == SCHEMA, "unsupported identity manifest schema") + expected = value.get("manifest_sha256") + _require(_is_sha256(expected), "identity manifest sha256 is invalid") + stable = {key: item for key, item in value.items() if key != "manifest_sha256"} + _require(behavior.canonical_sha256(stable) == expected, "identity manifest hash drift detected") + core = value.get("identity_inputs") + _require(isinstance(core, dict), "identity inputs are missing") + _require( + set(core) + == { + "identity_name", + "source_commit", + "runtime", + "model", + "skills", + "permissions", + "canonical_database", + "identity_sources", + "session_boundary", + "gatewayrunner_execution_contract", + }, + "identity input contract fields are invalid", + ) + identity_hash = value.get("identity_inputs_sha256") + _require(_is_sha256(identity_hash), "identity input hash is invalid") + _require(behavior.canonical_sha256(core) == identity_hash, "identity input hash drift detected") + _require(core.get("identity_name") == "Leo", "identity name must be Leo") + + runtime = core.get("runtime") + _require(isinstance(runtime, dict), "runtime binding is missing") + _require( + set(runtime) + == { + "identity_runtime_sha256", + "hermes_git_head", + "hermes_source_sha256", + "teleo_git_head", + "teleo_source_sha256", + "python", + }, + "runtime binding fields are invalid", + ) + _require(_is_sha256(runtime.get("identity_runtime_sha256")), "identity runtime binding is invalid") + for field in ("hermes_git_head", "teleo_git_head"): + _require(bool(HEX_40.fullmatch(str(runtime.get(field) or ""))), f"runtime {field} is invalid") + for field in ("hermes_source_sha256", "teleo_source_sha256"): + _require(_is_sha256(runtime.get(field)), f"runtime {field} is invalid") + _require(core.get("source_commit") == runtime["teleo_git_head"], "source commit is not bound to Teleo Git") + python_runtime = runtime.get("python") + _require(isinstance(python_runtime, dict), "Python runtime binding is missing") + _require( + bool(python_runtime.get("implementation") and python_runtime.get("python_version")), + "Python runtime binding is incomplete", + ) + _require(_is_sha256(python_runtime.get("runtime_distributions_sha256")), "Python distribution binding is invalid") + + model = core.get("model") + _require(isinstance(model, dict), "model binding is missing") + _require( + set(model) + == { + "provider", + "default_model", + "smart_routing", + "smart_routing_model", + "gateway_smart_model_routing", + "model_section_sha256", + "hosted_weights", + "actual_model_required_in_turn_receipt", + }, + "model binding fields are invalid", + ) + _require(bool(model.get("provider") and model.get("default_model")), "model provider and default must be pinned") + _require(_is_sha256(model.get("model_section_sha256")), "model section binding is invalid") + _require( + model.get("hosted_weights") == "external_provider_managed_not_locally_hashable", + "hosted model boundary is invalid", + ) + _require(model.get("actual_model_required_in_turn_receipt") is True, "per-turn model receipt gate is invalid") + + skills = core.get("skills") + _require(isinstance(skills, dict) and set(skills) == {"content_sha256", "file_count"}, "skills binding is invalid") + _require(_is_sha256(skills.get("content_sha256")), "skills content hash is invalid") + _require( + isinstance(skills.get("file_count"), int) + and not isinstance(skills.get("file_count"), bool) + and skills["file_count"] > 0, + "skills file count is invalid", + ) + + permissions = core.get("permissions") + _require(isinstance(permissions, dict), "permission binding is missing") + _require( + set(permissions) + == { + "identity_config_sha256", + "gateway_permissions_sha256", + "tool_permissions_sha256", + "runtime_policy", + "authorization_decision_required_in_runtime_receipt", + }, + "permission binding fields are invalid", + ) + for field in ("identity_config_sha256", "gateway_permissions_sha256", "tool_permissions_sha256"): + _require(_is_sha256(permissions.get(field)), f"permission binding {field} is invalid") + _require( + permissions.get("runtime_policy") == expected_runtime_policy(), + "identity runtime policy must be the exact local readback policy", + ) + _require( + permissions.get("authorization_decision_required_in_runtime_receipt") is True, + "runtime authorization receipt gate is invalid", + ) + + database = core.get("canonical_database") + _require(isinstance(database, dict), "canonical database binding is missing") + for field in ("fingerprint_sha256", "table_rows_sha256", "structure_sha256"): + _require(_is_sha256(database.get(field)), f"canonical database {field} is invalid") + _require( + bool(database.get("database") and database.get("database_user") and database.get("system_identifier")), + "canonical database identity is incomplete", + ) + allowed_authorities = {"synthetic_noncanonical_fixture": False} + _require(database.get("authority") in allowed_authorities, "database authority is invalid") + _require( + database.get("canonical_authority_granted") is allowed_authorities[database["authority"]], + "database canonical authority grant is invalid", + ) + _require( + isinstance(database.get("table_count"), int) + and not isinstance(database.get("table_count"), bool) + and database["table_count"] > 0, + "canonical database table_count is invalid", + ) + _require( + isinstance(database.get("total_rows"), int) + and not isinstance(database.get("total_rows"), bool) + and database["total_rows"] >= 0, + "canonical database total_rows is invalid", + ) + + def validate_source_binding(binding: Any, *, label: str, content_required: bool) -> None: + _require(isinstance(binding, dict), f"{label} source binding is missing") + repo_path = binding.get("repo_path") + _require( + isinstance(repo_path, str) + and bool(repo_path) + and not Path(repo_path).is_absolute() + and ".." not in Path(repo_path).parts, + f"{label} source path is invalid", + ) + _require(_is_sha256(binding.get("semantic_sha256")), f"{label} semantic binding is invalid") + _require( + isinstance(binding.get("record_count"), int) + and not isinstance(binding.get("record_count"), bool) + and binding["record_count"] > 0, + f"{label} record count is invalid", + ) + if content_required: + _require(_is_sha256(binding.get("content_sha256")), f"{label} content binding is invalid") + else: + _require("content_sha256" not in binding, f"{label} content binding must exclude volatile capture markers") + + validate_source_binding(database.get("source"), label="database fingerprint", content_required=False) + sources = core.get("identity_sources") + _require( + isinstance(sources, dict) and set(sources) == {"static_constitution", "database_derived_identity"}, + "identity source bindings are invalid", + ) + constitution = sources["static_constitution"] + derived = sources["database_derived_identity"] + _require(isinstance(constitution, dict), "constitution source binding is invalid") + _require(isinstance(derived, dict), "database identity source binding is invalid") + _require(constitution.get("authority") == "pinned_static_source", "constitution authority is invalid") + _require(derived.get("authority") == database["authority"], "database identity authority is invalid") + provenance = derived.get("provenance") + _require(isinstance(provenance, dict), "database identity provenance is missing") + _require(provenance.get("authority") == database["authority"], "database identity provenance authority is invalid") + _require( + provenance.get("canonical_authority_granted") is database["canonical_authority_granted"], + "database identity provenance grant is invalid", + ) + _require(provenance.get("mode") == SYNTHETIC_DATABASE_MODE, "synthetic database provenance mode is invalid") + _require( + provenance.get("same_transaction_as_fingerprint") is False, + "synthetic fixture transaction claim is invalid", + ) + _require(provenance.get("export_receipt_sha256") is None, "synthetic fixture must not claim an export receipt") + _require( + derived.get("database_fingerprint_sha256") == database["fingerprint_sha256"], + "database identity source is misbound", + ) + validate_source_binding(constitution, label="constitution", content_required=True) + validate_source_binding(derived, label="database identity", content_required=True) + _require(core.get("session_boundary") == expected_session_boundary(), "session authority boundary is invalid") + _require( + core.get("gatewayrunner_execution_contract") == expected_gatewayrunner_contract(), + "GatewayRunner execution contract is invalid", + ) + + views = value.get("compiled_views") + _require(isinstance(views, dict) and set(views) == {"SOUL.md", "identity.json"}, "compiled views are incomplete") + for name, view in views.items(): + _require(isinstance(view, dict) and _is_sha256(view.get("sha256")), f"compiled view {name} is invalid") + _require( + set(view) == {"role", "sha256", "generated_from_identity_inputs_sha256"}, + f"compiled view {name} fields are invalid", + ) + _require(view.get("role") == "generated_view_not_authority", f"compiled view {name} authority is invalid") + _require( + view.get("generated_from_identity_inputs_sha256") == identity_hash, f"compiled view {name} is misbound" + ) + + +def verify_bound_sources(value: dict[str, Any], source_root: Path) -> dict[str, str]: + validate_identity_manifest(value) + core = value["identity_inputs"] + bindings = { + "database fingerprint": core["canonical_database"]["source"], + "constitution source": core["identity_sources"]["static_constitution"], + "database identity source": core["identity_sources"]["database_derived_identity"], + } + paths = {label: source_root / binding["repo_path"] for label, binding in bindings.items()} + for label, binding in bindings.items(): + path = paths[label] + _require(path.is_file(), f"bound {label} is missing") + if binding.get("content_sha256"): + _require(behavior.file_sha256(path) == binding["content_sha256"], f"bound {label} content drift detected") + fingerprint = load_json(paths["database fingerprint"], "database fingerprint") + validate_database_fingerprint(fingerprint) + _require( + behavior.canonical_sha256(database_fingerprint_semantic(fingerprint)) + == bindings["database fingerprint"]["semantic_sha256"], + "database fingerprint semantic drift detected", + ) + marker = fingerprint["read_consistency"]["before"] + for field in ("database", "database_user", "system_identifier"): + _require(marker[field] == core["canonical_database"][field], f"canonical database {field} drift detected") + rules = canonical_rules(load_json(paths["constitution source"], "constitution source")) + database_identity = load_json(paths["database identity source"], "database identity source") + records = canonical_database_records( + database_identity, + fingerprint_sha256=fingerprint["fingerprint_sha256"], + ) + database_provenance = database_identity_provenance( + database_identity, + fingerprint=fingerprint, + ) + _require( + behavior.canonical_sha256(rules) == bindings["constitution source"]["semantic_sha256"], + "constitution semantic drift detected", + ) + _require( + behavior.canonical_sha256({"provenance": database_provenance, "records": records}) + == bindings["database identity source"]["semantic_sha256"], + "database identity semantic drift detected", + ) + _require( + database_provenance == core["identity_sources"]["database_derived_identity"]["provenance"], + "database identity provenance drift detected", + ) + _require( + fingerprint["fingerprint_sha256"] == core["canonical_database"]["fingerprint_sha256"], + "canonical database fingerprint drift detected", + ) + views = render_identity_views( + identity_inputs_sha256=value["identity_inputs_sha256"], + database_fingerprint_sha256=fingerprint["fingerprint_sha256"], + database_provenance=database_provenance, + rules=rules, + records=records, + ) + for name, content in views.items(): + _require( + behavior.sha256_bytes(content.encode("utf-8")) == value["compiled_views"][name]["sha256"], + f"compiled view contract drift detected for {name}", + ) + return views + + +def write_json(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + generate = subparsers.add_parser("generate") + generate.add_argument("--behavior-manifest", type=Path, required=True) + generate.add_argument("--database-fingerprint", type=Path, required=True) + generate.add_argument("--constitution", type=Path, required=True) + generate.add_argument("--database-identity", type=Path, required=True) + generate.add_argument("--source-root", type=Path, required=True) + generate.add_argument("--source-commit") + generate.add_argument("--output", type=Path, required=True) + verify = subparsers.add_parser("verify") + verify.add_argument("--manifest", type=Path, required=True) + verify.add_argument("--source-root", type=Path) + args = parser.parse_args() + try: + if args.command == "generate": + manifest = build_identity_manifest( + behavior_manifest=load_json(args.behavior_manifest, "behavior manifest"), + database_fingerprint_path=args.database_fingerprint, + constitution_path=args.constitution, + database_identity_path=args.database_identity, + source_root=args.source_root, + source_commit=args.source_commit, + ) + write_json(args.output, manifest) + else: + manifest = load_json(args.manifest, "identity manifest") + validate_identity_manifest(manifest) + if args.source_root: + verify_bound_sources(manifest, args.source_root) + print(json.dumps({"status": "ok", "manifest_sha256": manifest["manifest_sha256"]})) + return 0 + except IdentityManifestError as exc: + print(json.dumps({"status": "error", "error": "identity_manifest_invalid", "message": str(exc)})) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/leo_identity_profile.py b/scripts/leo_identity_profile.py new file mode 100644 index 0000000..11003ca --- /dev/null +++ b/scripts/leo_identity_profile.py @@ -0,0 +1,495 @@ +#!/usr/bin/env python3 +"""Compile, verify, and query a disposable Leo identity profile.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +import uuid +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import yaml + +if __package__ in {None, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from scripts import leo_behavior_manifest as behavior +from scripts import leo_identity_manifest as identity + +LOCK_SCHEMA = "livingip.leoIdentityProfileLock.v1" +COMPILE_RECEIPT_SCHEMA = "livingip.leoIdentityCompileReceipt.v1" +QUERY_RECEIPT_SCHEMA = "livingip.leoIdentityQueryReceipt.v1" +SESSION_RECORD_SCHEMA = "livingip.leoTemporarySessionRecord.v1" +FIXED_QUERY = "What defines Leo's identity, and what is temporary?" +STATIC_PROFILE_ENTRIES = ("config.yaml", "skills", "plugins", "bin") +COMPILED_PROFILE_ENTRIES = frozenset( + { + "config.yaml", + "skills", + "plugins", + "bin", + "SOUL.md", + "identity.json", + "identity-manifest.json", + "identity-lock.json", + "compile-receipt.json", + "sessions", + "state", + } +) +OMITTED_NONAUTHORITATIVE_ENTRIES = ( + "memories", + "MEMORY.md", + "USER.md", + "platforms/pairing", + ".skills_prompt_snapshot.json", + ".hermes.md", + "HERMES.md", + "AGENTS.md", + "CLAUDE.md", + ".cursorrules", +) + + +class IdentityProfileError(identity.IdentityManifestError): + """A compiled profile cannot be trusted or started.""" + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise IdentityProfileError(message) + + +def _copy_static_profile(template: Path, target: Path) -> list[str]: + _require(template.is_dir(), "profile template is missing") + _require(not target.exists(), "compiled profile target already exists") + target.mkdir(parents=True, mode=0o700) + copied: list[str] = [] + for name in STATIC_PROFILE_ENTRIES: + source = template / name + if not source.exists(): + continue + destination = target / name + _require(not source.is_symlink(), f"unsafe symlinked static profile entry: {name}") + if source.is_dir(): + _require( + not any(path.is_symlink() for path in source.rglob("*")), + f"unsafe symlink inside static profile entry: {name}", + ) + shutil.copytree(source, destination, symlinks=False) + elif source.is_file() and not source.is_symlink(): + if name == "config.yaml": + try: + raw_config = yaml.safe_load(source.read_text(encoding="utf-8")) or {} + except (OSError, TypeError, yaml.YAMLError) as exc: + raise IdentityProfileError(f"cannot sanitize profile config: {type(exc).__name__}") from exc + _require(isinstance(raw_config, dict), "profile config must be a mapping") + destination.write_text( + yaml.safe_dump(behavior.identity_config_projection(raw_config), sort_keys=False), + encoding="utf-8", + ) + destination.chmod(0o600) + else: + shutil.copy2(source, destination) + else: + raise IdentityProfileError(f"unsafe static profile entry: {name}") + copied.append(name) + _require("config.yaml" in copied, "profile template config.yaml is missing") + return copied + + +def _behavior(profile: Path, hermes_root: Path, deployment_root: Path) -> dict[str, Any]: + return behavior.build_manifest(profile, hermes_root, deployment_root) + + +def _verify_runtime_binding( + manifest: dict[str, Any], + *, + profile: Path, + hermes_root: Path, + deployment_root: Path, +) -> dict[str, Any]: + observed = _behavior(profile, hermes_root, deployment_root) + identity.validate_behavior_manifest(observed) + expected = manifest["identity_inputs"]["runtime"] + _require( + observed["identity_runtime_sha256"] == expected["identity_runtime_sha256"], "identity runtime drift detected" + ) + _require(observed["hermes_runtime"]["git_head"] == expected["hermes_git_head"], "Hermes Git drift detected") + _require( + observed["hermes_runtime"]["source_tree"]["sha256"] == expected["hermes_source_sha256"], + "Hermes source drift detected", + ) + _require( + observed["teleo_infrastructure_runtime"]["git_head"] == expected["teleo_git_head"], + "Teleo Git drift detected", + ) + _require( + observed["teleo_infrastructure_runtime"]["identity_source_tree"]["sha256"] == expected["teleo_source_sha256"], + "Teleo identity source drift detected", + ) + _require(observed["python_runtime"] == expected["python"], "Python runtime drift detected") + _require( + manifest["identity_inputs"]["source_commit"] == observed["teleo_infrastructure_runtime"]["git_head"], + "source commit drift detected", + ) + + expected_model = manifest["identity_inputs"]["model"] + observed_model = observed["model_runtime"] + model_bindings = { + "provider": observed_model.get("provider"), + "default_model": observed_model.get("default_model"), + "smart_routing": observed_model.get("smart_routing"), + "smart_routing_model": observed_model.get("smart_routing_model"), + "gateway_smart_model_routing": observed_model.get("gateway_smart_model_routing"), + "model_section_sha256": observed_model.get("model_section_sha256"), + "hosted_weights": observed_model.get("base_model_weights"), + "actual_model_required_in_turn_receipt": observed_model.get( + "actual_per_turn_model_must_be_recorded_by_the_call_receipt" + ), + } + for field, observed_value in model_bindings.items(): + _require(expected_model.get(field) == observed_value, f"model runtime binding drift detected: {field}") + + expected_permissions = manifest["identity_inputs"]["permissions"] + permission_bindings = { + "identity_config_sha256": observed_model.get("identity_config_sha256"), + "gateway_permissions_sha256": observed_model.get("gateway_permissions_sha256"), + "tool_permissions_sha256": observed_model.get("tool_permissions_sha256"), + "runtime_policy": observed_model.get("identity_runtime_policy"), + "authorization_decision_required_in_runtime_receipt": True, + } + for field, observed_value in permission_bindings.items(): + _require( + expected_permissions.get(field) == observed_value, f"permission runtime binding drift detected: {field}" + ) + + observed_skills = observed["components"]["procedural_skills"]["content"] + expected_skills = manifest["identity_inputs"]["skills"] + _require(expected_skills["content_sha256"] == observed_skills["sha256"], "skills runtime binding drift detected") + _require(expected_skills["file_count"] == observed_skills["file_count"], "skills file count drift detected") + return observed + + +def _view_file_sha256(profile: Path, name: str) -> str: + path = profile / name + _require(path.is_file() and not path.is_symlink(), f"compiled view is missing or unsafe: {name}") + return behavior.file_sha256(path) + + +def _verify_nonauthoritative_surfaces( + profile: Path, + *, + require_empty_sessions: bool, + require_compile_receipt: bool, +) -> dict[str, Any]: + omitted = { + name: not (profile / name).exists() and not (profile / name).is_symlink() + for name in OMITTED_NONAUTHORITATIVE_ENTRIES + } + _require(all(omitted.values()), "profile contains a forbidden nonauthoritative input surface") + expected_entries = set(COMPILED_PROFILE_ENTRIES) + if not require_compile_receipt: + expected_entries.remove("compile-receipt.json") + profile_entries = list(profile.iterdir()) + actual_entries = {path.name for path in profile_entries} + _require(actual_entries == expected_entries, "compiled profile entry set is not exact") + _require(not any(path.is_symlink() for path in profile_entries), "compiled profile entries must not be symlinks") + if require_compile_receipt: + compile_receipt = identity.load_json(profile / "compile-receipt.json", "identity compile receipt") + _require(compile_receipt.get("schema") == COMPILE_RECEIPT_SCHEMA, "identity compile receipt schema is invalid") + _require(compile_receipt.get("status") == "pass", "identity compile receipt status is invalid") + state = profile / "state" + sessions = profile / "sessions" + for path, label in ((state, "state"), (sessions, "sessions")): + _require(path.is_dir() and not path.is_symlink(), f"profile {label} directory is missing or unsafe") + _require(not any(state.iterdir()), "profile state directory must remain empty") + session_files = list(sessions.iterdir()) + if require_empty_sessions: + _require(not session_files, "profile sessions must start empty") + for path in session_files: + _require(path.is_file() and not path.is_symlink() and path.suffix == ".json", "unsafe session entry detected") + record = identity.load_json(path, "temporary session record") + _require( + set(record) + == { + "schema", + "authority", + "classification", + "query_sha256", + "answer_sha256", + "may_be_used_as_evidence", + }, + "temporary session record fields are invalid", + ) + _require(record.get("schema") == SESSION_RECORD_SCHEMA, "temporary session record schema is invalid") + _require(record.get("authority") == "none", "temporary session authority is invalid") + _require(record.get("classification") == "temporary_noncanonical", "temporary session class is invalid") + _require(record.get("may_be_used_as_evidence") is False, "temporary session cannot be evidence") + _require(identity._is_sha256(record.get("query_sha256")), "temporary session query hash is invalid") + _require(identity._is_sha256(record.get("answer_sha256")), "temporary session answer hash is invalid") + return { + "nonauthoritative_inputs_omitted": omitted, + "session_file_count": len(session_files), + "state_empty": True, + } + + +def compile_profile( + manifest: dict[str, Any], + *, + source_root: Path, + profile_template: Path, + profile: Path, + hermes_root: Path, + deployment_root: Path, +) -> dict[str, Any]: + views = identity.verify_bound_sources(manifest, source_root) + template_behavior = _behavior(profile_template, hermes_root, deployment_root) + identity.validate_behavior_manifest(template_behavior) + _require( + template_behavior["identity_runtime_sha256"] + == manifest["identity_inputs"]["runtime"]["identity_runtime_sha256"], + "profile template does not match the pinned identity runtime", + ) + copied = _copy_static_profile(profile_template, profile) + try: + for name, content in views.items(): + path = profile / name + path.write_text(content, encoding="utf-8") + path.chmod(0o600) + identity.write_json(profile / "identity-manifest.json", manifest) + (profile / "identity-manifest.json").chmod(0o600) + for directory in (profile / "sessions", profile / "state"): + directory.mkdir(mode=0o700) + lock = { + "schema": LOCK_SCHEMA, + "manifest_sha256": manifest["manifest_sha256"], + "identity_inputs_sha256": manifest["identity_inputs_sha256"], + "compiled_views": {name: {"sha256": _view_file_sha256(profile, name)} for name in sorted(views)}, + "session_boundary": manifest["identity_inputs"]["session_boundary"], + } + identity.write_json(profile / "identity-lock.json", lock) + (profile / "identity-lock.json").chmod(0o600) + observed = _verify_runtime_binding( + manifest, + profile=profile, + hermes_root=hermes_root, + deployment_root=deployment_root, + ) + for name, expected in manifest["compiled_views"].items(): + _require(_view_file_sha256(profile, name) == expected["sha256"], f"compiled {name} hash mismatch") + soul_files = observed["components"]["runtime_identity"]["content"]["files"] + soul = next((item for item in soul_files if item.get("path") == "SOUL.md"), None) + _require( + isinstance(soul, dict) and soul.get("sha256") == manifest["compiled_views"]["SOUL.md"]["sha256"], + "SOUL runtime binding failed", + ) + surface_readback = _verify_nonauthoritative_surfaces( + profile, + require_empty_sessions=True, + require_compile_receipt=False, + ) + receipt = { + "schema": COMPILE_RECEIPT_SCHEMA, + "status": "pass", + "manifest_sha256": manifest["manifest_sha256"], + "identity_inputs_sha256": manifest["identity_inputs_sha256"], + "profile_static_entries": copied, + "compiled_view_sha256": { + name: manifest["compiled_views"][name]["sha256"] for name in sorted(manifest["compiled_views"]) + }, + "runtime_identity_sha256": observed["identity_runtime_sha256"], + "session_directories_started_empty": all( + not any((profile / name).iterdir()) for name in ("sessions", "state") + ), + "nonauthoritative_inputs_omitted": surface_readback["nonauthoritative_inputs_omitted"], + "generated_views_are_authority": False, + } + identity.write_json(profile / "compile-receipt.json", receipt) + return receipt + except BaseException: + shutil.rmtree(profile, ignore_errors=True) + raise + + +def verify_profile( + profile: Path, + *, + source_root: Path, + hermes_root: Path, + deployment_root: Path, +) -> tuple[dict[str, Any], dict[str, str], dict[str, Any]]: + manifest = identity.load_json(profile / "identity-manifest.json", "compiled identity manifest") + views = identity.verify_bound_sources(manifest, source_root) + lock = identity.load_json(profile / "identity-lock.json", "identity profile lock") + _verify_nonauthoritative_surfaces( + profile, + require_empty_sessions=False, + require_compile_receipt=True, + ) + _require(lock.get("schema") == LOCK_SCHEMA, "identity profile lock schema is invalid") + _require( + lock.get("manifest_sha256") == manifest["manifest_sha256"], "identity profile lock manifest drift detected" + ) + _require( + lock.get("identity_inputs_sha256") == manifest["identity_inputs_sha256"], + "identity profile lock input drift detected", + ) + compile_receipt = identity.load_json(profile / "compile-receipt.json", "identity compile receipt") + _require( + compile_receipt.get("manifest_sha256") == manifest["manifest_sha256"], + "identity compile receipt manifest drift detected", + ) + _require( + compile_receipt.get("identity_inputs_sha256") == manifest["identity_inputs_sha256"], + "identity compile receipt input drift detected", + ) + for name, expected_content in views.items(): + expected_hash = behavior.sha256_bytes(expected_content.encode("utf-8")) + _require(expected_hash == manifest["compiled_views"][name]["sha256"], f"manifest view drift detected: {name}") + _require(_view_file_sha256(profile, name) == expected_hash, f"compiled view drift detected: {name}") + _require( + (lock.get("compiled_views") or {}).get(name, {}).get("sha256") == expected_hash, + f"profile lock view drift detected: {name}", + ) + observed = _verify_runtime_binding( + manifest, + profile=profile, + hermes_root=hermes_root, + deployment_root=deployment_root, + ) + return manifest, views, observed + + +def query_profile( + profile: Path, + *, + query: str, + source_root: Path, + hermes_root: Path, + deployment_root: Path, +) -> dict[str, Any]: + _require(query == FIXED_QUERY, "only the pinned identity readback query is allowed") + manifest, _views, observed = verify_profile( + profile, + source_root=source_root, + hermes_root=hermes_root, + deployment_root=deployment_root, + ) + structured = identity.load_json(profile / "identity.json", "compiled structured identity view") + rule_count = len(structured["static_constitution"]["rules"]) + record_count = len(structured["database_identity"]["records"]) + fingerprint = structured["database_identity"]["database_fingerprint_sha256"] + canonical_authority = structured["database_identity"]["canonical_authority_granted"] + database_description = "canonical database" if canonical_authority else "synthetic noncanonical fixture" + answer = ( + f"Leo is defined by {rule_count} pinned static constitutional rules and {record_count} active " + f"database-derived identity rows from a {database_description} at fingerprint {fingerprint}. " + "SOUL.md is a generated view; " + "runtime/session memory is temporary, noncanonical, and cannot be evidence." + ) + query_sha256 = behavior.sha256_bytes(query.encode("utf-8")) + answer_sha256 = behavior.sha256_bytes(answer.encode("utf-8")) + instance_id = uuid.uuid4().hex + session_record = { + "schema": SESSION_RECORD_SCHEMA, + "authority": "none", + "classification": "temporary_noncanonical", + "query_sha256": query_sha256, + "answer_sha256": answer_sha256, + "may_be_used_as_evidence": False, + } + session_path = profile / "sessions" / f"{instance_id}.json" + identity.write_json(session_path, session_record) + receipt = { + "schema": QUERY_RECEIPT_SCHEMA, + "status": "pass", + "started_at_utc": datetime.now(UTC).isoformat(), + "process_id": os.getpid(), + "runtime_instance_id": instance_id, + "query": query, + "query_sha256": query_sha256, + "answer": answer, + "answer_sha256": answer_sha256, + "manifest_sha256": manifest["manifest_sha256"], + "identity_inputs_sha256": manifest["identity_inputs_sha256"], + "output_provenance": { + "static_constitution_sha256": manifest["identity_inputs"]["identity_sources"]["static_constitution"][ + "semantic_sha256" + ], + "database_identity_sha256": manifest["identity_inputs"]["identity_sources"]["database_derived_identity"][ + "semantic_sha256" + ], + "database_fingerprint_sha256": fingerprint, + "database_authority": structured["database_identity"]["authority"], + "database_canonical_authority_granted": canonical_authority, + "compiled_identity_sha256": manifest["compiled_views"]["identity.json"]["sha256"], + "compiled_soul_sha256": manifest["compiled_views"]["SOUL.md"]["sha256"], + "runtime_identity_sha256": observed["identity_runtime_sha256"], + "actual_model_call_performed": False, + }, + "authorization": { + "declared_runtime_policy": manifest["identity_inputs"]["permissions"]["runtime_policy"], + "authorization_decision_observed": False, + "claim": "local_policy_binding_not_an_authorizer_readback", + }, + "session": { + "record_sha256": behavior.file_sha256(session_path), + "classification": "temporary_noncanonical", + "included_in_output_provenance": False, + "may_be_used_as_evidence": False, + }, + } + return receipt + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + compile_command = subparsers.add_parser("compile") + compile_command.add_argument("--manifest", type=Path, required=True) + compile_command.add_argument("--source-root", type=Path, required=True) + compile_command.add_argument("--profile-template", type=Path, required=True) + compile_command.add_argument("--profile", type=Path, required=True) + compile_command.add_argument("--hermes-root", type=Path, required=True) + compile_command.add_argument("--deployment-root", type=Path, required=True) + query_command = subparsers.add_parser("query") + query_command.add_argument("--profile", type=Path, required=True) + query_command.add_argument("--query", default=FIXED_QUERY) + query_command.add_argument("--source-root", type=Path, required=True) + query_command.add_argument("--hermes-root", type=Path, required=True) + query_command.add_argument("--deployment-root", type=Path, required=True) + args = parser.parse_args() + try: + if args.command == "compile": + result = compile_profile( + identity.load_json(args.manifest, "identity manifest"), + source_root=args.source_root, + profile_template=args.profile_template, + profile=args.profile, + hermes_root=args.hermes_root, + deployment_root=args.deployment_root, + ) + else: + result = query_profile( + args.profile, + query=args.query, + source_root=args.source_root, + hermes_root=args.hermes_root, + deployment_root=args.deployment_root, + ) + print(json.dumps(result, sort_keys=True)) + return 0 + except identity.IdentityManifestError as exc: + print(json.dumps({"status": "error", "error": "identity_drift", "message": str(exc)})) + return 3 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_leo_identity_reconstruction_canary.py b/scripts/run_leo_identity_reconstruction_canary.py new file mode 100644 index 0000000..2850804 --- /dev/null +++ b/scripts/run_leo_identity_reconstruction_canary.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +"""Prove manifest-driven Leo identity reconstruction across a cold restart.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import signal +import subprocess +import sys +import tempfile +import time +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +if __package__ in {None, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from scripts import leo_behavior_manifest as behavior +from scripts import leo_identity_manifest as identity +from scripts import leo_identity_profile as profile_runtime + +SCHEMA = "livingip.leoIdentityReconstructionReceipt.v1" + + +class CanaryError(RuntimeError): + """The disposable identity runtime did not satisfy its T2 contract.""" + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise CanaryError(message) + + +def _process_group_absent(process_group: int) -> bool: + try: + os.killpg(process_group, 0) + except ProcessLookupError: + return True + except PermissionError: + return False + return False + + +def _wait_for_process_group_absence(process_group: int, timeout_seconds: float = 5) -> bool: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if _process_group_absent(process_group): + return True + time.sleep(0.02) + return _process_group_absent(process_group) + + +def _signal_process_group(process_group: int, signal_number: signal.Signals) -> bool: + try: + os.killpg(process_group, signal_number) + return True + except (ProcessLookupError, PermissionError): + return False + + +def _cleanup_remaining_process_group(process_group: int) -> str | None: + if not _signal_process_group(process_group, signal.SIGTERM): + return None + if _wait_for_process_group_absence(process_group, timeout_seconds=1): + return "SIGTERM" + if not _signal_process_group(process_group, signal.SIGKILL): + return "SIGTERM" + return "SIGKILL" + + +def _run_query_child( + *, + deployment_root: Path, + profile: Path, + source_root: Path, + hermes_root: Path, + timeout_seconds: float = 60, +) -> dict[str, Any]: + command = [ + sys.executable, + "-m", + "scripts.leo_identity_profile", + "query", + "--profile", + str(profile), + "--source-root", + str(source_root), + "--hermes-root", + str(hermes_root), + "--deployment-root", + str(deployment_root), + "--query", + profile_runtime.FIXED_QUERY, + ] + process = subprocess.Popen( + command, + cwd=deployment_root, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, + ) + timed_out = False + terminated_with: str | None = None + stdout = "" + stderr = "" + process_group_absent_after_wait = False + orphan_cleanup_required = False + try: + try: + stdout, stderr = process.communicate(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + timed_out = True + if _signal_process_group(process.pid, signal.SIGTERM): + terminated_with = "SIGTERM" + try: + stdout, stderr = process.communicate(timeout=5) + except subprocess.TimeoutExpired: + if _signal_process_group(process.pid, signal.SIGKILL): + terminated_with = "SIGKILL" + stdout, stderr = process.communicate(timeout=5) + finally: + if process.poll() is None: + if _signal_process_group(process.pid, signal.SIGTERM): + terminated_with = terminated_with or "SIGTERM" + try: + process.communicate(timeout=5) + except subprocess.TimeoutExpired: + if _signal_process_group(process.pid, signal.SIGKILL): + terminated_with = "SIGKILL" + process.communicate(timeout=5) + orphan_cleanup_required = not _process_group_absent(process.pid) + if orphan_cleanup_required: + terminated_with = _cleanup_remaining_process_group(process.pid) or terminated_with + process_group_absent_after_wait = _wait_for_process_group_absence(process.pid) + try: + payload = json.loads(stdout.strip().splitlines()[-1]) if stdout.strip() else {} + except json.JSONDecodeError as exc: + raise CanaryError("identity child returned invalid JSON") from exc + return { + "command": command, + "launcher_pid": process.pid, + "returncode": process.returncode, + "timed_out": timed_out, + "terminated_with": terminated_with, + "orphan_cleanup_required": orphan_cleanup_required, + "stdout": payload, + "stderr_sha256": behavior.sha256_bytes(stderr.encode("utf-8")), + "process_group_absent_after_wait": process_group_absent_after_wait, + } + + +def _query_passed(child: dict[str, Any]) -> bool: + payload = child.get("stdout") or {} + return bool( + child.get("returncode") == 0 + and child.get("timed_out") is False + and child.get("orphan_cleanup_required") is False + and child.get("process_group_absent_after_wait") is True + and payload.get("schema") == profile_runtime.QUERY_RECEIPT_SCHEMA + and payload.get("status") == "pass" + ) + + +def _drift_attempt( + *, + deployment_root: Path, + source_root: Path, + hermes_root: Path, + drift_profile: Path, +) -> dict[str, Any]: + with (drift_profile / "SOUL.md").open("a", encoding="utf-8") as handle: + handle.write("\nInjected drift must fail closed.\n") + child = _run_query_child( + deployment_root=deployment_root, + profile=drift_profile, + source_root=source_root, + hermes_root=hermes_root, + ) + child["fail_closed"] = bool( + child["returncode"] == 3 + and child["timed_out"] is False + and child["orphan_cleanup_required"] is False + and child["process_group_absent_after_wait"] is True + and (child.get("stdout") or {}).get("error") == "identity_drift" + and "compiled view drift detected" in str((child.get("stdout") or {}).get("message")) + ) + child["answer_returned"] = bool((child.get("stdout") or {}).get("answer")) + return child + + +def run_canary(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + output = args.output.resolve() + temporary_root = Path(tempfile.mkdtemp(prefix="leo-identity-reconstruction-")) + report: dict[str, Any] = { + "schema": SCHEMA, + "generated_at_utc": datetime.now(UTC).isoformat(), + "required_tier": "T2_runtime", + "mode": "drift_before_restart" + if args.inject_drift_before_restart + else "successful_restart_plus_drift_negative", + "fixed_query": profile_runtime.FIXED_QUERY, + "fixed_query_sha256": behavior.sha256_bytes(profile_runtime.FIXED_QUERY.encode("utf-8")), + "production_profile_replaced": False, + "production_database_contacted": False, + "telegram_message_sent": False, + "actual_hosted_model_call_performed": False, + "errors": [], + } + exit_code = 1 + try: + behavior_manifest = behavior.build_manifest(args.profile_template, args.hermes_root, args.deployment_root) + identity.validate_behavior_manifest(behavior_manifest) + manifest = identity.build_identity_manifest( + behavior_manifest=behavior_manifest, + database_fingerprint_path=args.database_fingerprint, + constitution_path=args.constitution, + database_identity_path=args.database_identity, + source_root=args.source_root, + ) + report["manifest"] = manifest + report["behavior_observation_before_compile"] = { + "behavior_sha256": behavior_manifest["behavior_sha256"], + "identity_runtime_sha256": behavior_manifest["identity_runtime_sha256"], + "source_commit": behavior_manifest["teleo_infrastructure_runtime"]["git_head"], + } + compiled_profile = temporary_root / "first-profile" + report["compile"] = profile_runtime.compile_profile( + manifest, + source_root=args.source_root, + profile_template=args.profile_template, + profile=compiled_profile, + hermes_root=args.hermes_root, + deployment_root=args.deployment_root, + ) + compiled_behavior_before_query = behavior.build_manifest( + compiled_profile, args.hermes_root, args.deployment_root + ) + report["compiled_profile_before_query"] = { + "behavior_sha256": compiled_behavior_before_query["behavior_sha256"], + "identity_runtime_sha256": compiled_behavior_before_query["identity_runtime_sha256"], + } + first = _run_query_child( + deployment_root=args.deployment_root, + profile=compiled_profile, + source_root=args.source_root, + hermes_root=args.hermes_root, + ) + report["first_start"] = first + _require(_query_passed(first), "first disposable identity process did not answer successfully") + after_first = behavior.build_manifest(compiled_profile, args.hermes_root, args.deployment_root) + report["session_boundary_readback"] = { + "full_behavior_changed_after_session": after_first["behavior_sha256"] + != compiled_behavior_before_query["behavior_sha256"], + "identity_runtime_unchanged_after_session": after_first["identity_runtime_sha256"] + == compiled_behavior_before_query["identity_runtime_sha256"], + "session_file_count": len(list((compiled_profile / "sessions").glob("*.json"))), + "session_classification": "temporary_noncanonical", + "session_used_as_output_provenance": False, + } + if args.inject_drift_before_restart: + with (compiled_profile / "SOUL.md").open("a", encoding="utf-8") as handle: + handle.write("\nInjected pre-restart drift.\n") + rejected = _run_query_child( + deployment_root=args.deployment_root, + profile=compiled_profile, + source_root=args.source_root, + hermes_root=args.hermes_root, + ) + report["pre_restart_drift"] = rejected + report["drift_target"] = "compiled SOUL.md generated view" + report["second_start_attempted"] = False + report["gates"] = { + "first_start_passed": True, + "first_process_stopped": first["process_group_absent_after_wait"], + "drift_detected_before_restart": rejected["returncode"] == 3 + and (rejected.get("stdout") or {}).get("error") == "identity_drift", + "drift_returned_no_answer": not bool((rejected.get("stdout") or {}).get("answer")), + "drift_process_stopped": rejected["process_group_absent_after_wait"], + "drift_process_had_no_orphan_cleanup": rejected["orphan_cleanup_required"] is False, + "second_start_not_attempted": True, + } + report["status"] = "pass" if all(report["gates"].values()) else "fail" + else: + profile_runtime.verify_profile( + compiled_profile, + source_root=args.source_root, + hermes_root=args.hermes_root, + deployment_root=args.deployment_root, + ) + report["pre_restart_verification"] = "pass" + report["second_start_attempted"] = True + restarted_profile = temporary_root / "restart-profile" + report["restart_compile"] = profile_runtime.compile_profile( + manifest, + source_root=args.source_root, + profile_template=args.profile_template, + profile=restarted_profile, + hermes_root=args.hermes_root, + deployment_root=args.deployment_root, + ) + profile_runtime.verify_profile( + restarted_profile, + source_root=args.source_root, + hermes_root=args.hermes_root, + deployment_root=args.deployment_root, + ) + second = _run_query_child( + deployment_root=args.deployment_root, + profile=restarted_profile, + source_root=args.source_root, + hermes_root=args.hermes_root, + ) + report["restart"] = second + _require(_query_passed(second), "restarted disposable identity process did not answer successfully") + first_payload = first["stdout"] + second_payload = second["stdout"] + drift_profile = temporary_root / "drift-profile" + report["drift_compile"] = profile_runtime.compile_profile( + manifest, + source_root=args.source_root, + profile_template=args.profile_template, + profile=drift_profile, + hermes_root=args.hermes_root, + deployment_root=args.deployment_root, + ) + drift = _drift_attempt( + deployment_root=args.deployment_root, + source_root=args.source_root, + hermes_root=args.hermes_root, + drift_profile=drift_profile, + ) + report["drift_negative"] = drift + report["drift_target"] = "compiled SOUL.md generated view" + report["gates"] = { + "manifest_generated": identity._is_sha256(manifest["manifest_sha256"]), + "views_compiled_and_bound": report["compile"]["status"] == "pass", + "first_start_passed": True, + "first_process_stopped": first["process_group_absent_after_wait"], + "pre_restart_verification_passed": True, + "restart_profile_recompiled_from_manifest": report["restart_compile"]["status"] == "pass", + "restart_profile_started_without_sessions": report["restart_compile"][ + "session_directories_started_empty" + ], + "restart_passed": True, + "restart_process_is_distinct": first_payload["process_id"] != second_payload["process_id"] + and first_payload["runtime_instance_id"] != second_payload["runtime_instance_id"], + "identity_inputs_reproduced": first_payload["identity_inputs_sha256"] + == second_payload["identity_inputs_sha256"], + "manifest_reproduced": first_payload["manifest_sha256"] == second_payload["manifest_sha256"], + "query_reproduced": first_payload["query_sha256"] == second_payload["query_sha256"], + "answer_and_provenance_explainable": first_payload["answer_sha256"] == second_payload["answer_sha256"] + and first_payload["output_provenance"] == second_payload["output_provenance"], + "session_excluded_from_identity": report["session_boundary_readback"][ + "identity_runtime_unchanged_after_session" + ], + "drift_failed_closed": drift["fail_closed"] and not drift["answer_returned"], + "drift_profile_started_without_sessions": report["drift_compile"]["session_directories_started_empty"], + "drift_process_stopped": drift["process_group_absent_after_wait"], + "drift_process_had_no_orphan_cleanup": drift["orphan_cleanup_required"] is False, + "restart_process_stopped": second["process_group_absent_after_wait"], + } + report["status"] = "pass" if all(report["gates"].values()) else "fail" + if report["status"] == "pass": + exit_code = 0 + except (CanaryError, identity.IdentityManifestError, OSError, subprocess.SubprocessError) as exc: + report["status"] = "fail" + report["errors"].append({"type": type(exc).__name__, "message": str(exc)}) + finally: + shutil.rmtree(temporary_root, ignore_errors=True) + report["cleanup"] = { + "temporary_root_removed": not temporary_root.exists(), + "no_profile_retained": not temporary_root.exists(), + } + if not all(report["cleanup"].values()): + report["status"] = "fail" + exit_code = 1 + positive_restart_passed = report.get("status") == "pass" and not args.inject_drift_before_restart + report["current_tier"] = "T2_runtime" if positive_restart_passed else "T1_model" + report["goal_tier_satisfied"] = positive_restart_passed + report["runtime_component_proven"] = ( + "fresh_profile_recompile_plus_distinct_process_restart" + if positive_restart_passed + else "first_local_process_plus_pre_restart_drift_refusal" + ) + report["runtime_variant"] = "local_deterministic_identity_compiler_readback" + report["tier_basis"] = ( + "Capability Tier Proof defines T2_runtime as local API/runtime behavior with restart where relevant; " + "this is compiler-process runtime proof and explicitly excludes GatewayRunner and hosted-model proof." + if positive_restart_passed + else "This negative component does not complete the required restart path, so it remains below T2_runtime." + ) + report["claim_ceiling"] = ( + "Local disposable identity compilation, fresh-profile reconstruction, and cold-process restart only; " + "not a live GatewayRunner, hosted-model, Telegram, production database, VPS restart, or T3 proof." + if positive_restart_passed + else "Local first-process and pre-restart drift-refusal component only; no successful restart proof, " + "GatewayRunner, hosted model, production database, VPS, or T3 claim." + ) + stable = {key: value for key, value in report.items() if key != "receipt_sha256"} + report["receipt_sha256"] = behavior.canonical_sha256(stable) + identity.write_json(output, report) + return report, exit_code + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--profile-template", type=Path, required=True) + parser.add_argument("--hermes-root", type=Path, required=True) + parser.add_argument("--deployment-root", type=Path, required=True) + parser.add_argument("--source-root", type=Path, required=True) + parser.add_argument("--database-fingerprint", type=Path, required=True) + parser.add_argument("--constitution", type=Path, required=True) + parser.add_argument("--database-identity", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--inject-drift-before-restart", action="store_true") + args = parser.parse_args() + report, exit_code = run_canary(args) + print( + json.dumps( + { + "status": report.get("status"), + "current_tier": report.get("current_tier"), + "receipt_sha256": report.get("receipt_sha256"), + "output": str(args.output), + }, + sort_keys=True, + ) + ) + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_leo_behavior_manifest.py b/tests/test_leo_behavior_manifest.py index 5b87992..f8959c2 100644 --- a/tests/test_leo_behavior_manifest.py +++ b/tests/test_leo_behavior_manifest.py @@ -4,6 +4,8 @@ import json import subprocess from pathlib import Path +import pytest + from scripts import leo_behavior_manifest as manifest ROOT = Path(__file__).resolve().parents[1] @@ -58,6 +60,11 @@ gateway: assert '{"private":"conversation"}' not in encoded +def test_identity_config_rejects_nonboolean_smart_model_routing() -> None: + with pytest.raises(ValueError, match="smart_model_routing must be boolean"): + manifest.identity_config_projection({"smart_model_routing": {"route": "untrusted"}}) + + def test_behavior_manifest_changes_when_a_behavior_layer_changes(tmp_path: Path) -> None: profile = tmp_path / "profile" hermes = tmp_path / "hermes" @@ -156,3 +163,64 @@ def test_git_state_marks_untracked_files_dirty(tmp_path: Path) -> None: assert state["worktree_clean"] is False assert len(str(state["status_sha256"])) == 64 + + +def test_identity_runtime_omits_secret_values_but_pins_semantic_model_settings(tmp_path: Path) -> None: + profile = tmp_path / "profile" + hermes = tmp_path / "hermes" + deployment = tmp_path / "deployment" + config = """model: + provider: fixture + default: identity-v1 + max_tokens: 512 +gateway: + telegram: + bot_token: token-a + botToken: camel-token-a +provider: + apiKey: camel-api-key-a +transport: + endpoint: https://fixture-user:fixture-password-a@example.invalid/v1 + headers: ['Authorization: Bearer fixture-header-a'] +tools: + allowed: [identity_readback] +identity_runtime: + network_send_enabled: false + database_write_enabled: false + allowed_tools: [identity_readback] +""" + _write(profile / "config.yaml", config) + _write(profile / "skills" / "identity" / "SKILL.md", "identity\n") + _write(profile / "plugins" / "identity" / "__init__.py", "BOUNDARY = True\n") + _write(profile / "bin" / "identity.py", "READ_ONLY = True\n") + _write(hermes / "run_agent.py", "runtime\n") + _write(hermes / "agent" / "prompt_builder.py", "prompts\n") + for name in ( + "leo_behavior_manifest.py", + "leo_identity_manifest.py", + "leo_identity_profile.py", + "run_leo_identity_reconstruction_canary.py", + ): + _write(deployment / "scripts" / name, f"# {name}\n") + + before = manifest.build_manifest(profile, hermes, deployment) + _write( + profile / "config.yaml", + config.replace("token-a", "token-b") + .replace("camel-api-key-a", "camel-api-key-b") + .replace("fixture-password-a", "fixture-password-b") + .replace("fixture-header-a", "fixture-header-b"), + ) + secret_rotated = manifest.build_manifest(profile, hermes, deployment) + _write(profile / "config.yaml", config.replace("max_tokens: 512", "max_tokens: 1024")) + model_changed = manifest.build_manifest(profile, hermes, deployment) + + assert before["behavior_sha256"] != secret_rotated["behavior_sha256"] + assert before["identity_runtime_sha256"] == secret_rotated["identity_runtime_sha256"] + assert before["identity_runtime_sha256"] != model_changed["identity_runtime_sha256"] + encoded = json.dumps(before) + assert "token-a" not in encoded + assert "camel-token-a" not in encoded + assert "camel-api-key-a" not in encoded + assert "fixture-password-a" not in encoded + assert "fixture-header-a" not in encoded diff --git a/tests/test_leo_identity_manifest.py b/tests/test_leo_identity_manifest.py new file mode 100644 index 0000000..35dd4d6 --- /dev/null +++ b/tests/test_leo_identity_manifest.py @@ -0,0 +1,721 @@ +from __future__ import annotations + +import argparse +import copy +import json +import os +import subprocess +import time +from pathlib import Path + +import pytest + +from scripts import leo_behavior_manifest as behavior +from scripts import leo_identity_manifest as identity +from scripts import leo_identity_profile as profile_runtime +from scripts import run_leo_identity_reconstruction_canary as canary + +ROOT = Path(__file__).resolve().parents[1] + + +def _write(path: Path, value: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(value, encoding="utf-8") + + +def _write_json(path: Path, value: dict) -> None: + _write(path, json.dumps(value, indent=2, sort_keys=True) + "\n") + + +def _fixture(tmp_path: Path) -> dict[str, Path | dict]: + repo = tmp_path / "repo" + profile = repo / "profile-template" + hermes = repo / "hermes-runtime" + compiled = tmp_path / "compiled-profile" + _write( + profile / "config.yaml", + """model: + provider: fixture-local + default: identity-v1 + max_tokens: 512 +memory: + enabled: false +gateway: + execution_mode: local_no_send + telegram: + bot_token: never-emit-this-fixture-value +transport: + endpoint: https://fixture-user:fixture-password@example.invalid/v1 + headers: ['Authorization: Bearer fixture-secret'] +tools: + allowed: [identity_readback] + write_enabled: false +identity_runtime: + network_send_enabled: false + database_write_enabled: false + allowed_tools: [identity_readback] +""", + ) + _write(profile / "skills" / "identity" / "SKILL.md", "# identity readback\n") + _write(profile / "plugins" / "identity" / "__init__.py", "BOUNDARY = True\n") + _write(profile / "bin" / "identity_readback.py", "READ_ONLY = True\n") + _write(hermes / "run_agent.py", "RUNTIME = 'identity-v1'\n") + _write(hermes / "agent" / "prompt_builder.py", "SOUL_IS_GENERATED = True\n") + _write(hermes / "gateway" / "run.py", "SESSION_IS_IDENTITY = False\n") + _write(hermes / "hermes_cli" / "tools_config.py", "TOOLS = ('identity_readback',)\n") + _write(hermes / "tools" / "registry.py", "READ_ONLY = True\n") + for name in ( + "leo_behavior_manifest.py", + "leo_identity_manifest.py", + "leo_identity_profile.py", + "run_leo_identity_reconstruction_canary.py", + ): + _write(repo / "scripts" / name, (ROOT / "scripts" / name).read_text(encoding="utf-8")) + + fingerprint_path = repo / "fixtures" / "database-fingerprint.json" + constitution_path = repo / "fixtures" / "constitution.json" + database_identity_path = repo / "fixtures" / "database-identity.json" + fingerprint = { + "schema": identity.DATABASE_FINGERPRINT_SCHEMA, + "status": "ok", + "environment": "disposable_fixture", + "fingerprint_sha256": "1" * 64, + "table_rows_sha256": "2" * 64, + "structure_sha256": "3" * 64, + "table_count": 2, + "total_rows": 2, + "read_consistency": { + "status": "stable_wal_marker", + "before": { + "database": "fixture", + "database_user": "reader", + "system_identifier": "12345", + "wal_lsn": "0/1", + }, + "after": { + "database": "fixture", + "database_user": "reader", + "system_identifier": "12345", + "wal_lsn": "0/1", + }, + }, + } + constitution = { + "schema": identity.CONSTITUTION_SCHEMA, + "identity_name": "Leo", + "rules": [ + {"id": "evidence", "text": "Never use temporary session memory as canonical evidence."}, + {"id": "truth", "text": "Fail closed when a pinned identity input drifts."}, + ], + } + database_identity = { + "schema": identity.DATABASE_IDENTITY_SCHEMA, + "identity_name": "Leo", + "database_fingerprint_sha256": "1" * 64, + "source_provenance": { + "mode": identity.SYNTHETIC_DATABASE_MODE, + "canonical_authority_granted": False, + "same_transaction_as_fingerprint": False, + "export_receipt_sha256": None, + }, + "records": [ + { + "table": "public.personas", + "row_id": "persona-1", + "kind": "persona", + "rank": 0, + "text": "Leo is an evidence-bound reasoning interface.", + "status": "active", + "evidence_sha256": "a" * 64, + }, + { + "table": "public.beliefs", + "row_id": "belief-1", + "kind": "belief", + "rank": 0, + "text": "Exact provenance is stronger than plausible recollection.", + "status": "active", + "evidence_sha256": "b" * 64, + }, + ], + } + _write_json(fingerprint_path, fingerprint) + _write_json(constitution_path, constitution) + _write_json(database_identity_path, database_identity) + + subprocess.run(["git", "init", "-q", str(repo)], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.email", "test@example.com"], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.name", "Test"], check=True) + subprocess.run(["git", "-C", str(repo), "add", "."], check=True) + subprocess.run( + ["git", "-C", str(repo), "commit", "-qm", "fixture"], + check=True, + env={ + **dict(__import__("os").environ), + "GIT_AUTHOR_DATE": "2026-01-01T00:00:00Z", + "GIT_COMMITTER_DATE": "2026-01-01T00:00:00Z", + }, + ) + behavior_manifest = behavior.build_manifest(profile, hermes, repo) + manifest = identity.build_identity_manifest( + behavior_manifest=behavior_manifest, + database_fingerprint_path=fingerprint_path, + constitution_path=constitution_path, + database_identity_path=database_identity_path, + source_root=repo, + ) + return { + "repo": repo, + "profile": profile, + "hermes": hermes, + "compiled": compiled, + "fingerprint": fingerprint_path, + "constitution": constitution_path, + "database_identity": database_identity_path, + "behavior": behavior_manifest, + "manifest": manifest, + } + + +def _fully_rehash_manifest(fixture: dict[str, Path | dict], manifest: dict) -> None: + identity_hash = behavior.canonical_sha256(manifest["identity_inputs"]) + manifest["identity_inputs_sha256"] = identity_hash + fingerprint = identity.load_json(fixture["fingerprint"], "database fingerprint") + rules = identity.canonical_rules(identity.load_json(fixture["constitution"], "constitution")) + records = identity.canonical_database_records( + identity.load_json(fixture["database_identity"], "database identity"), + fingerprint_sha256=fingerprint["fingerprint_sha256"], + ) + database_provenance = identity.database_identity_provenance( + identity.load_json(fixture["database_identity"], "database identity"), + fingerprint=fingerprint, + ) + rendered = identity.render_identity_views( + identity_inputs_sha256=identity_hash, + database_fingerprint_sha256=fingerprint["fingerprint_sha256"], + database_provenance=database_provenance, + rules=rules, + records=records, + ) + for name, content in rendered.items(): + manifest["compiled_views"][name]["sha256"] = behavior.sha256_bytes(content.encode("utf-8")) + manifest["compiled_views"][name]["generated_from_identity_inputs_sha256"] = identity_hash + stable = {key: value for key, value in manifest.items() if key != "manifest_sha256"} + manifest["manifest_sha256"] = behavior.canonical_sha256(stable) + + +def _rehash_behavior_manifest(manifest: dict) -> None: + manifest["identity_runtime_sha256"] = behavior.canonical_sha256(behavior.identity_runtime_payload(manifest)) + manifest["static_runtime_sha256"] = behavior.canonical_sha256(behavior.static_runtime_payload(manifest)) + manifest["behavior_sha256"] = behavior.canonical_sha256(behavior.stable_manifest_payload(manifest)) + + +def _canary_args(fixture: dict[str, Path | dict], output: Path, *, inject_drift: bool = False) -> argparse.Namespace: + return argparse.Namespace( + profile_template=fixture["profile"], + hermes_root=fixture["hermes"], + deployment_root=fixture["repo"], + source_root=fixture["repo"], + database_fingerprint=fixture["fingerprint"], + constitution=fixture["constitution"], + database_identity=fixture["database_identity"], + output=output, + inject_drift_before_restart=inject_drift, + ) + + +def test_manifest_compiles_generated_views_and_reproduces_identity_across_queries(tmp_path: Path) -> None: + fixture = _fixture(tmp_path) + manifest = fixture["manifest"] + assert isinstance(manifest, dict) + rebuilt = identity.build_identity_manifest( + behavior_manifest=fixture["behavior"], + database_fingerprint_path=fixture["fingerprint"], + constitution_path=fixture["constitution"], + database_identity_path=fixture["database_identity"], + source_root=fixture["repo"], + ) + assert rebuilt == manifest + + compile_receipt = profile_runtime.compile_profile( + manifest, + source_root=fixture["repo"], + profile_template=fixture["profile"], + profile=fixture["compiled"], + hermes_root=fixture["hermes"], + deployment_root=fixture["repo"], + ) + first = profile_runtime.query_profile( + fixture["compiled"], + query=profile_runtime.FIXED_QUERY, + source_root=fixture["repo"], + hermes_root=fixture["hermes"], + deployment_root=fixture["repo"], + ) + second = profile_runtime.query_profile( + fixture["compiled"], + query=profile_runtime.FIXED_QUERY, + source_root=fixture["repo"], + hermes_root=fixture["hermes"], + deployment_root=fixture["repo"], + ) + + assert compile_receipt["status"] == "pass" + assert "never-emit-this-fixture-value" not in (fixture["compiled"] / "config.yaml").read_text(encoding="utf-8") + assert "fixture-password" not in (fixture["compiled"] / "config.yaml").read_text(encoding="utf-8") + assert "fixture-secret" not in (fixture["compiled"] / "config.yaml").read_text(encoding="utf-8") + assert (fixture["compiled"] / "SOUL.md").read_text(encoding="utf-8").startswith("