Files
boshu2__agentops/scripts/check-proof-coherence.sh
T
Bo 2c5d93e16b feat(ci): scripts/check-proof-coherence.sh (soc-57hbj #proof-coherence) (#647)
## What

Proof-coherence gate for the reconciliation engine (Wave 3C). Two axes
(plan: `.agents/plans/2026-05-07-reconciliation-engine-full-arc.md`):
claim coherence (advisory) and **proof coherence** (claim-ledger row →
daemon criterion-verdict event, **blocking for L2/L3 claims**). This PR
ships proof coherence.

## Coherence predicate

For every L2/L3 claim-ledger row, each `evidence_event_refs[]` entry
must:
1. resolve to a daemon event matching `event_type`+`run_id`
(+`criterion_id` if named) — else **DANGLING**;
2. carry `verdict: pass` — else **FAILED** (demote).

An L2/L3 row with zero refs is **UNBACKED**. L0/L1 rows are not gated.

## Forward-compatible

Wave 1's claim ledger and Wave 3B's `evidence_event_refs` schema haven't
landed, so with no ledger present the gate is a **PASS no-op** — it
lands as blocking CI surface now and becomes load-bearing automatically
once L2/L3 rows appear. Inputs auto-discover
(`docs/contracts/factory-claim-ledger{,.example}.json`,
`cli/.agents/daemon/ledger.jsonl`), overridable via
`--ledger`/`--events`.

## Output / exit codes

Text (default) + `--json`. Exit `0` coherent / no ledger · `1`
incoherent · `2` usage|env.

## Tests

`tests/scripts/check-proof-coherence.bats` — 13 hermetic cases, exact
exit codes + messages, both drift directions (missing event + failed
verdict), unbacked L2/L3, L0/L1 ungated, no-ledger no-op, JSON
pass/fail, malformed-ledger + bad-flag env errors.

## Gates

- `shellcheck -S warning scripts/check-proof-coherence.sh` — clean.
- `bats tests/scripts/check-proof-coherence.bats` — 13/13 pass.
- `git diff origin/main --stat` — scope-only (the two intended files).

## Scope note

validate.yml wiring deferred: a no-op until the ledger ships (Wave 1/3B
precondition); wiring lands with the first L2/L3 ledger row.

Closes-scenario: soc-57hbj#proof-coherence
Bounded-context: BC2-Validation
Evidence: scripts/check-proof-coherence.sh
2026-05-31 12:49:54 -04:00

277 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
# check-proof-coherence.sh — proof-coherence gate (reconciliation engine, Wave 3C; soc-57hbj).
#
# The reconciliation engine has two axes (plan: .agents/plans/2026-05-07-reconciliation-engine-full-arc.md):
# - claim coherence (surface-to-surface wording alignment) — advisory.
# - proof coherence (claim-ledger row -> daemon criterion-verdict event) — BLOCKING for L2/L3 claims.
#
# This is the proof-coherence axis. A claim-ledger row promoted to release-posture
# L2 or L3 asserts "this claim is backed by a passing factory event." That assertion
# is only honest if the referenced event actually exists in the daemon event stream
# AND carries verdict: pass. This gate closes the gap between "the ledger says it's
# proven" and "the event stream proves it" — it is the difference between a claim and
# a coherent claim.
#
# Coherence predicate (applied to every L2/L3 ledger row; L0/L1 rows are not gated):
# For each entry in the row's evidence_event_refs[]:
# 1. an event with matching event_type AND run_id (AND criterion_id, if the ref
# names one) MUST exist in the daemon ledger -> else: DANGLING (incoherent).
# 2. that event's verdict MUST be "pass" -> else: FAILED (incoherent, demote).
# A row with zero evidence_event_refs at L2/L3 is itself incoherent (UNBACKED).
#
# Inputs (auto-discovered; all optional so the gate is CI-safe before the ledger ships):
# --ledger <file> Claim ledger JSON. Default: first existing of
# docs/contracts/factory-claim-ledger.json
# docs/contracts/factory-claim-ledger.example.json
# --events <file> Daemon event-stream JSONL. Default: first existing of
# cli/.agents/daemon/ledger.jsonl
# .agents/daemon/ledger.jsonl
# --json Emit a machine-readable JSON report on stdout instead of text.
#
# When no claim ledger exists, the gate is a PASS no-op (nothing to prove yet) — this
# lets it land in CI as blocking before Wave 1's ledger lands, then become load-bearing
# automatically once L2/L3 rows with evidence_event_refs appear.
#
# Claim-ledger row shape (the fields this gate reads):
# {
# "rows": [
# { "claim_id": "C-worker-context",
# "promotion_state": "L2", # L0|L1|L2|L3
# "evidence_event_refs": [
# { "event_type": "agent_update.criterion_verdict",
# "run_id": "rpi-2026-05-12T...",
# "criterion_id": "context-on-passes" } # criterion_id optional
# ] } ] }
#
# Daemon event shape (the fields this gate reads; extra fields ignored):
# { "event_type": "agent_update.criterion_verdict",
# "run_id": "rpi-2026-05-12T...", # or request_id as fallback
# "payload": { "criterion_id": "context-on-passes", "verdict": "pass" } }
# (verdict / criterion_id are also accepted at top level.)
#
# Exit codes:
# 0 = coherent (every L2/L3 row's refs resolve to a passing event), or no ledger to check
# 1 = incoherent (a dangling ref, a failed-verdict ref, or an unbacked L2/L3 row)
# 2 = usage / environment error (bad flag, missing jq, unreadable input)
set -euo pipefail
PROG="check-proof-coherence"
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd -P)"
ledger=""
events=""
json_mode=false
usage() {
cat >&2 <<USAGE
Usage: scripts/${PROG}.sh [--ledger <file>] [--events <file>] [--json]
Proof-coherence gate: every L2/L3 claim-ledger row must reference daemon events
that exist and carry verdict: pass. Dangling refs, failed verdicts, and unbacked
L2/L3 rows are incoherent.
Options:
--ledger <file> Claim ledger JSON (default: auto-discover under docs/contracts/).
--events <file> Daemon event-stream JSONL (default: auto-discover under .agents/daemon/).
--json Emit a JSON report instead of human-readable text.
-h, --help Show this help.
Exit: 0 coherent / no ledger · 1 incoherent · 2 usage or environment error
USAGE
}
while [ "$#" -gt 0 ]; do
case "$1" in
--ledger)
[ "$#" -ge 2 ] || { echo "${PROG}: --ledger needs a value" >&2; usage; exit 2; }
ledger="$2"; shift 2 ;;
--events)
[ "$#" -ge 2 ] || { echo "${PROG}: --events needs a value" >&2; usage; exit 2; }
events="$2"; shift 2 ;;
--json)
json_mode=true; shift ;;
-h|--help)
usage; exit 0 ;;
*)
echo "${PROG}: unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
if ! command -v jq >/dev/null 2>&1; then
echo "${PROG}: jq is required but not found on PATH" >&2
exit 2
fi
# --- discover inputs ---------------------------------------------------------
if [ -z "$ledger" ]; then
for cand in \
"$repo_root/docs/contracts/factory-claim-ledger.json" \
"$repo_root/docs/contracts/factory-claim-ledger.example.json"; do
if [ -f "$cand" ]; then ledger="$cand"; break; fi
done
fi
# No ledger anywhere -> nothing to prove yet. PASS no-op (forward-compatible gate).
if [ -z "$ledger" ]; then
if $json_mode; then
printf '{"gate":"proof-coherence","status":"pass","reason":"no-claim-ledger","rows_checked":0,"incoherent":[]}\n'
else
echo "ok: ${PROG}: no claim ledger present — nothing to prove (gate is a no-op until the ledger ships)"
fi
exit 0
fi
if [ ! -r "$ledger" ]; then
echo "${PROG}: claim ledger not readable: $ledger" >&2
exit 2
fi
if ! jq -e . "$ledger" >/dev/null 2>&1; then
echo "${PROG}: claim ledger is not valid JSON: $ledger" >&2
exit 2
fi
if [ -z "$events" ]; then
for cand in \
"$repo_root/cli/.agents/daemon/ledger.jsonl" \
"$repo_root/.agents/daemon/ledger.jsonl"; do
if [ -f "$cand" ]; then events="$cand"; break; fi
done
fi
# An empty/absent event stream is valid input: it just means every ref is dangling.
if [ -n "$events" ] && [ ! -r "$events" ]; then
echo "${PROG}: daemon event stream not readable: $events" >&2
exit 2
fi
# --- build a lookup of daemon criterion-verdict events -----------------------
# Key = "<event_type>\t<run_id>\t<criterion_id>" ; value = verdict.
# run_id falls back to request_id; criterion_id/verdict accepted top-level or in payload.
events_index="$(mktemp -t "${PROG}.events.XXXXXX")"
trap 'rm -f "$events_index"' EXIT
if [ -n "$events" ] && [ -s "$events" ]; then
# Parse each line independently; tolerate blank/garbage lines via fromjson?.
jq -rR '
fromjson? // empty
| {
et: (.event_type // ""),
rid: (.run_id // .request_id // ""),
cid: (.criterion_id // .payload.criterion_id // ""),
v: (.verdict // .payload.verdict // "")
}
| select(.et != "")
| [.et, .rid, .cid, .v] | @tsv
' "$events" > "$events_index" 2>/dev/null || true
fi
# Look up the verdict for a ref. Echoes one of:
# pass | fail | <other-verdict-string> (an event matched)
# __MISSING__ (no event matched -> dangling ref)
lookup_verdict() {
local et="$1" rid="$2" cid="$3"
# Exact match including criterion_id when the ref names one; otherwise match
# on (event_type, run_id) and take the first verdict for that pair.
awk -F'\t' -v et="$et" -v rid="$rid" -v cid="$cid" '
$1 == et && $2 == rid {
if (cid == "" || $3 == cid) { print $4; found=1; exit }
}
END { if (!found) print "__MISSING__" }
' "$events_index"
}
# --- iterate L2/L3 rows ------------------------------------------------------
# Emit one TSV record per L2/L3 row: claim_id, promotion_state, refs-as-json.
rows_tsv="$(jq -r '
(.rows // [])
| map(select(((.promotion_state // .promotion // .level // "") | ascii_upcase) as $p
| ($p == "L2" or $p == "L3")))
| .[]
| [ (.claim_id // .id // "<unnamed>"),
((.promotion_state // .promotion // .level // "") | ascii_upcase),
((.evidence_event_refs // []) | tojson) ]
| @tsv
' "$ledger")"
rows_checked=0
incoherent_count=0
# Collected incoherence records for the JSON report.
incoherent_json="[]"
add_incoherent() {
local claim="$1" state="$2" kind="$3" detail="$4"
incoherent_count=$((incoherent_count + 1))
incoherent_json="$(jq -c \
--arg claim "$claim" --arg state "$state" --arg kind "$kind" --arg detail "$detail" \
'. + [{claim_id:$claim, promotion_state:$state, kind:$kind, detail:$detail}]' \
<<<"$incoherent_json")"
}
if [ -n "$rows_tsv" ]; then
while IFS=$'\t' read -r claim_id state refs_json; do
[ -n "$claim_id" ] || continue
rows_checked=$((rows_checked + 1))
ref_count="$(jq 'length' <<<"$refs_json")"
if [ "$ref_count" -eq 0 ]; then
add_incoherent "$claim_id" "$state" "unbacked" \
"row promoted to ${state} but has no evidence_event_refs"
$json_mode || echo "INCOHERENT: ${claim_id} (${state}): UNBACKED — no evidence_event_refs" >&2
continue
fi
# Walk each ref.
while IFS=$'\t' read -r et rid cid; do
[ -n "${et}${rid}${cid}" ] || continue
verdict="$(lookup_verdict "$et" "$rid" "$cid")"
refdesc="event_type=${et} run_id=${rid}${cid:+ criterion_id=${cid}}"
case "$verdict" in
pass)
$json_mode || echo "ok: ${claim_id} (${state}): ${refdesc} -> pass" ;;
__MISSING__)
add_incoherent "$claim_id" "$state" "dangling" \
"ref has no matching daemon event: ${refdesc}"
$json_mode || echo "INCOHERENT: ${claim_id} (${state}): DANGLING — no daemon event for ${refdesc}" >&2 ;;
"")
add_incoherent "$claim_id" "$state" "no-verdict" \
"matched daemon event carries no verdict: ${refdesc}"
$json_mode || echo "INCOHERENT: ${claim_id} (${state}): NO-VERDICT — event present but no verdict for ${refdesc}" >&2 ;;
*)
add_incoherent "$claim_id" "$state" "failed" \
"referenced event verdict is '${verdict}', not pass: ${refdesc}"
$json_mode || echo "INCOHERENT: ${claim_id} (${state}): FAILED — verdict='${verdict}' (not pass) for ${refdesc}" >&2 ;;
esac
done < <(jq -r '.[] | [ (.event_type // ""), (.run_id // ""), (.criterion_id // "") ] | @tsv' <<<"$refs_json")
done <<<"$rows_tsv"
fi
# --- verdict -----------------------------------------------------------------
status=0
[ "$incoherent_count" -gt 0 ] && status=1
if $json_mode; then
jq -nc \
--argjson rows "$rows_checked" \
--argjson n "$incoherent_count" \
--argjson inc "$incoherent_json" \
--arg ledger "$ledger" \
--arg events "${events:-}" \
'{
gate: "proof-coherence",
status: (if $n == 0 then "pass" else "fail" end),
ledger: $ledger,
events: $events,
rows_checked: $rows,
incoherent_count: $n,
incoherent: $inc
}'
else
if [ "$incoherent_count" -eq 0 ]; then
echo "ok: ${PROG}: ${rows_checked} L2/L3 row(s) coherent (all refs resolve to passing daemon events)"
else
echo "${PROG}: ${incoherent_count} incoherence(s) across ${rows_checked} L2/L3 row(s) — proof coherence FAILED" >&2
fi
fi
exit "$status"