147 lines
5.2 KiB
Python
147 lines
5.2 KiB
Python
|
|
"""INFD-WP-0002-T03 sitting bind preflight.
|
||
|
|
|
||
|
|
Reports whether the compact sitting can be submitted to the live Stage 1
|
||
|
|
surface. Does not present, acknowledge, bind, or create approvals.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
from pathlib import Path
|
||
|
|
from urllib.error import HTTPError, URLError
|
||
|
|
from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen
|
||
|
|
|
||
|
|
from informed_decision.records import memo_from
|
||
|
|
|
||
|
|
ROOT = Path(__file__).resolve().parents[1] / "docs" / "batches" / "2026-09-14"
|
||
|
|
|
||
|
|
|
||
|
|
def load_sitting(root: Path = ROOT) -> dict:
|
||
|
|
sitting = json.loads((root / "sitting.json").read_text())
|
||
|
|
memos = []
|
||
|
|
for name in ("credentials", "decisions"):
|
||
|
|
index = json.loads((root / name / "index.json").read_text())
|
||
|
|
for row in index["ordinal"]:
|
||
|
|
memo = memo_from(json.loads((root / name / row["memo"]).read_text()))
|
||
|
|
memos.append(
|
||
|
|
{
|
||
|
|
"batch": name,
|
||
|
|
"memo_id": memo.id,
|
||
|
|
"approval_id": memo.approval_id,
|
||
|
|
"principal": memo.binding.principal.id,
|
||
|
|
"question": memo.question,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
return {"sitting": sitting, "memos": memos}
|
||
|
|
|
||
|
|
|
||
|
|
class _NoRedirect(HTTPRedirectHandler):
|
||
|
|
def http_error_302(self, req, fp, code, msg, headers):
|
||
|
|
raise HTTPError(req.full_url, code, msg, headers, fp)
|
||
|
|
|
||
|
|
http_error_301 = http_error_303 = http_error_307 = http_error_308 = http_error_302
|
||
|
|
|
||
|
|
|
||
|
|
def _default_opener():
|
||
|
|
return build_opener(_NoRedirect()).open
|
||
|
|
|
||
|
|
|
||
|
|
def probe_origin(origin: str, opener=None) -> dict:
|
||
|
|
origin = origin.rstrip("/")
|
||
|
|
fetch = opener or _default_opener()
|
||
|
|
result = {"origin": origin, "healthz": None, "readyz": None, "auth_start": None}
|
||
|
|
|
||
|
|
def get(path):
|
||
|
|
req = Request(origin + path, method="GET")
|
||
|
|
try:
|
||
|
|
with fetch(req, timeout=10) as response:
|
||
|
|
return _probe_row(response, getattr(response, "status", None) or response.getcode())
|
||
|
|
except HTTPError as exc:
|
||
|
|
return _probe_row(exc, exc.code)
|
||
|
|
except (URLError, TimeoutError, OSError) as exc:
|
||
|
|
return {"http": None, "error": type(exc).__name__}
|
||
|
|
|
||
|
|
result["healthz"] = get("/healthz")
|
||
|
|
result["readyz"] = get("/readyz")
|
||
|
|
result["auth_start"] = get("/auth/start")
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def _probe_row(response, status):
|
||
|
|
headers = getattr(response, "headers", None)
|
||
|
|
location = headers.get("Location") if headers else None
|
||
|
|
raw = response.read(4096)
|
||
|
|
try:
|
||
|
|
body = json.loads(raw.decode("utf-8")) if raw else None
|
||
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||
|
|
body = None
|
||
|
|
row = {"http": status}
|
||
|
|
if location:
|
||
|
|
row["location_host"] = location.split("/")[2] if "://" in location else location
|
||
|
|
if isinstance(body, dict):
|
||
|
|
row["status"] = body.get("status")
|
||
|
|
if "reason" in body:
|
||
|
|
row["reason"] = body.get("reason")
|
||
|
|
return row
|
||
|
|
|
||
|
|
|
||
|
|
def evaluate(bundle: dict, origin_probe: dict | None = None) -> dict:
|
||
|
|
sitting = bundle["sitting"]
|
||
|
|
memos = bundle["memos"]
|
||
|
|
missing_act = [row["memo_id"] for row in memos if not row["approval_id"]]
|
||
|
|
pending_principal = [row["memo_id"] for row in memos if row["principal"] == "pending-human-session"]
|
||
|
|
gates = []
|
||
|
|
if sitting.get("status") != "draft-unsigned" or sitting.get("submitted") is True:
|
||
|
|
gates.append("sitting_already_submitted")
|
||
|
|
if len(memos) != 8:
|
||
|
|
gates.append("unexpected_memo_count")
|
||
|
|
if missing_act:
|
||
|
|
gates.append("missing_act_binding")
|
||
|
|
if pending_principal:
|
||
|
|
gates.append("principal_not_live_subject")
|
||
|
|
live_accept = None
|
||
|
|
if origin_probe is not None:
|
||
|
|
health = (origin_probe.get("healthz") or {}).get("http")
|
||
|
|
ready = (origin_probe.get("readyz") or {}).get("http")
|
||
|
|
start = (origin_probe.get("auth_start") or {}).get("http")
|
||
|
|
if health != 200:
|
||
|
|
gates.append("origin_healthz_not_ok")
|
||
|
|
if start not in (302, 303):
|
||
|
|
gates.append("auth_start_not_redirect")
|
||
|
|
if ready != 200:
|
||
|
|
gates.append("live_accept_closed")
|
||
|
|
live_accept = "closed"
|
||
|
|
else:
|
||
|
|
live_accept = "open"
|
||
|
|
ready_to_sit = not gates
|
||
|
|
return {
|
||
|
|
"kind": "informed-decision-sitting-preflight",
|
||
|
|
"sitting_id": sitting.get("id"),
|
||
|
|
"memo_count": len(memos),
|
||
|
|
"missing_act_binding": missing_act,
|
||
|
|
"principal_not_live_subject": pending_principal,
|
||
|
|
"live_accept": live_accept,
|
||
|
|
"gates": gates,
|
||
|
|
"ready_to_sit": ready_to_sit,
|
||
|
|
"agent_disposition": "forbidden",
|
||
|
|
"origin_probe": origin_probe,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
||
|
|
parser.add_argument("--origin", help="Probe this Stage 1 origin; omit for file checks only")
|
||
|
|
parser.add_argument("--receipt", type=Path, help="Write the JSON report")
|
||
|
|
args = parser.parse_args()
|
||
|
|
probe = probe_origin(args.origin) if args.origin else None
|
||
|
|
report = evaluate(load_sitting(), probe)
|
||
|
|
text = json.dumps(report, indent=2, ensure_ascii=False) + "\n"
|
||
|
|
if args.receipt:
|
||
|
|
args.receipt.write_text(text, encoding="utf-8")
|
||
|
|
print(text, end="")
|
||
|
|
return 0 if report["ready_to_sit"] else 2
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
raise SystemExit(main())
|