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 15c30f1fbe30c8f2295ddc33e293a45788a95706 Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Wed, 15 Jul 2026 03:19:15 +0200 Subject: [PATCH 2/8] Make Leo skill onboarding clean-context reproducible (#150) * make Leo skill onboarding clean-context reproducible * harden Leo clean-context skill acceptance --- .../skills/leo-telegram-canary-ops/SKILL.md | 16 +- .agents/skills/living-ip-kb-interop/SKILL.md | 4 +- .agents/skills/openclaw-agent/SKILL.md | 7 +- .agents/skills/teleo-gcp-parity-ops/SKILL.md | 16 ++ .../skills/teleo-infra-provenance/SKILL.md | 4 +- .../teleo-kb-db-change-workflow/SKILL.md | 5 +- .agents/skills/teleo-leo-onboarding/SKILL.md | 58 +++- .../scripts/install_skill_pack.py | 106 ++++++++ .../scripts/run_clean_context_canary.py | 172 ++++++++++++ .../scripts/validate_skill_pack.py | 248 ++++++++++++++++++ .../teleo-reconstruction-recovery/SKILL.md | 83 ++++++ .../agents/openai.yaml | 4 + .../working-leo-m3taversal-outcomes/SKILL.md | 9 +- README.md | 24 +- docs/kb-rebuild-and-recompile.md | 16 +- .../current-truth-index.md | 50 +++- .../fable-leo-teleo-onboarding.md | 16 +- ...architecture-work-and-outcomes-20260714.md | 71 +++-- .../operator-surface-map.md | 17 +- ...ck-clean-context-agent-review-current.json | 106 ++++++++ ...ill-pack-clean-context-canary-current.json | 71 +++++ .../skill-pack-manifest.json | 142 ++++++++-- .../skill-pack-readme.md | 107 ++++++-- tests/test_repo_skill_pack.py | 169 +++++++++++- 24 files changed, 1389 insertions(+), 132 deletions(-) create mode 100755 .agents/skills/teleo-leo-onboarding/scripts/install_skill_pack.py create mode 100755 .agents/skills/teleo-leo-onboarding/scripts/run_clean_context_canary.py create mode 100755 .agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py create mode 100644 .agents/skills/teleo-reconstruction-recovery/SKILL.md create mode 100644 .agents/skills/teleo-reconstruction-recovery/agents/openai.yaml create mode 100644 docs/reports/leo-working-state-20260709/skill-pack-clean-context-agent-review-current.json create mode 100644 docs/reports/leo-working-state-20260709/skill-pack-clean-context-canary-current.json diff --git a/.agents/skills/leo-telegram-canary-ops/SKILL.md b/.agents/skills/leo-telegram-canary-ops/SKILL.md index 0788e1c..2e187c3 100644 --- a/.agents/skills/leo-telegram-canary-ops/SKILL.md +++ b/.agents/skills/leo-telegram-canary-ops/SKILL.md @@ -50,7 +50,7 @@ Name the canary before sending: ## Message Discipline -Use unique markers such as `WL-LIVE-TG-CORY-YYYYMMDD-Tn`. +Use unique markers such as `WL-LIVE-TG-M3TAVERSAL-YYYYMMDD-Tn`. Ask Leo for machine-checkable reply markers like: @@ -74,10 +74,14 @@ After each live Telegram canary: ## Current Known Proof -- Live memory and KB audit passed for marker `WL-LIVE-TG-CORY-20260709`. -- Live staged write passed for `WL-LIVE-TG-CORY-20260709-T3`. -- Readback passed for `WL-LIVE-TG-CORY-20260709-T4`. -- Open-ended m3taversal-style read-only triage passed for the legacy marker - `WL-LIVE-TG-CORY-OPEN-20260709`. +- Live memory and KB audit are retained in + `docs/reports/leo-working-state-20260709/telegram-live-canary-current.json`. +- Live staged-write proof is retained in + `docs/reports/leo-working-state-20260709/telegram-live-db-write-canary-20260709.json`. +- Open-ended m3taversal-standard read-only triage is retained in + `docs/reports/leo-working-state-20260709/telegram-live-open-ended-suite-current.md`. + +Historical receipts contain immutable legacy marker IDs. Treat them only as +evidence identifiers; never reuse them as a participant name or future marker. Use `docs/reports/leo-working-state-20260709/current-truth-index.md` for artifact paths. diff --git a/.agents/skills/living-ip-kb-interop/SKILL.md b/.agents/skills/living-ip-kb-interop/SKILL.md index 5b4d3bb..7236117 100644 --- a/.agents/skills/living-ip-kb-interop/SKILL.md +++ b/.agents/skills/living-ip-kb-interop/SKILL.md @@ -25,7 +25,7 @@ Prefer deterministic local surfaces before asking an LLM: - generated claim indexes from `lib/claim_index.py`; - search helpers in `lib/search.py`; - copied SQLite state through `teleo-db-operator`; -- retained proof JSON in `.crabbox-results/` or `proof/`. +- retained proof JSON at a caller-specified generated-output path. Read outputs must include file paths, source paths, claim/entity IDs when available, and the exact query used. @@ -80,7 +80,7 @@ If a runtime cannot implement one of these, record the missing tool as a blocker ## Expected Artifact -Write `.crabbox-results/kb-interop-proof.json` or a caller-specified proof path containing: +Write `/kb-interop-proof.json` containing: - runtime name; - model/provider if known; diff --git a/.agents/skills/openclaw-agent/SKILL.md b/.agents/skills/openclaw-agent/SKILL.md index 1f6b0ad..6a94ae3 100644 --- a/.agents/skills/openclaw-agent/SKILL.md +++ b/.agents/skills/openclaw-agent/SKILL.md @@ -28,9 +28,10 @@ Create or update: - `AGENTS.md`: scope, repo boundaries, proof requirements; - `SOUL.md`: Rio or Theseus identity; - `TOOLS.md`: bounded tools only; -- `skills/decision-engine-refinement/SKILL.md`; -- `skills/living-ip-kb-interop/SKILL.md`; -- `skills/teleo-db-operator/SKILL.md` only for read-only local copies unless explicitly authorized. +- `/skills/decision-engine-refinement/SKILL.md`; +- `/skills/living-ip-kb-interop/SKILL.md`; +- `/skills/teleo-db-operator/SKILL.md` only for read-only + local copies unless explicitly authorized. ## Tool Policy diff --git a/.agents/skills/teleo-gcp-parity-ops/SKILL.md b/.agents/skills/teleo-gcp-parity-ops/SKILL.md index 6d9191e..5cd671d 100644 --- a/.agents/skills/teleo-gcp-parity-ops/SKILL.md +++ b/.agents/skills/teleo-gcp-parity-ops/SKILL.md @@ -83,6 +83,22 @@ Do not describe a passing Hermes memory sync as canonical KB parity. - Persistent GCP `teleo_canonical` remains the older staging copy measured at `52,164` rows and `26` proposals. It has not been promoted or cut over. +## Open Least-Privilege Candidate + +PR #148, `Scope GCP Leo runtime to least-privilege Cloud SQL access`, is open as +of the 2026-07-15 skill-pack reconciliation. It proposes scoped runtime roles, +secret access, fail-closed Cloud SQL behavior, deployment rollback, and positive +plus negative permission checks. Those branch files and proposed live outcomes +are candidate evidence only. Before using them, run: + +```bash +gh pr view 148 --json state,mergedAt,mergeCommit,headRefName,url +``` + +Until the PR is merged and its runtime receipt passes, use only paths present on +canonical `main`, keep persistent GCP classified as staging, do not promote it, +and do not infer least-privilege cutover from a branch or dry run. + Primary retained proof: - `docs/reports/leo-working-state-20260709/gcp-db-first-working-leo-20260714.md` diff --git a/.agents/skills/teleo-infra-provenance/SKILL.md b/.agents/skills/teleo-infra-provenance/SKILL.md index 0329f39..8c242c6 100644 --- a/.agents/skills/teleo-infra-provenance/SKILL.md +++ b/.agents/skills/teleo-infra-provenance/SKILL.md @@ -26,11 +26,13 @@ Prevent incorrect assumptions about where Leo/Teleo code, runtime state, DB stat - Hermes/leoclean runtime profile. - Postgres canonical DB. - `kb_stage` proposal ledger. -- Retained proof artifacts under local `outputs/`. +- Caller-specific external output directories, which are retained evidence but + are not repository routes unless copied into the repo evidence pack. - Synced report artifacts under the VPS profile report directory. ## Current Known Path Map +- Canonical code/deployment repository: GitHub `living-ip/teleo-infrastructure`. - Repo-local evidence pack: `docs/reports/leo-working-state-20260709/` - VPS deploy/source area: `/opt/teleo-eval/workspaces/deploy-infra` - VPS deploy stamp: `/opt/teleo-eval/.last-deploy-sha` diff --git a/.agents/skills/teleo-kb-db-change-workflow/SKILL.md b/.agents/skills/teleo-kb-db-change-workflow/SKILL.md index 6827cc9..34ce68e 100644 --- a/.agents/skills/teleo-kb-db-change-workflow/SKILL.md +++ b/.agents/skills/teleo-kb-db-change-workflow/SKILL.md @@ -44,7 +44,7 @@ Run the deterministic source-to-proposal canary: ```bash .venv/bin/python scripts/run_leo_local_ingestion_proposal_canary.py \ --fixture fixtures/working-leo/document-ingestion-v1.json \ - --output outputs/leo-local-ingestion-proposal-canary-current.json + --output /tmp/leo-local-ingestion-proposal-canary-current.json ``` Require the source, claim, evidence, and proposal UUIDs to link exactly; claim @@ -131,7 +131,8 @@ Read: - `docs/reports/leo-working-state-20260709/approve-claim-clone-canary-current.json` - `docs/reports/leo-working-state-20260709/leo-source-composition-clone-checkpoint-current.json` -- `outputs/leo-local-ingestion-proposal-canary-current.json` +- `docs/reports/leo-working-state-20260709/leo-v3-document-source-lifecycle-current.md` +- `docs/reports/leo-working-state-20260709/source-document-compiler-canary-20260713.md` ## Claim Ceiling diff --git a/.agents/skills/teleo-leo-onboarding/SKILL.md b/.agents/skills/teleo-leo-onboarding/SKILL.md index da5a823..61fb45f 100644 --- a/.agents/skills/teleo-leo-onboarding/SKILL.md +++ b/.agents/skills/teleo-leo-onboarding/SKILL.md @@ -1,13 +1,15 @@ --- name: teleo-leo-onboarding -description: Use when a worker needs fast context on Teleo/Living IP, Leo, the product architecture, current VPS/GCP split, and the July 9 working-state evidence before doing Leo, Teleo, KB, Telegram, VPS, or GCP work. +description: Use when a worker needs fast context on Teleo/Living IP, Leo architecture, VPS/GCP and Hermes runtime boundaries, database provenance, ingestion/apply/reconstruction, identity, testing, security, secrets, or incident recovery before doing Leo or Teleo work. --- # Teleo / Leo Onboarding ## Job -Orient the worker before action. Build a current, proof-linked understanding of the company/product, Leo's role, the infrastructure surfaces, and the exact claim ceiling. +Orient the worker before action. Build a current, proof-linked understanding of +the company/product, Leo's role, the infrastructure surfaces, and the exact +claim ceiling without rediscovering the system from historical chat. ## Trigger Phrases @@ -17,6 +19,8 @@ Orient the worker before action. Build a current, proof-linked understanding of - "Fable handoff for Leo" - "before working on Teleo infra" - "explain the architecture" +- "recover or reconstruct Leo" +- "which skill should I use" ## Core Model @@ -46,6 +50,52 @@ Orient the worker before action. Build a current, proof-linked understanding of - `SOUL.md` is a rendered/runtime artifact. Direct file edits are not canonical identity changes without DB row and render/sync proof. +## Clean-Context First Commands + +From the repository root, validate the pack without a virtual environment or +network access: + +```bash +.agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py --root . +``` + +Install exactly the manifest-indexed skills into an empty temporary agent skill +root with: + +```bash +.agents/skills/teleo-leo-onboarding/scripts/install_skill_pack.py \ + --root . \ + --target /private/tmp/teleo-clean-agent/skills +``` + +Installed skills still resolve repository-relative routes against the current +`teleo-infrastructure` checkout. Keep the agent working directory at the repo +root. For pytest, use `.venv/bin/python`; if `.venv` is absent, create it from +`README.md` before running tests. Do not guess a bare `python` command. + +Run the deterministic isolated acceptance canary with: + +```bash +.agents/skills/teleo-leo-onboarding/scripts/run_clean_context_canary.py \ + --root . \ + --output docs/reports/leo-working-state-20260709/skill-pack-clean-context-canary-current.json +``` + +## Task Router + +| Area | Skill | Canonical first route | +|---|---|---| +| Company, product, architecture | `teleo-leo-onboarding` | `docs/reports/leo-working-state-20260709/current-truth-index.md` | +| VPS runtime and incidents | `teleo-vps-runtime-ops` | Fresh service, deploy, DB, and cleanup readbacks | +| GCP and Cloud SQL | `teleo-gcp-parity-ops` | Current main evidence; keep open PR #148 candidate-only | +| Hermes packaging/runtime | `nousresearch-hermes-agent` | `hermes-agent/` plus the live service-specific ops skill | +| Database provenance | `teleo-infra-provenance` and `teleo-db-operator` | Separate Git, runtime, SQLite, Postgres, and retained proof | +| Source ingestion and proposal/apply | `teleo-kb-db-change-workflow` | Hash-bound source, review, guarded apply, receipt | +| Reconstruction and recovery | `teleo-reconstruction-recovery` | `docs/kb-rebuild-and-recompile.md` | +| Identity and rendered soul | `working-leo-m3taversal-outcomes` | Canonical identity rows before `SOUL.md` | +| Testing and proof tiers | `teleo-proof-handoff` | Exact tier, command, receipt, cleanup, claim ceiling | +| Secrets | `private-password-storage` | Provider login first; never paste or retrieve a secret | + ## Read First From the repo root, read: @@ -61,6 +111,7 @@ From the repo root, read: 9. `.agents/skills/working-leo-m3taversal-outcomes/SKILL.md` 10. `docs/reports/leo-working-state-20260709/approve-claim-clone-canary-current.md` 11. `docs/reports/leo-working-state-20260709/gcp-cloud-sql-t3-live-readonly-current.md` +12. `docs/kb-rebuild-and-recompile.md` ## Required Status Split @@ -82,6 +133,9 @@ Do not collapse GCP demo readiness into Telegram completion. Do not collapse pro - Do not production-apply DB packets unless explicitly authorized. - Do not expose secret contents. - Do not treat an old summary as current if a fresh readback is cheap. +- PRs #146 and #147 are merged repository truth. PR #148 remains an open GCP + least-privilege candidate until live `gh pr view 148` readback says otherwise; + never route a deployment from its branch as though it were `main`. - Use `ssh -o BatchMode=yes teleo-gcp-staging true` to preflight passwordless GCP VM access. If the firewall route drifts, rediscover the authenticated non-secret route before declaring a blocker. The OIDC/IAP workflow is still diff --git a/.agents/skills/teleo-leo-onboarding/scripts/install_skill_pack.py b/.agents/skills/teleo-leo-onboarding/scripts/install_skill_pack.py new file mode 100755 index 0000000..e8e1166 --- /dev/null +++ b/.agents/skills/teleo-leo-onboarding/scripts/install_skill_pack.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Install the manifest-indexed Leo/Teleo skills into an empty skill root.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +DEFAULT_MANIFEST = Path("docs/reports/leo-working-state-20260709/skill-pack-manifest.json") +SKILL_NAME = re.compile(r"[a-z0-9-]{1,64}") + + +def _tracked_skill_files(root: Path, skill_dir: Path) -> list[Path]: + relative_dir = skill_dir.relative_to(root) + result = subprocess.run( + ["git", "-C", str(root), "ls-files", "-z", "--", str(relative_dir)], + check=False, + capture_output=True, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.decode("utf-8", errors="replace")) + files = [root / Path(item.decode("utf-8")) for item in result.stdout.split(b"\0") if item] + if not files: + raise ValueError(f"skill has no tracked files: {relative_dir}") + for source in files: + if source.is_symlink(): + raise ValueError(f"skill contains a symlink: {source.relative_to(root)}") + if not source.is_file(): + raise ValueError(f"skill route is not a regular file: {source.relative_to(root)}") + source.resolve().relative_to(skill_dir.resolve()) + return files + + +def install(root: Path, manifest_path: Path, target: Path) -> dict[str, object]: + manifest_file = root / manifest_path + manifest = json.loads(manifest_file.read_text(encoding="utf-8")) + skills = manifest["skills"] + if target.exists(): + raise ValueError(f"target already exists: {target}") + + target.parent.mkdir(parents=True, exist_ok=True) + stage = Path(tempfile.mkdtemp(prefix=".teleo-skill-pack-", dir=target.parent)) + installed: list[str] = [] + try: + for entry in skills: + name = entry["name"] + if not isinstance(name, str) or SKILL_NAME.fullmatch(name) is None: + raise ValueError(f"invalid skill name: {name!r}") + expected_path = Path(".agents") / "skills" / name / "SKILL.md" + if Path(entry["path"]) != expected_path: + raise ValueError(f"skill path must be {expected_path}: {entry['path']!r}") + if name in installed: + raise ValueError(f"duplicate skill name: {name}") + + source = root / expected_path.parent + source.resolve().relative_to((root / ".agents" / "skills").resolve()) + if not source.is_dir(): + raise FileNotFoundError(source) + destination = stage / name + destination.resolve().relative_to(stage.resolve()) + for tracked_source in _tracked_skill_files(root, source): + relative = tracked_source.relative_to(source) + tracked_destination = destination / relative + tracked_destination.resolve().relative_to(destination.resolve()) + tracked_destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(tracked_source, tracked_destination) + installed.append(name) + stage.rename(target) + except Exception: + shutil.rmtree(stage, ignore_errors=True) + raise + + return { + "artifact": "leo_teleo_skill_pack_install", + "status": "pass", + "installed_skill_count": len(installed), + "installed_skills": installed, + "contains_secrets": False, + "production_mutation_authorized": False, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + parser.add_argument("--target", type=Path, required=True) + args = parser.parse_args() + try: + result = install(args.root.resolve(), args.manifest, args.target.resolve()) + except Exception as exc: + result = {"artifact": "leo_teleo_skill_pack_install", "status": "fail", "error": str(exc)} + sys.stdout.write(json.dumps(result, indent=2, sort_keys=True) + "\n") + return 1 + sys.stdout.write(json.dumps(result, indent=2, sort_keys=True) + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/teleo-leo-onboarding/scripts/run_clean_context_canary.py b/.agents/skills/teleo-leo-onboarding/scripts/run_clean_context_canary.py new file mode 100755 index 0000000..3c36733 --- /dev/null +++ b/.agents/skills/teleo-leo-onboarding/scripts/run_clean_context_canary.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Run the deterministic T2 isolated-install acceptance canary for the skill pack.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +DEFAULT_MANIFEST = Path("docs/reports/leo-working-state-20260709/skill-pack-manifest.json") + +SEMANTIC_CONTRACT = { + "authority": ( + "teleo-infra-provenance", + "Canonical code/deployment repository: GitHub `living-ip/teleo-infrastructure`.", + "GitHub living-ip/teleo-infrastructure", + ), + "vps_database": ( + "teleo-leo-onboarding", + "VPS canonical KB: Docker Postgres `teleo-pg`, database `teleo`.", + "teleo-pg / teleo", + ), + "gcp_database": ( + "teleo-leo-onboarding", + "GCP canonical KB: private Cloud SQL database `teleo_canonical`.", + "private Cloud SQL teleo_canonical; persistent copy remains staging", + ), + "live_service": ( + "teleo-infra-provenance", + "Live service: `leoclean-gateway.service`", + "leoclean-gateway.service", + ), + "merged_reconstruction": ( + "teleo-reconstruction-recovery", + "PR #146 is merged", + "PR #146 merged deterministic genesis-plus-ledger reconstruction", + ), + "merged_reasoning_verifier": ( + "teleo-reconstruction-recovery", + "PR #147 is merged", + "PR #147 merged unseen-reasoning verifier hardening", + ), + "candidate_only_gcp": ( + "teleo-reconstruction-recovery", + "PR #148 is a separate, open GCP least-privilege candidate", + "PR #148 open candidate only; not canonical main", + ), + "next_safe_action": ( + "teleo-leo-onboarding", + ".agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py --root .", + "run the repo-local path validator before any operational action", + ), +} + + +def _run(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, check=False, capture_output=True, text=True) + + +def _contains_contract(text: str, required_text: str) -> bool: + """Compare prose semantically across ordinary Markdown line wrapping.""" + + return " ".join(required_text.split()) in " ".join(text.split()) + + +def run_canary(root: Path, manifest_path: Path) -> dict[str, object]: + manifest = json.loads((root / manifest_path).read_text(encoding="utf-8")) + installer = root / manifest["installer"] + selected_skills = sorted({contract[0] for contract in SEMANTIC_CONTRACT.values()}) + result: dict[str, object] + + with tempfile.TemporaryDirectory(prefix="teleo-clean-context-") as temporary_text: + temporary = Path(temporary_text) + installed_root = temporary / "skills" + install_process = _run( + [str(installer), "--root", str(root), "--manifest", str(manifest_path), "--target", str(installed_root)] + ) + try: + install_receipt = json.loads(install_process.stdout) + except json.JSONDecodeError: + install_receipt = {"status": "fail", "error": install_process.stdout or install_process.stderr} + + semantic_checks: dict[str, bool] = {} + answers: dict[str, str] = {} + evidence_paths: dict[str, str] = {} + for key, (skill_name, required_text, answer) in SEMANTIC_CONTRACT.items(): + installed_skill = installed_root / skill_name / "SKILL.md" + text = installed_skill.read_text(encoding="utf-8") if installed_skill.is_file() else "" + semantic_checks[key] = _contains_contract(text, required_text) + answers[key] = answer + evidence_paths[key] = f".agents/skills/{skill_name}/SKILL.md" + + installed_validator = installed_root / "teleo-leo-onboarding" / "scripts" / "validate_skill_pack.py" + validator_process = _run([str(installed_validator), "--root", str(root), "--manifest", str(manifest_path)]) + try: + validator_receipt = json.loads(validator_process.stdout) + except json.JSONDecodeError: + validator_receipt = {"status": "fail", "error": validator_process.stdout or validator_process.stderr} + + problems: list[dict[str, str]] = [] + if install_process.returncode != 0 or install_receipt.get("status") != "pass": + problems.append({"kind": "install_failed", "detail": str(install_receipt.get("error", "unknown"))}) + if validator_process.returncode != 0 or validator_receipt.get("status") != "pass": + problems.append({"kind": "path_validation_failed", "detail": str(validator_receipt.get("problems", []))}) + for key, passed in semantic_checks.items(): + if not passed: + problems.append({"kind": "semantic_route_missing", "detail": key}) + + passed = not problems + result = { + "artifact": "leo_teleo_skill_pack_isolated_install_canary", + "status": "pass" if passed else "fail", + "required_tier": "T2_runtime", + "current_tier": "T2_runtime" if passed else "T1_model", + "proof_scope": "deterministic_isolated_install_and_route_validation", + "fresh_temporary_skill_root": True, + "ambient_skill_root_used": False, + "agent_reasoning_exercised": False, + "installed_skill_count": install_receipt.get("installed_skill_count", 0), + "selected_installed_skills": selected_skills, + "semantic_checks": semantic_checks, + "expected_answers": answers, + "evidence_paths": evidence_paths, + "canary_command": ".agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py --root .", + "canary_exit_status": validator_process.returncode, + "canary_receipt": validator_receipt, + "missing_to_reach_required_tier": [] if passed else problems, + "strongest_claim_allowed": ( + "T2 deterministic isolated local skill install, route-contract, and path validation only; this artifact " + "does not claim an agent reasoning run, and retained product proofs keep their own VPS, GCP-staging, " + "isolated-clone, Telegram, and production claim ceilings" + ), + "contains_secrets": False, + "production_mutation_authorized": False, + "external_runtime_contacted": False, + "problems": problems, + } + + result["cleanup_readback"] = { + "temporary_skill_root_removed": not Path(temporary_text).exists(), + "orphan_processes_started": False, + } + if not result["cleanup_readback"]["temporary_skill_root_removed"]: + result["status"] = "fail" + result["current_tier"] = "T1_model" + result["problems"].append({"kind": "cleanup_failed", "detail": "temporary skill root remains"}) + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + + root = args.root.resolve() + result = run_canary(root, args.manifest) + payload = json.dumps(result, indent=2, sort_keys=True) + "\n" + if args.output: + output = args.output if args.output.is_absolute() else root / args.output + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(payload, encoding="utf-8") + sys.stdout.write(payload) + return 0 if result["status"] == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py b/.agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py new file mode 100755 index 0000000..8ecad61 --- /dev/null +++ b/.agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Validate the repo-native Leo/Teleo skill pack and its local routes.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shlex +import subprocess +import sys +from pathlib import Path + +DEFAULT_MANIFEST = Path("docs/reports/leo-working-state-20260709/skill-pack-manifest.json") +REPO_PREFIXES = ( + ".agents/", + ".github/", + "deploy/", + "docs/", + "fixtures/", + "hermes-agent/", + "lib/", + "ops/", + "outputs/", + "schemas/", + "scripts/", + "systemd/", + "tests/", +) +PATH_SUFFIXES = (".json", ".jsonl", ".md", ".py", ".sh", ".sql", ".yaml", ".yml") +CODE_SPAN = re.compile(r"`([^`\n]+)`") +MARKDOWN_LINK = re.compile(r"\[[^\]]+\]\(([^)]+)\)") +FENCED_SHELL = re.compile(r"```(?:bash|sh|shell)\n(.*?)```", re.DOTALL) + + +def _frontmatter(text: str) -> dict[str, str]: + if not text.startswith("---\n"): + return {} + try: + block = text.split("---\n", 2)[1] + except IndexError: + return {} + values: dict[str, str] = {} + for line in block.splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + values[key.strip()] = value.strip().strip('"') + return values + + +def _tracked_paths(root: Path) -> set[Path]: + result = subprocess.run( + ["git", "-C", str(root), "ls-files", "-z"], + check=False, + capture_output=True, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.decode("utf-8", errors="replace")) + return {Path(item.decode("utf-8")) for item in result.stdout.split(b"\0") if item} + + +def _is_tracked(root: Path, path: Path, tracked: set[Path]) -> bool: + relative = path.resolve().relative_to(root.resolve()) + if path.is_file(): + return relative in tracked + prefix = f"{relative.as_posix().rstrip('/')}/" + return any(item.as_posix().startswith(prefix) for item in tracked) + + +def _clean_token(token: str) -> str: + token = token.strip().strip("'\"") + token = token.rstrip(",;.)]") + token = re.sub(r":\d+$", "", token) + return token + + +def _candidate_tokens(span: str) -> list[str]: + try: + tokens = shlex.split(span) + except ValueError: + tokens = span.split() + return [_clean_token(token) for token in tokens] + + +def _resolve_candidate(root: Path, source: Path, token: str) -> Path | None: + if not token or token in {".md", ".json", ".py", ".sh", ".sql"}: + return None + if any(marker in token for marker in ("*", "<", ">", "...", "${", "$LEDGER")): + return None + if token.startswith(("http://", "https://", "/", "~/")): + return None + if token.startswith(REPO_PREFIXES) or token in {"README.md", "CODEOWNERS"}: + candidate = root / token + elif token.endswith(PATH_SUFFIXES) and "/" in token: + candidate = source.parent / token + else: + return None + try: + candidate.resolve().relative_to(root.resolve()) + except ValueError: + return None + return candidate + + +def _inline_paths(root: Path, source: Path) -> set[Path]: + text = source.read_text(encoding="utf-8") + candidates: set[Path] = set() + spans = CODE_SPAN.findall(text) + MARKDOWN_LINK.findall(text) + for block in FENCED_SHELL.findall(text): + spans.extend(line for line in block.splitlines() if line.strip()) + for span in spans: + for token in _candidate_tokens(span): + candidate = _resolve_candidate(root, source, token) + if candidate is not None: + candidates.add(candidate) + return candidates + + +def validate(root: Path, manifest_path: Path) -> dict[str, object]: + problems: list[dict[str, str]] = [] + checked: set[Path] = set() + manifest_file = root / manifest_path + if not manifest_file.is_file(): + return { + "artifact": "leo_teleo_skill_pack_path_validation", + "status": "fail", + "problems": [{"kind": "missing_manifest", "path": str(manifest_path)}], + } + + manifest = json.loads(manifest_file.read_text(encoding="utf-8")) + tracked = _tracked_paths(root) + if not _is_tracked(root, manifest_file, tracked): + problems.append({"kind": "untracked_manifest", "path": str(manifest_path)}) + skills = manifest.get("skills", []) + skill_names = {entry.get("name") for entry in skills} + + for entry in skills: + relative = Path(entry["path"]) + path = root / relative + checked.add(path) + if not path.is_file(): + problems.append({"kind": "missing_skill", "path": str(relative)}) + continue + if not _is_tracked(root, path, tracked): + problems.append({"kind": "untracked_skill", "path": str(relative)}) + metadata = _frontmatter(path.read_text(encoding="utf-8")) + if metadata.get("name") != entry.get("name"): + problems.append({"kind": "skill_name_mismatch", "path": str(relative)}) + if not metadata.get("description"): + problems.append({"kind": "missing_skill_description", "path": str(relative)}) + if set(metadata) != {"name", "description"}: + problems.append({"kind": "invalid_skill_frontmatter_keys", "path": str(relative)}) + if not re.fullmatch(r"[a-z0-9-]{1,64}", metadata.get("name", "")): + problems.append({"kind": "invalid_skill_name", "path": str(relative)}) + if relative.parent.name != metadata.get("name"): + problems.append({"kind": "skill_folder_name_mismatch", "path": str(relative)}) + + required_coverage = manifest.get("required_coverage", {}) + for area, owners in required_coverage.items(): + for owner in owners: + if owner not in skill_names: + problems.append({"kind": "unknown_coverage_owner", "path": f"{area}:{owner}"}) + + reference_paths = list(manifest.get("reference_files", [])) + command_paths = list(manifest.get("command_files", [])) + scan_paths = list(manifest.get("scan_files", [])) + for relative_text in reference_paths + command_paths + scan_paths: + relative = Path(relative_text) + path = root / relative + checked.add(path) + if not path.is_file(): + problems.append({"kind": "missing_manifest_path", "path": str(relative)}) + elif not _is_tracked(root, path, tracked): + problems.append({"kind": "untracked_manifest_path", "path": str(relative)}) + + for relative_text in manifest.get("executable_files", []): + relative = Path(relative_text) + path = root / relative + checked.add(path) + if not path.is_file(): + problems.append({"kind": "missing_executable", "path": str(relative)}) + elif not os.access(path, os.X_OK): + problems.append({"kind": "not_executable", "path": str(relative)}) + + scan_sources = [root / entry["path"] for entry in skills] + scan_sources += [root / relative for relative in manifest.get("scan_files", [])] + for source in scan_sources: + if not source.is_file(): + continue + for candidate in _inline_paths(root, source): + checked.add(candidate) + if not candidate.exists(): + problems.append( + { + "kind": "missing_inline_path", + "path": str(candidate.resolve().relative_to(root.resolve())), + "source": str(source.relative_to(root)), + } + ) + elif not _is_tracked(root, candidate, tracked): + problems.append( + { + "kind": "untracked_inline_path", + "path": str(candidate.resolve().relative_to(root.resolve())), + "source": str(source.relative_to(root)), + } + ) + + relative_paths = sorted(str(path.resolve().relative_to(root.resolve())) for path in checked) + digest = hashlib.sha256("\n".join(relative_paths).encode("utf-8")).hexdigest() + return { + "artifact": "leo_teleo_skill_pack_path_validation", + "required_tier": "T2_runtime", + "status": "pass" if not problems else "fail", + "manifest": str(manifest_path), + "skill_count": len(skills), + "coverage_area_count": len(required_coverage), + "checked_path_count": len(relative_paths), + "checked_paths_sha256": digest, + "contains_secrets": False, + "production_mutation_authorized": False, + "problems": problems, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + + root = args.root.resolve() + result = validate(root, args.manifest) + payload = json.dumps(result, indent=2, sort_keys=True) + "\n" + if args.output: + output = args.output if args.output.is_absolute() else root / args.output + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(payload, encoding="utf-8") + sys.stdout.write(payload) + return 0 if result["status"] == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/teleo-reconstruction-recovery/SKILL.md b/.agents/skills/teleo-reconstruction-recovery/SKILL.md new file mode 100644 index 0000000..e7e5f71 --- /dev/null +++ b/.agents/skills/teleo-reconstruction-recovery/SKILL.md @@ -0,0 +1,83 @@ +--- +name: teleo-reconstruction-recovery +description: Use when restoring, rebuilding, recompiling, or incident-recovering Leo's Teleo knowledge database, including snapshot recovery, genesis-plus-ledger replay, source recompilation, parity verification, and honest recovery claim ceilings. +--- + +# Teleo Reconstruction Recovery + +## Job + +Choose the smallest recovery path that restores a usable Leo knowledge system +without confusing snapshot restoration, strict ledger replay, and semantic +source recompilation. + +## Authority + +Read `docs/kb-rebuild-and-recompile.md` first. It is the canonical recovery +contract. Use `docs/reports/leo-working-state-20260709/current-truth-index.md` +only for the newest retained proof point, and refresh live state before making a +current VPS or GCP claim. + +PR #146 is merged and owns the deterministic genesis-plus-ledger reconstruction +slice in `ops/run_local_genesis_ledger_rebuild.py`. PR #147 is merged and owns +the current unseen-reasoning verifier in +`scripts/verify_leo_unseen_reasoning_chain.py`. PR #148 is a separate, open GCP +least-privilege candidate; do not treat its branch files or proposed live state +as canonical `main` recovery instructions until it merges. + +## Select The Recovery Mode + +| Need | Path | Honest result | +|---|---|---| +| Restore the latest verified state quickly | `ops/run_local_canonical_postgres_rebuild.py` | Exact snapshot recovery | +| Prove strict post-genesis applies replay exactly | `ops/run_local_genesis_ledger_rebuild.py` | Isolated insert-only ledger replay | +| Turn one retained source into a reviewed packet | `scripts/compile_kb_source_packet.py` | Deterministic proposal packet, not canonical knowledge | +| Rebuild every row from original sources | Follow the semantic recompilation contract in `docs/kb-rebuild-and-recompile.md` | Partial until every row and receipt is accounted for | +| Diagnose a live incident before recovery | Use `.agents/skills/teleo-vps-runtime-ops/SKILL.md` and `.agents/skills/teleo-infra-provenance/SKILL.md` | Fresh read-only incident map | + +## Safe Order + +1. Identify the intended source database, snapshot, manifest, commit, and hashes. +2. Keep private dumps, row payloads, source excerpts, and replay material outside + the repository and mode `0600`. +3. Run the selected path in an isolated, network-disabled local container. +4. Verify schema, constraints, roles, counts, row hashes, key queries, and exact + cleanup. +5. Run the relevant focused tests, then the unseen-reasoning verifier when the + restored state is meant to support Leo answers. +6. Separate local reconstruction from VPS restore, GCP restore, service restart, + Telegram delivery, promotion, and production apply. Each is its own proof row. + +## Commands + +Use the repository virtual environment documented in `README.md`: + +```bash +.venv/bin/python ops/run_local_canonical_postgres_rebuild.py --help +.venv/bin/python ops/run_local_genesis_ledger_rebuild.py --help +.venv/bin/python scripts/compile_kb_source_packet.py --help +.venv/bin/python -m pytest -q \ + tests/test_run_local_canonical_postgres_rebuild.py \ + tests/test_run_local_genesis_ledger_rebuild.py \ + tests/test_verify_leo_unseen_reasoning_chain.py +``` + +The help and focused-test commands are safe local canaries. A real recovery +requires caller-supplied private material and must retain a sanitized receipt +without the material itself. + +## Stop Conditions + +Stop before any live restore or restart when the source snapshot is not +hash-bound, the source authority is ambiguous, parity fails, private material +would enter Git or logs, cleanup cannot be proved, or the requested operation +would promote GCP or mutate production without exact authorization. + +## Claim Ceiling + +- Exact snapshot recovery is working when its parity receipt passes. +- The merged genesis-plus-ledger path proves an isolated insert-only slice. +- Semantic source-to-blank-database recompilation remains incomplete until every + canonical row traces to a genesis record or reviewed replay receipt. +- No local receipt proves a live VPS/GCP restore, service restart, Telegram + delivery, production apply, or GCP promotion. diff --git a/.agents/skills/teleo-reconstruction-recovery/agents/openai.yaml b/.agents/skills/teleo-reconstruction-recovery/agents/openai.yaml new file mode 100644 index 0000000..9148b48 --- /dev/null +++ b/.agents/skills/teleo-reconstruction-recovery/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Teleo Reconstruction Recovery" + short_description: "Recover and reconstruct Leo knowledge safely" + default_prompt: "Use $teleo-reconstruction-recovery to choose and verify the safest Leo database recovery path." diff --git a/.agents/skills/working-leo-m3taversal-outcomes/SKILL.md b/.agents/skills/working-leo-m3taversal-outcomes/SKILL.md index bf3e3c4..ee69a52 100644 --- a/.agents/skills/working-leo-m3taversal-outcomes/SKILL.md +++ b/.agents/skills/working-leo-m3taversal-outcomes/SKILL.md @@ -62,11 +62,16 @@ A working Leo is a Telegram-facing agent that: ## Current Benchmark Cases -- Marker memory: `WL-LIVE-TG-CORY-20260709`. +- Historical marker-memory receipt: + `docs/reports/leo-working-state-20260709/telegram-live-canary-current.json`. - Truth correction: `approved != applied`. - Applied canary: proposal `00957f6c-9883-4015-95a4-6b09367efb0e` and edge `c167933e-d513-4f43-9335-d5d8aeb259f2`. - Staged write canary: proposal `8dfedb3f-3aa4-4200-970f-4c0016f6869f`, status `pending_review`, with no new public canonical rows after the test timestamp. -- Open-ended triage canary: `WL-LIVE-TG-CORY-OPEN-20260709`, where Leo inferred the likely failure mode from "agents not working / same state as last night" and explained the proposed, approved, and applied split without exact IDs. +- Open-ended triage receipt: + `docs/reports/leo-working-state-20260709/telegram-live-open-ended-suite-current.md`, + where Leo inferred the likely failure mode from "agents not working / same + state as last night" and explained the proposed, approved, and applied split + without exact IDs. - Old rich proposal packets: `14fa5ecc...`, `ac036c9d...`, and `a64df080...` are not to be silently production-applied. - Guarded apply proof: both generic and real Helmer v3 receipts pass `37/37` in disposable PostgreSQL; production Helmer remains unapplied. diff --git a/README.md b/README.md index 4a0f09c..e6bc31a 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,8 @@ A mirror sync (every 2 minutes) fast-forwards the PR onto Forgejo, where the pipeline picks it up. From there it's the same flow as agent-authored PRs — same tiers, same reviewers, same merge rules. -The contributor-facing guide lives in -[`teleo-codex/CONTRIBUTING.md`](https://github.com/living-ip/teleo-codex/blob/main/CONTRIBUTING.md). +The contributor-facing guide is the +[Teleo Codex contributing guide](https://github.com/living-ip/teleo-codex/blob/main/CONTRIBUTING.md). ## Repository layout @@ -119,10 +119,26 @@ status report in their Pentagon profile. ## Development ```bash -pip install -e ".[dev]" -pytest +python3 -m venv .venv +.venv/bin/pip install -e ".[dev]" +.venv/bin/python -m pytest ``` +## Leo / Teleo Operator Onboarding + +Start with the repo-native skill router, not historical chat or a runtime mirror: + +```bash +.agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py --root . +``` + +Then read `.agents/skills/teleo-leo-onboarding/SKILL.md` and +`docs/reports/leo-working-state-20260709/current-truth-index.md`. Recovery work +starts at `docs/kb-rebuild-and-recompile.md`. GitHub +`living-ip/teleo-infrastructure` is canonical code/deployment truth; VPS/GCP +runtime, canonical Postgres, proposal staging, Hermes memory, and retained +proofs are separate state surfaces. + ## Operations Production deployment runs on a single VPS. Runbook, restart procedures, diff --git a/docs/kb-rebuild-and-recompile.md b/docs/kb-rebuild-and-recompile.md index 2442702..2baf932 100644 --- a/docs/kb-rebuild-and-recompile.md +++ b/docs/kb-rebuild-and-recompile.md @@ -25,11 +25,12 @@ Run: --output /tmp/teleo-canonical-rebuild-receipt.json ``` -The newest retained 2026-07-14 post-V3 canary restored a fresh, network-isolated +The retained 2026-07-14 post-V3 canary restored a fresh, network-isolated Postgres and then removed it. The restored target matched the source across all 39 manifest tables and all 52,167 rows, with no schema, data, constraint, role, -or performance mismatch. Total runtime was 10.56 seconds, including 2.71 -seconds for `pg_restore`. Key rows included: +or performance mismatch. The same source snapshot was subsequently restored to +a disposable private-TLS GCP Cloud SQL clone with exact parity, a bounded +no-send reasoning turn, and verified cleanup. Key rows included: - 1,837 claims; - 4,145 sources; @@ -38,9 +39,10 @@ seconds for `pg_restore`. Key rows included: - 17 reasoning tools; - 29 proposals. -This snapshot includes the completed V3 source canary. It is ready as the -current restore input, but it has not yet been restored to GCP; current GCP -parity therefore remains unproven. +This snapshot includes the completed V3 source canary. Disposable GCP restore +parity is proven at that retained point. Persistent GCP `teleo_canonical` +remains the older staging copy, so ongoing parity, promotion, and production +cutover remain unproven. This is the fastest disaster-recovery path. It does not require Leo to re-extract or relearn the corpus. @@ -134,7 +136,7 @@ kept all database counts and the proposal payload hash unchanged, left the Leo gateway on the same PID with zero restarts, and removed the temporary private receipt. The full receipt is deliberately not committed because it can contain claim bodies or source excerpts; the sanitized proof is retained as -`reports/leo-working-state-20260709/kb-apply-replay-receipt-current.json`. +`docs/reports/leo-working-state-20260709/kb-apply-replay-receipt-current.json`. Normal applies mark the SQL hash as `exact_executed_sql`. A later `--receipt-only` recovery marks it as `reconstructed_current_engine`; it never diff --git a/docs/reports/leo-working-state-20260709/current-truth-index.md b/docs/reports/leo-working-state-20260709/current-truth-index.md index 03387e8..947ee1e 100644 --- a/docs/reports/leo-working-state-20260709/current-truth-index.md +++ b/docs/reports/leo-working-state-20260709/current-truth-index.md @@ -2,6 +2,30 @@ Use this file before making status claims. Prefer fresh VPS/GCP readbacks when cheap; this directory is the retained July 9 evidence snapshot committed to the Teleo infrastructure repo. +## Repository And Skill Routing Addendum - 2026-07-15 + +- PRs #146 and #147 are merged into canonical `main`. The reconstruction source + of truth is `docs/kb-rebuild-and-recompile.md`; the merged deterministic paths + are `ops/run_local_genesis_ledger_rebuild.py`, + `scripts/leo_turn_execution_manifest.py`, and + `scripts/verify_leo_unseen_reasoning_chain.py`. +- PR #148 remains open candidate evidence for least-privilege GCP Cloud SQL + runtime access. Do not use its branch-only runbook, deployment script, role + migration, or proposed live outcome as canonical `main` until the PR is merged + and its retained runtime receipt passes. +- The repo-native onboarding entry point is + `.agents/skills/teleo-leo-onboarding/SKILL.md`. Its standard-library validator + is `.agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py`; run it + before relying on any skill route from a clean context. +- The deterministic isolated install/routing/cleanup receipt is + `docs/reports/leo-working-state-20260709/skill-pack-clean-context-canary-current.json`. +- The separate no-history agent onboarding receipt is + `docs/reports/leo-working-state-20260709/skill-pack-clean-context-agent-review-current.json`. +- Exact snapshot restoration is working. The merged genesis-plus-ledger path is + an isolated insert-only recovery slice. Full semantic recompilation from every + original source remains incomplete. None of these local tiers proves a live + VPS/GCP restore, Telegram delivery, production apply, or GCP promotion. + ## GCP Database-First Addendum - 2026-07-14 10:07 UTC This addendum supersedes the GCP-pending statements in the earlier July 14 @@ -135,8 +159,8 @@ sections below override this newer status. - VPS canonical KB: claims `1837`, sources `4145`, evidence `4670`, edges `4916`, proposals `26`; proposal states are applied `2`, approved `3`, pending review `14`, canceled `7`. -- VPS direct Cory answers: no-send `DC-01` through `DC-06` strict-score `6/6`. -- Telegram direct Cory answers: partial. `DC-01` was visibly sent and its live +- VPS direct m3taversal-standard answers: no-send `DC-01` through `DC-06` strict-score `6/6`. +- Telegram direct m3taversal-standard answers: partial. `DC-01` was visibly sent and its live gateway reply strict-passed server-side, but the reply itself is not yet captured in Telegram. `DC-02` through `DC-06` are not yet sent. - DB composition/application: deterministic source-to-proposal, full-data clone @@ -146,7 +170,7 @@ sections below override this newer status. - GCP canonical DB: exact VPS parity is proven across `39` tables and `52,164` rows over private TLS, with zero row/catalog/role/extension/performance mismatches. Gateway PID `148735`, `NRestarts=0` stayed unchanged. -- GCP Cory replay: six DB-read routes pass. One real no-send model replay +- GCP m3taversal-standard replay: six DB-read routes pass. One real no-send model replay nominally scored `6/6`, but it was rejected because two replies printed false zero canonical counts. The harness now enables the required `status` read and rejects any printed count that differs from the canonical status receipt. A @@ -170,16 +194,16 @@ Newest artifacts: - `docs/reports/leo-working-state-20260709/gcp-canonical-parity-live-20260712.json` - `docs/reports/leo-working-state-20260709/teleo_clone_cory_20260712t1940z-canonical-parity-receipt.json` - `docs/reports/leo-working-state-20260709/telegram-visible-direct-claim-dc01-partial-current.json` -- `docs/reports/leo-working-state-20260709/gcp-cory-model-replay-current.json` (created only after the hardened rerun) +- `docs/reports/leo-working-state-20260709/gcp-db-first-blind-claim-current.json` - `docs/reports/leo-working-state-20260709/gcp-operator-access-blocker-current.json` ## Definition And Outcomes - Working Leo definition: `docs/reports/leo-working-state-20260709/working-leo-definition-20260709.md` -- Cory expected outcomes: `docs/reports/leo-working-state-20260709/cory-expected-working-leo-outcomes-20260709.md` +- Legacy-named expected-outcomes evidence: `docs/reports/leo-working-state-20260709/cory-expected-working-leo-outcomes-20260709.md` - Current state: `docs/reports/leo-working-state-20260709/working-leo-current-state-20260709.md` - Execution plan: `docs/reports/leo-working-state-20260709/working-leo-execution-plan-current.md` -- Open-ended benchmark spec: `docs/reports/leo-working-state-20260709/working-leo-open-ended-benchmark-spec.json` (5 live-safe open prompts, 9 broader Cory-style outcome scenarios, and 6 no-context direct-claim follow-up prompts for disposable clone/sandbox first) +- Open-ended benchmark spec: `docs/reports/leo-working-state-20260709/working-leo-open-ended-benchmark-spec.json` (5 live-safe open prompts, 9 broader m3taversal-standard outcome scenarios, and 6 no-context direct-claim follow-up prompts for disposable clone/sandbox first) - Open-ended live Telegram canary: `docs/reports/leo-working-state-20260709/telegram-live-open-ended-cory-style-20260709.md` - Open-ended live Telegram canary JSON: `docs/reports/leo-working-state-20260709/telegram-live-open-ended-cory-style-20260709.json` - Retained Telegram benchmark score for the open-ended canary: `docs/reports/leo-working-state-20260709/telegram-live-open-ended-benchmark-score-current.md` @@ -187,9 +211,9 @@ Newest artifacts: - Full live-safe open-ended Telegram suite report: `docs/reports/leo-working-state-20260709/telegram-live-open-ended-suite-current.md` - Full live-safe open-ended Telegram suite score: `docs/reports/leo-working-state-20260709/telegram-live-open-ended-suite-score-current.md` - Full live-safe open-ended Telegram suite score JSON: `docs/reports/leo-working-state-20260709/telegram-live-open-ended-suite-score-current.json` -- Full Cory-style outcome/direct-claim sandbox results: `docs/reports/leo-working-state-20260709/working-leo-cory-outcome-sandbox-results-current.md` -- Full Cory-style outcome/direct-claim sandbox score: `docs/reports/leo-working-state-20260709/working-leo-cory-outcome-sandbox-score-current.md` -- Full Cory-style outcome sandbox score JSON: `docs/reports/leo-working-state-20260709/working-leo-cory-outcome-sandbox-score-current.json` +- Full m3taversal-standard outcome/direct-claim sandbox results: `docs/reports/leo-working-state-20260709/working-leo-cory-outcome-sandbox-results-current.md` +- Full m3taversal-standard outcome/direct-claim sandbox score: `docs/reports/leo-working-state-20260709/working-leo-cory-outcome-sandbox-score-current.md` +- Full m3taversal-standard outcome sandbox score JSON: `docs/reports/leo-working-state-20260709/working-leo-cory-outcome-sandbox-score-current.json` - Live VPS handler direct-claim suite report: `docs/reports/leo-working-state-20260709/telegram-handler-direct-claim-suite-current.json` - Live VPS handler direct-claim suite score: `docs/reports/leo-working-state-20260709/telegram-handler-direct-claim-suite-score-current.md` - Live VPS handler direct-claim suite review: `docs/reports/leo-working-state-20260709/telegram-handler-direct-claim-suite-review-current.md` @@ -311,9 +335,9 @@ Newest artifacts: - Skill-pack map: `docs/reports/leo-working-state-20260709/leo-db-state-13-skill-pack.svg` - Helmer packet proof: `docs/reports/leo-working-state-20260709/leo-db-state-14-helmer-packet.svg` -- Open-ended Cory-style canary map: `docs/reports/leo-working-state-20260709/leo-db-state-15-open-ended-canary.svg` +- Open-ended m3taversal-standard canary map: `docs/reports/leo-working-state-20260709/leo-db-state-15-open-ended-canary.svg` - Helmer ledger packet proof: `docs/reports/leo-working-state-20260709/leo-db-state-16-helmer-ledger.svg` -- Cory-style outcome benchmark map: `docs/reports/leo-working-state-20260709/leo-db-state-17-cory-outcome-benchmark.svg` +- m3taversal-standard outcome benchmark map: `docs/reports/leo-working-state-20260709/leo-db-state-17-cory-outcome-benchmark.svg` - Leoclean DB versus workspace map: `docs/reports/leo-working-state-20260709/leo-db-state-18-db-vs-workspace.svg` - Claim/body/metadata schema map: `docs/reports/leo-working-state-20260709/leo-db-state-19-claims-body-metadata.svg` - Cross-surface strategy anchor proof: `docs/reports/leo-working-state-20260709/leo-db-state-20-cross-surface-anchor.svg` @@ -331,7 +355,7 @@ Newest artifacts: - VPS Leo Telegram memory, KB audit, and staged-write canaries are live-proven. - VPS Leo live-safe open-ended Telegram suite is live-proven and retained-benchmark-scored for `OE-01` through `OE-05`: full selected coverage, `5/5` scored prompts passing, no overclaim, and no marker/staging/canonical KB rows created by the suite. -- The broader sandbox-first Cory-style benchmark has a full-catalog mixed-evidence score: real retained live Telegram evidence for `OE-01` through `OE-05`, sandbox fixture replies for `CS-01` through `CS-09`, and no-context direct-claim expected-answer/follow-up fixtures for `DC-01` through `DC-06`, `20/20` passing. This is not a live Telegram run for the CS/DC prompts. +- The broader sandbox-first m3taversal-standard benchmark has a full-catalog mixed-evidence score: real retained live Telegram evidence for `OE-01` through `OE-05`, sandbox fixture replies for `CS-01` through `CS-09`, and no-context direct-claim expected-answer/follow-up fixtures for `DC-01` through `DC-06`, `20/20` passing. This is not a live Telegram run for the CS/DC prompts. - After deploy commit `0183a5c805fa3b51d638267afd20f67a1bc3777f`, three fresh VPS GatewayRunner clean-session trials strict-scored `6/6` (`18/18` replies). Every trial posted nothing, made no live profile or production DB change, preserved counts (`1837/4145/4916/4670/26`), removed its temp profile, and left `leoclean-gateway.service` active/running with PID `2403328`, zero restarts, and the same start timestamp. Final cleanup found no matching temp directories. - Intentional restart survival is now live-proven as a separate required proof row, not implied by `NRestarts=0`: `systemctl restart leoclean-gateway.service` returned `0`, service moved from `MainPID=1908033` to `MainPID=2845730`, stayed active/running after restart and after the no-post handler smoke, canonical DB counts stayed unchanged (`1837/4145/4670/4916/26`), no Telegram post or production DB apply ran, no live profile change was recorded, temp profile cleanup succeeded, and the post-restart no-post `DC-01` through `DC-06` handler smoke returned six runtime replies. The retained pre-repair post-restart strict score was `5/6`; after deploying repair SHA `090becae1621436467610e529d6328d70964d11f`, the same no-post direct-claim suite strict-scored `6/6` with `DC-01` through `DC-06` all passing, DB counts unchanged, no Telegram post, no production DB apply, no live profile change, and cleanup confirmed. The raw handler JSON is retained on the VPS at `/tmp/leo-post-skill-repair-direct-claim-current.json` with SHA256 `6fffdbafc18913c4fc62feb00906988dee2e696d279f22dd284951a79451f39c`. - The Telegram-visible direct-claim benchmark was run in group `Leo` (`-5146042086`): all six prompts and replies are visible, and DB/service state is unchanged. The hardened semantic scorer gives `5/6`; `DC-05` incorrectly suggests applying the strict canary `add_edge` path to one of three legacy approved proposals whose payload types do not all match. @@ -364,7 +388,7 @@ Newest artifacts: - Latest cleanup readback found no leftover rehearsal/clone/staging database names on the VPS Postgres container and did not mutate production DB or post to Telegram. - A live Telegram regression canary should be rerun after any production apply; no production apply has been authorized or executed. - GCP control-plane Chrome readback now proves project `teleo-501523`, running VM `teleo-prod-1` in `europe-west6-a`, and one available private-only PostgreSQL `16.14` Cloud SQL instance, `teleo-pgvector-standby`, in `europe-west6-c`. Cloud SQL Studio is reachable but exposes no enabled existing IAM database principal and requires built-in password authentication; no credential was submitted and no SQL ran. Database-row/runtime/DC-01-through-DC-06 parity therefore remains unproven. The exact next gate is an already-existing authenticated Studio principal or a separately authorized IAM/database-user enablement window. -- GCP Console observability now separately proves `teleo-prod-1` is running and that serial output through `2026-07-10T15:57:54Z` contains recurring successful `leoclean-cloudsql-memory-sync.service` cycles. The latest exact `leoclean-gcp-prod-parallel.service` lifecycle event in the retained serial buffer is a Hermes start on July 9 with no later exact stop/failure. Cloud Logging has `92` guest/platform records over seven days but zero matches for `leoclean`, `hermes`, `gateway`, `postgres`, or `teleo-kb`, and Ops Agent is absent. This is bounded unit-activity evidence, not current PID/restart/SHA/profile/model/auth/Telegram/DB-row parity; no-post Cory execution remains unsafe and was not attempted. The agent-created Console tab was closed and no GCP/DB/service/IAM/deploy/credential/Telegram mutation occurred. +- GCP Console observability now separately proves `teleo-prod-1` is running and that serial output through `2026-07-10T15:57:54Z` contains recurring successful `leoclean-cloudsql-memory-sync.service` cycles. The latest exact `leoclean-gcp-prod-parallel.service` lifecycle event in the retained serial buffer is a Hermes start on July 9 with no later exact stop/failure. Cloud Logging has `92` guest/platform records over seven days but zero matches for `leoclean`, `hermes`, `gateway`, `postgres`, or `teleo-kb`, and Ops Agent is absent. This is bounded unit-activity evidence, not current PID/restart/SHA/profile/model/auth/Telegram/DB-row parity; no-post m3taversal-standard execution remains unsafe and was not attempted. The agent-created Console tab was closed and no GCP/DB/service/IAM/deploy/credential/Telegram mutation occurred. - PR #77 auto-deployed to the VPS checkout at merge `1a0abd882d00c1b58fe203f42de42d4eeea61cbf` without restarting Leo: the deploy journal says `No Python changes - services not restarted`, the gateway remained PID `2999690` with `NRestarts=0`, and the apply worker/timer remained disabled and inactive. This proves source synchronization and runtime non-change, not a GCP replay. - A passwordless GCP operator is implemented and locally validated but is not yet bootstrapped in project `teleo-501523`. It uses GitHub OIDC/Workload Identity Federation, separate status and clone-operation service accounts, IAP TCP forwarding, OS Login, five-minute SSH keys, and a root-owned dispatcher limited to `status`, `direct-claim-replay`, and `cleanup-clone`. It stores no Google password, local refresh token, service-account JSON key, or API key. One final authenticated GCP admin session is still required to install and verify the IAM/IAP/OS Login/dispatcher path; the old public `/32` rule must remain enabled until the workflow status artifact passes. - The broader GCP working-Leo runner now has a complete `20`-prompt catalog and requires `32` GCP-executable non-tautological composition checks plus lifecycle, role-separation, immutable-source, and secret-redaction gates. Local tests and validation-only execution pass, and an independent VPS source baseline is retained with counts `1837/4145/4670/4916/17/26`; live GCP execution remains unproven because the SHA-bound target-identity receipt, four separated database roles/credentials, and passwordless operator bootstrap are not yet present. This does not inherit or claim the VPS-only `34/34` result. diff --git a/docs/reports/leo-working-state-20260709/fable-leo-teleo-onboarding.md b/docs/reports/leo-working-state-20260709/fable-leo-teleo-onboarding.md index 7dbc69a..bfc4f09 100644 --- a/docs/reports/leo-working-state-20260709/fable-leo-teleo-onboarding.md +++ b/docs/reports/leo-working-state-20260709/fable-leo-teleo-onboarding.md @@ -1,18 +1,20 @@ # Fable / Worker Onboarding Prompt - Leo / Teleo -current_canary: read the retained evidence index, then produce a repo/VPS/GCP/Telegram working-state map with exact claim ceilings and next executable action. +current_canary: validate the repo-native skill routes, then produce a repo/VPS/GCP/Telegram working-state map with exact claim ceilings and next executable action. Use the Teleo repo skill pack at: `.agents/skills/` -Load these draft skills first: +Run `.agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py --root .`, +then load these manifest-indexed skills first: -1. `skills/teleo-leo-onboarding/SKILL.md` -2. `skills/working-leo-m3taversal-outcomes/SKILL.md` -3. `skills/teleo-kb-db-change-workflow/SKILL.md` -4. `skills/teleo-vps-runtime-ops/SKILL.md` -5. `skills/teleo-proof-handoff/SKILL.md` +1. `.agents/skills/teleo-leo-onboarding/SKILL.md` +2. `.agents/skills/working-leo-m3taversal-outcomes/SKILL.md` +3. `.agents/skills/teleo-kb-db-change-workflow/SKILL.md` +4. `.agents/skills/teleo-reconstruction-recovery/SKILL.md` +5. `.agents/skills/teleo-vps-runtime-ops/SKILL.md` +6. `.agents/skills/teleo-proof-handoff/SKILL.md` Hard constraints: diff --git a/docs/reports/leo-working-state-20260709/leo-architecture-work-and-outcomes-20260714.md b/docs/reports/leo-working-state-20260709/leo-architecture-work-and-outcomes-20260714.md index 864d9f1..d50313d 100644 --- a/docs/reports/leo-working-state-20260709/leo-architecture-work-and-outcomes-20260714.md +++ b/docs/reports/leo-working-state-20260709/leo-architecture-work-and-outcomes-20260714.md @@ -1,6 +1,7 @@ # Leo Architecture, Work, And Outcomes -Current as of `2026-07-14 01:40 UTC`. +Original delivery summary from `2026-07-14 01:40 UTC`, reconciled with merged +repository truth on 2026-07-15. This is the executive summary of what Leo is, how its knowledge system works, what changed during the July 10-14 delivery wave, what is genuinely working, @@ -17,10 +18,16 @@ managed gateway restart. The current broad no-post suite passes `15/15`. **The whole system is not complete.** The current broad suite has not yet been repeated visibly in the real Telegram group through authenticated Google Chrome. -The newest post-V3 database copy has not yet been restored and replayed on GCP. -No V3 production proposal has been canonically applied. A snapshot can rebuild -the current database exactly, but the entire historical database cannot yet be -recompiled from raw documents and review history alone. +The newest post-V3 database copy has since been restored to a disposable private +GCP Cloud SQL clone with exact parity and a bounded no-send reasoning replay, +then cleaned up. Persistent GCP remains staging and older than that disposable +proof point. No V3 production proposal has been canonically applied. A snapshot +can rebuild the current database exactly, and merged PR #146 adds an exact +insert-only genesis-plus-ledger slice; the entire historical database still +cannot be recompiled from raw documents and review history alone. + +Repository reconciliation: PRs #146 and #147 are merged `main` truth. PR #148 +remains an open least-privilege GCP candidate and is not canonical runtime proof. ## The Architecture @@ -255,7 +262,8 @@ left the gateway unchanged, and removed the private receipt afterward. | Grounded proposal staging | live VPS `pending_review` row | Achieved | | Exact snapshot rebuild | isolated local Postgres, `52,167` rows | Achieved | | New strict-apply row receipt | deployed VPS read-only recovery | Achieved | -| Prior GCP database copy and six-question replay | private Cloud SQL and GCP compute | Achieved at the earlier `52,164`-row proof point | +| Current disposable GCP copy and ID-free replay | private Cloud SQL and GCP compute, `52,167/52,167` rows | Achieved and cleaned up | +| Genesis plus strict insert-only replay | isolated local Postgres through merged PR #146 | Achieved for supported strict contracts | ## What Is Not Achieved @@ -263,9 +271,9 @@ left the gateway unchanged, and removed the private receipt afterward. | --- | --- | | Current broad Telegram-visible proof | Telegram Web in the connected Google Chrome is not authenticated; handler proof is not visible-chat proof | | V3 production canonical apply | proposal remains `pending_review`; no review or apply was authorized | -| Current GCP parity | the `52,167`-row post-V3 snapshot has not yet been restored and replayed on GCP | | Arbitrary automatic document/post/tweet intake | the current source command handles one bounded text-like artifact; general fetch/extract/stage automation is not complete | -| Full source-to-blank-DB recompilation | historical provenance gaps remain and the genesis-plus-ledger replay runner is not finished | +| Persistent GCP parity and promotion | the disposable `52,167`-row proof passed, but persistent `teleo_canonical` remains the older staging copy and is not cut over | +| Full source-to-blank-DB recompilation | historical provenance gaps and unsupported mutating contracts remain beyond the merged insert-only genesis-plus-ledger slice | | General DB-to-identity rendering | no complete scheduled DB-to-`SOUL.md` renderer is proven | | Deployed decision matrix | matrix tables and voting flow are absent from the current live schema | @@ -282,13 +290,13 @@ left the gateway unchanged, and removed the private receipt afterward. ## How This Moves GCP Forward -- The prior GCP vertical slice proved the architecture can run against private - Cloud SQL and real GCP compute. -- The current post-V3 dump and manifest are already captured and locally - verified, so GCP work starts from a known input rather than rebuilding one. -- The same `15/15` suite and exact V3 question are ready for replay. -- The remaining GCP work is operational: authenticate, restore the current copy, - verify parity/private connectivity/performance, replay, and clean up. +- The current post-V3 snapshot has been restored to a disposable private Cloud + SQL clone with exact parity, bounded no-send replay, and cleanup. +- Persistent `teleo_canonical` remains the older staging copy; promotion, + continuous replication, Telegram delivery, and production cutover are + separate decisions and proof rows. +- Open PR #148 proposes least-privilege runtime access. Its branch and proposed + live end state remain candidate evidence until merge and receipt verification. ## How This Moves On-Demand Rebuild Forward @@ -311,10 +319,11 @@ verified post-V3 snapshot = genesis epoch -> broad answer benchmark ``` -The new receipt path closes the future side of this model. The next repo-owned -step is to replay one reviewed `approve_claim` receipt onto a clean genesis clone -and prove exact post-apply parity. Historical import gaps can then be repaired -without allowing new gaps to accumulate. +The new receipt path closes the future side of this model. Merged PR #146 now +replays supported strict insert-only receipts on a clean genesis clone and proves +exact post-apply parity. The next reconstruction work is to retain complete +deltas for mutating contracts, handle or reject legacy freeform applies, and +repair historical import gaps without allowing new gaps to accumulate. ## Delivery Chain @@ -333,16 +342,21 @@ The latest coherent delivery sequence is merged: | `#135` | current post-V3 snapshot and exact rebuild proof | | `#136` | private replay receipts for guarded applies | | `#137` | deployed receipt runtime proof | +| `#144` | GCP database-first restore, fail-closed helper, and live restart proof | +| `#146` | unified turn manifest and deterministic genesis-plus-ledger replay | +| `#147` | unseen-reasoning verifier hardened for valid reasoning variance | + +PR #148 is open candidate work and is intentionally excluded from the merged +delivery chain above. ## Immediate Next Outcomes 1. Authenticate Telegram Web in the connected Google Chrome and run visible, out-of-sample m3taversal-style questions. -2. Authenticate Google Cloud CLI through the connected Chrome flow, restore the - current post-V3 snapshot to disposable GCP staging, verify parity, replay the - broad suite, and remove temporary resources. -3. Complete the genesis-plus-receipt replayer for `approve_claim` bundles and - prove exact clone-to-replay parity. +2. Review and merge or reject PR #148, then verify the least-privilege runtime + receipt before considering any persistent GCP promotion. +3. Extend genesis-plus-receipt reconstruction beyond supported insert-only + contracts while retaining exact before/after deltas. 4. Run one explicitly reviewed source packet through guarded apply in a disposable clone before seeking any production apply authorization. 5. Reconstruct the remaining historical source, evidence, and edge provenance. @@ -363,7 +377,8 @@ The latest coherent delivery sequence is merged: The last three days produced a stable, testable, database-grounded Leo on the VPS at the handler tier, a real source-to-review path, exact snapshot recovery, -and durable replay evidence for future strict applies. They did not complete -current Telegram-visible broad proof, current GCP parity, a V3 production apply, -general automatic source ingestion, or full historical source-derived database -recompilation. +and durable replay evidence for future strict applies. Subsequent work added +disposable current GCP parity plus no-send replay and an exact insert-only +genesis-plus-ledger slice. The system still lacks current Telegram-visible broad +proof, persistent GCP parity/promotion, a V3 production apply, general automatic +source ingestion, and full historical source-derived database recompilation. diff --git a/docs/reports/leo-working-state-20260709/operator-surface-map.md b/docs/reports/leo-working-state-20260709/operator-surface-map.md index f62247e..4e790ad 100644 --- a/docs/reports/leo-working-state-20260709/operator-surface-map.md +++ b/docs/reports/leo-working-state-20260709/operator-surface-map.md @@ -32,10 +32,19 @@ Always refresh before claiming current state. ## GCP - Project: `teleo-501523` -- Expected active account for project access: `billy@livingip.xyz` -- Current retained probe: local gcloud config still selects `billy@livingip.xyz` and `teleo-501523`, but this runner cannot resolve `oauth2.googleapis.com`, so parity is blocked before token refresh/project access can be rechecked. -- Current artifact: `docs/reports/leo-working-state-20260709/gcp-db-parity-current-probe-current.json` -- Prior auth-capable artifact: `outputs/gcp-db-parity-current-blocker-20260709.json` +- Compute: `teleo-prod-1` in `europe-west6-a`. +- Private Cloud SQL: `teleo-pgvector-standby`, database `teleo_canonical`. +- Direct passwordless alias `ssh teleo-gcp-staging` and `sudo -n` were retained as + working on 2026-07-14; always re-run the batch-mode preflight before relying on + them. +- A disposable private-TLS clone matched the current VPS snapshot at `39` tables + and `52,167` rows, then was deleted. Persistent `teleo_canonical` remains the + older staging copy and is not promoted or cut over. +- Current artifacts: + `docs/reports/leo-working-state-20260709/gcp-db-first-working-leo-20260714.md` + and `docs/reports/leo-working-state-20260709/gcp-db-first-parity-current.json`. +- PR #148 is open candidate evidence for scoped runtime access; its branch and + proposed live outcome are not canonical `main`. ## Telegram diff --git a/docs/reports/leo-working-state-20260709/skill-pack-clean-context-agent-review-current.json b/docs/reports/leo-working-state-20260709/skill-pack-clean-context-agent-review-current.json new file mode 100644 index 0000000..2f9fddd --- /dev/null +++ b/docs/reports/leo-working-state-20260709/skill-pack-clean-context-agent-review-current.json @@ -0,0 +1,106 @@ +{ + "artifact": "leo_teleo_skill_pack_fresh_agent_review", + "status": "pass", + "required_tier": "T2_runtime", + "current_tier": "T2_runtime", + "proof_scope": "fresh_context_agent_onboarding_and_stale_claim_rejection", + "evidence_origin": { + "agent": "/root/fresh_skill_canary", + "fork_turns": "none", + "onboarding_authority": "isolated installed skill pack only", + "memory_used": false + }, + "answers": { + "canonical_code_deployment_repository_authority": { + "repository": "GitHub living-ip/teleo-infrastructure", + "canonical_branch": "main", + "vps_deploy_source_mirror": "/opt/teleo-eval/workspaces/deploy-infra", + "mirror_is_canonical_authority": false + }, + "vps_canonical_database": { + "container": "teleo-pg", + "database": "teleo" + }, + "live_vps_gateway_service": "leoclean-gateway.service", + "persistent_gcp_database": { + "instance": "teleo-pgvector-standby", + "database": "teleo_canonical", + "status": "older staging copy", + "retained_measurement": { + "tables": 39, + "rows": 52164, + "proposals": 26 + }, + "promoted": false, + "cut_over": false + }, + "pull_requests": { + "146": "merged into canonical main", + "147": "merged into canonical main", + "148": "open least-privilege GCP candidate; not canonical main" + }, + "approved_proposals_equal_applied_proposals": { + "equal": false, + "retained_vps_proposal_counts": { + "total": 29, + "applied": 2, + "approved": 3, + "pending_review": 16, + "canceled": 8 + }, + "meaning": "Approved proposals remain staged until canonical application and row-level proof." + }, + "first_safe_local_validation_command": ".agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py --root ." + }, + "stale_claims_rejected": { + "pr_148_is_merged": true, + "persistent_gcp_is_promoted": true, + "all_approved_proposals_are_applied": true + }, + "validator": { + "command": "/private/tmp/teleo-clean-agent-final-20260715/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py --root /private/tmp/teleo-fleet-20260715/skills-docs-onboarding", + "exit_code": 0, + "status": "pass", + "checked_path_count": 233, + "checked_paths_sha256": "ae919c3e7d8cef19a1782a00c640eeeb66c49dfbce3ad9ceaf30eb96e77bda16", + "coverage_area_count": 13, + "skill_count": 16, + "production_mutation_authorized": false, + "contains_secrets": false, + "problems": [] + }, + "repository_readback": { + "status_before": [ + " M .agents/skills/teleo-leo-onboarding/scripts/install_skill_pack.py", + " M .agents/skills/teleo-leo-onboarding/scripts/run_clean_context_canary.py", + " M tests/test_repo_skill_pack.py", + "?? goal.md" + ], + "status_after": [ + " M .agents/skills/teleo-leo-onboarding/scripts/install_skill_pack.py", + " M .agents/skills/teleo-leo-onboarding/scripts/run_clean_context_canary.py", + " M tests/test_repo_skill_pack.py", + "?? goal.md" + ], + "unchanged": true + }, + "files_read": [ + ".agents/skills/teleo-leo-onboarding/SKILL.md", + ".agents/skills/teleo-infra-provenance/SKILL.md", + "docs/reports/leo-working-state-20260709/current-truth-index.md", + "docs/reports/leo-working-state-20260709/operator-surface-map.md", + "docs/reports/leo-working-state-20260709/gcp-db-first-working-leo-20260714.md", + "docs/reports/leo-working-state-20260709/working-leo-definition-20260709.md", + "docs/reports/leo-working-state-20260709/approve-claim-clone-canary-current.md" + ], + "cleanup_readback": { + "temporary_skill_root_removed": true, + "matching_temporary_roots_remaining": 0, + "orphan_processes_started": false + }, + "external_runtime_contacted": false, + "production_mutation": false, + "contains_secrets": false, + "problems": [], + "strongest_claim_allowed": "T2 fresh-context local onboarding and route validation only; this does not prove live VPS, GCP promotion, Telegram delivery, or production proposal application" +} diff --git a/docs/reports/leo-working-state-20260709/skill-pack-clean-context-canary-current.json b/docs/reports/leo-working-state-20260709/skill-pack-clean-context-canary-current.json new file mode 100644 index 0000000..f5c23e5 --- /dev/null +++ b/docs/reports/leo-working-state-20260709/skill-pack-clean-context-canary-current.json @@ -0,0 +1,71 @@ +{ + "agent_reasoning_exercised": false, + "ambient_skill_root_used": false, + "artifact": "leo_teleo_skill_pack_isolated_install_canary", + "canary_command": ".agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py --root .", + "canary_exit_status": 0, + "canary_receipt": { + "artifact": "leo_teleo_skill_pack_path_validation", + "checked_path_count": 234, + "checked_paths_sha256": "19ed1813f9cb4c19489a180081007842e241a2dcae659a5d873582fbbd50cff6", + "contains_secrets": false, + "coverage_area_count": 13, + "manifest": "docs/reports/leo-working-state-20260709/skill-pack-manifest.json", + "problems": [], + "production_mutation_authorized": false, + "required_tier": "T2_runtime", + "skill_count": 16, + "status": "pass" + }, + "cleanup_readback": { + "orphan_processes_started": false, + "temporary_skill_root_removed": true + }, + "contains_secrets": false, + "current_tier": "T2_runtime", + "evidence_paths": { + "authority": ".agents/skills/teleo-infra-provenance/SKILL.md", + "candidate_only_gcp": ".agents/skills/teleo-reconstruction-recovery/SKILL.md", + "gcp_database": ".agents/skills/teleo-leo-onboarding/SKILL.md", + "live_service": ".agents/skills/teleo-infra-provenance/SKILL.md", + "merged_reasoning_verifier": ".agents/skills/teleo-reconstruction-recovery/SKILL.md", + "merged_reconstruction": ".agents/skills/teleo-reconstruction-recovery/SKILL.md", + "next_safe_action": ".agents/skills/teleo-leo-onboarding/SKILL.md", + "vps_database": ".agents/skills/teleo-leo-onboarding/SKILL.md" + }, + "expected_answers": { + "authority": "GitHub living-ip/teleo-infrastructure", + "candidate_only_gcp": "PR #148 open candidate only; not canonical main", + "gcp_database": "private Cloud SQL teleo_canonical; persistent copy remains staging", + "live_service": "leoclean-gateway.service", + "merged_reasoning_verifier": "PR #147 merged unseen-reasoning verifier hardening", + "merged_reconstruction": "PR #146 merged deterministic genesis-plus-ledger reconstruction", + "next_safe_action": "run the repo-local path validator before any operational action", + "vps_database": "teleo-pg / teleo" + }, + "external_runtime_contacted": false, + "fresh_temporary_skill_root": true, + "installed_skill_count": 16, + "missing_to_reach_required_tier": [], + "problems": [], + "production_mutation_authorized": false, + "proof_scope": "deterministic_isolated_install_and_route_validation", + "required_tier": "T2_runtime", + "selected_installed_skills": [ + "teleo-infra-provenance", + "teleo-leo-onboarding", + "teleo-reconstruction-recovery" + ], + "semantic_checks": { + "authority": true, + "candidate_only_gcp": true, + "gcp_database": true, + "live_service": true, + "merged_reasoning_verifier": true, + "merged_reconstruction": true, + "next_safe_action": true, + "vps_database": true + }, + "status": "pass", + "strongest_claim_allowed": "T2 deterministic isolated local skill install, route-contract, and path validation only; this artifact does not claim an agent reasoning run, and retained product proofs keep their own VPS, GCP-staging, isolated-clone, Telegram, and production claim ceilings" +} diff --git a/docs/reports/leo-working-state-20260709/skill-pack-manifest.json b/docs/reports/leo-working-state-20260709/skill-pack-manifest.json index 613a99e..36de40a 100644 --- a/docs/reports/leo-working-state-20260709/skill-pack-manifest.json +++ b/docs/reports/leo-working-state-20260709/skill-pack-manifest.json @@ -1,39 +1,51 @@ { "pack": "leo-teleo-skill-pack", "created_date": "2026-07-09", - "last_updated_utc": "2026-07-13T06:45:00Z", - "status": "repo_native_validated", + "last_updated_utc": "2026-07-15T00:16:53Z", + "status": "repo_native_path_validated", "contains_secrets": false, "production_mutation_authorized": false, "repo_skill_root": ".agents/skills", "optional_local_install_target": "/Users/user/.codex/skills", - "claim_ceiling": "Repo-native onboarding and operations skills. Generic and Helmer guarded-apply canaries pass 37/37 in disposable PostgreSQL. PR #72 source auto-synchronized to the VPS checkout without a gateway restart or profile-source delta; the permission migration was not applied, the apply worker remained disabled/inactive, and Helmer remained unapplied. Current source-composition/apply lifecycle proof is stronger and remains isolated. GCP gateway and exact canonical DB parity are proven across 39 tables and 52,164 rows, and the hardened adapter-free model replay passes 6/6 with exact count consistency. Durable GCP operator identity and retained clone cleanup remain open. Broad blind VPS reasoning is not reliable yet: independent strict judges accepted only 1/12 and 2/12 outright. Current skills require exact m3taversal naming, neutral follow-up labels, and current-v1 schema truth.", + "claim_ceiling": "Repo-native onboarding and operations skills at T2 clean-context local runtime. PRs #146 and #147 are merged and own deterministic reconstruction plus unseen-reasoning verification. PR #148 is open candidate evidence only. Generic and Helmer guarded-apply canaries pass 37/37 in disposable PostgreSQL, while production rich packets remain unapplied. The latest disposable GCP clone matched 39 tables and 52,167 rows and a no-send model turn passed 18/18 runtime checks plus 6/6 reasoning outcomes; persistent GCP teleo_canonical remains staging and no promotion, production apply, or current Telegram-visible end-to-end proof is claimed.", + "repository_reconciliation": { + "merged_pull_requests": [146, 147], + "candidate_only_pull_requests": [148], + "canonical_reconstruction_contract": "docs/kb-rebuild-and-recompile.md", + "canonical_turn_manifest": "scripts/leo_turn_execution_manifest.py", + "canonical_unseen_reasoning_verifier": "scripts/verify_leo_unseen_reasoning_chain.py" + }, "skills": [ { "name": "teleo-leo-onboarding", - "role": "Company/product/architecture orientation for Leo and Teleo work", + "role": "Company, product, architecture, task routing, and clean-context entry point", "path": ".agents/skills/teleo-leo-onboarding/SKILL.md" }, { "name": "working-leo-m3taversal-outcomes", - "role": "Definition of working Leo from m3taversal outcomes and tests", + "role": "Definition of working Leo, identity discipline, behavior, and proof ceiling", "path": ".agents/skills/working-leo-m3taversal-outcomes/SKILL.md" }, { "name": "teleo-vps-runtime-ops", - "role": "VPS runtime navigation, service health, Docker/Postgres checks, report sync", + "role": "VPS runtime navigation, incidents, service health, Postgres, and cleanup", "path": ".agents/skills/teleo-vps-runtime-ops/SKILL.md" }, { "name": "teleo-gcp-parity-ops", - "role": "GCP operator access, private Cloud SQL parity, m3taversal replay, rollback, and cleanup", + "role": "GCP access, private Cloud SQL parity, no-send replay, rollback, and cleanup", "path": ".agents/skills/teleo-gcp-parity-ops/SKILL.md" }, { "name": "teleo-kb-db-change-workflow", - "role": "Strict proposal normalization, separated review/apply authority, isolated canary, and row-level proof", + "role": "Source ingestion, proposal normalization, review, guarded apply, and receipts", "path": ".agents/skills/teleo-kb-db-change-workflow/SKILL.md" }, + { + "name": "teleo-reconstruction-recovery", + "role": "Snapshot recovery, genesis-ledger replay, source recompilation, and incident recovery", + "path": ".agents/skills/teleo-reconstruction-recovery/SKILL.md" + }, { "name": "leo-telegram-canary-ops", "role": "Live Telegram bot testing through authenticated Chrome and Computer Use", @@ -41,29 +53,121 @@ }, { "name": "teleo-infra-provenance", - "role": "Repo/deploy/runtime provenance and path confusion prevention", + "role": "Repository, deploy, runtime, database, and retained-proof provenance", "path": ".agents/skills/teleo-infra-provenance/SKILL.md" }, { "name": "teleo-proof-handoff", - "role": "Evidence-indexed handoff format for Fable/Codex/Opus workers", + "role": "Capability tiers, retained evidence, cleanup, and worker handoff", "path": ".agents/skills/teleo-proof-handoff/SKILL.md" + }, + { + "name": "nousresearch-hermes-agent", + "role": "Hermes agent packaging, skills, prompts, memory, model routing, and no-secret policy", + "path": ".agents/skills/nousresearch-hermes-agent/SKILL.md" + }, + { + "name": "teleo-db-operator", + "role": "Read-first Teleo pipeline SQLite discovery, audit, backup, and guarded writes", + "path": ".agents/skills/teleo-db-operator/SKILL.md" + }, + { + "name": "private-password-storage", + "role": "Device-local credential presence and secure-input handling without chat disclosure", + "path": ".agents/skills/private-password-storage/SKILL.md" + }, + { + "name": "crabbox", + "role": "Isolated remote Linux and CI proof without production deployment", + "path": ".agents/skills/crabbox/SKILL.md" + }, + { + "name": "decision-engine-refinement", + "role": "Replayable model, evaluator, rubric, and decision-engine quality work", + "path": ".agents/skills/decision-engine-refinement/SKILL.md" + }, + { + "name": "living-ip-kb-interop", + "role": "Safe external-agent knowledge read and propose-first writeback contracts", + "path": ".agents/skills/living-ip-kb-interop/SKILL.md" + }, + { + "name": "openclaw-agent", + "role": "No-secret Living IP agent packaging for OpenClaw workspaces", + "path": ".agents/skills/openclaw-agent/SKILL.md" } ], + "required_coverage": { + "company_product_context": ["teleo-leo-onboarding"], + "vps": ["teleo-vps-runtime-ops"], + "gcp": ["teleo-gcp-parity-ops"], + "hermes_runtime": ["nousresearch-hermes-agent", "teleo-vps-runtime-ops"], + "database_provenance": ["teleo-infra-provenance", "teleo-db-operator"], + "ingestion": ["teleo-kb-db-change-workflow", "living-ip-kb-interop"], + "proposal_apply": ["teleo-kb-db-change-workflow"], + "reconstruction": ["teleo-reconstruction-recovery"], + "identity": ["teleo-leo-onboarding", "working-leo-m3taversal-outcomes"], + "testing": ["teleo-leo-onboarding", "teleo-proof-handoff", "crabbox", "decision-engine-refinement"], + "security": ["teleo-vps-runtime-ops", "teleo-gcp-parity-ops", "private-password-storage"], + "secrets": ["private-password-storage"], + "incident_recovery": ["teleo-reconstruction-recovery", "teleo-vps-runtime-ops"] + }, "reference_files": [ + "docs/reports/leo-working-state-20260709/skill-pack-clean-context-agent-review-current.json", + "docs/reports/leo-working-state-20260709/skill-pack-clean-context-canary-current.json", "docs/reports/leo-working-state-20260709/current-truth-index.md", "docs/reports/leo-working-state-20260709/operator-surface-map.md", "docs/reports/leo-working-state-20260709/fable-leo-teleo-onboarding.md", + "docs/reports/leo-working-state-20260709/gcp-db-first-working-leo-20260714.md", + "docs/reports/leo-working-state-20260709/gcp-db-first-parity-current.json", + "docs/reports/leo-working-state-20260709/gcp-db-first-blind-claim-current.json", + "docs/reports/leo-working-state-20260709/gcp-db-first-cleanup-current.json", + "docs/reports/leo-working-state-20260709/gcp-db-first-live-deploy-restart-current.json", "docs/reports/leo-working-state-20260709/approve-claim-clone-canary-current.md", - "docs/reports/leo-working-state-20260709/gcp-cloud-sql-t3-live-readonly-current.md", - "docs/reports/leo-working-state-20260709/gcp-cloudsql-studio-parity-gate-current.md", - "docs/reports/leo-working-state-20260709/pr72-vps-auto-deploy-runtime-nonchange-current.md", - "docs/reports/leo-working-state-20260709/gcp-leo-runtime-observability-current.md", - "docs/reports/leo-working-state-20260709/gcp-canonical-parity-live-20260712.json", - "docs/reports/leo-working-state-20260709/gcp-operator-access-blocker-current.json", - "docs/reports/leo-working-state-20260709/telegram-visible-direct-claim-dc01-partial-current.json", "docs/reports/leo-working-state-20260709/working-leo-current-proof-20260712.md", - "docs/reports/leo-working-state-20260709/working-leo-current-proof-20260712.json" + "docs/reports/leo-working-state-20260709/leo-unified-turn-manifest-canary-current.json", + "docs/reports/leo-working-state-20260709/leo-architecture-work-and-outcomes-20260714.md", + "docs/kb-rebuild-and-recompile.md" ], - "validation": "tests/test_repo_skill_pack.py" + "command_files": [ + ".agents/skills/teleo-leo-onboarding/scripts/install_skill_pack.py", + ".agents/skills/teleo-leo-onboarding/scripts/run_clean_context_canary.py", + ".agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py", + "ops/run_local_canonical_postgres_rebuild.py", + "ops/run_local_genesis_ledger_rebuild.py", + "ops/capture_vps_canonical_postgres_snapshot.py", + "ops/restore_gcp_generated_postgres_snapshot.py", + "ops/verify_postgres_parity_manifest.py", + "scripts/compile_kb_source_packet.py", + "scripts/approve_proposal.py", + "scripts/apply_proposal.py", + "scripts/leo_turn_execution_manifest.py", + "scripts/verify_leo_unseen_reasoning_chain.py", + "tests/test_repo_skill_pack.py" + ], + "scan_files": [ + "docs/reports/leo-working-state-20260709/skill-pack-readme.md", + "docs/reports/leo-working-state-20260709/current-truth-index.md", + "docs/reports/leo-working-state-20260709/operator-surface-map.md", + "docs/reports/leo-working-state-20260709/fable-leo-teleo-onboarding.md", + "docs/reports/leo-working-state-20260709/leo-architecture-work-and-outcomes-20260714.md", + "README.md", + "docs/kb-rebuild-and-recompile.md" + ], + "executable_files": [ + ".agents/skills/teleo-leo-onboarding/scripts/install_skill_pack.py", + ".agents/skills/teleo-leo-onboarding/scripts/run_clean_context_canary.py", + ".agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py", + ".agents/skills/private-password-storage/scripts/private-password-storage" + ], + "validator": ".agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py", + "installer": ".agents/skills/teleo-leo-onboarding/scripts/install_skill_pack.py", + "isolated_install_canary": ".agents/skills/teleo-leo-onboarding/scripts/run_clean_context_canary.py", + "fresh_agent_receipt": "docs/reports/leo-working-state-20260709/skill-pack-clean-context-agent-review-current.json", + "validation": "tests/test_repo_skill_pack.py", + "validation_commands": [ + ".agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py --root .", + ".agents/skills/teleo-leo-onboarding/scripts/run_clean_context_canary.py --root .", + ".venv/bin/python -m pytest -q tests/test_repo_skill_pack.py" + ] } diff --git a/docs/reports/leo-working-state-20260709/skill-pack-readme.md b/docs/reports/leo-working-state-20260709/skill-pack-readme.md index 6c8f299..d655049 100644 --- a/docs/reports/leo-working-state-20260709/skill-pack-readme.md +++ b/docs/reports/leo-working-state-20260709/skill-pack-readme.md @@ -1,42 +1,91 @@ -# Leo / Teleo Skill Pack - 2026-07-09 +# Leo / Teleo Skill Pack - 2026-07-15 -Purpose: reusable onboarding and operating knowledge for future Codex/Fable workers working on Leo, Teleo, the VPS, GCP parity, Telegram canaries, and KB database changes. +Purpose: give a new engineer or agent one repo-native route into Leo/Teleo +architecture, operations, database lifecycle, proof tiers, secrets, and recovery +without relying on historical chat or unmerged branch prose. -This is the repo-native skill set under `.agents/skills/`. It is evidence-linked and validated by `tests/test_repo_skill_pack.py`. It does not contain secrets, private key contents, bot tokens, or production mutation authorization. +The pack lives under `.agents/skills/`, contains no credentials, and grants no +production database mutation or GCP promotion authority. + +## Clean-Context Entry Point + +From the repository root: + +```bash +.agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py --root . +.agents/skills/teleo-leo-onboarding/scripts/install_skill_pack.py \ + --root . \ + --target /private/tmp/teleo-clean-agent/skills +.agents/skills/teleo-leo-onboarding/scripts/run_clean_context_canary.py --root . +``` + +The validator uses standard-library Python through its executable shebang. For +pytest, create the repository virtual environment from `README.md` and use +`.venv/bin/python`; do not assume a bare `python` executable exists. ## Skills In This Pack -1. `teleo-leo-onboarding`: company/product/architecture orientation before touching Leo or Teleo. -2. `working-leo-m3taversal-outcomes`: m3taversal's expected Leo behavior and the current benchmark. -3. `teleo-vps-runtime-ops`: VPS service, paths, Postgres, Docker, report sync, and stability checks. -4. `teleo-gcp-parity-ops`: passwordless GCP access, Cloud SQL parity, no-send replay, rollback, and cleanup. -5. `teleo-kb-db-change-workflow`: source composition plus approved proposal to canonical-row workflow with clone rehearsal and rollback. -6. `leo-telegram-canary-ops`: live Telegram/Chrome/Computer Use canaries for Leo bot behavior. -7. `teleo-infra-provenance`: repo, deploy, mirror, and runtime provenance map. -8. `teleo-proof-handoff`: evidence bundle and Fable/Codex worker handoff protocol. +1. `teleo-leo-onboarding`: product, architecture, task router, and first canary. +2. `working-leo-m3taversal-outcomes`: exact identity discipline and working-Leo proof ceiling. +3. `teleo-vps-runtime-ops`: VPS services, Postgres, incidents, and cleanup. +4. `teleo-gcp-parity-ops`: GCP/Cloud SQL parity, no-send replay, and rollback. +5. `teleo-kb-db-change-workflow`: source ingestion through guarded proposal/apply. +6. `teleo-reconstruction-recovery`: snapshot, ledger, and semantic recovery modes. +7. `leo-telegram-canary-ops`: authorized Leo-specific Telegram canaries. +8. `teleo-infra-provenance`: repository, deploy, runtime, DB, and proof provenance. +9. `teleo-proof-handoff`: tiered evidence and continuation handoffs. +10. `nousresearch-hermes-agent`: Hermes packaging and runtime boundaries. +11. `teleo-db-operator`: read-first pipeline SQLite operation. +12. `private-password-storage`: device-local secure input and presence checks. +13. `crabbox`: isolated remote Linux and CI proof. +14. `decision-engine-refinement`: replayable evaluator and model-quality work. +15. `living-ip-kb-interop`: propose-first external-agent KB access. +16. `openclaw-agent`: no-secret OpenClaw workspace packaging. + +The machine-readable topic coverage and exact paths are in +`docs/reports/leo-working-state-20260709/skill-pack-manifest.json`. + +## Repository Reconciliation + +- PR #146 is merged: deterministic turn manifests and the first exact + genesis-plus-ledger reconstruction slice are canonical `main` behavior. +- PR #147 is merged: the unseen-reasoning verifier accepts valid reasoning + variance while keeping evidence and receipt gates. +- PR #148 is open candidate evidence only. Its proposed least-privilege GCP + files and live end state are not canonical until merged and receipt-verified. ## Current Claim Ceiling -- VPS Leo Telegram memory, KB audit, and staged write canaries are live-proven. -- A strict canonical `add_edge` apply canary is live-proven. -- Deterministic source composition, full-data clone composition/reasoning, and guarded approved-bundle application are isolated-proven with exact source/evidence/claim links, row deltas, rollback, and cleanup. Production rich packets remain unapplied. -- Narrow VPS DB truth and restart behavior are proven. Broad blind reasoning is not yet reliable; exact participant naming, neutral labels, and current-v1 schema guards are now required. -- GCP gateway liveness, private Cloud SQL identity, exact VPS-to-GCP canonical parity, and hardened adapter-free model replay are live-proven. The database matches across `39` tables and `52,164` rows. Durable operator identity and retained replay-clone cleanup remain open because the intended target service accounts are absent. +- T2 clean-context local skill install, route validation, and focused tests are + the onboarding target for this pack. +- VPS no-send behavior, isolated proposal/apply, snapshot recovery, disposable + GCP parity, and bounded no-send GCP reasoning have retained evidence. +- Persistent GCP `teleo_canonical` remains staging. No GCP promotion or cutover + is authorized or claimed. +- Production rich packets remain unapplied. A current Telegram-visible complete + flow is not proven by this pack. ## Key References -- Current whole-system proof: `docs/reports/leo-working-state-20260709/working-leo-current-proof-20260712.md` and `.json` -- Current truth index: `docs/reports/leo-working-state-20260709/current-truth-index.md` -- Operator surface map: `docs/reports/leo-working-state-20260709/operator-surface-map.md` -- Fable onboarding prompt: `docs/reports/leo-working-state-20260709/fable-leo-teleo-onboarding.md` -- PR #72 VPS auto-deploy/runtime readback: `docs/reports/leo-working-state-20260709/pr72-vps-auto-deploy-runtime-nonchange-current.md` -- GCP canonical parity: `docs/reports/leo-working-state-20260709/gcp-canonical-parity-live-20260712.json` -- GCP model replay (legacy artifact filename): `docs/reports/leo-working-state-20260709/gcp-cory-model-replay-current.json` -- Three-day delivery delta: `docs/reports/leo-working-state-20260709/working-leo-three-day-delivery-summary-20260713.md` -- Blind out-of-sample audit: `docs/reports/leo-working-state-20260709/telegram-handler-blind-oos-audit-current.json` -- GCP operator access gate: `docs/reports/leo-working-state-20260709/gcp-operator-access-blocker-current.json` -- Skill manifest: `skill-pack-manifest.json` +- Fresh-agent onboarding receipt: `docs/reports/leo-working-state-20260709/skill-pack-clean-context-agent-review-current.json` +- Isolated-install acceptance receipt: `docs/reports/leo-working-state-20260709/skill-pack-clean-context-canary-current.json` +- Current truth: `docs/reports/leo-working-state-20260709/current-truth-index.md` +- Architecture/outcomes: `docs/reports/leo-working-state-20260709/leo-architecture-work-and-outcomes-20260714.md` +- Recovery contract: `docs/kb-rebuild-and-recompile.md` +- GCP database-first proof: `docs/reports/leo-working-state-20260709/gcp-db-first-working-leo-20260714.md` +- GCP no-send reasoning receipt: `docs/reports/leo-working-state-20260709/gcp-db-first-blind-claim-current.json` +- Unified turn receipt: `docs/reports/leo-working-state-20260709/leo-unified-turn-manifest-canary-current.json` +- Operator map: `docs/reports/leo-working-state-20260709/operator-surface-map.md` -## Discovery +## Team Change Notes -Codex-compatible workers should discover the committed `.agents/skills/` tree from this repository. The manifest is the index; each skill points to current retained evidence rather than embedding secrets or pretending a stale snapshot is live truth. +- Replaced the missing legacy GCP replay route with the current database-first + no-send receipt. +- Added deterministic install and path-validation commands. +- Added separate T2 receipts for deterministic isolated install/path validation + and fresh-context agent reasoning, stale-claim rejection, and cleanup. +- Added an explicit topic router and recovery skill. +- Added merged-vs-open PR boundaries so branch candidates cannot silently become + source-of-truth instructions. +- Added tests for coverage, route existence, installer behavior, and exact + m3taversal identity prose. diff --git a/tests/test_repo_skill_pack.py b/tests/test_repo_skill_pack.py index c50d6ae..b218b8f 100644 --- a/tests/test_repo_skill_pack.py +++ b/tests/test_repo_skill_pack.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import re +import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -15,12 +17,12 @@ def _skill(name: str) -> str: def test_manifest_indexes_valid_repo_native_skills() -> None: manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) - assert manifest["status"] == "repo_native_validated" + assert manifest["status"] == "repo_native_path_validated" assert manifest["repo_skill_root"] == ".agents/skills" assert manifest["validation"] == "tests/test_repo_skill_pack.py" assert "37/37" in manifest["claim_ceiling"] - assert "auto-synchronized" in manifest["claim_ceiling"] - assert "apply worker remained disabled/inactive" in manifest["claim_ceiling"] + assert "PRs #146 and #147 are merged" in manifest["claim_ceiling"] + assert "PR #148 is open candidate evidence only" in manifest["claim_ceiling"] for entry in manifest["skills"]: path = ROOT / entry["path"] @@ -34,6 +36,139 @@ def test_manifest_indexes_valid_repo_native_skills() -> None: assert (ROOT / relative).is_file(), relative +def test_manifest_covers_the_required_onboarding_surface() -> None: + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + expected = { + "company_product_context", + "vps", + "gcp", + "hermes_runtime", + "database_provenance", + "ingestion", + "proposal_apply", + "reconstruction", + "identity", + "testing", + "security", + "secrets", + "incident_recovery", + } + assert set(manifest["required_coverage"]) == expected + skill_names = {entry["name"] for entry in manifest["skills"]} + repo_skill_names = {path.parent.name for path in (ROOT / ".agents" / "skills").glob("*/SKILL.md")} + assert skill_names == repo_skill_names + for owners in manifest["required_coverage"].values(): + assert owners + assert set(owners) <= skill_names + + +def test_path_validator_and_clean_installer() -> None: + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + validator = ROOT / manifest["validator"] + result = subprocess.run( + [str(validator), "--root", str(ROOT)], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + payload = json.loads(result.stdout) + assert payload["status"] == "pass" + assert payload["skill_count"] == len(manifest["skills"]) + assert payload["coverage_area_count"] == len(manifest["required_coverage"]) + assert payload["problems"] == [] + + +def test_installer_copies_only_manifest_skills(tmp_path: Path) -> None: + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + installer = ROOT / manifest["installer"] + target = tmp_path / "skills" + result = subprocess.run( + [str(installer), "--root", str(ROOT), "--target", str(target)], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + payload = json.loads(result.stdout) + assert payload["status"] == "pass" + assert payload["installed_skill_count"] == len(manifest["skills"]) + assert {path.name for path in target.iterdir()} == {entry["name"] for entry in manifest["skills"]} + assert all((target / entry["name"] / "SKILL.md").is_file() for entry in manifest["skills"]) + + +def test_installer_rejects_manifest_path_traversal(tmp_path: Path) -> None: + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + manifest["skills"][0]["name"] = "../escape" + malicious_manifest = tmp_path / "manifest.json" + malicious_manifest.write_text(json.dumps(manifest), encoding="utf-8") + target = tmp_path / "target" / "skills" + + result = subprocess.run( + [ + str(ROOT / manifest["installer"]), + "--root", + str(ROOT), + "--manifest", + str(malicious_manifest), + "--target", + str(target), + ], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert "invalid skill name" in result.stdout + assert not (tmp_path / "target" / "escape").exists() + assert not target.exists() + + +def test_isolated_install_canary_reaches_t2_without_claiming_agent_reasoning(tmp_path: Path) -> None: + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + canary = ROOT / manifest["isolated_install_canary"] + output = tmp_path / "receipt.json" + result = subprocess.run( + [str(canary), "--root", str(ROOT), "--output", str(output)], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + payload = json.loads(output.read_text(encoding="utf-8")) + assert payload["status"] == "pass" + assert payload["required_tier"] == "T2_runtime" + assert payload["current_tier"] == "T2_runtime" + assert payload["proof_scope"] == "deterministic_isolated_install_and_route_validation" + assert payload["fresh_temporary_skill_root"] is True + assert payload["ambient_skill_root_used"] is False + assert payload["agent_reasoning_exercised"] is False + assert payload["installed_skill_count"] == len(manifest["skills"]) + assert all(payload["semantic_checks"].values()) + assert payload["canary_exit_status"] == 0 + assert payload["cleanup_readback"]["temporary_skill_root_removed"] is True + assert payload["missing_to_reach_required_tier"] == [] + + +def test_fresh_agent_receipt_rejects_stale_routes() -> None: + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + receipt = json.loads((ROOT / manifest["fresh_agent_receipt"]).read_text(encoding="utf-8")) + + assert receipt["status"] == "pass" + assert receipt["evidence_origin"]["fork_turns"] == "none" + assert receipt["evidence_origin"]["memory_used"] is False + assert all(receipt["stale_claims_rejected"].values()) + assert receipt["validator"]["status"] == "pass" + assert receipt["validator"]["skill_count"] == 16 + assert receipt["repository_readback"]["unchanged"] is True + assert receipt["cleanup_readback"]["temporary_skill_root_removed"] is True + assert receipt["external_runtime_contacted"] is False + assert receipt["production_mutation"] is False + assert receipt["contains_secrets"] is False + assert receipt["problems"] == [] + + def test_db_change_skill_tracks_guarded_v2_runtime_proof() -> None: text = _skill("teleo-kb-db-change-workflow") squashed = " ".join(text.split()) @@ -120,3 +255,31 @@ def test_vps_onboarding_and_m3taversal_skills_point_to_current_proof() -> None: assert "Next proof-changing follow-up:" in outcomes assert "working-leo-current-proof-20260712.md" in outcomes assert "Cory" not in outcomes + + +def test_reconstruction_and_pr_state_are_routed_without_candidate_overclaim() -> None: + reconstruction = _skill("teleo-reconstruction-recovery") + onboarding = _skill("teleo-leo-onboarding") + gcp = _skill("teleo-gcp-parity-ops") + current_truth = (REPORT_DIR / "current-truth-index.md").read_text(encoding="utf-8") + + assert "ops/run_local_genesis_ledger_rebuild.py" in reconstruction + assert "scripts/verify_leo_unseen_reasoning_chain.py" in reconstruction + assert "Semantic source-to-blank-database recompilation remains incomplete" in reconstruction + assert "PRs #146 and #147 are merged repository truth" in onboarding + assert "PR #148 remains an open GCP" in onboarding + assert "candidate evidence only" in gcp + assert "PRs #146 and #147 are merged into canonical `main`" in current_truth + assert "PR #148 remains open candidate evidence" in current_truth + + +def test_active_onboarding_prose_uses_exact_m3taversal_identity() -> None: + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + active_files = [ + *[ROOT / relative for relative in manifest["scan_files"]], + *[ROOT / entry["path"] for entry in manifest["skills"]], + ] + for path in active_files: + text = path.read_text(encoding="utf-8") + prose = re.sub(r"`[^`]*\/[^`]+\.(?:md|json|svg)`", "", text) + assert re.search(r"\bcory(?:-style)?\b", prose, flags=re.IGNORECASE) is None, path From a6947a3f8bb74323a452c3b20a4060d19e1f94f3 Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Wed, 15 Jul 2026 03:19:49 +0200 Subject: [PATCH 3/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 92da54d804ea5a59ac97274493fea604469a2b88 Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Wed, 15 Jul 2026 03:43:05 +0200 Subject: [PATCH 4/8] Add authenticated GCP Observatory read adapter (#154) --- .../gcp-observatory-read-adapter.yml | 199 ++++++++++ Dockerfile.observatory-read-adapter | 20 + docs/observatory-read-adapter-gcp-staging.md | 102 ++++++ .../platform-goal-current.json | 12 + observatory_read_adapter/__init__.py | 6 + observatory_read_adapter/__main__.py | 18 + observatory_read_adapter/app.py | 111 ++++++ observatory_read_adapter/config.py | 61 +++ observatory_read_adapter/db.py | 315 ++++++++++++++++ ops/observatory_read_role.sql | 183 +++++++++ pyproject.toml | 5 +- tests/test_observatory_read_adapter.py | 346 ++++++++++++++++++ tests/test_observatory_read_role_postgres.py | 182 +++++++++ 13 files changed, 1559 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/gcp-observatory-read-adapter.yml create mode 100644 Dockerfile.observatory-read-adapter create mode 100644 docs/observatory-read-adapter-gcp-staging.md create mode 100644 docs/reports/observatory-read-adapter/platform-goal-current.json create mode 100644 observatory_read_adapter/__init__.py create mode 100644 observatory_read_adapter/__main__.py create mode 100644 observatory_read_adapter/app.py create mode 100644 observatory_read_adapter/config.py create mode 100644 observatory_read_adapter/db.py create mode 100644 ops/observatory_read_role.sql create mode 100644 tests/test_observatory_read_adapter.py create mode 100644 tests/test_observatory_read_role_postgres.py diff --git a/.github/workflows/gcp-observatory-read-adapter.yml b/.github/workflows/gcp-observatory-read-adapter.yml new file mode 100644 index 0000000..e4c088f --- /dev/null +++ b/.github/workflows/gcp-observatory-read-adapter.yml @@ -0,0 +1,199 @@ +name: gcp-observatory-read-adapter + +on: + workflow_dispatch: + inputs: + action: + description: Build only, or deploy the protected staging service and run live receipts + required: true + default: build_only + type: choice + options: + - build_only + - deploy_staging + +permissions: + contents: read + id-token: write + +concurrency: + group: gcp-observatory-read-adapter-${{ github.ref }} + cancel-in-progress: false + +env: + PROJECT_ID: teleo-501523 + REGION: europe-west6 + ZONE: europe-west6-a + REPOSITORY: teleo + IMAGE_NAME: observatory-read-adapter + SERVICE_NAME: observatory-read-adapter-staging + RUNTIME_SERVICE_ACCOUNT: sa-observatory-read-adapter@teleo-501523.iam.gserviceaccount.com + DB_IAM_USER: sa-observatory-read-adapter@teleo-501523.iam + API_KEY_SECRET: observatory-read-api-key-staging + WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github + DEPLOY_SERVICE_ACCOUNT: sa-artifact-builder@teleo-501523.iam.gserviceaccount.com + +jobs: + build: + name: Test, build, and push adapter image + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + image_ref: ${{ steps.image.outputs.image_ref }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Run focused adapter tests + run: | + python -m pip install -e '.[dev]' + python -m pytest tests/test_observatory_read_adapter.py -q + python -m ruff check observatory_read_adapter tests/test_observatory_read_adapter.py + + - id: auth + uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ env.DEPLOY_SERVICE_ACCOUNT }} + + - uses: google-github-actions/setup-gcloud@v3 + with: + project_id: ${{ env.PROJECT_ID }} + + - name: Configure Artifact Registry + run: gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet + + - id: image + name: Build, smoke, and push immutable image + shell: bash + run: | + set -euo pipefail + tag="${GITHUB_SHA::12}" + image="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_NAME}:${tag}" + docker build -f Dockerfile.observatory-read-adapter -t "${image}" . + docker run --rm --env PYTHONPYCACHEPREFIX=/tmp/pycache \ + "${image}" python -m compileall -q /app/observatory_read_adapter + docker run --rm "${image}" python -c 'import observatory_read_adapter; print("adapter-import-ok")' + docker push "${image}" | tee docker-push.log + digest="$(awk '/digest: sha256:/ {print $3}' docker-push.log | tail -1)" + test -n "${digest}" + image_ref="${image}@${digest}" + printf 'image_ref=%s\n' "${image_ref}" >> "${GITHUB_OUTPUT}" + printf 'revision=%s\nimage_ref=%s\n' "${GITHUB_SHA}" "${image_ref}" > observatory-adapter-image.txt + + - uses: actions/upload-artifact@v4 + with: + name: observatory-adapter-image + path: observatory-adapter-image.txt + if-no-files-found: error + + deploy-staging: + name: Deploy and prove staging read path + if: ${{ inputs.action == 'deploy_staging' }} + needs: build + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - id: auth + uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ env.DEPLOY_SERVICE_ACCOUNT }} + + - uses: google-github-actions/setup-gcloud@v3 + with: + project_id: ${{ env.PROJECT_ID }} + + - name: Deploy bounded GCP staging service + shell: bash + env: + IMAGE_REF: ${{ needs.build.outputs.image_ref }} + run: | + set -euo pipefail + gcloud run deploy "${SERVICE_NAME}" \ + --project="${PROJECT_ID}" \ + --region="${REGION}" \ + --image="${IMAGE_REF}" \ + --execution-environment=gen2 \ + --service-account="${RUNTIME_SERVICE_ACCOUNT}" \ + --network=teleo-staging-net \ + --subnet=teleo-staging-europe-west6 \ + --vpc-egress=private-ranges-only \ + --ingress=all \ + --allow-unauthenticated \ + --port=8080 \ + --cpu=1 \ + --memory=512Mi \ + --concurrency=8 \ + --max-instances=2 \ + --timeout=15 \ + --set-secrets="OBSERVATORY_API_KEY=${API_KEY_SECRET}:latest" \ + --set-env-vars="GCP_PROJECT_ID=${PROJECT_ID},CLOUD_SQL_INSTANCE=${PROJECT_ID}:${REGION}:teleo-pgvector-standby,DB_NAME=teleo_canonical,DB_IAM_USER=${DB_IAM_USER},DB_AUTHORIZATION_ROLE=kb_observatory_read,SERVICE_REVISION=${GITHUB_SHA}" \ + --labels="livingip-tier=gcp-staging,livingip-surface=observatory-read-adapter" \ + --quiet + + - name: Capture positive and negative live receipts + shell: bash + run: | + set -euo pipefail + service_url="$(gcloud run services describe "${SERVICE_NAME}" --project="${PROJECT_ID}" --region="${REGION}" --format='value(status.url)')" + revision="$(gcloud run services describe "${SERVICE_NAME}" --project="${PROJECT_ID}" --region="${REGION}" --format='value(status.latestReadyRevisionName)')" + api_key="$(gcloud secrets versions access latest --secret="${API_KEY_SECRET}" --project="${PROJECT_ID}")" + test -n "${service_url}" + test -n "${revision}" + test -n "${api_key}" + + curl --fail --silent --show-error \ + --header "X-Api-Key: ${api_key}" \ + "${service_url}/v1/claims/sample" > positive.json + jq -e ' + .schema == "livingip.observatory-canonical-claim.v1" + and .read_only == true + and .provenance.database == "teleo_canonical" + and .provenance.authorization_role == "kb_observatory_read" + and .provenance.transaction_read_only == true + and .provenance.write_privileges_denied == true + and (.canonical.evidence | length) > 0 + and .proposal_ledger.distinct_from_canonical == true + ' positive.json >/dev/null + + anonymous_status="$(curl --silent --output anonymous.json --write-out '%{http_code}' "${service_url}/v1/claims/sample")" + write_status="$(curl --silent --output write.json --write-out '%{http_code}' \ + --request POST --header "X-Api-Key: ${api_key}" --header 'Content-Type: application/json' \ + --data '{"status":"applied"}' "${service_url}/v1/claims/sample")" + test "${anonymous_status}" = 401 + test "${write_status}" = 405 + + jq -n \ + --arg service_url "${service_url}" \ + --arg revision "${revision}" \ + --arg git_sha "${GITHUB_SHA}" \ + --argjson positive "$(cat positive.json)" \ + --arg anonymous_status "${anonymous_status}" \ + --arg write_status "${write_status}" \ + '{ + schema: "livingip.observatory-read-adapter-live-receipt.v1", + required_tier: "T3_live_readonly", + service_url: $service_url, + revision: $revision, + git_sha: $git_sha, + positive: $positive, + negative: { + anonymous_http_status: ($anonymous_status | tonumber), + authenticated_post_http_status: ($write_status | tonumber) + }, + production_repoint_executed: false + }' > observatory-read-adapter-live-receipt.json + unset api_key + + - uses: actions/upload-artifact@v4 + with: + name: observatory-read-adapter-live-receipt + path: observatory-read-adapter-live-receipt.json + if-no-files-found: error diff --git a/Dockerfile.observatory-read-adapter b/Dockerfile.observatory-read-adapter new file mode 100644 index 0000000..92c3a1e --- /dev/null +++ b/Dockerfile.observatory-read-adapter @@ -0,0 +1,20 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PORT=8080 + +WORKDIR /app + +COPY pyproject.toml README.md /app/ +COPY observatory_read_adapter /app/observatory_read_adapter +COPY lib /app/lib + +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir ".[observatory]" \ + && addgroup --system --gid 10001 adapter \ + && adduser --system --uid 10001 --ingroup adapter --no-create-home adapter + +USER 10001:10001 + +CMD ["python", "-m", "observatory_read_adapter"] diff --git a/docs/observatory-read-adapter-gcp-staging.md b/docs/observatory-read-adapter-gcp-staging.md new file mode 100644 index 0000000..b4d6960 --- /dev/null +++ b/docs/observatory-read-adapter-gcp-staging.md @@ -0,0 +1,102 @@ +# GCP Observatory Read Adapter + +## Definition of Working + +- Working target: an authenticated GCP staging API returns one canonical claim + with source/evidence provenance and a separately labeled proposal ledger. +- Operator path: `GET /v1/claims/sample` or `GET /v1/claims/{uuid}` with the + server-only `X-Api-Key` value. +- Done means: the response names `teleo_canonical`, the dedicated IAM database + principal and `kb_observatory_read`, reports a read-only transaction and + denied write privileges, and live anonymous/write HTTP attempts return + `401`/`405`. +- Not done: the VPS `teleo-pg`, a public Cloud SQL address, a browser-visible + key, a mock-only receipt, or a production Vercel repoint. +- Required tier: `T3_live_readonly`. + +## Existing Connector Boundary + +The current `living-ip/livingip-web` main branch reads claims through +`src/lib/api.ts`. It selects `NEXT_PUBLIC_API_URL` and otherwise falls back to +`http://77.42.65.182:8081`, then calls the legacy `/api/claims` routes. That is +the VPS-local/public Observatory path. It is not safe to replace that public +environment variable with this protected API: the response shape differs and +the adapter key must never enter a `NEXT_PUBLIC_` variable or browser bundle. + +The existing Teleo route `GET /api/kb/claims` is the compatibility reference +for route-scoped authentication, but its default database/container settings +still describe the VPS-local process. This service is a separate GCP runtime; +it does not change Argus or PR #148's Leo runtime files. + +## Runtime Contract + +- Cloud Run service: `observatory-read-adapter-staging` in `europe-west6`. +- Direct VPC egress: `teleo-staging-net` / `teleo-staging-europe-west6`. +- Cloud SQL: `teleo-501523:europe-west6:teleo-pgvector-standby`, private IP. +- Database: `teleo_canonical` only. +- Runtime identity: `sa-observatory-read-adapter@teleo-501523.iam.gserviceaccount.com`. +- IAM database user: `sa-observatory-read-adapter@teleo-501523.iam`. +- Database authorization role: `kb_observatory_read` (`NOLOGIN`). +- API authentication: route-scoped key from Secret Manager secret + `observatory-read-api-key-staging`; the value is not in Git or deploy logs. +- No mutation routes exist. Authenticated unsupported methods return `405`. + +The Cloud SQL Python Connector uses Application Default Credentials from the +Cloud Run service identity, automatic IAM database authentication, and private +IP. Every request starts a read-only transaction and refuses the response if +the database, IAM principal, authorization membership, required reads, or +effective write-denial checks drift. + +## One-Time Staging Bootstrap + +These are staging mutations. Run them only with an authenticated GCP operator; +they do not route production traffic. + +1. Create the dedicated runtime service account and grant only + `roles/cloudsql.client` and `roles/cloudsql.instanceUser` in project + `teleo-501523`. +2. Add the service account as a Cloud SQL IAM service-account user on + `teleo-pgvector-standby`. +3. From the existing private GCP VM database-admin path, run + `ops/observatory_read_role.sql` against `teleo_canonical` with + `OBSERVATORY_DB_IAM_USER=sa-observatory-read-adapter@teleo-501523.iam`. + Do not read, copy, or retain the administrator password. +4. Create `observatory-read-api-key-staging`, add a generated 32-byte-or-longer + value through stdin, and grant that secret's accessor role only to the + runtime service account and the bounded staging canary identity. +5. Dispatch `.github/workflows/gcp-observatory-read-adapter.yml` with + `action=deploy_staging` from the exact reviewed branch revision. + +The workflow builds an immutable image, deploys at most two Cloud Run +instances, and retains the positive response plus anonymous `401` and +authenticated POST `405` receipts. It does not modify Vercel. + +## Unexecuted Cutover Packet + +Production cutover requires a separate exact authorization and a +`living-ip/livingip-web` change. Do not execute these steps from this lane. + +1. Add server-only Vercel secrets `OBSERVATORY_CANONICAL_API_URL` and + `OBSERVATORY_CANONICAL_API_KEY` to a protected preview environment. The URL + targets the GCP service; neither setting may use `NEXT_PUBLIC_`. +2. Add a Next.js server route or server action that calls the GCP API, validates + `livingip.observatory-canonical-claim.v1`, and maps it to the Observatory UI. + The browser calls only the same-origin Next.js route. +3. Prove preview Deployment Protection, anonymous denial, source/evidence + rendering, proposal/live labels, and no browser key exposure. +4. Obtain exact production-repoint authorization naming the reviewed + `livingip-web` commit and Vercel project before changing production values. + +## Unexecuted Rollback Packet + +1. Revert the authorized `livingip-web` connector commit or disable its + server-side feature flag. +2. Restore the prior production Vercel values and redeploy the last accepted + production revision. +3. Verify `/claims` again uses the prior `/api/claims` contract. +4. Leave the private Cloud SQL instance and adapter role intact while receipts + are reviewed; scale the staging Cloud Run service to zero if isolation is + required. +5. Delete the staging service/role/secret only under a separate cleanup + decision. Rollback never adds a Cloud SQL public IP and never redirects the + adapter to VPS `teleo-pg`. diff --git a/docs/reports/observatory-read-adapter/platform-goal-current.json b/docs/reports/observatory-read-adapter/platform-goal-current.json new file mode 100644 index 0000000..6c60df3 --- /dev/null +++ b/docs/reports/observatory-read-adapter/platform-goal-current.json @@ -0,0 +1,12 @@ +{ + "fallback_goal_path": "goal.md", + "platform_goal_after": { + "objective": "Complete and verify the private GCP Observatory read adapter defined in this goal file, stopping before production repoint without exact authorization.", + "status": "active", + "thread_id": "019f6350-69fa-71f3-944b-3fc0065f4fec" + }, + "platform_goal_before": null, + "platform_goal_repair_action": "create_goal", + "platform_goal_replaced": false, + "why_not_replaced": "No prior platform Goal existed; a new active Goal was created from goal.md." +} diff --git a/observatory_read_adapter/__init__.py b/observatory_read_adapter/__init__.py new file mode 100644 index 0000000..c4e4945 --- /dev/null +++ b/observatory_read_adapter/__init__.py @@ -0,0 +1,6 @@ +"""Authenticated, read-only Observatory adapter for canonical Teleo claims.""" + +from .app import create_app +from .config import Settings + +__all__ = ["Settings", "create_app"] diff --git a/observatory_read_adapter/__main__.py b/observatory_read_adapter/__main__.py new file mode 100644 index 0000000..61803e2 --- /dev/null +++ b/observatory_read_adapter/__main__.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import logging + +from aiohttp import web + +from .app import create_app +from .config import Settings + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + settings = Settings.from_env() + web.run_app(create_app(settings), host="0.0.0.0", port=settings.port) + + +if __name__ == "__main__": + main() diff --git a/observatory_read_adapter/app.py b/observatory_read_adapter/app.py new file mode 100644 index 0000000..baf1c24 --- /dev/null +++ b/observatory_read_adapter/app.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import asyncio +import hmac +import logging +import uuid +from typing import Any + +from aiohttp import web + +from .config import Settings +from .db import CloudSqlClaimReader + +logger = logging.getLogger("teleo.observatory_read_adapter") + +SETTINGS_KEY = web.AppKey("observatory_settings", Settings) +READER_KEY = web.AppKey("observatory_reader", object) +PUBLIC_PATHS = frozenset({"/healthz", "/readyz"}) + + +def _private_json(payload: dict[str, Any], *, status: int = 200) -> web.Response: + response = web.json_response(payload, status=status) + response.headers["Cache-Control"] = "private, no-store, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Vary"] = "X-Api-Key" + response.headers["X-Content-Type-Options"] = "nosniff" + return response + + +@web.middleware +async def api_key_auth(request: web.Request, handler: Any) -> web.StreamResponse: + if request.path in PUBLIC_PATHS: + return await handler(request) + settings = request.app[SETTINGS_KEY] + provided = request.headers.get("X-Api-Key", "") + try: + authenticated = hmac.compare_digest( + provided.encode("ascii"), + settings.api_key.encode("ascii"), + ) + except UnicodeEncodeError: + authenticated = False + if not authenticated: + return _private_json({"error": "unauthorized"}, status=401) + return await handler(request) + + +async def healthz(_request: web.Request) -> web.Response: + return web.json_response({"status": "ok"}) + + +async def readyz(request: web.Request) -> web.Response: + reader = request.app[READER_KEY] + try: + await asyncio.wait_for(asyncio.to_thread(reader.check_ready), timeout=12.0) + except Exception as exc: + logger.warning("readiness check failed (%s)", type(exc).__name__) + return web.json_response({"status": "unavailable"}, status=503) + return web.json_response({"status": "ready"}) + + +async def get_sample_claim(request: web.Request) -> web.Response: + return await _claim_response(request, None) + + +async def get_claim(request: web.Request) -> web.Response: + raw_claim_id = request.match_info["claim_id"] + try: + claim_id = str(uuid.UUID(raw_claim_id)) + except ValueError: + return _private_json({"error": "invalid_claim_id"}, status=400) + return await _claim_response(request, claim_id) + + +async def _claim_response(request: web.Request, claim_id: str | None) -> web.Response: + reader = request.app[READER_KEY] + try: + payload = await asyncio.wait_for( + asyncio.to_thread(reader.read_claim, claim_id), + timeout=12.0, + ) + except Exception as exc: + logger.error("canonical claim read failed (%s)", type(exc).__name__) + return _private_json({"error": "canonical_claim_unavailable"}, status=503) + if payload is None: + return _private_json({"error": "claim_not_found"}, status=404) + return _private_json(payload) + + +async def _cleanup(app: web.Application) -> None: + reader = app[READER_KEY] + await asyncio.to_thread(reader.close) + + +def create_app( + settings: Settings | None = None, + *, + reader: Any | None = None, +) -> web.Application: + settings = settings or Settings.from_env() + settings.validate() + reader = reader or CloudSqlClaimReader(settings) + app = web.Application(middlewares=[api_key_auth], client_max_size=16 * 1024) + app[SETTINGS_KEY] = settings + app[READER_KEY] = reader + app.router.add_get("/healthz", healthz) + app.router.add_get("/readyz", readyz) + app.router.add_get("/v1/claims/sample", get_sample_claim) + app.router.add_get("/v1/claims/{claim_id}", get_claim) + app.on_cleanup.append(_cleanup) + return app diff --git a/observatory_read_adapter/config.py b/observatory_read_adapter/config.py new file mode 100644 index 0000000..b01b5ac --- /dev/null +++ b/observatory_read_adapter/config.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass, field + +EXPECTED_PROJECT = "teleo-501523" +EXPECTED_REGION = "europe-west6" +EXPECTED_INSTANCE = "teleo-pgvector-standby" +EXPECTED_CONNECTION_NAME = f"{EXPECTED_PROJECT}:{EXPECTED_REGION}:{EXPECTED_INSTANCE}" +EXPECTED_DATABASE = "teleo_canonical" +EXPECTED_AUTHORIZATION_ROLE = "kb_observatory_read" + + +@dataclass(frozen=True) +class Settings: + project_id: str + instance_connection_name: str + database: str + iam_database_user: str + authorization_role: str + api_key: str = field(repr=False) + service_revision: str = "unknown" + port: int = 8080 + + @classmethod + def from_env(cls) -> Settings: + settings = cls( + project_id=os.environ.get("GCP_PROJECT_ID", ""), + instance_connection_name=os.environ.get("CLOUD_SQL_INSTANCE", ""), + database=os.environ.get("DB_NAME", ""), + iam_database_user=os.environ.get("DB_IAM_USER", ""), + authorization_role=os.environ.get("DB_AUTHORIZATION_ROLE", ""), + api_key=os.environ.get("OBSERVATORY_API_KEY", ""), + service_revision=os.environ.get("SERVICE_REVISION", "unknown"), + port=int(os.environ.get("PORT", "8080")), + ) + settings.validate() + return settings + + def validate(self) -> None: + if self.project_id != EXPECTED_PROJECT: + raise ValueError(f"GCP_PROJECT_ID must be {EXPECTED_PROJECT}") + if self.instance_connection_name != EXPECTED_CONNECTION_NAME: + raise ValueError(f"CLOUD_SQL_INSTANCE must be {EXPECTED_CONNECTION_NAME}") + if self.database != EXPECTED_DATABASE: + raise ValueError(f"DB_NAME must be {EXPECTED_DATABASE}") + if self.authorization_role != EXPECTED_AUTHORIZATION_ROLE: + raise ValueError(f"DB_AUTHORIZATION_ROLE must be {EXPECTED_AUTHORIZATION_ROLE}") + if not self.iam_database_user.endswith(f"@{EXPECTED_PROJECT}.iam"): + raise ValueError("DB_IAM_USER must be the scoped Cloud SQL service-account user") + if any(character.isspace() for character in self.iam_database_user): + raise ValueError("DB_IAM_USER must not contain whitespace") + if ( + len(self.api_key) < 32 + or len(self.api_key) > 512 + or not self.api_key.isascii() + or any(character.isspace() for character in self.api_key) + ): + raise ValueError("OBSERVATORY_API_KEY must be 32-512 non-whitespace ASCII characters") + if not 1 <= self.port <= 65535: + raise ValueError("PORT is outside the valid TCP port range") diff --git a/observatory_read_adapter/db.py b/observatory_read_adapter/db.py new file mode 100644 index 0000000..8ed89c4 --- /dev/null +++ b/observatory_read_adapter/db.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +from collections.abc import Callable +from datetime import date, datetime, timezone +from decimal import Decimal +from typing import Any + +from .config import Settings + +ConnectionFactory = Callable[[], Any] + + +class ReaderContractError(RuntimeError): + """The live database session does not match the adapter's read-only contract.""" + + +def _json_value(value: Any) -> Any: + if isinstance(value, Decimal): + return float(value) + if isinstance(value, (date, datetime)): + return value.isoformat() + if isinstance(value, tuple): + return list(value) + return value + + +def _column_name(column: Any) -> str: + return str(column.name) if hasattr(column, "name") else str(column[0]) + + +def _row_dict(cursor: Any, row: Any) -> dict[str, Any] | None: + if row is None: + return None + names = [_column_name(column) for column in cursor.description] + return {name: _json_value(value) for name, value in zip(names, row, strict=True)} + + +def _rows(cursor: Any) -> list[dict[str, Any]]: + names = [_column_name(column) for column in cursor.description] + return [ + {name: _json_value(value) for name, value in zip(names, row, strict=True)} + for row in cursor.fetchall() + ] + + +class CloudSqlClaimReader: + def __init__(self, settings: Settings, *, connection_factory: ConnectionFactory | None = None): + self.settings = settings + self._connector: Any | None = None + if connection_factory is not None: + self._connection_factory = connection_factory + return + + from google.cloud.sql.connector import Connector, IPTypes + + self._connector = Connector(refresh_strategy="LAZY") + + def connect() -> Any: + return self._connector.connect( + settings.instance_connection_name, + "pg8000", + user=settings.iam_database_user, + db=settings.database, + enable_iam_auth=True, + ip_type=IPTypes.PRIVATE, + ) + + self._connection_factory = connect + + def close(self) -> None: + if self._connector is not None: + self._connector.close() + + def check_ready(self) -> dict[str, Any]: + connection = self._connection_factory() + try: + cursor = connection.cursor() + self._begin_read_only(cursor) + session = self._load_session_contract(cursor) + connection.rollback() + return { + "ready": True, + "database": session["database"], + "authorization_role": self.settings.authorization_role, + "transaction_read_only": True, + } + finally: + connection.close() + + def read_claim(self, claim_id: str | None = None) -> dict[str, Any] | None: + connection = self._connection_factory() + try: + cursor = connection.cursor() + self._begin_read_only(cursor) + session = self._load_session_contract(cursor) + claim = self._load_claim(cursor, claim_id) + if claim is None: + connection.rollback() + return None + + evidence = self._load_evidence(cursor, claim["id"]) + edges = self._load_edges(cursor, claim["id"]) + proposal_counts = self._load_proposal_counts(cursor) + related_proposals = self._load_related_proposals(cursor, claim["id"]) + recent_proposals = self._load_recent_proposals(cursor) + connection.rollback() + finally: + connection.close() + + generated_at = datetime.now(timezone.utc).isoformat() + return { + "schema": "livingip.observatory-canonical-claim.v1", + "generated_at": generated_at, + "read_only": True, + "provenance": { + "gcp_project": self.settings.project_id, + "cloud_sql_instance": self.settings.instance_connection_name, + "database": session["database"], + "database_principal": session["database_principal"], + "authorization_role": self.settings.authorization_role, + "transaction_read_only": True, + "write_privileges_denied": bool(session["required_writes_denied"]), + "service_revision": self.settings.service_revision, + }, + "canonical": { + "relation": "public.claims", + "claim": claim, + "evidence_relation": "public.claim_evidence -> public.sources", + "evidence": evidence, + "edge_relation": "public.claim_edges", + "edges": edges, + }, + "proposal_ledger": { + "relation": "kb_stage.kb_proposals", + "distinct_from_canonical": True, + "status_counts": proposal_counts, + "related": related_proposals, + "recent": recent_proposals, + "semantics": { + "pending_review": "staged only; not canonical", + "approved": "reviewed intent; not canonical until applied_at is set", + "applied": "proposal receipt only; canonical rows remain authoritative", + }, + }, + } + + @staticmethod + def _begin_read_only(cursor: Any) -> None: + cursor.execute("set transaction read only") + cursor.execute("set local statement_timeout = '5000ms'") + cursor.execute("set local lock_timeout = '1000ms'") + + def _load_session_contract(self, cursor: Any) -> dict[str, Any]: + cursor.execute( + """ + select current_database() as database, + current_user as database_principal, + current_setting('transaction_read_only') as transaction_read_only, + pg_has_role(current_user, %s, 'member') as has_authorization_role, + has_table_privilege(current_user, 'public.claims', 'select') + and has_table_privilege(current_user, 'public.sources', 'select') + and has_table_privilege(current_user, 'public.claim_evidence', 'select') + and has_table_privilege(current_user, 'public.claim_edges', 'select') + and has_table_privilege(current_user, 'kb_stage.kb_proposals', 'select') + as has_required_reads, + not ( + has_table_privilege(current_user, 'public.claims', 'insert,update,delete,truncate') + or has_table_privilege(current_user, 'public.sources', 'insert,update,delete,truncate') + or has_table_privilege(current_user, 'public.claim_evidence', 'insert,update,delete,truncate') + or has_table_privilege(current_user, 'public.claim_edges', 'insert,update,delete,truncate') + or has_table_privilege(current_user, 'kb_stage.kb_proposals', 'insert,update,delete,truncate') + ) as required_writes_denied + """, + (self.settings.authorization_role,), + ) + session = _row_dict(cursor, cursor.fetchone()) + if session is None: + raise ReaderContractError("database session contract returned no row") + if session["database"] != self.settings.database: + raise ReaderContractError("database session selected an unexpected database") + if session["database_principal"] != self.settings.iam_database_user: + raise ReaderContractError("database session selected an unexpected principal") + if session["transaction_read_only"] != "on": + raise ReaderContractError("database session is not read-only") + if not session["has_authorization_role"] or not session["has_required_reads"]: + raise ReaderContractError("database principal lacks the exact read authorization") + if not session["required_writes_denied"]: + raise ReaderContractError("database principal has an unexpected write privilege") + return session + + @staticmethod + def _load_claim(cursor: Any, claim_id: str | None) -> dict[str, Any] | None: + columns = """ + select c.id::text as id, + c.type::text as type, + left(c.text, 12000) as text, + c.status::text as status, + c.confidence, + coalesce(c.tags, '{}'::text[]) as tags, + c.superseded_by::text as superseded_by, + c.created_at, + c.updated_at + from public.claims c + """ + if claim_id is not None: + cursor.execute(columns + " where c.id = %s::uuid", (claim_id,)) + return _row_dict(cursor, cursor.fetchone()) + + cursor.execute( + columns + + """ + where exists ( + select 1 from public.claim_evidence ce where ce.claim_id = c.id + ) + order by coalesce(c.updated_at, c.created_at) desc, c.id desc + limit 1 + """ + ) + claim = _row_dict(cursor, cursor.fetchone()) + if claim is not None: + return claim + cursor.execute(columns + " order by coalesce(c.updated_at, c.created_at) desc, c.id desc limit 1") + return _row_dict(cursor, cursor.fetchone()) + + @staticmethod + def _load_evidence(cursor: Any, claim_id: str) -> list[dict[str, Any]]: + cursor.execute( + """ + select ce.id::text as evidence_id, + ce.role::text as role, + ce.weight, + ce.created_at as linked_at, + s.id::text as source_id, + s.source_type::text as source_type, + s.url, + s.storage_path, + left(coalesce(s.excerpt, ''), 2400) as excerpt, + s.hash as content_hash, + s.captured_at, + s.created_at as source_created_at + from public.claim_evidence ce + join public.sources s on s.id = ce.source_id + where ce.claim_id = %s::uuid + order by ce.weight desc nulls last, ce.created_at, ce.id + limit 30 + """, + (claim_id,), + ) + return _rows(cursor) + + @staticmethod + def _load_edges(cursor: Any, claim_id: str) -> list[dict[str, Any]]: + cursor.execute( + """ + select e.id::text as edge_id, + case when e.from_claim = %s::uuid then 'outgoing' else 'incoming' end as direction, + e.edge_type::text as edge_type, + e.weight, + case when e.from_claim = %s::uuid then e.to_claim::text else e.from_claim::text end + as connected_claim_id, + e.created_at + from public.claim_edges e + where e.from_claim = %s::uuid or e.to_claim = %s::uuid + order by e.created_at, e.id + limit 40 + """, + (claim_id, claim_id, claim_id, claim_id), + ) + return _rows(cursor) + + @staticmethod + def _load_proposal_counts(cursor: Any) -> dict[str, int]: + cursor.execute( + "select status::text as status, count(*)::int as count " + "from kb_stage.kb_proposals group by status order by status" + ) + return {str(status): int(count) for status, count in cursor.fetchall()} + + @staticmethod + def _load_related_proposals(cursor: Any, claim_id: str) -> list[dict[str, Any]]: + cursor.execute( + """ + select p.id::text as id, + p.proposal_type::text as proposal_type, + p.status::text as status, + to_jsonb(p)->>'source_ref' as source_ref, + p.reviewed_at, + p.applied_at, + p.updated_at + from kb_stage.kb_proposals p + where p.payload::text like %s + order by p.updated_at desc, p.id desc + limit 20 + """, + (f"%{claim_id}%",), + ) + return _rows(cursor) + + @staticmethod + def _load_recent_proposals(cursor: Any) -> list[dict[str, Any]]: + cursor.execute( + """ + select p.id::text as id, + p.proposal_type::text as proposal_type, + p.status::text as status, + to_jsonb(p)->>'source_ref' as source_ref, + p.reviewed_at, + p.applied_at, + p.updated_at + from kb_stage.kb_proposals p + order by p.updated_at desc, p.id desc + limit 10 + """ + ) + return _rows(cursor) diff --git a/ops/observatory_read_role.sql b/ops/observatory_read_role.sql new file mode 100644 index 0000000..0536237 --- /dev/null +++ b/ops/observatory_read_role.sql @@ -0,0 +1,183 @@ +\set ON_ERROR_STOP on +\getenv observatory_iam_user OBSERVATORY_DB_IAM_USER + +-- Provision only after Cloud SQL has created the dedicated IAM database user. +-- The service-account username omits the .gserviceaccount.com suffix. +select set_config('observatory.iam_user', :'observatory_iam_user', false); + +do $preflight$ +declare + principal text := current_setting('observatory.iam_user'); +begin + if current_database() <> 'teleo_canonical' then + raise exception 'observatory_read_role must run only in teleo_canonical'; + end if; + if principal !~ '^[a-z0-9-]+@teleo-501523[.]iam$' then + raise exception 'OBSERVATORY_DB_IAM_USER is not the scoped GCP service-account database user'; + end if; + if not exists (select 1 from pg_catalog.pg_roles where rolname = principal) then + raise exception 'Cloud SQL IAM database user does not exist: %', principal; + end if; +end +$preflight$; + +select 'create role kb_observatory_read nologin nosuperuser nocreatedb nocreaterole noinherit noreplication nobypassrls connection limit -1' +where not exists (select 1 from pg_catalog.pg_roles where rolname = 'kb_observatory_read') +\gexec + +alter role kb_observatory_read + with nologin nosuperuser nocreatedb nocreaterole noinherit noreplication nobypassrls connection limit -1 + password null; +alter role kb_observatory_read reset all; + +revoke all privileges on database teleo_canonical from kb_observatory_read; +grant connect on database teleo_canonical to kb_observatory_read; + +revoke all on schema public, kb_stage from kb_observatory_read; +revoke all privileges on all tables in schema public, kb_stage from kb_observatory_read; +revoke all privileges on all sequences in schema public, kb_stage from kb_observatory_read; +revoke all privileges on all functions in schema public, kb_stage from kb_observatory_read; +revoke all privileges on all procedures in schema public, kb_stage from kb_observatory_read; + +select format( + 'revoke all privileges (%s) on table %I.%I from kb_observatory_read', + pg_catalog.string_agg(pg_catalog.quote_ident(attribute.attname), ', ' order by attribute.attnum), + namespace.nspname, + relation.relname + ) + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + join pg_catalog.pg_attribute attribute on attribute.attrelid = relation.oid + where namespace.nspname in ('public', 'kb_stage') + and relation.relkind in ('r', 'p', 'v', 'm', 'f') + and attribute.attnum > 0 + and not attribute.attisdropped + group by namespace.nspname, relation.relname +\gexec + +grant usage on schema public, kb_stage to kb_observatory_read; +grant select on + public.claims, + public.sources, + public.claim_evidence, + public.claim_edges, + kb_stage.kb_proposals +to kb_observatory_read; + +do $membership$ +declare + principal text := current_setting('observatory.iam_user'); + unexpected text; +begin + select pg_catalog.string_agg(granted.rolname, ', ' order by granted.rolname) + into unexpected + from pg_catalog.pg_auth_members membership + join pg_catalog.pg_roles granted on granted.oid = membership.roleid + join pg_catalog.pg_roles member on member.oid = membership.member + where member.rolname = principal + and granted.rolname <> 'kb_observatory_read'; + if unexpected is not null then + raise exception 'dedicated Observatory principal has unexpected role memberships: %', unexpected; + end if; + + select pg_catalog.string_agg(member.rolname, ', ' order by member.rolname) + into unexpected + from pg_catalog.pg_auth_members membership + join pg_catalog.pg_roles granted on granted.oid = membership.roleid + join pg_catalog.pg_roles member on member.oid = membership.member + where granted.rolname = 'kb_observatory_read' + and member.rolname <> principal; + if unexpected is not null then + raise exception 'kb_observatory_read has unexpected members: %', unexpected; + end if; + + execute pg_catalog.format('grant kb_observatory_read to %I', principal); + execute pg_catalog.format( + 'alter role %I in database teleo_canonical set default_transaction_read_only = on', + principal + ); + execute pg_catalog.format( + 'alter role %I in database teleo_canonical set statement_timeout = %L', + principal, + '5s' + ); + execute pg_catalog.format( + 'alter role %I in database teleo_canonical set lock_timeout = %L', + principal, + '1s' + ); +end +$membership$; + +do $verify$ +declare + principal text := current_setting('observatory.iam_user'); + unexpected text; +begin + if not pg_catalog.pg_has_role(principal, 'kb_observatory_read', 'member') then + raise exception 'Observatory principal is not a member of kb_observatory_read'; + end if; + if not exists ( + select 1 from pg_catalog.pg_roles + where rolname = principal and rolinherit and not rolsuper and not rolcreatedb + and not rolcreaterole and not rolreplication and not rolbypassrls + ) then + raise exception 'Observatory principal has unsafe role attributes or cannot inherit read grants'; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%I.%I:%s', namespace.nspname, relation.relname, privilege.name), + ', ' order by namespace.nspname, relation.relname, privilege.name + ) + into unexpected + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + cross join (values ('INSERT'), ('UPDATE'), ('DELETE'), ('TRUNCATE'), ('REFERENCES'), ('TRIGGER')) privilege(name) + where namespace.nspname in ('public', 'kb_stage') + and relation.relkind in ('r', 'p', 'v', 'm', 'f') + and pg_catalog.has_table_privilege(principal, relation.oid, privilege.name); + if unexpected is not null then + raise exception 'Observatory principal has effective table writes: %', unexpected; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%I.%I', namespace.nspname, relation.relname), + ', ' order by namespace.nspname, relation.relname + ) + into unexpected + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + where namespace.nspname in ('public', 'kb_stage') + and relation.relkind in ('r', 'p', 'v', 'm', 'f') + and pg_catalog.has_table_privilege(principal, relation.oid, 'SELECT') + and (namespace.nspname, relation.relname) not in ( + ('public', 'claims'), + ('public', 'sources'), + ('public', 'claim_evidence'), + ('public', 'claim_edges'), + ('kb_stage', 'kb_proposals') + ); + if unexpected is not null then + raise exception 'Observatory principal can SELECT outside its allowlist: %', unexpected; + end if; + + if not ( + pg_catalog.has_table_privilege(principal, 'public.claims', 'SELECT') + and pg_catalog.has_table_privilege(principal, 'public.sources', 'SELECT') + and pg_catalog.has_table_privilege(principal, 'public.claim_evidence', 'SELECT') + and pg_catalog.has_table_privilege(principal, 'public.claim_edges', 'SELECT') + and pg_catalog.has_table_privilege(principal, 'kb_stage.kb_proposals', 'SELECT') + ) then + raise exception 'Observatory principal is missing a required read grant'; + end if; +end +$verify$; + +select jsonb_build_object( + 'database', current_database(), + 'authorization_role', 'kb_observatory_read', + 'database_principal', current_setting('observatory.iam_user'), + 'required_reads', true, + 'effective_table_writes_denied', true, + 'default_transaction_read_only', true +)::text; diff --git a/pyproject.toml b/pyproject.toml index cf64b66..e264213 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,9 +18,12 @@ dev = [ "pytest-asyncio>=0.23", "ruff>=0.3", ] +observatory = [ + "cloud-sql-python-connector[pg8000]>=1.20.4,<1.21", +] [tool.setuptools] -packages = ["lib"] +packages = ["lib", "observatory_read_adapter"] [tool.ruff] target-version = "py311" diff --git a/tests/test_observatory_read_adapter.py b/tests/test_observatory_read_adapter.py new file mode 100644 index 0000000..f259145 --- /dev/null +++ b/tests/test_observatory_read_adapter.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +from aiohttp.test_utils import TestClient, TestServer + +from observatory_read_adapter.app import create_app +from observatory_read_adapter.config import EXPECTED_CONNECTION_NAME, Settings +from observatory_read_adapter.db import CloudSqlClaimReader + +CLAIM_ID = "d0000000-0000-0000-0000-000000000005" +API_KEY = "observatory-test-api-key-000000000000000000000000" +ROOT = Path(__file__).resolve().parents[1] + + +def settings(**overrides: object) -> Settings: + values = { + "project_id": "teleo-501523", + "instance_connection_name": EXPECTED_CONNECTION_NAME, + "database": "teleo_canonical", + "iam_database_user": "sa-observatory-read-adapter@teleo-501523.iam", + "authorization_role": "kb_observatory_read", + "api_key": API_KEY, + "service_revision": "test-revision", + } + values.update(overrides) + return Settings(**values) + + +class FakeReader: + def __init__(self) -> None: + self.closed = False + + def check_ready(self) -> dict[str, object]: + return { + "ready": True, + "database": "teleo_canonical", + "authorization_role": "kb_observatory_read", + "transaction_read_only": True, + } + + def read_claim(self, claim_id: str | None = None) -> dict[str, object] | None: + if claim_id not in (None, CLAIM_ID): + return None + return { + "schema": "livingip.observatory-canonical-claim.v1", + "read_only": True, + "provenance": { + "database": "teleo_canonical", + "database_principal": "sa-observatory-read-adapter@teleo-501523.iam", + "authorization_role": "kb_observatory_read", + "transaction_read_only": True, + "write_privileges_denied": True, + }, + "canonical": { + "claim": {"id": CLAIM_ID, "status": "open", "text": "Canonical claim"}, + "evidence": [{"source_id": "e0000000-0000-0000-0000-000000000006"}], + }, + "proposal_ledger": { + "distinct_from_canonical": True, + "status_counts": {"pending_review": 2, "approved": 1, "applied": 1}, + "related": [{"status": "approved", "applied_at": None}], + }, + } + + def close(self) -> None: + self.closed = True + + +class FakeCursor: + def __init__(self) -> None: + self.description: list[tuple[str]] = [] + self.result: list[tuple[object, ...]] = [] + self.executed: list[tuple[str, tuple[object, ...]]] = [] + + def execute(self, sql: str, parameters: tuple[object, ...] = ()) -> None: + normalized = " ".join(sql.split()).lower() + self.executed.append((normalized, parameters)) + self.description = [] + self.result = [] + if "current_database() as database" in normalized: + self.description = [ + ("database",), + ("database_principal",), + ("transaction_read_only",), + ("has_authorization_role",), + ("has_required_reads",), + ("required_writes_denied",), + ] + self.result = [ + ( + "teleo_canonical", + "sa-observatory-read-adapter@teleo-501523.iam", + "on", + True, + True, + True, + ) + ] + elif "from public.claims c" in normalized: + self.description = [ + ("id",), + ("type",), + ("text",), + ("status",), + ("confidence",), + ("tags",), + ("superseded_by",), + ("created_at",), + ("updated_at",), + ] + self.result = [ + ( + CLAIM_ID, + "strategy", + "Canonical claim", + "open", + 0.8, + ["test"], + None, + "2026-07-15T00:00:00+00:00", + "2026-07-15T00:00:00+00:00", + ) + ] + elif "from public.claim_evidence ce" in normalized: + self.description = [ + ("evidence_id",), + ("role",), + ("weight",), + ("linked_at",), + ("source_id",), + ("source_type",), + ("url",), + ("storage_path",), + ("excerpt",), + ("content_hash",), + ("captured_at",), + ("source_created_at",), + ] + self.result = [ + ( + "e0000000-0000-0000-0000-000000000006", + "grounds", + 1.0, + "2026-07-15T00:00:00+00:00", + "f0000000-0000-0000-0000-000000000007", + "document", + "https://example.test/source", + None, + "Source excerpt", + "sha256:test", + "2026-07-15T00:00:00+00:00", + "2026-07-15T00:00:00+00:00", + ) + ] + elif "from public.claim_edges e" in normalized: + self.description = [ + ("edge_id",), + ("direction",), + ("edge_type",), + ("weight",), + ("connected_claim_id",), + ("created_at",), + ] + self.result = [] + elif "group by status" in normalized: + self.description = [("status",), ("count",)] + self.result = [("approved", 1), ("applied", 1), ("pending_review", 2)] + elif "where p.payload::text like" in normalized: + self.description = [ + ("id",), + ("proposal_type",), + ("status",), + ("source_ref",), + ("reviewed_at",), + ("applied_at",), + ("updated_at",), + ] + self.result = [ + ( + "a0000000-0000-0000-0000-000000000001", + "revise_claim", + "approved", + "source:test", + "2026-07-15T00:00:00+00:00", + None, + "2026-07-15T00:00:00+00:00", + ) + ] + elif "from kb_stage.kb_proposals p" in normalized: + self.description = [ + ("id",), + ("proposal_type",), + ("status",), + ("source_ref",), + ("reviewed_at",), + ("applied_at",), + ("updated_at",), + ] + self.result = [] + + def fetchone(self) -> tuple[object, ...] | None: + return self.result.pop(0) if self.result else None + + def fetchall(self) -> list[tuple[object, ...]]: + result, self.result = self.result, [] + return result + + +class FakeConnection: + def __init__(self) -> None: + self.cursor_value = FakeCursor() + self.rolled_back = False + self.closed = False + + def cursor(self) -> FakeCursor: + return self.cursor_value + + def rollback(self) -> None: + self.rolled_back = True + + def close(self) -> None: + self.closed = True + + +def test_settings_fail_closed_on_wrong_database_role_or_short_key() -> None: + for overrides in ( + {"database": "teleo"}, + {"authorization_role": "leoclean_kb_runtime"}, + {"api_key": "too-short"}, + {"instance_connection_name": "other:region:instance"}, + ): + candidate = settings(**overrides) + try: + candidate.validate() + except ValueError: + pass + else: + raise AssertionError(f"unsafe settings accepted: {overrides}") + + +def test_authenticated_claim_response_and_negative_http_paths() -> None: + async def exercise() -> None: + reader = FakeReader() + client = TestClient(TestServer(create_app(settings(), reader=reader))) + await client.start_server() + try: + anonymous = await client.get("/v1/claims/sample") + assert anonymous.status == 401 + assert await anonymous.json() == {"error": "unauthorized"} + + response = await client.get( + f"/v1/claims/{CLAIM_ID}", + headers={"X-Api-Key": API_KEY}, + ) + assert response.status == 200 + assert response.headers["Cache-Control"] == "private, no-store, max-age=0" + payload = await response.json() + assert payload["schema"] == "livingip.observatory-canonical-claim.v1" + assert payload["provenance"]["database"] == "teleo_canonical" + assert payload["provenance"]["write_privileges_denied"] is True + assert payload["canonical"]["evidence"] + assert payload["proposal_ledger"]["distinct_from_canonical"] is True + assert payload["proposal_ledger"]["related"][0]["applied_at"] is None + assert API_KEY not in json.dumps(payload) + + write_attempt = await client.post( + f"/v1/claims/{CLAIM_ID}", + headers={"X-Api-Key": API_KEY}, + json={"status": "applied"}, + ) + assert write_attempt.status == 405 + + invalid = await client.get( + "/v1/claims/not-a-uuid", + headers={"X-Api-Key": API_KEY}, + ) + assert invalid.status == 400 + finally: + await client.close() + assert reader.closed is True + + asyncio.run(exercise()) + + +def test_readiness_checks_database_contract_without_exposing_claims() -> None: + async def exercise() -> None: + client = TestClient(TestServer(create_app(settings(), reader=FakeReader()))) + await client.start_server() + try: + response = await client.get("/readyz") + payload = await response.json() + assert response.status == 200 + assert payload == {"status": "ready"} + finally: + await client.close() + + asyncio.run(exercise()) + + +def test_database_reader_returns_provenance_and_keeps_proposals_distinct() -> None: + connection = FakeConnection() + reader = CloudSqlClaimReader(settings(), connection_factory=lambda: connection) + + payload = reader.read_claim(CLAIM_ID) + + assert payload is not None + assert payload["provenance"]["database"] == "teleo_canonical" + assert payload["provenance"]["database_principal"] == settings().iam_database_user + assert payload["provenance"]["write_privileges_denied"] is True + assert payload["canonical"]["claim"]["id"] == CLAIM_ID + assert payload["canonical"]["evidence"][0]["content_hash"] == "sha256:test" + assert payload["proposal_ledger"]["distinct_from_canonical"] is True + assert payload["proposal_ledger"]["related"][0]["status"] == "approved" + assert payload["proposal_ledger"]["related"][0]["applied_at"] is None + assert connection.rolled_back is True + assert connection.closed is True + assert any("set transaction read only" in sql for sql, _parameters in connection.cursor_value.executed) + + +def test_database_role_contract_is_select_only_and_iam_scoped() -> None: + sql = (ROOT / "ops" / "observatory_read_role.sql").read_text(encoding="utf-8") + lowered = sql.lower() + + assert "kb_observatory_read nologin" in lowered + assert "default_transaction_read_only = on" in lowered + assert "public.claims" in lowered + assert "public.sources" in lowered + assert "public.claim_evidence" in lowered + assert "public.claim_edges" in lowered + assert "kb_stage.kb_proposals" in lowered + assert "grant select on" in lowered + assert "grant insert" not in lowered + assert "grant update" not in lowered + assert "grant delete" not in lowered + assert "effective_table_writes_denied" in lowered + + +def test_container_runs_as_non_root_and_contains_no_password_contract() -> None: + dockerfile = (ROOT / "Dockerfile.observatory-read-adapter").read_text(encoding="utf-8") + + assert "USER 10001:10001" in dockerfile + assert "PGPASSWORD" not in dockerfile + assert "DB_PASS" not in dockerfile diff --git a/tests/test_observatory_read_role_postgres.py b/tests/test_observatory_read_role_postgres.py new file mode 100644 index 0000000..be2b155 --- /dev/null +++ b/tests/test_observatory_read_role_postgres.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import shutil +import subprocess +import time +import uuid +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +ROLE_SQL = ROOT / "ops" / "observatory_read_role.sql" +POSTGRES_IMAGE = "postgres:16-alpine" +DATABASE = "teleo_canonical" +IAM_USER = "sa-observatory-read-adapter@teleo-501523.iam" +TEST_PASSWORD = "generated-test-only-password" + +BOOTSTRAP_SQL = f""" +begin; +create schema kb_stage; +create role "{IAM_USER}" login inherit password '{TEST_PASSWORD}'; +create table public.claims (id uuid primary key); +create table public.sources (id uuid primary key); +create table public.claim_evidence (id uuid primary key); +create table public.claim_edges (id uuid primary key); +create table public.private_notes (id uuid primary key); +create table kb_stage.kb_proposals (id uuid primary key); +insert into public.claims values ('11111111-1111-1111-1111-111111111111'); +commit; +""" + + +def run(command: list[str], *, input_text: str | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + input=input_text, + text=True, + capture_output=True, + check=check, + timeout=30, + ) + + +@pytest.mark.skipif(shutil.which("docker") is None, reason="Docker is required") +def test_observatory_role_is_read_only_and_exactly_allowlisted() -> None: + container = f"teleo-observatory-role-{uuid.uuid4().hex[:10]}" + run( + [ + "docker", + "run", + "--detach", + "--rm", + "--name", + container, + "--env", + f"POSTGRES_PASSWORD={TEST_PASSWORD}", + "--env", + f"POSTGRES_DB={DATABASE}", + POSTGRES_IMAGE, + ] + ) + try: + deadline = time.monotonic() + 25 + while time.monotonic() < deadline: + ready = run( + ["docker", "exec", container, "pg_isready", "-U", "postgres", "-d", DATABASE], + check=False, + ) + if ready.returncode == 0: + break + time.sleep(0.25) + else: + raise AssertionError("ephemeral Postgres did not become ready") + + bootstrap: subprocess.CompletedProcess[str] | None = None + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + bootstrap = run( + [ + "docker", + "exec", + "-i", + container, + "psql", + "--set", + "ON_ERROR_STOP=1", + "-U", + "postgres", + "-d", + DATABASE, + ], + input_text=BOOTSTRAP_SQL, + check=False, + ) + if bootstrap.returncode == 0: + break + time.sleep(0.25) + else: + assert bootstrap is not None + raise AssertionError(f"ephemeral Postgres bootstrap failed: {bootstrap.stderr[-1000:]}") + migration = run( + [ + "docker", + "exec", + "-e", + f"OBSERVATORY_DB_IAM_USER={IAM_USER}", + "-i", + container, + "psql", + "-U", + "postgres", + "-d", + DATABASE, + ], + input_text=ROLE_SQL.read_text(encoding="utf-8"), + ) + assert '"effective_table_writes_denied": true' in migration.stdout + + readback = run( + [ + "docker", + "exec", + "-e", + f"PGPASSWORD={TEST_PASSWORD}", + container, + "psql", + "-h", + "127.0.0.1", + "-U", + IAM_USER, + "-d", + DATABASE, + "-At", + "-c", + "show default_transaction_read_only; select count(*) from public.claims;", + ] + ) + assert readback.stdout.splitlines() == ["on", "1"] + + write_attempt = run( + [ + "docker", + "exec", + "-e", + f"PGPASSWORD={TEST_PASSWORD}", + container, + "psql", + "-h", + "127.0.0.1", + "-U", + IAM_USER, + "-d", + DATABASE, + "-c", + "insert into public.claims values ('22222222-2222-2222-2222-222222222222');", + ], + check=False, + ) + assert write_attempt.returncode != 0 + + outside_allowlist = run( + [ + "docker", + "exec", + "-e", + f"PGPASSWORD={TEST_PASSWORD}", + container, + "psql", + "-h", + "127.0.0.1", + "-U", + IAM_USER, + "-d", + DATABASE, + "-c", + "select * from public.private_notes;", + ], + check=False, + ) + assert outside_allowlist.returncode != 0 + finally: + run(["docker", "rm", "--force", container], check=False) From 53c8de0a1959a744e17888df1a70e935dfef0143 Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Wed, 15 Jul 2026 03:51:54 +0200 Subject: [PATCH 5/8] Make Observatory staging deploy rerunnable (#155) --- .../workflows/gcp-observatory-read-adapter.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/gcp-observatory-read-adapter.yml b/.github/workflows/gcp-observatory-read-adapter.yml index e4c088f..95b18a4 100644 --- a/.github/workflows/gcp-observatory-read-adapter.yml +++ b/.github/workflows/gcp-observatory-read-adapter.yml @@ -74,12 +74,21 @@ jobs: set -euo pipefail tag="${GITHUB_SHA::12}" image="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_NAME}:${tag}" - docker build -f Dockerfile.observatory-read-adapter -t "${image}" . + digest="$(gcloud artifacts docker images describe "${image}" \ + --project="${PROJECT_ID}" \ + --format='value(image_summary.digest)' 2>/dev/null || true)" + if [[ -n "${digest}" ]]; then + docker pull "${image}" + else + docker build -f Dockerfile.observatory-read-adapter -t "${image}" . + fi docker run --rm --env PYTHONPYCACHEPREFIX=/tmp/pycache \ "${image}" python -m compileall -q /app/observatory_read_adapter docker run --rm "${image}" python -c 'import observatory_read_adapter; print("adapter-import-ok")' - docker push "${image}" | tee docker-push.log - digest="$(awk '/digest: sha256:/ {print $3}' docker-push.log | tail -1)" + if [[ -z "${digest}" ]]; then + docker push "${image}" | tee docker-push.log + digest="$(awk '/digest: sha256:/ {print $3}' docker-push.log | tail -1)" + fi test -n "${digest}" image_ref="${image}@${digest}" printf 'image_ref=%s\n' "${image_ref}" >> "${GITHUB_OUTPUT}" From e4348a0071f97e3962bdfbaf69a00b95b002df36 Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Wed, 15 Jul 2026 03:59:36 +0200 Subject: [PATCH 6/8] Record Observatory staging deployment gate (#156) --- .../gcp-staging-current-gate.json | 40 ++++++++++++++ .../gcp-staging-live-attempt-redacted.json | 55 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 docs/reports/observatory-read-adapter/gcp-staging-current-gate.json create mode 100644 docs/reports/observatory-read-adapter/gcp-staging-live-attempt-redacted.json diff --git a/docs/reports/observatory-read-adapter/gcp-staging-current-gate.json b/docs/reports/observatory-read-adapter/gcp-staging-current-gate.json new file mode 100644 index 0000000..f87f8cb --- /dev/null +++ b/docs/reports/observatory-read-adapter/gcp-staging-current-gate.json @@ -0,0 +1,40 @@ +{ + "schema": "livingip.observatoryReadAdapterCurrentGate.v1", + "current_canary": "Authenticated GET /v1/claims/sample in GCP staging returns a provenance-bearing teleo_canonical claim, anonymous GET returns 401, and authenticated POST returns 405.", + "permission_profile": "approval_policy_never_with_unrestricted_filesystem_and_network", + "attempted_routes": [ + { + "route": "merged GitHub Actions deployment with artifact-builder WIF", + "result": "image build, smoke, and push passed; deploy stopped because run.googleapis.com is disabled" + }, + { + "route": "local billy@livingip.xyz gcloud user credential", + "result": "token refresh and service enable both stop at noninteractive Google reauthentication" + }, + { + "route": "local application-default credential", + "result": "token refresh stops at noninteractive Google reauthentication" + }, + { + "route": "other locally authenticated Google accounts", + "result": "valid tokens but no access to project teleo-501523" + }, + { + "route": "fixed read-only GCP IAP status workflow", + "result": "WIF token exchange failed because the teleo-iap-operator provider target is missing or disabled" + }, + { + "route": "local browser or computer-use session", + "result": "no browser or computer-use tool is exposed to this task; no foreground takeover was attempted" + } + ], + "exact_gate": { + "first_infrastructure_gate": "Cloud Run Admin API run.googleapis.com is disabled in teleo-501523.", + "current_authorization_gate": "The only local account authorized for teleo-501523, billy@livingip.xyz, requires Google password or MFA reauthentication before project APIs and staging IAM resources can be changed.", + "alternate_private_operator_gate": "The read-only teleo-iap-operator WIF provider returns invalid_target and cannot reach its status operation." + }, + "why_autonomous_repair_stops": "Google password or MFA reauthentication is a human-only credential boundary, and no authenticated project-admin browser or computer-use surface is available in this task.", + "clear_CTA": "Run `gcloud auth login billy@livingip.xyz --update-adc`, complete Google password or MFA, and then report `GCP reauth complete`.", + "next_non_user_action": "Enable the required staging APIs; create or verify the runtime service account, API-key secret, Cloud SQL IAM database user, and exact read-only role grants; deploy the retained immutable image; capture authenticated GET, anonymous 401, authenticated POST 405, SQL write-denial, exact service/database identity, and cleanup receipts.", + "production_boundary": "Do not change Vercel, production routing, Cloud SQL network exposure, or any write endpoint." +} diff --git a/docs/reports/observatory-read-adapter/gcp-staging-live-attempt-redacted.json b/docs/reports/observatory-read-adapter/gcp-staging-live-attempt-redacted.json new file mode 100644 index 0000000..8c50ace --- /dev/null +++ b/docs/reports/observatory-read-adapter/gcp-staging-live-attempt-redacted.json @@ -0,0 +1,55 @@ +{ + "schema": "livingip.observatoryReadAdapterStagingReceipt.v1", + "status": "blocked_before_runtime_creation", + "required_runtime_tier": "T3_live_gcp_staging_api", + "current_runtime_tier": "below_T3_build_artifact_only", + "project_id": "teleo-501523", + "region": "europe-west6", + "service_name": "observatory-read-adapter-staging", + "database_name": "teleo_canonical", + "cloud_sql_instance": "teleo-501523:europe-west6:teleo-pgvector-standby", + "authorization_role": "kb_observatory_read", + "runtime_service_account": "sa-observatory-read-adapter@teleo-501523.iam.gserviceaccount.com", + "image": { + "revision": "53c8de0a1959a744e17888df1a70e935dfef0143", + "digest": "sha256:b5c9b2051559730cbb9003d74e5d8e577fa2f43f49b72577f251f2843a0650c5", + "artifact_registry_push": "pass", + "container_smoke": "pass", + "intentional_retained_resource": true + }, + "workflow": { + "run_id": 29382683335, + "url": "https://github.com/living-ip/teleo-infrastructure/actions/runs/29382683335", + "build_job": "success", + "deploy_job": "failure", + "failure_class": "SERVICE_DISABLED", + "failure_detail": "run.googleapis.com is disabled in teleo-501523", + "authenticated_as": "sa-artifact-builder@teleo-501523.iam.gserviceaccount.com" + }, + "live_receipts": { + "service_url": null, + "authenticated_get_sample": null, + "anonymous_get_401": null, + "authenticated_post_405": null, + "database_write_denial": null, + "database_identity": null + }, + "negative_claims": { + "cloud_run_service_created": false, + "cloud_sql_public_exposure_changed": false, + "production_or_vercel_routing_changed": false, + "credentials_logged": false + }, + "cleanup": { + "github_jobs_completed": true, + "local_observatory_test_containers": 0, + "local_observatory_test_images": 0, + "orphan_local_build_processes": 0, + "artifact_registry_image_retained_for_next_deploy": true + }, + "proof_paths": [ + "/private/tmp/observatory-read-adapter-run-29382683335/observatory-adapter-image/observatory-adapter-image.txt", + "/private/tmp/observatory-iap-status-29382844567/gcp-iap-operator-29382844567/result.json" + ], + "claim_ceiling": "The immutable staging image exists, but no live API or database query receipt exists. T3 is not complete." +} From 3b138fd2e1117244405fe19c856c81e8aafe4131 Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Wed, 15 Jul 2026 04:00:14 +0200 Subject: [PATCH 7/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 8/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"] + )