Files
thanhnvandClaude Opus 4.8 36a4812ef3 refactor(structure): promote app to repo root + remove redundant workspace cruft
Standard production layout: the OKR app (was nested under AINative_OKR_CASAN5/) is now
the repository root. No more wrapper directory.

- Promote AINative_OKR_CASAN5/* -> repo root (backend/ frontend/ packages/ apps/
  .specify/ docs/ infra/ nginx/ scripts/ + configs). Merge tool dirs: .gitea (kept the
  active deploy ci.yml, added harness-ci.yml + runbooks), .claude (agents/commands +
  launch.json), .github moved up.
- Remove redundant: 00_SUBMISSION_PACKAGE, scattered root notes (FPT_CASAN_Full.md,
  tu-tuong-casan.md, casan-tu-sinh..., casan_harness_assessment.md, source-review...,
  README_CASAN5_REFINED.md), casan-next-plans/ and optimize-docs/ (competition/planning
  artifacts — roadmap + design history preserved in git log / commit messages).
- Update all references to the old layout:
  - .gitea/workflows/{ci,harness-ci}.yml, .github/workflows/{ci,deploy}.yml:
    working-directory .; drop AINative_OKR_CASAN5/ prefix; .specify/{tests,scripts}
    -> packages/casan-harness/... (.specify/logs state kept)
  - .claude/launch.json, .gitea/*-runbook.md: path prefixes
  - CLAUDE.md, README.md: docs/input -> apps/okr/domain/input
  - policy-bundle.yaml: 8 policy paths -> packages/casan-harness/...; manifest re-signed
- secrets-scan.sh: fixture excludes -> new package/domain paths.

Full gate from the new root: PASS=64 FAIL=0 SKIP=3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:26:36 +09:00

264 lines
9.5 KiB
Python

#!/usr/bin/env python3
"""CASAN Loop Trace / Replay (Plan-17 Track 4, harness-owned).
Records each loop iteration (§3 Loop Contract) into a per-run, append-only,
hash-linked trace so a whole loop can be audited and deterministically replayed.
Gives loops the "loop view" the audit chain lacked.
Subcommands:
record append one Iteration to the run's trace (hash-linked to the prev).
show render the loop view (JSON on stdout; a human table on stderr).
replay re-run the per-iteration Verify Contract (loop-gate) on each
recorded artifact and compare the fresh verdict to the recorded
one -> detects non-determinism / tampered artifacts.
verify-chain recompute the hash chain -> any edited/removed record BREAKs it.
Fail-closed & tail-safe (Plan-17 §2): the trace is append-only; replay never
mutates the source trace (it runs loop-gate under throwaway run-ids). A corrupt
trace file fails closed (verify-chain BREAK / show error).
KMS-anchoring of the chain head (17.16 full) is a TIER-2 add-on (Plan-07 B3 / A7);
this offline slice proves local tamper-evidence without it.
Usage:
loop-trace.py record --run-id R --step N --intent ... --action ... \
[--tool T] [--inputs-ref REF] [--gate-verdict PASS|FAIL|DENY|ESCALATE] \
[--progress P] [--decision CONTINUE|HALT|ESCALATE|DONE] \
[--artifact PATH] [--success-criteria FILE] [--evidence-ref REF] \
[--budget-snapshot JSON]
loop-trace.py show --run-id R [--json]
loop-trace.py replay --run-id R [--profile prod|dev]
loop-trace.py verify-chain --run-id R
Exit codes:
0 ok / chain intact / replay all-match
3 chain BREAK, replay drift detected, or fail-closed error
2 usage error
"""
import argparse
import json
import os
import subprocess
import sys
import loop_common as lc
def _emit(payload, stream=sys.stdout):
json.dump(payload, stream, ensure_ascii=False, indent=2, sort_keys=True)
stream.write("\n")
def _trace_path(run_id):
return os.path.join(lc.run_dir(run_id), "trace.jsonl")
def _read_entries(run_id):
"""Return the list of raw chain entries (each {seq, prev_hash, iteration, hash}).
A malformed line fails closed."""
path = _trace_path(run_id)
entries = []
if os.path.isfile(path):
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except ValueError:
raise lc.PolicyError("trace_corrupt")
return entries
def cmd_record(args):
budget = None
if args.budget_snapshot:
try:
budget = json.loads(args.budget_snapshot)
except ValueError:
raise lc.PolicyError("bad_budget_snapshot_json")
iteration = {
"run_id": args.run_id,
"step": args.step,
"intent": args.intent,
"action": args.action,
"tool": args.tool,
"inputs_ref": args.inputs_ref,
"gate_verdict": args.gate_verdict,
"progress": args.progress,
"budget_snapshot": budget,
"decision": args.decision,
"artifact": args.artifact,
"success_criteria": args.success_criteria,
"evidence_ref": args.evidence_ref,
"ts": lc.now_iso(),
}
path = _trace_path(args.run_id)
os.makedirs(os.path.dirname(path), exist_ok=True)
entries = _read_entries(args.run_id)
prev = entries[-1]["hash"] if entries else lc.GENESIS_HASH
seq = len(entries)
base = {"seq": seq, "prev_hash": prev, "iteration": iteration}
base["hash"] = lc.chain_hash(base)
with open(path, "a", encoding="utf-8") as fh:
fh.write(lc.canonical(base) + "\n")
_emit({"decision": "RECORDED", "run_id": args.run_id, "step": args.step, "seq": seq})
return 0
def cmd_show(args):
entries = _read_entries(args.run_id)
iterations = [e["iteration"] for e in entries]
payload = {
"run_id": args.run_id,
"count": len(iterations),
"iterations": iterations,
"provenance": lc.provenance("loop-trace", _trace_path(args.run_id), verified=False),
}
# Human-readable loop view on stderr; machine JSON on stdout.
hdr = f"{'seq':>3} {'step':>4} {'verdict':<9} {'decision':<10} {'progress':>8} intent"
print(hdr, file=sys.stderr)
for e in entries:
it = e["iteration"]
print(
f"{e['seq']:>3} {str(it.get('step','')):>4} "
f"{str(it.get('gate_verdict','')):<9} {str(it.get('decision','')):<10} "
f"{str(it.get('progress','')):>8} {str(it.get('intent',''))[:48]}",
file=sys.stderr,
)
_emit(payload)
return 0
def cmd_verify_chain(args):
try:
entries = _read_entries(args.run_id)
except lc.PolicyError as exc:
_emit({"decision": "BREAK", "reason": "fail_closed", "detail": str(exc), "run_id": args.run_id})
return 3
prev = lc.GENESIS_HASH
for i, e in enumerate(entries):
stored = e.get("hash")
base = {"seq": e.get("seq"), "prev_hash": e.get("prev_hash"), "iteration": e.get("iteration")}
recomputed = lc.chain_hash(base)
if e.get("prev_hash") != prev or e.get("seq") != i or recomputed != stored:
_emit({
"decision": "BREAK", "reason": "chain_broken", "at_seq": i,
"run_id": args.run_id,
"provenance": lc.provenance("loop-trace", _trace_path(args.run_id), verified=False),
})
return 3
prev = stored
_emit({
"decision": "OK", "run_id": args.run_id, "records": len(entries),
"provenance": lc.provenance("loop-trace", _trace_path(args.run_id), verified=True),
})
return 0
def cmd_replay(args):
try:
entries = _read_entries(args.run_id)
except lc.PolicyError as exc:
_emit({"decision": "FAIL", "reason": "fail_closed", "detail": str(exc), "run_id": args.run_id})
return 3
gate = os.path.join(os.path.dirname(__file__), "loop-gate.py")
diffs = []
replayed = 0
for e in entries:
it = e["iteration"]
artifact = it.get("artifact")
recorded = it.get("gate_verdict")
if not artifact or not recorded:
continue # only iterations with a verifiable artifact + verdict
replayed += 1
cmd = [
sys.executable, gate, "verify",
"--run-id", f"{args.run_id}-replay-{e['seq']}",
"--step", str(it.get("step", 0)),
"--artifact", artifact,
]
if it.get("success_criteria"):
cmd += ["--success-criteria", it["success_criteria"]]
if args.profile:
cmd += ["--profile", args.profile]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
fresh = json.loads(r.stdout).get("verdict") if r.stdout.strip() else "ERROR"
except Exception as exc:
fresh = f"ERROR:{exc}"
if fresh != recorded:
diffs.append({"seq": e["seq"], "step": it.get("step"), "recorded": recorded, "replayed": fresh})
payload = {
"run_id": args.run_id,
"replayed": replayed,
"diffs": diffs,
"provenance": lc.provenance("loop-trace", _trace_path(args.run_id), verified=not diffs),
}
if diffs:
payload["decision"] = "DRIFT"
payload["reason"] = "verdict_mismatch"
lc.append_audit({"kind": "trace_replay_drift", **payload})
_emit(payload)
return 3
payload["decision"] = "MATCH"
_emit(payload)
return 0
def build_parser():
p = argparse.ArgumentParser(description="CASAN Loop Trace / Replay (Plan-17 T4)")
sub = p.add_subparsers(dest="cmd", required=True)
r = sub.add_parser("record", help="Append one Iteration to the run trace")
r.add_argument("--run-id", required=True)
r.add_argument("--step", type=int, required=True)
r.add_argument("--intent", default=None)
r.add_argument("--action", default=None)
r.add_argument("--tool", default=None)
r.add_argument("--inputs-ref", default=None)
r.add_argument("--gate-verdict", default=None)
r.add_argument("--progress", type=float, default=None)
r.add_argument("--decision", default=None)
r.add_argument("--artifact", default=None)
r.add_argument("--success-criteria", default=None)
r.add_argument("--evidence-ref", default=None)
r.add_argument("--budget-snapshot", default=None)
r.set_defaults(func=cmd_record)
s = sub.add_parser("show", help="Render the loop view")
s.add_argument("--run-id", required=True)
s.add_argument("--json", action="store_true")
s.set_defaults(func=cmd_show)
rp = sub.add_parser("replay", help="Deterministically re-verify recorded artifacts")
rp.add_argument("--run-id", required=True)
rp.add_argument("--profile", default=None)
rp.set_defaults(func=cmd_replay)
vc = sub.add_parser("verify-chain", help="Recompute the hash chain (tamper-evidence)")
vc.add_argument("--run-id", required=True)
vc.set_defaults(func=cmd_verify_chain)
return p
def main(argv=None):
args = build_parser().parse_args(argv)
try:
return args.func(args)
except lc.PolicyError as exc:
_emit({"decision": "FAIL", "reason": "fail_closed", "detail": str(exc)})
return 3
except Exception as exc: # never fail open
_emit({"decision": "FAIL", "reason": "internal_error", "detail": str(exc)}, sys.stderr)
return 3
if __name__ == "__main__":
raise SystemExit(main())