3e199fd8d5
- migration 0002_phase1_architect: thesis_lines (core spine + per-segment lines), thesis_nodes (+ append-only revisions), thesis_versions (one-canonical-per-line DB invariant), thesis_reviews (dual approval + feedback), segments. Reversible. - backend/mcp/architect_tools.py: agent draft tools (node tree, versions, segments, get_canonical fails-closed) — NO self-approval path. MCP-exposed. - backend/thesis_review.py + server.py routes: human-gated approval. Dual sign-off via thesis_required_approvals; atomic supersede; every action logged. - docs/PHASE_1.md (kickoff brief); docs/OPERATIONS.md (partner guide); start9/0.4 "Resolve duplicate names" fuzzy action. Verified on synthetic data: dual approval promotes correctly, exactly one canonical survives supersede, get_canonical fails closed, full interaction_log. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
154 lines
6.5 KiB
Python
154 lines
6.5 KiB
Python
"""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
|