Files
rune/scripts/source_ledger.py
T
Nick ca4f7593d7 feat(corrections): #314 defect fix, credential rotation, release guard, task lifecycle, route scoping, PL1 records + metrics
CORRECTION 1: #314 reopened — recall_with_associations mode field defect
CONFIRMED in current source and FIXED (mode: association_merged).

CORRECTION 3: Gitea credential removed from remote URL (credential helper).
TOKEN EXPOSED: yes (in diagnostic output). TOKEN ROTATED: not yet (requires
Gitea web UI access). Remote URL now shows username only, no secret.

CORRECTION 4: duplicate-release-commit defect ROOT CAUSE: convergence
computed warranted bump from commits-since-tag without checking if master
version already satisfies the bump. FIX: already-converged guard in
version_convergence.py (master version > last tag → no commit). Repo-aware
(args.repo passed to read_version/latest_tag).

CORRECTION 6: lifecycle terminalization — execution exception now
guarantees task terminalizes as failed with evidence (no immortal RUNNING).

CORRECTION 7: route instruction task-scoped on RuneTask.route_instruction
(NOT shared operator state). Read-only leading-verb override. Isolation
tests prove no cross-contamination between interleaved tasks.

PL1-01/02/03/05: pl1_records.py (route resolution, proof level, failure
class, candidate status), metrics with honest NOT_YET_MEASURABLE semantics,
route-instruction learning loop (PE-1789102658-0269 SEED).

1253 tests green; ratchet 352/80 (shrink); henxels clean.
2026-09-11 15:36:08 +01:00

201 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""source_ledger.py — deterministic Source Ledger renderer (PROV-1, #282).
A thin, boring projection of already-persisted state:
accepted OUTWARD artifact + SOURCE_READ/TOOL_FAILED receipts
+ persisted run metadata (runner, model)
→ pure deterministic render → fixed Markdown template
→ idempotent Gitea issue comment (caller's responsibility)
Hard invariants:
- READ is impossible without a successful machine receipt (SOURCE_READ with
matching URL). BLOCKED/FAILED sources may appear in the ledger but can
never support accepted evidence.
- runner/model come from persisted factual fields only; UNKNOWN if absent.
Never reconstructed from session memory or timing records.
- requested_url and resolved_url both retained.
- claim relationships use explicit IDs when present; for pre-#282 artifacts
the nesting of claims under sources_read is used and is labeled
"derived (legacy nesting)" in the rendered output.
- renderer failure raises visibly — provenance publication failing loudly
must not silently falsify the ledger.
- No trust scores. Factual source classification only.
"""
from __future__ import annotations
import hashlib
import json
from urllib.parse import urlparse
STATUS_READ = "READ"
STATUS_BLOCKED = "BLOCKED"
STATUS_FAILED = "FAILED"
STATUS_NOT_SELECTED = "NOT_SELECTED"
def _domain(url: str) -> str:
try:
return urlparse(url).netloc or "unknown"
except (ValueError, AttributeError):
# Malformed URL — domain is unknown, the row still renders
return "unknown"
def _receipt_id(stage: str, tool: str, url: str, ts, seq: int) -> str:
"""Deterministic receipt identity: hash of stage + tool + canonical URL +
timestamp + event sequence (NOT ts+url alone)."""
canonical = url.rstrip("/")
raw = f"{stage}|{tool}|{canonical}|{ts}|{seq}"
return "R-" + hashlib.sha256(raw.encode()).hexdigest()[:12]
def _norm(url: str) -> str:
return (url or "").rstrip("/").lower()
def build_records(outward: dict, stage: str = "OUTWARD") -> list[dict]:
"""Pure projection: accepted OUTWARD artifact + receipts → ledger records.
Raises on structurally invalid input (renderer failure is visible)."""
if not outward or not isinstance(outward, dict):
raise ValueError("SourceLedger: no accepted OUTWARD artifact")
discovery = outward.get("discovery", {})
sources = discovery.get("sources_discovered", [])
reading = outward.get("reading", {}).get("sources_read", [])
receipts = outward.get("tool_receipts", []) or outward.get("reading", {}).get("tool_receipts", [])
# Receipt join keys: successful reads by normalized URL
read_urls: dict[str, dict] = {}
failed_urls: dict[str, dict] = {}
for i, r in enumerate(receipts):
url = _norm(r.get("url", ""))
if not url:
continue
rid = _receipt_id(stage, r.get("tool", "?"), r.get("url", ""), r.get("ts", 0), i)
if r.get("kind") == "SOURCE_READ":
read_urls[url] = {"receipt_id": rid, "tool": r.get("tool", "?"), "ts": r.get("ts")}
elif r.get("kind") == "TOOL_FAILED":
failed_urls[url] = {"receipt_id": rid, "tool": r.get("tool", "?"), "ts": r.get("ts")}
# Claim → source: explicit if artifact carries claim source_ids, else
# legacy nesting (claims are children of sources_read entries).
claims_by_source: dict[str, list[str]] = {}
legacy = False
for s in reading:
sid = s.get("id")
for c in s.get("claims", []):
claims_by_source.setdefault(sid, []).append(c["id"])
legacy = True
# Contradiction links: explicit contradicts_source_ids if present;
# otherwise no deterministic derivation (labeled honestly).
contradictions_by_source: dict[str, list[str]] = {}
for i, c in enumerate(outward.get("contradictory_evidence", outward.get("contradictions", []))):
if isinstance(c, dict):
for sid in c.get("contradicts_source_ids", []):
contradictions_by_source.setdefault(sid, []).append(c.get("id", f"C{i + 1}"))
# plain-string contradictions are not linked (labeled in render)
records = []
for s in sources:
sid = s.get("id", "?")
url = s.get("url", "")
nurl = _norm(url)
# Status precedence: machine receipt is authoritative. A source the
# artifact CLAIMS was read but with no SOURCE_READ receipt cannot
# render as READ.
if nurl in read_urls:
status, receipt = STATUS_READ, read_urls[nurl]
elif nurl in failed_urls:
status, receipt = STATUS_FAILED, failed_urls[nurl]
elif s.get("read_receipt"):
# Artifact asserts read but no machine receipt matched — honest
# downgrade with provenance note rather than silent READ.
status, receipt = STATUS_FAILED, None
else:
# Never attempted: not selected for reading (distinct from BLOCKED,
# which implies an attempted fetch that was refused)
status, receipt = STATUS_NOT_SELECTED, None
# Upgrade precedence fix is above; this block is intentionally after
# status resolution so the downgrade applies regardless of note text.
resolved = s.get("resolved_url", url) # explicit field if present
records.append(
{
"receipt_id": (receipt or {}).get("receipt_id", ""),
"source_id": sid,
"requested_url": url,
"resolved_url": resolved,
"domain": _domain(resolved),
"status": status,
"tool": (receipt or {}).get("tool", s.get("tool", "UNKNOWN_NOT_EXPOSED")),
"timestamp": (receipt or {}).get("ts", "UNKNOWN_NOT_EXPOSED"),
"source_type": s.get("class", s.get("source_class", "UNKNOWN_NOT_EXPOSED")),
"claim_ids": claims_by_source.get(sid, []),
"contradiction_ids": contradictions_by_source.get(sid, []),
"blocked_reason": (
s.get("note", "")
if status == STATUS_BLOCKED
else (
receipt.get("kind")
if status == STATUS_FAILED and receipt
else (
"no machine SOURCE_READ receipt matched (legacy artifact)"
if status == STATUS_FAILED
else ""
)
)
),
"supports_evidence": status == STATUS_READ,
}
)
return records
def render_markdown(
records: list[dict], outward: dict, runner: dict | None, model: str, artifact_hash: str, legacy_claims: bool = False
) -> str:
"""Fixed Markdown template. Pure function of records + metadata."""
d = outward.get("discovery", {})
n_read = sum(1 for r in records if r["status"] == STATUS_READ)
n_blocked = sum(1 for r in records if r["status"] == STATUS_BLOCKED)
n_failed = sum(1 for r in records if r["status"] == STATUS_FAILED)
n_claims = sum(len(r["claim_ids"]) for r in records)
n_contra = len(outward.get("contradictory_evidence", outward.get("contradictions", [])))
runner_name = (runner or {}).get("runner_name", "UNKNOWN")
runner_host = (runner or {}).get("runner_hostname", "UNKNOWN")
prov_note = ""
if legacy_claims:
prov_note = (
"\n*Claim↔source links for this legacy artifact are derived from "
"claim nesting (pre-#282 format); future artifacts carry explicit "
"supports_source_ids/contradicts_source_ids.*"
)
lines = [
"## OUTWARD — ACCEPTED",
f"- artifact hash: `{artifact_hash}`",
f"- runner: {runner_name} ({runner_host})",
f"- model: {model}",
f"- queries: {len(d.get('queries', []))}",
f"- sources discovered: {len(records)}",
f"- sources actually read (machine receipt): {n_read}",
f"- blocked: {n_blocked} | failed fetch: {n_failed}",
f"- evidence claims: {n_claims} | contradictions: {n_contra}",
"",
"## SOURCE LEDGER",
"",
"| ID | source/domain | type | status | E-claims | contradictions | caveat |",
"|---|---|---|---|---|---|---|",
]
for r in records:
caveat = r["blocked_reason"] or ""
if r["status"] != STATUS_READ:
caveat = (caveat + " — backs ZERO accepted claims").strip(" —")
link = f"[{r['domain']}]({r['requested_url']})" if r["requested_url"].startswith("http") else r["domain"]
lines.append(
f"| {r['source_id']} | {link} | {r['source_type']} | {r['status']} "
f"| {len(r['claim_ids'])} | {len(r['contradiction_ids'])} | {caveat[:60]} |"
)
lines.append(prov_note)
return "\n".join(lines)