The discipline (ADR-0003, the promote flow, the operating rules) was written down and still depended on whoever was driving choosing to follow it. On 2026-07-25 an agent session wrote five documents into the production ledger through direct API calls, bypassing the promote flow entirely — a correct result reached by a path nobody could audit. A rule an operator can skip is a recommendation. The five stages are now chained by artefacts on disk. Each refuses to run until the previous produced its file, and the file says what it needs to hear: rehearse (sandbox, host-guarded) -> judge --pre -> gate (human) -> apply (prod) -> judge --post. The gate binds to a manifest digest, so approving a change-set approves THAT change-set. An op is defined ONCE, as an API call, and replayed on the sandbox then on production — because the first design described each write twice (a sandbox script input and a prod API body) and the pre-gate judge immediately caught them diverging: the rehearsal was creating a EUR invoice with no due date while production would have received a USD one at 60 days. Two descriptions of the same write are two things that can disagree. Judges are context-free, cross-family per the PRD qa-strategy rule, and advisory: a BLOCK still lets the operator approve, and the override is recorded with their name. Blocking authority stays with the human gate and the host guards — an LLM verdict never silently starts or stops a production write. Verified end to end against the real 24/08 change-set (M3 deferred, USD 3,000): - pre-gate judge (Mistral) returned BLOCK twice, correctly — first on the sandbox/prod divergence, then on a duplicate left by a repeated rehearsal; - apply refuses after a rejected gate; - apply refuses without ARCO_PROD_CONFIRM; - editing an amount after approval invalidates the gate on digest mismatch. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
282 lines
14 KiB
Python
282 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Gated promote pipeline: rehearse → judge → human gate → apply → judge.
|
|
|
|
Stages are chained by artefacts on disk, not by discipline: each one refuses to
|
|
run until the previous produced its file and that file says what this stage
|
|
needs. See README.md for why.
|
|
|
|
Stdlib only.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import getpass
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
|
|
REPO = os.path.realpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", ".."))
|
|
SKILL = os.path.join(REPO, ".claude", "skills")
|
|
SANDBOX_HOST = "erp-sandbox.arcodange.lab"
|
|
STAGES = {"rehearsal": "01-rehearsal.json", "pre": "02-pre-verdict.json", "gate": "03-gate.json",
|
|
"applied": "04-applied.json", "post": "05-post-verdict.json"}
|
|
|
|
|
|
def die(msg: str) -> None:
|
|
sys.exit(f"pipeline: REFUSED — {msg}")
|
|
|
|
|
|
def now() -> str:
|
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
|
|
|
|
def digest(obj) -> str:
|
|
return hashlib.sha256(json.dumps(obj, sort_keys=True, ensure_ascii=False).encode()).hexdigest()[:16]
|
|
|
|
|
|
def art(run_dir: str, stage: str) -> str:
|
|
return os.path.join(run_dir, STAGES[stage])
|
|
|
|
|
|
def read_stage(run_dir: str, stage: str, why: str) -> dict:
|
|
p = art(run_dir, stage)
|
|
if not os.path.exists(p):
|
|
die(f"{why}\n missing: {p}\n run the earlier stage first.")
|
|
return json.load(open(p))
|
|
|
|
|
|
def write_stage(run_dir: str, stage: str, payload: dict) -> str:
|
|
p = art(run_dir, stage)
|
|
json.dump(payload, open(p, "w"), indent=2, ensure_ascii=False)
|
|
with open(os.path.join(run_dir, "journal.jsonl"), "a") as j:
|
|
j.write(json.dumps({"at": now(), "stage": stage, "file": os.path.basename(p)},
|
|
ensure_ascii=False) + "\n")
|
|
return p
|
|
|
|
|
|
def env_from(path: str) -> dict:
|
|
cfg = {}
|
|
for line in open(path):
|
|
if "=" in line and not line.strip().startswith("#"):
|
|
k, v = line.strip().split("=", 1)
|
|
cfg[k] = v.strip().strip('"').strip("'")
|
|
return cfg
|
|
|
|
|
|
def api(base: str, key: str, method: str, path: str, body=None):
|
|
url = f"{base.rstrip('/')}/api/index.php{path}"
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
req = urllib.request.Request(url, data=data, method=method,
|
|
headers={"DOLAPIKEY": key, "Content-Type": "application/json",
|
|
"Accept": "application/json"})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=120) as r:
|
|
raw = r.read().decode()
|
|
except urllib.error.HTTPError as e:
|
|
return {"_error": e.code, "_body": e.read().decode()[:300]}
|
|
try:
|
|
return json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return raw.strip().strip('"')
|
|
|
|
|
|
# --- stage 1: rehearse -------------------------------------------------------
|
|
|
|
def stage_rehearse(args) -> None:
|
|
manifest = json.load(open(args.manifest))
|
|
os.makedirs(args.run_dir, exist_ok=True)
|
|
cfg = env_from(os.path.join(SKILL, "dolibarr-sandbox-write", ".env"))
|
|
# the write skill namespaces its keys (DOLIBARR_SANDBOX_*), the read skill does not
|
|
base = cfg.get("DOLIBARR_SANDBOX_URL") or cfg.get("DOLIBARR_URL", "")
|
|
skey = cfg.get("DOLIBARR_SANDBOX_API_KEY") or cfg.get("DOLIBARR_API_KEY", "")
|
|
if SANDBOX_HOST not in base:
|
|
die(f"the write .env points at {base!r}, not the sandbox — rehearsal must target {SANDBOX_HOST}")
|
|
|
|
ops, before = manifest.get("ops", []), {}
|
|
for probe in manifest.get("observe", []):
|
|
before[probe] = api(base, skey, "GET", probe)
|
|
|
|
results = []
|
|
for op in ops:
|
|
call = op.get("api")
|
|
if not call:
|
|
die(f"op {op.get('label')!r} has no 'api' block. An op is defined ONCE and replayed on both\n"
|
|
" targets — two descriptions of the same write are two things that can disagree.")
|
|
r = api(base, skey, call["method"], call["path"], call.get("body"))
|
|
failed = isinstance(r, dict) and "_error" in r
|
|
results.append({"label": op.get("label", ""), "method": call["method"], "path": call["path"],
|
|
"rc": 1 if failed else 0, "result": r})
|
|
print(f" [{op.get('label','op')}] {call['method']} {call['path']} -> {'ok' if not failed else r}")
|
|
for follow in op.get("then", []):
|
|
fr = api(base, skey, follow["method"], follow["path"].replace("{id}", str(r)), follow.get("body"))
|
|
ffailed = isinstance(fr, dict) and "_error" in fr
|
|
results.append({"label": f"{op.get('label','')} :: {follow.get('label','follow-up')}",
|
|
"method": follow["method"], "path": follow["path"],
|
|
"rc": 1 if ffailed else 0, "result": fr})
|
|
print(f" └ {follow.get('label','follow-up')} -> {'ok' if not ffailed else fr}")
|
|
|
|
after = {p: api(base, skey, "GET", p) for p in manifest.get("observe", [])}
|
|
ok = all(r["rc"] == 0 for r in results)
|
|
payload = {"at": now(), "target": base, "manifest_file": os.path.abspath(args.manifest),
|
|
"manifest_digest": digest(manifest), "manifest": manifest,
|
|
"results": results, "observed_before": before, "observed_after": after,
|
|
"all_writes_succeeded": ok}
|
|
print(f"→ {write_stage(args.run_dir, 'rehearsal', payload)} (writes ok: {ok})")
|
|
if not ok:
|
|
print(" some ops failed — the pre-gate judge will see that, and apply stays blocked.")
|
|
|
|
|
|
# --- stage 2 & 5: judges -----------------------------------------------------
|
|
|
|
def call_runtime(runtime: str, prompt: str, model: str | None) -> tuple[str, str]:
|
|
if runtime == "mistral":
|
|
r = subprocess.run(["vibe", "-p", prompt, "--max-turns", "1", "--output", "text"],
|
|
capture_output=True, text=True, timeout=600)
|
|
if r.returncode != 0:
|
|
die(f"vibe failed: {r.stderr[-300:]}")
|
|
return r.stdout.strip(), "vibe -p (mistral)"
|
|
if runtime in ("ornith", "mlx"):
|
|
model = model or "leonsarmiento/Ornith-1.0-35B-5bit-mlx"
|
|
body = json.dumps({"model": model, "messages": [{"role": "user", "content": prompt}],
|
|
"temperature": 0, "max_tokens": 3000}).encode()
|
|
req = urllib.request.Request("http://127.0.0.1:18080/v1/chat/completions", data=body,
|
|
headers={"Content-Type": "application/json"})
|
|
with urllib.request.urlopen(req, timeout=900) as r:
|
|
m = json.load(r)["choices"][0]["message"]
|
|
return (m.get("content") or m.get("reasoning") or ""), model
|
|
die(f"unknown runtime {runtime!r} (admitted: mistral, ornith, mlx)")
|
|
raise AssertionError
|
|
|
|
|
|
def stage_judge(args) -> None:
|
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
if args.stage == "pre":
|
|
reh = read_stage(args.run_dir, "rehearsal", "a pre-gate judge needs a rehearsal to judge")
|
|
tmpl = open(os.path.join(here, "judges", "pre-gate.md")).read()
|
|
evidence = {k: reh[k] for k in ("manifest", "results", "observed_before", "observed_after",
|
|
"all_writes_succeeded")}
|
|
else:
|
|
applied = read_stage(args.run_dir, "applied", "a post-gate judge needs a production apply to verify")
|
|
reh = read_stage(args.run_dir, "rehearsal", "the post-gate judge compares production to the rehearsal")
|
|
tmpl = open(os.path.join(here, "judges", "post-gate.md")).read()
|
|
evidence = {"manifest": reh["manifest"], "sandbox_after": reh["observed_after"],
|
|
"production_after": applied.get("observed_after"), "apply_results": applied.get("results")}
|
|
|
|
prompt = f"{tmpl}\n\n--- EVIDENCE (JSON) ---\n{json.dumps(evidence, indent=2, ensure_ascii=False)}\n--- END ---"
|
|
text, model = call_runtime(args.runtime, prompt, args.model)
|
|
verdict = "BLOCK" if "BLOCK" in text.upper()[:400] else ("PASS" if "PASS" in text.upper()[:400] else "UNCLEAR")
|
|
payload = {"at": now(), "stage": args.stage, "runtime": args.runtime, "model": model,
|
|
"prompt_sha256": hashlib.sha256(prompt.encode()).hexdigest(), "verdict": verdict,
|
|
"response": text}
|
|
print(f"→ {write_stage(args.run_dir, args.stage, payload)} verdict={verdict} ({model})")
|
|
print(text[:1200])
|
|
|
|
|
|
# --- stage 3: the human gate -------------------------------------------------
|
|
|
|
def stage_gate(args) -> None:
|
|
reh = read_stage(args.run_dir, "rehearsal", "nothing to approve — rehearse first")
|
|
pre = read_stage(args.run_dir, "pre", "the human decides WITH a judge's opinion, not without one")
|
|
m = reh["manifest"]
|
|
print("=" * 72)
|
|
print("HUMAN GATE — this approves writing to the PRODUCTION ledger.")
|
|
print("=" * 72)
|
|
print(f"change-set : {m.get('title', '(untitled)')}")
|
|
print(f"digest : {reh['manifest_digest']} (approval binds to this exact change-set)")
|
|
print(f"ops : {len(m.get('ops', []))}")
|
|
for op in m.get("ops", []):
|
|
print(f" - {op.get('label') or op['script']}")
|
|
print(f"rehearsal : writes ok = {reh['all_writes_succeeded']} on {reh['target']}")
|
|
print(f"judge ({pre['runtime']}) : {pre['verdict']}")
|
|
for line in pre["response"].strip().splitlines()[:8]:
|
|
print(f" | {line[:100]}")
|
|
print("=" * 72)
|
|
if not reh["all_writes_succeeded"]:
|
|
print("NOTE: the rehearsal had failing ops. Approving anyway is your call, and is recorded.")
|
|
if pre["verdict"] == "BLOCK":
|
|
print("NOTE: the judge says BLOCK. You may still approve — the override is recorded.")
|
|
|
|
if args.decision:
|
|
decision, who = args.decision, args.by or getpass.getuser()
|
|
else:
|
|
decision = input("\ntype 'approve' to promote, anything else to abort: ").strip().lower()
|
|
who = input("your name (recorded in the evidence pack): ").strip() or getpass.getuser()
|
|
|
|
payload = {"at": now(), "decision": "approved" if decision == "approve" else "rejected",
|
|
"by": who, "manifest_digest": reh["manifest_digest"],
|
|
"judge_verdict": pre["verdict"],
|
|
"override_of_judge": pre["verdict"] == "BLOCK" and decision == "approve",
|
|
"override_of_failed_rehearsal": (not reh["all_writes_succeeded"]) and decision == "approve"}
|
|
print(f"→ {write_stage(args.run_dir, 'gate', payload)} decision={payload['decision']} by {who}")
|
|
|
|
|
|
# --- stage 4: apply to production -------------------------------------------
|
|
|
|
def stage_apply(args) -> None:
|
|
reh = read_stage(args.run_dir, "rehearsal", "nothing rehearsed")
|
|
gate = read_stage(args.run_dir, "gate", "production requires a human decision on record")
|
|
if gate["decision"] != "approved":
|
|
die(f"the gate recorded '{gate['decision']}' by {gate['by']} — not approved.")
|
|
if gate["manifest_digest"] != reh["manifest_digest"]:
|
|
die("the approved change-set is not the one about to be applied "
|
|
f"(approved {gate['manifest_digest']}, current {reh['manifest_digest']}).\n"
|
|
" Re-run the gate on the current change-set.")
|
|
if os.environ.get("ARCO_PROD_CONFIRM") != "I-UNDERSTAND-THIS-WRITES-PROD":
|
|
die("set ARCO_PROD_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD to apply to production")
|
|
|
|
cfg = env_from(os.path.join(SKILL, "dolibarr", ".env"))
|
|
base, key = cfg["DOLIBARR_URL"], cfg["DOLIBARR_API_KEY"]
|
|
if SANDBOX_HOST in base:
|
|
die(f"the prod .env points at the sandbox ({base}) — nothing to promote to")
|
|
print(f"*** PRODUCTION: {base} — approved by {gate['by']} at {gate['at']} ***")
|
|
|
|
m = reh["manifest"]
|
|
results = []
|
|
for op in m.get("ops", []):
|
|
call = op["api"]
|
|
r = api(base, key, call["method"], call["path"], call.get("body"))
|
|
failed = isinstance(r, dict) and "_error" in r
|
|
results.append({"label": op.get("label"), "method": call["method"], "path": call["path"],
|
|
"result": r, "ok": not failed})
|
|
print(f" [{op.get('label')}] {call['method']} {call['path']} -> {'ok' if not failed else r}")
|
|
if failed and not args.keep_going:
|
|
break
|
|
for follow in op.get("then", []):
|
|
fr = api(base, key, follow["method"], follow["path"].replace("{id}", str(r)), follow.get("body"))
|
|
ffailed = isinstance(fr, dict) and "_error" in fr
|
|
results.append({"label": f"{op.get('label')} :: {follow.get('label','follow-up')}",
|
|
"method": follow["method"], "path": follow["path"],
|
|
"result": fr, "ok": not ffailed})
|
|
print(f" └ {follow.get('label','follow-up')} -> {'ok' if not ffailed else fr}")
|
|
if ffailed and not args.keep_going:
|
|
break
|
|
after = {p: api(base, key, "GET", p) for p in m.get("observe", [])}
|
|
payload = {"at": now(), "target": base, "approved_by": gate["by"],
|
|
"manifest_digest": reh["manifest_digest"], "results": results, "observed_after": after,
|
|
"all_ok": all(r["ok"] for r in results)}
|
|
print(f"→ {write_stage(args.run_dir, 'applied', payload)} (all ok: {payload['all_ok']})")
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description="gated promote pipeline")
|
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
r = sub.add_parser("rehearse"); r.add_argument("--manifest", required=True); r.add_argument("--run-dir", required=True)
|
|
j = sub.add_parser("judge"); j.add_argument("--run-dir", required=True)
|
|
j.add_argument("--stage", choices=["pre", "post"], required=True)
|
|
j.add_argument("--runtime", default="mistral"); j.add_argument("--model")
|
|
g = sub.add_parser("gate"); g.add_argument("--run-dir", required=True)
|
|
g.add_argument("--decision", choices=["approve", "reject"]); g.add_argument("--by")
|
|
a = sub.add_parser("apply"); a.add_argument("--run-dir", required=True); a.add_argument("--keep-going", action="store_true")
|
|
args = ap.parse_args()
|
|
{"rehearse": stage_rehearse, "judge": stage_judge, "gate": stage_gate, "apply": stage_apply}[args.cmd](args)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|