Phase 1 Workstream A+E: thesis substrate + dual-approval gate
- 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>
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
"""Architect MCP tool logic (Phase 1, Workstream A/E) — plain, testable functions.
|
||||
|
||||
The Architect drafts and iterates on the thesis; it CANNOT make anything canonical
|
||||
— promotion to canonical is a human-only action on a CRM HTTP route (server.py),
|
||||
not exposed here (guardrail #4). Every write goes through interaction_log
|
||||
(guardrail #5). Mirrors crm_tools.py conventions.
|
||||
|
||||
Tool surface:
|
||||
reads list_thesis_lines, get_thesis, get_node, get_node_history,
|
||||
list_versions, get_canonical_thesis, get_review_feedback,
|
||||
list_segments, get_segment
|
||||
drafts create_thesis_line, upsert_thesis_node, create_thesis_version,
|
||||
submit_version_for_review, upsert_segment
|
||||
NO approve/promote/publish/outbound tool exists.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ingest"))
|
||||
import config # noqa: E402
|
||||
|
||||
|
||||
def _conn(db=None):
|
||||
c = sqlite3.connect(db or os.environ.get("CRM_DB_PATH") or config.DEFAULT_DB)
|
||||
c.row_factory = sqlite3.Row
|
||||
c.execute("PRAGMA foreign_keys=ON")
|
||||
return c
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + "Z"
|
||||
|
||||
|
||||
def _eid(prefix):
|
||||
return f"{prefix}_{uuid.uuid4().hex[:16]}"
|
||||
|
||||
|
||||
def _log(c, action, target_id, payload, actor_id="architect", actor_type="agent"):
|
||||
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(), actor_type, actor_id, action, "thesis", target_id,
|
||||
json.dumps(payload) if payload is not None else None, "architect", _now()))
|
||||
|
||||
|
||||
def _line_by_key(c, line_key):
|
||||
return c.execute("SELECT * FROM thesis_lines WHERE line_key=? AND deleted_at IS NULL", (line_key,)).fetchone()
|
||||
|
||||
|
||||
# ── reads ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def list_thesis_lines(db=None):
|
||||
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, "count": len(rows)}
|
||||
|
||||
|
||||
def _node_tree(c, line_id):
|
||||
nodes = [dict(r) for r in c.execute(
|
||||
"SELECT * FROM thesis_nodes WHERE line_id=? AND deleted_at IS NULL ORDER BY ord", (line_id,))]
|
||||
by_parent = {}
|
||||
for n in nodes:
|
||||
by_parent.setdefault(n["parent_id"], []).append(n)
|
||||
def build(pid):
|
||||
out = []
|
||||
for n in by_parent.get(pid, []):
|
||||
out.append({**{k: n[k] for k in ("id", "node_type", "title", "body", "status", "variant_group", "ord")},
|
||||
"children": build(n["id"])})
|
||||
return out
|
||||
return build(None)
|
||||
|
||||
|
||||
def get_thesis(line_key, db=None):
|
||||
"""A thesis line + its node tree."""
|
||||
c = _conn(db)
|
||||
line = _line_by_key(c, line_key)
|
||||
if not line:
|
||||
c.close()
|
||||
return {"error": "not_found", "line_key": line_key}
|
||||
out = {"line": dict(line), "tree": _node_tree(c, line["id"])}
|
||||
c.close()
|
||||
return out
|
||||
|
||||
|
||||
def get_node(node_id, db=None):
|
||||
c = _conn(db)
|
||||
r = c.execute("SELECT * FROM thesis_nodes WHERE id=?", (node_id,)).fetchone()
|
||||
c.close()
|
||||
return dict(r) if r else {"error": "not_found", "node_id": node_id}
|
||||
|
||||
|
||||
def get_node_history(node_id, db=None):
|
||||
c = _conn(db)
|
||||
rows = [dict(r) for r in c.execute(
|
||||
"SELECT rev_no, body, title, status, change_summary, change_reason, actor_type, actor_id, created_at "
|
||||
"FROM thesis_node_revisions WHERE node_id=? ORDER BY rev_no DESC", (node_id,))]
|
||||
c.close()
|
||||
return {"node_id": node_id, "revisions": rows}
|
||||
|
||||
|
||||
def list_versions(line_key, db=None):
|
||||
c = _conn(db)
|
||||
line = _line_by_key(c, line_key)
|
||||
if not line:
|
||||
c.close()
|
||||
return {"error": "not_found", "line_key": line_key}
|
||||
rows = [dict(r) for r in c.execute(
|
||||
"SELECT id, version_no, status, rationale, created_by, created_at, approved_at "
|
||||
"FROM thesis_versions WHERE line_id=? ORDER BY version_no DESC", (line["id"],))]
|
||||
c.close()
|
||||
return {"line_key": line_key, "versions": rows}
|
||||
|
||||
|
||||
def get_canonical_thesis(line_key, db=None):
|
||||
"""The single canonical version's body_json. FAILS CLOSED if none approved —
|
||||
so Scribe/downstream agents can never generate against an unapproved thesis."""
|
||||
c = _conn(db)
|
||||
line = _line_by_key(c, line_key)
|
||||
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_review_feedback(version_id, db=None):
|
||||
"""Partners' reviews/feedback on a version — what the Architect iterates on."""
|
||||
c = _conn(db)
|
||||
rows = [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,))]
|
||||
approvals = sum(1 for r in rows if r["decision"] == "approve")
|
||||
c.close()
|
||||
return {"version_id": version_id, "reviews": rows, "approvals": approvals}
|
||||
|
||||
|
||||
def list_segments(db=None):
|
||||
c = _conn(db)
|
||||
rows = [dict(r) for r in c.execute(
|
||||
"SELECT segment_key, name, definition, needs_to_hear, avoid, version_no FROM segments WHERE status='active' ORDER BY name")]
|
||||
c.close()
|
||||
return {"segments": rows, "count": len(rows)}
|
||||
|
||||
|
||||
def get_segment(segment_key, db=None):
|
||||
c = _conn(db)
|
||||
r = c.execute("SELECT * FROM segments WHERE segment_key=? AND status='active'", (segment_key,)).fetchone()
|
||||
c.close()
|
||||
return dict(r) if r else {"error": "not_found", "segment_key": segment_key}
|
||||
|
||||
|
||||
# ── draft writes (logged; never canonical) ────────────────────────────────────
|
||||
|
||||
def create_thesis_line(line_key, name, segment_key=None, is_core=False, description=None, db=None):
|
||||
c = _conn(db)
|
||||
lid = _eid("thl")
|
||||
c.execute("""INSERT INTO thesis_lines (id, line_key, name, segment_key, is_core, description, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?)""",
|
||||
(lid, line_key, name, segment_key, 1 if is_core else 0, description, _now(), _now()))
|
||||
_log(c, "thesis.line_created", lid, {"line_key": line_key, "segment_key": segment_key, "is_core": bool(is_core)})
|
||||
c.commit()
|
||||
c.close()
|
||||
return {"id": lid, "line_key": line_key}
|
||||
|
||||
|
||||
def upsert_thesis_node(line_id, node_type, body, title=None, parent_id=None, ord=None,
|
||||
variant_group=None, node_id=None, change_reason=None, change_summary=None,
|
||||
actor_id="architect", claude_session_id=None, meta=None, db=None):
|
||||
"""Create or edit a node. On edit, the prior state is written to
|
||||
thesis_node_revisions before the live row changes (full provenance)."""
|
||||
c = _conn(db)
|
||||
if node_id:
|
||||
prev = c.execute("SELECT * FROM thesis_nodes WHERE id=?", (node_id,)).fetchone()
|
||||
if not prev:
|
||||
c.close()
|
||||
return {"error": "not_found", "node_id": node_id}
|
||||
rev_no = (c.execute("SELECT COALESCE(MAX(rev_no),0) FROM thesis_node_revisions WHERE node_id=?",
|
||||
(node_id,)).fetchone()[0]) + 1
|
||||
c.execute("""INSERT INTO thesis_node_revisions
|
||||
(id, node_id, line_id, rev_no, node_type, title, body, status, ord, variant_group, meta,
|
||||
change_summary, change_reason, actor_type, actor_id, claude_session_id, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(str(uuid.uuid4()), node_id, prev["line_id"], rev_no, prev["node_type"], prev["title"], prev["body"],
|
||||
prev["status"], prev["ord"], prev["variant_group"], prev["meta"], change_summary, change_reason,
|
||||
"agent", actor_id, claude_session_id, _now()))
|
||||
c.execute("""UPDATE thesis_nodes SET node_type=?, title=COALESCE(?,title), body=?, ord=COALESCE(?,ord),
|
||||
variant_group=?, meta=COALESCE(?,meta), updated_at=? WHERE id=?""",
|
||||
(node_type, title, body, ord, variant_group, json.dumps(meta) if meta else None, _now(), node_id))
|
||||
_log(c, "thesis.node_revised", node_id, {"line_id": prev["line_id"], "rev_no": rev_no, "reason": change_reason})
|
||||
out = {"id": node_id, "rev_no": rev_no}
|
||||
else:
|
||||
nid = _eid("thn")
|
||||
if ord is None:
|
||||
ord = (c.execute("SELECT COALESCE(MAX(ord),0) FROM thesis_nodes WHERE line_id=? AND parent_id IS ?",
|
||||
(line_id, parent_id)).fetchone()[0]) + 1.0
|
||||
c.execute("""INSERT INTO thesis_nodes (id, line_id, parent_id, node_type, ord, title, body, status, variant_group, meta, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?, 'draft', ?,?,?,?)""",
|
||||
(nid, line_id, parent_id, node_type, ord, title, body, variant_group,
|
||||
json.dumps(meta) if meta else None, _now(), _now()))
|
||||
_log(c, "thesis.node_created", nid, {"line_id": line_id, "node_type": node_type})
|
||||
out = {"id": nid, "rev_no": 0}
|
||||
c.commit()
|
||||
c.close()
|
||||
return out
|
||||
|
||||
|
||||
def create_thesis_version(line_key, rationale=None, created_by="architect", db=None):
|
||||
"""Freeze the current node tree of a line into an immutable draft version
|
||||
(body_json = the Architect->Scribe contract). Stays 'draft' until submitted
|
||||
and human-approved."""
|
||||
c = _conn(db)
|
||||
line = _line_by_key(c, line_key)
|
||||
if not line:
|
||||
c.close()
|
||||
return {"error": "not_found", "line_key": line_key}
|
||||
tree = _node_tree(c, line["id"])
|
||||
# typed projection for the Scribe contract
|
||||
flat = [dict(r) for r in c.execute(
|
||||
"SELECT node_type, title, body FROM thesis_nodes WHERE line_id=? AND deleted_at IS NULL ORDER BY ord", (line["id"],))]
|
||||
def of(t):
|
||||
return [{"title": n["title"], "body": n["body"]} for n in flat if n["node_type"] == t]
|
||||
body_json = {
|
||||
"line_key": line_key, "name": line["name"], "segment_key": line["segment_key"],
|
||||
"throughline": of("throughline"), "pillars": of("section"), "claims": of("claim"),
|
||||
"proof_points": of("proof_point"), "objections": of("objection"), "segment_cuts": of("segment_cut"),
|
||||
"tree": tree, "generated_at": _now(),
|
||||
}
|
||||
vno = (c.execute("SELECT COALESCE(MAX(version_no),0) FROM thesis_versions WHERE line_id=?",
|
||||
(line["id"],)).fetchone()[0]) + 1
|
||||
vid = _eid("thv")
|
||||
c.execute("""INSERT INTO thesis_versions (id, line_id, version_no, body_json, status, rationale, created_by, created_at)
|
||||
VALUES (?,?,?,?, 'draft', ?,?,?)""",
|
||||
(vid, line["id"], vno, json.dumps(body_json), rationale, created_by, _now()))
|
||||
_log(c, "thesis.version_created", vid, {"line_key": line_key, "version_no": vno})
|
||||
c.commit()
|
||||
c.close()
|
||||
return {"id": vid, "version_no": vno, "status": "draft"}
|
||||
|
||||
|
||||
def submit_version_for_review(version_id, db=None):
|
||||
c = _conn(db)
|
||||
r = c.execute("SELECT status FROM thesis_versions WHERE id=?", (version_id,)).fetchone()
|
||||
if not r:
|
||||
c.close()
|
||||
return {"error": "not_found", "version_id": version_id}
|
||||
if r["status"] != "draft":
|
||||
c.close()
|
||||
return {"error": "not_draft", "status": r["status"]}
|
||||
c.execute("UPDATE thesis_versions SET status='in_review' WHERE id=?", (version_id,))
|
||||
_log(c, "thesis.submitted_for_review", version_id, None)
|
||||
c.commit()
|
||||
c.close()
|
||||
return {"version_id": version_id, "status": "in_review"}
|
||||
|
||||
|
||||
def upsert_segment(segment_key, name, definition=None, needs_to_hear=None, avoid=None, db=None):
|
||||
"""Create/replace a segment's active definition (retire the prior active row)."""
|
||||
c = _conn(db)
|
||||
prev = c.execute("SELECT version_no FROM segments WHERE segment_key=? AND status='active'", (segment_key,)).fetchone()
|
||||
vno = (prev["version_no"] + 1) if prev else 1
|
||||
if prev:
|
||||
c.execute("UPDATE segments SET status='retired', updated_at=? WHERE segment_key=? AND status='active'",
|
||||
(_now(), segment_key))
|
||||
sid = _eid("seg")
|
||||
c.execute("""INSERT INTO segments (id, segment_key, name, definition, needs_to_hear, avoid, version_no, status, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?, 'active', ?,?)""",
|
||||
(sid, segment_key, name, definition, needs_to_hear, avoid, vno, _now(), _now()))
|
||||
_log(c, "segment.upserted", sid, {"segment_key": segment_key, "version_no": vno})
|
||||
c.commit()
|
||||
c.close()
|
||||
return {"id": sid, "segment_key": segment_key, "version_no": vno}
|
||||
@@ -18,6 +18,7 @@ import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import crm_tools as t # noqa: E402
|
||||
import architect_tools as at # noqa: E402
|
||||
|
||||
from mcp.server.fastmcp import FastMCP # noqa: E402
|
||||
|
||||
@@ -84,5 +85,73 @@ def set_entity_enrichment(lp_id: str, fields: dict, actor_id: str = "analyst") -
|
||||
return t.set_entity_enrichment(lp_id, fields, actor_id=actor_id)
|
||||
|
||||
|
||||
# ── Architect thesis tools (Phase 1; drafts only — no approve/promote here) ──
|
||||
@mcp.tool()
|
||||
def get_thesis(line_key: str) -> dict:
|
||||
"""Fetch a thesis line and its node tree (throughline → sections → claims → proof-points)."""
|
||||
return at.get_thesis(line_key)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_thesis_lines() -> dict:
|
||||
"""List all thesis lines (the core spine + per-segment lines)."""
|
||||
return at.list_thesis_lines()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_canonical_thesis(line_key: str) -> dict:
|
||||
"""The current partner-APPROVED canonical thesis for a line. Fails closed if none approved."""
|
||||
return at.get_canonical_thesis(line_key)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_review_feedback(version_id: str) -> dict:
|
||||
"""Partners' reviews/feedback on a thesis version — what to iterate on."""
|
||||
return at.get_review_feedback(version_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def create_thesis_line(line_key: str, name: str, segment_key: str = "", is_core: bool = False,
|
||||
description: str = "") -> dict:
|
||||
"""Create a new thesis line (a narrative, e.g. the core spine or a per-segment line)."""
|
||||
return at.create_thesis_line(line_key, name, segment_key=segment_key or None,
|
||||
is_core=is_core, description=description or None)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def upsert_thesis_node(line_id: str, node_type: str, body: str, title: str = "", parent_id: str = "",
|
||||
node_id: str = "", variant_group: str = "", change_reason: str = "") -> dict:
|
||||
"""Create or edit a thesis node (a claim, section, proof-point, etc.). Edits are revisioned."""
|
||||
return at.upsert_thesis_node(line_id, node_type, body, title=title or None,
|
||||
parent_id=parent_id or None, node_id=node_id or None,
|
||||
variant_group=variant_group or None, change_reason=change_reason or None)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def create_thesis_version(line_key: str, rationale: str = "") -> dict:
|
||||
"""Freeze the current node tree into an immutable DRAFT version (stays draft until a human approves)."""
|
||||
return at.create_thesis_version(line_key, rationale=rationale or None)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def submit_version_for_review(version_id: str) -> dict:
|
||||
"""Move a draft thesis version to 'in_review' so the partners can weigh in. Cannot make it canonical."""
|
||||
return at.submit_version_for_review(version_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_segments() -> dict:
|
||||
"""List active LP segment definitions."""
|
||||
return at.list_segments()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def upsert_segment(segment_key: str, name: str, definition: str = "", needs_to_hear: str = "",
|
||||
avoid: str = "") -> dict:
|
||||
"""Create/replace an LP segment's active definition."""
|
||||
return at.upsert_segment(segment_key, name, definition=definition or None,
|
||||
needs_to_hear=needs_to_hear or None, avoid=avoid or None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Reversal of 0002_phase1_architect.sql. Run manually (never auto-applied).
|
||||
DROP TABLE IF EXISTS thesis_reviews;
|
||||
DROP TABLE IF EXISTS thesis_node_revisions;
|
||||
DROP TABLE IF EXISTS thesis_nodes;
|
||||
DROP TABLE IF EXISTS thesis_versions;
|
||||
DROP TABLE IF EXISTS thesis_lines;
|
||||
DROP TABLE IF EXISTS segments;
|
||||
DELETE FROM schema_migrations WHERE filename = '0002_phase1_architect.sql';
|
||||
@@ -0,0 +1,124 @@
|
||||
-- Phase 1 — Workstream A/E: the Architect's thesis substrate.
|
||||
--
|
||||
-- ADDITIVE AND REVERSIBLE ONLY (guardrail #3). Reversal: 0002_phase1_architect.down.sql.
|
||||
-- Applied once by backend/core_migrations.py (schema_migrations ledger).
|
||||
--
|
||||
-- Models the "living messaging source of truth" as: multiple THESIS LINES (a core
|
||||
-- spine + per-segment narratives), each a tree of typed NODES with full revision
|
||||
-- history, frozen into immutable VERSIONS that a human signs off to make canonical.
|
||||
-- Dual approval + collaborative text feedback live in thesis_reviews. Segments are
|
||||
-- versioned defs that tie to the Phase-0 canonical_entities.segment pointer.
|
||||
|
||||
-- ============================================================================
|
||||
-- thesis_lines — each distinct narrative line. is_core=1 is the shared spine;
|
||||
-- others are segment-specific (Grant: different segments may carry different,
|
||||
-- related thesis lines). Full-length ids (not the 8-char CRM ids).
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS thesis_lines (
|
||||
id TEXT PRIMARY KEY, -- 'thl_' + uuid4 hex
|
||||
line_key TEXT NOT NULL UNIQUE, -- slug: 'core' | 'btc_native_hnwi' | ...
|
||||
name TEXT NOT NULL,
|
||||
segment_key TEXT, -- NULL for the core spine; else the segment this line serves
|
||||
is_core INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- thesis_nodes — typed node tree per line (the unit of iteration).
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS thesis_nodes (
|
||||
id TEXT PRIMARY KEY, -- 'thn_' + uuid4 hex
|
||||
line_id TEXT NOT NULL REFERENCES thesis_lines(id) ON DELETE CASCADE,
|
||||
parent_id TEXT, -- tree edge; NULL for thesis_root
|
||||
node_type TEXT NOT NULL, -- thesis_root|throughline|section|claim|proof_point|objection|rebuttal|segment_cut
|
||||
ord REAL NOT NULL DEFAULT 0,-- REAL so insert-between never renumbers siblings
|
||||
title TEXT,
|
||||
body TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft', -- draft|candidate|approved|retired
|
||||
variant_group TEXT, -- nodes sharing this are competing phrasings of one idea (A/B)
|
||||
meta TEXT, -- JSON: tone tags, confidence, evidence_refs -> canonical_entities/source ids
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
deleted_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_thesis_nodes_line ON thesis_nodes(line_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_thesis_nodes_parent ON thesis_nodes(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_thesis_nodes_variant ON thesis_nodes(variant_group);
|
||||
|
||||
-- ============================================================================
|
||||
-- thesis_node_revisions — append-only per-node history (provenance + undo).
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS thesis_node_revisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
line_id TEXT,
|
||||
rev_no INTEGER NOT NULL,
|
||||
node_type TEXT, title TEXT, body TEXT, status TEXT, ord REAL, variant_group TEXT, meta TEXT,
|
||||
change_summary TEXT,
|
||||
change_reason TEXT, -- WHY the edit
|
||||
actor_type TEXT, -- human | agent
|
||||
actor_id TEXT, -- users.id or 'architect'
|
||||
claude_session_id TEXT, -- ties an agent edit back to its reasoning session
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_thesis_revs_node ON thesis_node_revisions(node_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- thesis_versions — immutable named snapshots per line; ONE canonical per line.
|
||||
-- body_json freezes the published artifact (the Architect->Scribe contract).
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS thesis_versions (
|
||||
id TEXT PRIMARY KEY, -- 'thv_' + uuid4 hex
|
||||
line_id TEXT NOT NULL REFERENCES thesis_lines(id) ON DELETE CASCADE,
|
||||
version_no INTEGER NOT NULL,
|
||||
body_json TEXT NOT NULL, -- {throughline,pillars,claims,proof_points,segment_angles,voice,guardrails}
|
||||
status TEXT NOT NULL DEFAULT 'draft', -- draft|in_review|canonical|superseded
|
||||
rationale TEXT,
|
||||
parent_version_id TEXT,
|
||||
created_by TEXT, -- users.id or 'architect'
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
approved_at TEXT,
|
||||
superseded_by TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_thesis_versions_line ON thesis_versions(line_id, version_no);
|
||||
-- Hard invariant: at most one canonical version per line.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_thesis_one_canonical ON thesis_versions(line_id) WHERE status = 'canonical';
|
||||
|
||||
-- ============================================================================
|
||||
-- thesis_reviews — dual approval + collaborative text feedback. Both partners
|
||||
-- can comment/approve; the Architect ingests feedback to iterate. Promotion to
|
||||
-- canonical happens when distinct 'approve' reviews meet the required count
|
||||
-- (app_settings 'thesis_required_approvals', default 1) via the human route.
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS thesis_reviews (
|
||||
id TEXT PRIMARY KEY,
|
||||
version_id TEXT NOT NULL REFERENCES thesis_versions(id) ON DELETE CASCADE,
|
||||
reviewer_user_id TEXT NOT NULL, -- users.id (the human partner)
|
||||
decision TEXT NOT NULL, -- approve | request_changes | comment
|
||||
feedback TEXT, -- free-text the Architect reads to iterate
|
||||
target_node_id TEXT, -- optional: feedback scoped to one node
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_thesis_reviews_version ON thesis_reviews(version_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- segments — versioned LP segment definitions; one active per segment_key.
|
||||
-- canonical_entities.segment (Phase 0) stores the segment_key pointer.
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS segments (
|
||||
id TEXT PRIMARY KEY,
|
||||
segment_key TEXT NOT NULL, -- slug
|
||||
name TEXT NOT NULL,
|
||||
definition TEXT,
|
||||
needs_to_hear TEXT,
|
||||
avoid TEXT,
|
||||
version_no INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active | retired
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_segments_one_active ON segments(segment_key) WHERE status = 'active';
|
||||
@@ -37,6 +37,12 @@ except Exception:
|
||||
jwt = None
|
||||
JWT_AVAILABLE = False
|
||||
|
||||
# Phase-1 Architect: human-gated thesis approval logic (pure stdlib; guarded).
|
||||
try:
|
||||
import thesis_review # type: ignore
|
||||
except Exception:
|
||||
thesis_review = None
|
||||
|
||||
# ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -1731,6 +1737,16 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
if path == '/api/audit-log':
|
||||
return self.handle_list_audit_log(user, params)
|
||||
|
||||
# ─── Architect thesis (Phase 1) ───
|
||||
if path == '/api/thesis/lines':
|
||||
return self.handle_list_thesis_lines(user)
|
||||
if path == '/api/thesis/versions':
|
||||
return self.handle_list_thesis_review_queue(user)
|
||||
if re.match(r'^/api/thesis/versions/[^/]+$', path):
|
||||
return self.handle_get_thesis_version(user, path.split('/')[-1])
|
||||
if re.match(r'^/api/thesis/[^/]+/canonical$', path):
|
||||
return self.handle_get_canonical_thesis(user, path.split('/')[-2])
|
||||
|
||||
self.send_error_json("Not found", 404)
|
||||
|
||||
def do_POST(self):
|
||||
@@ -1795,6 +1811,10 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
if path == '/api/fundraising/backup-verify':
|
||||
return self.handle_verify_fundraising_backups(user)
|
||||
|
||||
# ─── Architect thesis review (Phase 1, human approval gate) ───
|
||||
if re.match(r'^/api/thesis/versions/[^/]+/review$', path):
|
||||
return self.handle_thesis_review(user, path.split('/')[-2], body)
|
||||
|
||||
self.send_error_json("Not found", 404)
|
||||
|
||||
def do_PUT(self):
|
||||
@@ -3408,6 +3428,41 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
conn.close()
|
||||
return self.send_json({"message": "Tag deleted"})
|
||||
|
||||
# ─── Architect thesis (Phase 1) ───
|
||||
def handle_list_thesis_lines(self, user):
|
||||
if thesis_review is None:
|
||||
return self.send_error_json("Thesis module unavailable", 503)
|
||||
return self.send_json(thesis_review.list_lines(DB_PATH))
|
||||
|
||||
def handle_list_thesis_review_queue(self, user):
|
||||
if thesis_review is None:
|
||||
return self.send_error_json("Thesis module unavailable", 503)
|
||||
return self.send_json(thesis_review.list_versions_for_review(DB_PATH))
|
||||
|
||||
def handle_get_thesis_version(self, user, version_id):
|
||||
if thesis_review is None:
|
||||
return self.send_error_json("Thesis module unavailable", 503)
|
||||
return self.send_json(thesis_review.get_version(DB_PATH, version_id))
|
||||
|
||||
def handle_get_canonical_thesis(self, user, line_key):
|
||||
if thesis_review is None:
|
||||
return self.send_error_json("Thesis module unavailable", 503)
|
||||
return self.send_json(thesis_review.get_canonical(DB_PATH, line_key))
|
||||
|
||||
def handle_thesis_review(self, user, version_id, body):
|
||||
# Promotion to canonical is a human partner action (guardrail #4).
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin required", 403)
|
||||
if thesis_review is None:
|
||||
return self.send_error_json("Thesis module unavailable", 503)
|
||||
body = body or {}
|
||||
res = thesis_review.record_review(DB_PATH, version_id, user['user_id'],
|
||||
body.get('decision'), body.get('feedback'),
|
||||
body.get('target_node_id'))
|
||||
if res.get('error'):
|
||||
return self.send_error_json(res['error'], 400)
|
||||
return self.send_json({"data": res})
|
||||
|
||||
def handle_list_users(self, user):
|
||||
conn = get_db()
|
||||
users = rows_to_list(conn.execute(
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user