"""Human-gated thesis approval (Phase 1 Workstream E). NOT an agent tool — called only by authenticated CRM routes in server.py. The Architect can draft and submit; only a human partner can promote a version to canonical. Supports Grant's dual-sign-off + collaborative feedback: both partners can leave reviews (approve / request_changes / comment with free-text the Architect reads to iterate); a version promotes to canonical once distinct 'approve' reviewers reach `thesis_required_approvals` (app_settings, default 1 — set to 2 for dual sign-off). Promotion atomically supersedes the prior canonical, honoring the one-canonical-per-line DB invariant. Everything is logged. """ import json import sqlite3 import uuid from datetime import datetime, timezone _VALID = ("approve", "request_changes", "comment") def _now(): return datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + "Z" def _conn(db): c = sqlite3.connect(db) c.row_factory = sqlite3.Row c.execute("PRAGMA foreign_keys=ON") return c def _log(c, actor_id, action, target_id, payload): c.execute("""INSERT INTO interaction_log (id, ts, actor_type, actor_id, action, target_type, target_id, payload, source, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)""", (str(uuid.uuid4()), _now(), "human", actor_id, action, "thesis", target_id, json.dumps(payload) if payload is not None else None, "crm_ui", _now())) def required_approvals(c): r = c.execute("SELECT value_json FROM app_settings WHERE key='thesis_required_approvals'").fetchone() try: return max(1, int(json.loads(r[0]))) if r else 1 except Exception: return 1 def _approver_count(c, version_id): return len({r[0] for r in c.execute( "SELECT DISTINCT reviewer_user_id FROM thesis_reviews WHERE version_id=? AND decision='approve'", (version_id,))}) def _promote(c, version_row, approver_user_id): """Atomically supersede the prior canonical (if any) for this line, then make this version canonical. Supersede-first keeps the one-canonical unique index satisfied.""" line_id = version_row["line_id"] prior = c.execute("SELECT id FROM thesis_versions WHERE line_id=? AND status='canonical'", (line_id,)).fetchone() if prior: c.execute("UPDATE thesis_versions SET status='superseded', superseded_by=? WHERE id=?", (version_row["id"], prior["id"])) c.execute("UPDATE thesis_versions SET status='canonical', approved_at=? WHERE id=?", (_now(), version_row["id"])) _log(c, approver_user_id, "thesis.approved", version_row["id"], {"line_id": line_id, "superseded": prior["id"] if prior else None}) def record_review(db, version_id, reviewer_user_id, decision, feedback=None, target_node_id=None): """Record a partner's review. Promotes to canonical when approve-threshold met. `reviewer_user_id` MUST be a real authenticated human (enforced by the route).""" if decision not in _VALID: return {"error": "bad_decision", "allowed": list(_VALID)} c = _conn(db) v = c.execute("SELECT * FROM thesis_versions WHERE id=?", (version_id,)).fetchone() if not v: c.close() return {"error": "not_found", "version_id": version_id} c.execute("""INSERT INTO thesis_reviews (id, version_id, reviewer_user_id, decision, feedback, target_node_id, created_at) VALUES (?,?,?,?,?,?,?)""", (str(uuid.uuid4()), version_id, reviewer_user_id, decision, feedback, target_node_id, _now())) _log(c, reviewer_user_id, f"thesis.review.{decision}", version_id, {"feedback": feedback, "target_node_id": target_node_id}) promoted = False approvals = _approver_count(c, version_id) need = required_approvals(c) if decision == "approve" and v["status"] in ("draft", "in_review") and approvals >= need: _promote(c, v, reviewer_user_id) promoted = True c.commit() c.close() return {"version_id": version_id, "decision": decision, "approvals": approvals, "required": need, "promoted_to_canonical": promoted} # ── reads for the review UI ─────────────────────────────────────────────────── def list_lines(db): c = _conn(db) rows = [dict(r) for r in c.execute( "SELECT id, line_key, name, segment_key, is_core, status FROM thesis_lines WHERE deleted_at IS NULL ORDER BY is_core DESC, name")] c.close() return {"lines": rows} def list_versions_for_review(db): c = _conn(db) rows = [] for v in c.execute("""SELECT v.id, v.line_id, l.line_key, l.name, v.version_no, v.status, v.created_at, v.rationale FROM thesis_versions v JOIN thesis_lines l ON l.id=v.line_id WHERE v.status='in_review' ORDER BY v.created_at DESC"""): d = dict(v) d["approvals"] = _approver_count(c, v["id"]) d["required"] = required_approvals(c) rows.append(d) c.close() return {"versions": rows} def get_canonical(db, line_key): """The one canonical version's frozen body_json; fails closed if none.""" c = _conn(db) line = c.execute("SELECT id FROM thesis_lines WHERE line_key=? AND deleted_at IS NULL", (line_key,)).fetchone() if not line: c.close() return {"status": "no_such_line", "line_key": line_key} r = c.execute("SELECT * FROM thesis_versions WHERE line_id=? AND status='canonical'", (line["id"],)).fetchone() c.close() if not r: return {"status": "no_canonical_thesis", "line_key": line_key} return {"status": "ok", "line_key": line_key, "version_id": r["id"], "version_no": r["version_no"], "approved_at": r["approved_at"], "thesis": json.loads(r["body_json"])} def get_version(db, version_id): c = _conn(db) v = c.execute("SELECT * FROM thesis_versions WHERE id=?", (version_id,)).fetchone() if not v: c.close() return {"error": "not_found", "version_id": version_id} out = dict(v) try: out["body"] = json.loads(v["body_json"]) except Exception: out["body"] = None out.pop("body_json", None) out["reviews"] = [dict(r) for r in c.execute( "SELECT reviewer_user_id, decision, feedback, target_node_id, created_at FROM thesis_reviews WHERE version_id=? ORDER BY created_at", (version_id,))] out["approvals"] = _approver_count(c, version_id) out["required"] = required_approvals(c) c.close() return out