verify-outcomes 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- verify/__init__.py +5 -0
- verify/__main__.py +3 -0
- verify/cli.py +135 -0
- verify/contract.py +33 -0
- verify/core.py +152 -0
- verify/hubspot.py +72 -0
- verify/intercom.py +82 -0
- verify/local.py +103 -0
- verify/mcp_server.py +117 -0
- verify/push.py +32 -0
- verify/shopify.py +118 -0
- verify/stripe.py +119 -0
- verify/zendesk.py +118 -0
- verify_outcomes-0.4.1.dist-info/METADATA +103 -0
- verify_outcomes-0.4.1.dist-info/RECORD +19 -0
- verify_outcomes-0.4.1.dist-info/WHEEL +5 -0
- verify_outcomes-0.4.1.dist-info/entry_points.txt +2 -0
- verify_outcomes-0.4.1.dist-info/licenses/LICENSE +21 -0
- verify_outcomes-0.4.1.dist-info/top_level.txt +1 -0
verify/__init__.py
ADDED
verify/__main__.py
ADDED
verify/cli.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""usage:
|
|
2
|
+
verify check <contract.json> <claim.json> [--ledger verify.db] exit 0 only on pass
|
|
3
|
+
verify check-batch <contract.json> <claims.jsonl> [--ledger verify.db] one line per claim; exit 0 only if all pass
|
|
4
|
+
verify push <ledger.db> --to <url> upload to the hosted ledger (key: env VERIFY_HOSTED_KEY)
|
|
5
|
+
verify validate <contract.json>...
|
|
6
|
+
verify ledger <db>
|
|
7
|
+
verify ledger export <db> [out.jsonl] hash-chained JSONL + chain head
|
|
8
|
+
verify ledger verify <export.jsonl> check an export offline
|
|
9
|
+
verify init {stripe,zendesk,hubspot,intercom,shopify} [dir]
|
|
10
|
+
verify --version
|
|
11
|
+
verify demo {local,stripe,zendesk}
|
|
12
|
+
verify selfcheck
|
|
13
|
+
"""
|
|
14
|
+
import json, os, sys
|
|
15
|
+
from . import contract, hubspot, intercom, local, shopify, stripe, zendesk
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .core import BadClaim, Ledger, verify, verify_export
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def reader_for(c):
|
|
21
|
+
"""Pick the read-only adapter from the contract's target_system. Credentials only from env."""
|
|
22
|
+
t = c["target_system"]
|
|
23
|
+
if t == "stripe":
|
|
24
|
+
key = stripe.test_key("STRIPE_VERIFY_KEY")
|
|
25
|
+
if not key: sys.exit("Set STRIPE_VERIFY_KEY (restricted rk_test_ key, Charges+Refunds Read).")
|
|
26
|
+
return stripe.make_reader(stripe.stripe_http, key)
|
|
27
|
+
if t == "zendesk":
|
|
28
|
+
sub, tok = os.environ.get("ZENDESK_SUBDOMAIN"), os.environ.get("ZENDESK_VERIFY_TOKEN")
|
|
29
|
+
if not (sub and tok): sys.exit("Set ZENDESK_SUBDOMAIN and ZENDESK_VERIFY_TOKEN (OAuth, scope read).")
|
|
30
|
+
return zendesk.make_reader(zendesk.zd_http(sub, f"Bearer {tok}"))
|
|
31
|
+
if t == "hubspot":
|
|
32
|
+
tok = os.environ.get("HUBSPOT_VERIFY_TOKEN")
|
|
33
|
+
if not tok: sys.exit("Set HUBSPOT_VERIFY_TOKEN (private app, crm.objects.deals.read only).")
|
|
34
|
+
return hubspot.make_reader(hubspot.hs_http(tok))
|
|
35
|
+
if t == "intercom":
|
|
36
|
+
tok = os.environ.get("INTERCOM_VERIFY_TOKEN")
|
|
37
|
+
if not tok: sys.exit("Set INTERCOM_VERIFY_TOKEN (Intercom app access token, Read conversations permission only).")
|
|
38
|
+
return intercom.make_reader(intercom.ic_http(tok))
|
|
39
|
+
if t == "shopify":
|
|
40
|
+
shop, tok = os.environ.get("SHOPIFY_SHOP"), os.environ.get("SHOPIFY_VERIFY_TOKEN")
|
|
41
|
+
if not (shop and tok): sys.exit("Set SHOPIFY_SHOP (subdomain of .myshopify.com) and SHOPIFY_VERIFY_TOKEN (custom app Admin API token, read_orders scope only).")
|
|
42
|
+
return shopify.make_reader(shopify.sh_http(shop, tok))
|
|
43
|
+
sys.exit(f"no adapter for target_system {t!r} (have: stripe, zendesk, hubspot, intercom, shopify)")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check(contract_path, claim_path, ledger_path="verify.db"):
|
|
47
|
+
c = contract.load(contract_path)
|
|
48
|
+
with open(claim_path) as f: claim = json.load(f)
|
|
49
|
+
try: run_id, v, level, reasons = verify(Ledger(ledger_path), None, claim, c, reader_for(c), actor=claim.get("actor", "unknown"))
|
|
50
|
+
except BadClaim as e: print(f"error: {e}", file=sys.stderr); return 2
|
|
51
|
+
print(json.dumps({"run_id": run_id, "verdict": v, "level": level, "reasons": reasons, "ledger": ledger_path}, indent=1))
|
|
52
|
+
return 0 if v == "pass" else 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_batch(contract_path, claims_path, ledger_path="verify.db", reader=None):
|
|
56
|
+
c = contract.load(contract_path)
|
|
57
|
+
led, reader = Ledger(ledger_path), reader or reader_for(c)
|
|
58
|
+
counts = {}
|
|
59
|
+
with open(claims_path) as f:
|
|
60
|
+
for n, line in enumerate(f, 1):
|
|
61
|
+
if not line.strip(): continue
|
|
62
|
+
claim = json.loads(line)
|
|
63
|
+
try: run_id, v, level, reasons = verify(led, None, claim, c, reader, actor=claim.get("actor", "unknown"))
|
|
64
|
+
except BadClaim as e: run_id, v, level, reasons = None, "bad_claim", "-", [str(e)]
|
|
65
|
+
counts[v] = counts.get(v, 0) + 1
|
|
66
|
+
print(json.dumps({"line": n, "run_id": run_id, "verdict": v, "level": level, "reasons": reasons}))
|
|
67
|
+
total = sum(counts.values())
|
|
68
|
+
print(f"# {counts.get('pass', 0)}/{total} pass " + " ".join(f"{k}={v}" for k, v in sorted(counts.items())), file=sys.stderr)
|
|
69
|
+
return 0 if total and counts.get("pass", 0) == total else 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
CLAIMS = {"stripe": {"actor": "support-agent", "charge_id": "ch_...", "refund_id": "re_...", "amount": 5000},
|
|
73
|
+
"zendesk": {"actor": "support-agent", "ticket": 123},
|
|
74
|
+
"hubspot": {"actor": "revops-agent", "deal": "123", "amount": 4200.0, "company": "456"},
|
|
75
|
+
"intercom": {"actor": "support-agent", "conversation": "123"},
|
|
76
|
+
"shopify": {"actor": "support-agent", "order": "123", "refund_id": "456", "amount": 50.0}}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def init(system, d="."):
|
|
80
|
+
mod = {"stripe": stripe, "zendesk": zendesk, "hubspot": hubspot, "intercom": intercom, "shopify": shopify}[system]
|
|
81
|
+
os.makedirs(os.path.join(d, "contracts"), exist_ok=True)
|
|
82
|
+
paths = [os.path.join(d, "contracts", mod.CONTRACT["id"] + ".json"), os.path.join(d, f"claim.{system}.json")]
|
|
83
|
+
for p, body in zip(paths, (mod.CONTRACT, CLAIMS[system])):
|
|
84
|
+
if os.path.exists(p): print(f"exists, kept: {p}"); continue
|
|
85
|
+
with open(p, "w") as f: json.dump(body, f, indent=2); f.write("\n")
|
|
86
|
+
print(f"wrote {p}")
|
|
87
|
+
print(f"next: edit {paths[1]}, then verify check {paths[0]} {paths[1]}")
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main(argv=None):
|
|
92
|
+
a = sys.argv[1:] if argv is None else argv
|
|
93
|
+
if a[:1] in (["--version"], ["-V"]): print(f"verify {__version__}"); return 0
|
|
94
|
+
if a[:1] == ["init"] and len(a) in (2, 3) and a[1] in CLAIMS: return init(*a[1:])
|
|
95
|
+
if a[:2] == ["ledger", "export"] and len(a) in (3, 4):
|
|
96
|
+
out = open(a[3], "w") if len(a) == 4 else sys.stdout
|
|
97
|
+
head = Ledger(a[2]).export(out)
|
|
98
|
+
if out is not sys.stdout: out.close(); print(f"chain head {head}")
|
|
99
|
+
return 0
|
|
100
|
+
if a[:2] == ["ledger", "verify"] and len(a) == 3:
|
|
101
|
+
with open(a[2]) as f: ok, n, head = verify_export(f)
|
|
102
|
+
print(f"{n} events, chain {'intact' if ok else 'BROKEN'}" + (f", head {head}" if ok else ""))
|
|
103
|
+
return 0 if ok else 1
|
|
104
|
+
if a[:1] == ["check"] and len(a) in (3, 5) and (len(a) == 3 or a[3] == "--ledger"):
|
|
105
|
+
return check(a[1], a[2], *a[4:5])
|
|
106
|
+
if a[:1] == ["check-batch"] and len(a) in (3, 5) and (len(a) == 3 or a[3] == "--ledger"):
|
|
107
|
+
return check_batch(a[1], a[2], *a[4:5])
|
|
108
|
+
if a[:1] == ["push"] and len(a) == 4 and a[2] == "--to":
|
|
109
|
+
from .push import push
|
|
110
|
+
key = os.environ.get("VERIFY_HOSTED_KEY")
|
|
111
|
+
if not key: print("error: set VERIFY_HOSTED_KEY", file=sys.stderr); return 2
|
|
112
|
+
r = push(a[1], a[3], key); print(f"pushed {r['pushed']} events, head {r['head']}"); return 0
|
|
113
|
+
if a[:1] == ["validate"] and len(a) > 1:
|
|
114
|
+
bad = 0
|
|
115
|
+
for p in a[1:]:
|
|
116
|
+
errs = contract.validate(json.load(open(p)))
|
|
117
|
+
print(f"{p}: " + ("ok" if not errs else "INVALID\n " + "\n ".join(errs)))
|
|
118
|
+
bad += bool(errs)
|
|
119
|
+
return 1 if bad else 0
|
|
120
|
+
if a[:1] == ["ledger"] and len(a) == 2:
|
|
121
|
+
led = Ledger(a[1])
|
|
122
|
+
for row in led.db.execute("SELECT run_id, verdict, level_reached, independence, reasons_json FROM verdict"):
|
|
123
|
+
print(*row, sep=" ")
|
|
124
|
+
ok = led.verify_chain()
|
|
125
|
+
print("hash chain intact:", ok)
|
|
126
|
+
return 0 if ok else 1
|
|
127
|
+
if a[:2] == ["demo", "local"]: local.demo(); return 0
|
|
128
|
+
if a[:2] == ["demo", "stripe"]: stripe.main(); return 0
|
|
129
|
+
if a[:2] == ["demo", "zendesk"]: zendesk.main(); return 0
|
|
130
|
+
if a[:1] == ["selfcheck"]: local.check(); stripe.check(); zendesk.check(); hubspot.check(); intercom.check(); shopify.check(); return 0
|
|
131
|
+
print(__doc__); return 2
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
sys.exit(main())
|
verify/contract.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Load and validate outcome contracts (JSON). Rules from the outcome contract schema v0.1."""
|
|
2
|
+
import json
|
|
3
|
+
from .core import OPS
|
|
4
|
+
|
|
5
|
+
REQUIRED = ("id", "version", "owner", "target_system", "subject_ref", "postconditions",
|
|
6
|
+
"required_level", "settle_window_s", "max_attempts", "scope")
|
|
7
|
+
LEVELS = ("L1", "L2", "L3", "L4")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate(c):
|
|
11
|
+
"""Return a list of problems; empty means valid. Rejected at registration, never at runtime."""
|
|
12
|
+
errs = [f"missing field: {f}" for f in REQUIRED if f not in c]
|
|
13
|
+
if errs: return errs
|
|
14
|
+
if not c["postconditions"]: errs.append("postconditions must not be empty")
|
|
15
|
+
if c["required_level"] not in LEVELS: errs.append(f"required_level must be one of {LEVELS}")
|
|
16
|
+
if not isinstance(c["max_attempts"], int) or c["max_attempts"] < 1: errs.append("max_attempts must be an int >= 1")
|
|
17
|
+
if not isinstance(c["settle_window_s"], (int, float)) or c["settle_window_s"] < 0: errs.append("settle_window_s must be >= 0")
|
|
18
|
+
scope = set(c["scope"])
|
|
19
|
+
for group in ("preconditions", "postconditions", "forbidden"):
|
|
20
|
+
for i, chk in enumerate(c.get(group, [])):
|
|
21
|
+
where = f"{group}[{i}]"
|
|
22
|
+
if chk.get("op") not in OPS: errs.append(f"{where}: unknown op {chk.get('op')!r}")
|
|
23
|
+
if chk.get("path") not in scope: errs.append(f"{where}: path {chk.get('path')!r} not in scope")
|
|
24
|
+
sp = c.get("subject_present")
|
|
25
|
+
if sp and sp not in scope: errs.append(f"subject_present {sp!r} not in scope")
|
|
26
|
+
return errs
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load(path):
|
|
30
|
+
with open(path) as f: c = json.load(f)
|
|
31
|
+
errs = validate(c)
|
|
32
|
+
if errs: raise ValueError(f"{path}: invalid contract:\n " + "\n ".join(errs))
|
|
33
|
+
return c
|
verify/core.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Verify core: contracts -> independent readback -> 5 verdicts -> hash-chained ledger."""
|
|
2
|
+
import sqlite3, json, hashlib, time, uuid
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
def now(): return datetime.now(timezone.utc).isoformat()
|
|
6
|
+
|
|
7
|
+
class Blocked(Exception):
|
|
8
|
+
"""Readback impossible (auth, outage, rate limit). retry_after: seconds the system asked us to wait."""
|
|
9
|
+
def __init__(self, msg, retry_after=None):
|
|
10
|
+
super().__init__(msg); self.retry_after = retry_after
|
|
11
|
+
|
|
12
|
+
# ---------------- evaluation ----------------
|
|
13
|
+
OPS = {"eq": lambda a, b: a == b, "ne": lambda a, b: a != b, "gt": lambda a, b: a is not None and a > b,
|
|
14
|
+
"lt": lambda a, b: a is not None and a < b, "exists": lambda a, b: a is not None}
|
|
15
|
+
|
|
16
|
+
def resolve(v, claim):
|
|
17
|
+
if isinstance(v, str) and v.startswith("{") and v.endswith("}"): return claim[v[1:-1]]
|
|
18
|
+
return v
|
|
19
|
+
|
|
20
|
+
def evaluate(contract, fields, claim):
|
|
21
|
+
"""Returns (verdict, level, reasons). Unknown fails closed."""
|
|
22
|
+
def run(checks): return [(c, fields.get(c["path"]), OPS[c["op"]](fields.get(c["path"]), resolve(c["value"], claim)))
|
|
23
|
+
for c in checks]
|
|
24
|
+
pre = run(contract["preconditions"])
|
|
25
|
+
if not all(ok for *_, ok in pre):
|
|
26
|
+
return "inapplicable", "L0", [f"precondition {c['path']}={got!r}" for c, got, ok in pre if not ok]
|
|
27
|
+
if fields.get(contract.get("subject_present", "refund.amount")) in (None, False): # subject record absent
|
|
28
|
+
return "not_observed", "L1", [f"{contract['subject_ref'].format(**claim)} not found in system of record"]
|
|
29
|
+
bad = [f"forbidden {c['path']}={got!r}" for c, got, hit in run(contract["forbidden"]) if hit]
|
|
30
|
+
bad += [f"{c['path']} = {got!r}, expected {c['op']} {resolve(c['value'], claim)!r}"
|
|
31
|
+
for c, got, ok in run(contract["postconditions"]) if not ok]
|
|
32
|
+
return ("fail", "L2", bad) if bad else ("pass", contract["required_level"], [])
|
|
33
|
+
|
|
34
|
+
# ---------------- ledger (doc 03) ----------------
|
|
35
|
+
LEDGER_SQL = """
|
|
36
|
+
CREATE TABLE IF NOT EXISTS contract(id TEXT, version TEXT, body_json TEXT NOT NULL, body_hash TEXT NOT NULL,
|
|
37
|
+
registered_at TEXT NOT NULL, owner TEXT NOT NULL, PRIMARY KEY(id, version));
|
|
38
|
+
CREATE TABLE IF NOT EXISTS run(run_id TEXT PRIMARY KEY, contract_id TEXT NOT NULL, contract_version TEXT NOT NULL,
|
|
39
|
+
subject_ref TEXT NOT NULL, actor TEXT NOT NULL, started_at TEXT NOT NULL);
|
|
40
|
+
CREATE TABLE IF NOT EXISTS event(seq INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, type TEXT NOT NULL,
|
|
41
|
+
at TEXT NOT NULL, clock_source TEXT NOT NULL, payload_json TEXT NOT NULL, evidence_hash TEXT, prev_hash TEXT, hash TEXT NOT NULL);
|
|
42
|
+
CREATE TABLE IF NOT EXISTS verdict(run_id TEXT PRIMARY KEY, verdict TEXT NOT NULL
|
|
43
|
+
CHECK (verdict IN ('pass','fail','inapplicable','not_observed','blocked')),
|
|
44
|
+
level_reached TEXT NOT NULL, independence TEXT NOT NULL, reasons_json TEXT NOT NULL, final_event_seq INTEGER NOT NULL);
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
class Ledger:
|
|
48
|
+
def __init__(self, path):
|
|
49
|
+
self.db = sqlite3.connect(path)
|
|
50
|
+
self.db.execute("PRAGMA journal_mode=WAL"); self.db.execute("PRAGMA synchronous=FULL")
|
|
51
|
+
self.db.executescript(LEDGER_SQL)
|
|
52
|
+
|
|
53
|
+
def event(self, run_id, type_, payload, evidence=None):
|
|
54
|
+
prev = (self.db.execute("SELECT hash FROM event ORDER BY seq DESC LIMIT 1").fetchone() or [""])[0]
|
|
55
|
+
row = dict(run_id=run_id, type=type_, at=now(), clock_source="system", payload_json=json.dumps(payload, sort_keys=True),
|
|
56
|
+
evidence_hash=hashlib.sha256(evidence.encode()).hexdigest() if evidence else None)
|
|
57
|
+
h = hashlib.sha256((prev + json.dumps(row, sort_keys=True)).encode()).hexdigest()
|
|
58
|
+
cur = self.db.execute("INSERT INTO event(run_id,type,at,clock_source,payload_json,evidence_hash,prev_hash,hash) VALUES (?,?,?,?,?,?,?,?)",
|
|
59
|
+
(*row.values(), prev, h))
|
|
60
|
+
self.db.commit(); return cur.lastrowid
|
|
61
|
+
|
|
62
|
+
EVENT_KEYS = ["run_id", "type", "at", "clock_source", "payload_json", "evidence_hash"]
|
|
63
|
+
|
|
64
|
+
def export(self, out):
|
|
65
|
+
"""Write every event as JSONL, then a final line with the chain head. Returns the head hash."""
|
|
66
|
+
head = ""
|
|
67
|
+
for seq, *row, p, h in self.db.execute("SELECT seq,run_id,type,at,clock_source,payload_json,evidence_hash,prev_hash,hash FROM event ORDER BY seq"):
|
|
68
|
+
out.write(json.dumps(dict(seq=seq, **dict(zip(self.EVENT_KEYS, row)), prev_hash=p, hash=h), sort_keys=True) + "\n")
|
|
69
|
+
head = h
|
|
70
|
+
out.write(json.dumps({"chain_head": head}) + "\n")
|
|
71
|
+
return head
|
|
72
|
+
|
|
73
|
+
def verify_chain(self):
|
|
74
|
+
prev = ""
|
|
75
|
+
for *row, p, h in self.db.execute("SELECT run_id,type,at,clock_source,payload_json,evidence_hash,prev_hash,hash FROM event ORDER BY seq"):
|
|
76
|
+
keys = ["run_id", "type", "at", "clock_source", "payload_json", "evidence_hash"]
|
|
77
|
+
if p != prev or h != hashlib.sha256((prev + json.dumps(dict(zip(keys, row)), sort_keys=True)).encode()).hexdigest():
|
|
78
|
+
return False
|
|
79
|
+
prev = h
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
def verify_export(lines):
|
|
83
|
+
"""Check an exported JSONL ledger offline. Returns (ok, events, head)."""
|
|
84
|
+
prev, n, head = "", 0, None
|
|
85
|
+
for line in lines:
|
|
86
|
+
e = json.loads(line)
|
|
87
|
+
if "chain_head" in e: head = e["chain_head"]; break
|
|
88
|
+
row = {k: e[k] for k in Ledger.EVENT_KEYS}
|
|
89
|
+
if e["prev_hash"] != prev or e["hash"] != hashlib.sha256((prev + json.dumps(row, sort_keys=True)).encode()).hexdigest():
|
|
90
|
+
return False, n, None
|
|
91
|
+
prev, n = e["hash"], n + 1
|
|
92
|
+
return head == prev, n, head
|
|
93
|
+
|
|
94
|
+
# ---------------- Verify ----------------
|
|
95
|
+
def claim_fields(contract):
|
|
96
|
+
"""Claim keys the contract needs: {placeholders} in subject_ref and in check values."""
|
|
97
|
+
import string
|
|
98
|
+
need = {f for _, f, _, _ in string.Formatter().parse(contract["subject_ref"]) if f}
|
|
99
|
+
for g in ("preconditions", "postconditions", "forbidden"):
|
|
100
|
+
for c in contract.get(g, []):
|
|
101
|
+
v = c.get("value")
|
|
102
|
+
if isinstance(v, str) and v.startswith("{") and v.endswith("}"): need.add(v[1:-1])
|
|
103
|
+
return need
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class BadClaim(ValueError): pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def verify(ledger, sor_path, claim, contract, reader, actor="support-agent", independence="full"):
|
|
110
|
+
missing = sorted(claim_fields(contract) - set(claim))
|
|
111
|
+
if missing: raise BadClaim(f"claim is missing {', '.join(missing)} (needed by contract {contract['id']})") # before any ledger write
|
|
112
|
+
body = json.dumps(contract, sort_keys=True)
|
|
113
|
+
ledger.db.execute("INSERT OR IGNORE INTO contract VALUES (?,?,?,?,?,?)", (contract["id"], contract["version"], body,
|
|
114
|
+
hashlib.sha256(body.encode()).hexdigest(), now(), contract["owner"]))
|
|
115
|
+
run_id = uuid.uuid4().hex[:8]
|
|
116
|
+
ledger.db.execute("INSERT INTO run VALUES (?,?,?,?,?,?)", (run_id, contract["id"], contract["version"],
|
|
117
|
+
contract["subject_ref"].format(**claim), actor, now()))
|
|
118
|
+
ledger.event(run_id, "action_claimed", claim) # claim recorded, never used as evidence
|
|
119
|
+
verdict, level, reasons = "not_observed", "L1", []
|
|
120
|
+
wait = contract["settle_window_s"]
|
|
121
|
+
for attempt in range(1, contract["max_attempts"] + 1):
|
|
122
|
+
time.sleep(wait)
|
|
123
|
+
wait = contract["settle_window_s"]
|
|
124
|
+
ledger.event(run_id, "readback_started", {"attempt": attempt})
|
|
125
|
+
try:
|
|
126
|
+
fields, raw = reader(sor_path, claim)
|
|
127
|
+
except Blocked as e:
|
|
128
|
+
ledger.event(run_id, "readback_blocked", {"error": str(e), "retry_after": e.retry_after})
|
|
129
|
+
wait = min(e.retry_after if e.retry_after is not None else max(1, wait) * 2 ** attempt, 60) # backoff, capped
|
|
130
|
+
verdict, level, reasons = "blocked", "L0", [str(e)]
|
|
131
|
+
continue
|
|
132
|
+
ledger.event(run_id, "readback_observed", fields, evidence=raw)
|
|
133
|
+
verdict, level, reasons = evaluate(contract, fields, claim)
|
|
134
|
+
if verdict != "not_observed": break # retry only missing evidence
|
|
135
|
+
seq = ledger.event(run_id, "verdict_final", {"verdict": verdict, "reasons": reasons})
|
|
136
|
+
ledger.db.execute("INSERT INTO verdict VALUES (?,?,?,?,?,?)", (run_id, verdict, level, independence, json.dumps(reasons), seq))
|
|
137
|
+
ledger.db.commit()
|
|
138
|
+
return run_id, verdict, level, reasons
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def check(contract, claim, reader, ledger="verify.db", actor=None, independence="full"):
|
|
143
|
+
"""Python API. contract: dict or path; claim: dict; reader(sor, claim) -> (fields, raw_json).
|
|
144
|
+
Returns dict with verdict, level, reasons, run_id. Only verdict == "pass" means success."""
|
|
145
|
+
from . import contract as _c
|
|
146
|
+
c = _c.load(contract) if isinstance(contract, str) else contract
|
|
147
|
+
if not isinstance(contract, str):
|
|
148
|
+
errs = _c.validate(c)
|
|
149
|
+
if errs: raise ValueError("invalid contract: " + "; ".join(errs))
|
|
150
|
+
led = ledger if isinstance(ledger, Ledger) else Ledger(ledger)
|
|
151
|
+
run_id, v, level, reasons = verify(led, None, claim, c, reader, actor=actor or claim.get("actor", "unknown"), independence=independence)
|
|
152
|
+
return {"run_id": run_id, "verdict": v, "level": level, "reasons": reasons, "ok": v == "pass"}
|
verify/hubspot.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Verify reader for HubSpot CRM deals. Stdlib only.
|
|
2
|
+
|
|
3
|
+
Env (never in code or chat):
|
|
4
|
+
HUBSPOT_VERIFY_TOKEN private-app token with ONLY crm.objects.deals.read (Verify: read-only)
|
|
5
|
+
|
|
6
|
+
Checks a RevOps agent's claim "deal moved to Closed Won for $X, linked to company Y".
|
|
7
|
+
"""
|
|
8
|
+
import json, os, tempfile, urllib.request, urllib.error
|
|
9
|
+
from .core import Blocked, Ledger, verify
|
|
10
|
+
|
|
11
|
+
CONTRACT = {
|
|
12
|
+
"id": "deal.closed_won.hubspot", "version": "1.0.0", "owner": "revops",
|
|
13
|
+
"target_system": "hubspot", "subject_ref": "deal:{deal}", "subject_present": "deal.exists",
|
|
14
|
+
"preconditions": [],
|
|
15
|
+
"postconditions": [{"path": "deal.dealstage", "op": "eq", "value": "closedwon"},
|
|
16
|
+
{"path": "deal.amount", "op": "eq", "value": "{amount}"},
|
|
17
|
+
{"path": "deal.company_id", "op": "eq", "value": "{company}"}],
|
|
18
|
+
"forbidden": [],
|
|
19
|
+
"required_level": "L2", "settle_window_s": 1.0, "max_attempts": 3,
|
|
20
|
+
"scope": ["deal.exists", "deal.dealstage", "deal.amount", "deal.company_id"],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def hs_http(token):
|
|
25
|
+
def http(method, path):
|
|
26
|
+
req = urllib.request.Request(f"https://api.hubapi.com{path}", method=method,
|
|
27
|
+
headers={"Authorization": f"Bearer {token}"})
|
|
28
|
+
try:
|
|
29
|
+
with urllib.request.urlopen(req, timeout=20) as r: return r.status, json.load(r)
|
|
30
|
+
except urllib.error.HTTPError as e:
|
|
31
|
+
if e.code == 404: return 404, {}
|
|
32
|
+
ra = e.headers.get("Retry-After")
|
|
33
|
+
raise Blocked(f"hubspot HTTP {e.code}", retry_after=float(ra) if ra else None)
|
|
34
|
+
except urllib.error.URLError as e:
|
|
35
|
+
raise Blocked(f"hubspot unreachable: {e.reason}")
|
|
36
|
+
return http
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def make_reader(http):
|
|
40
|
+
def reader(_unused, claim):
|
|
41
|
+
st, d = http("GET", f"/crm/v3/objects/deals/{claim['deal']}?properties=dealstage,amount&associations=companies")
|
|
42
|
+
p = d.get("properties", {}) if st == 200 else {}
|
|
43
|
+
companies = [a["id"] for a in d.get("associations", {}).get("companies", {}).get("results", [])] if st == 200 else []
|
|
44
|
+
amt = p.get("amount")
|
|
45
|
+
raw = {"deal.exists": st == 200, "deal.dealstage": p.get("dealstage"),
|
|
46
|
+
"deal.amount": float(amt) if amt not in (None, "") else None, # HubSpot returns strings
|
|
47
|
+
"deal.company_id": companies[0] if len(companies) == 1 else (companies or None)}
|
|
48
|
+
return raw, json.dumps(raw, sort_keys=True)
|
|
49
|
+
return reader
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fake_hubspot(deals):
|
|
53
|
+
def http(method, path):
|
|
54
|
+
if method != "GET": raise Blocked("hubspot HTTP 403: read-only scope")
|
|
55
|
+
d = deals.get(path.split("/")[5].split("?")[0])
|
|
56
|
+
if d is None: return 404, {}
|
|
57
|
+
return 200, {"properties": {"dealstage": d[0], "amount": d[1]},
|
|
58
|
+
"associations": {"companies": {"results": [{"id": c} for c in d[2]]}}}
|
|
59
|
+
return http
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check():
|
|
63
|
+
deals = {"1": ("closedwon", "4200", ["77"]), "2": ("contractsent", "4200", ["77"]),
|
|
64
|
+
"3": ("closedwon", "420", ["77"]), "4": ("closedwon", "4200", [])}
|
|
65
|
+
c = dict(CONTRACT, settle_window_s=0)
|
|
66
|
+
led, reader = Ledger(os.path.join(tempfile.mkdtemp(), "l.db")), make_reader(fake_hubspot(deals))
|
|
67
|
+
got = [verify(led, None, {"deal": d, "amount": 4200.0, "company": "77"}, c, reader)[1] for d in ("1", "2", "3", "4", "9")]
|
|
68
|
+
assert got == ["pass", "fail", "fail", "fail", "not_observed"], got
|
|
69
|
+
try: fake_hubspot(deals)("PATCH", "/crm/v3/objects/deals/1"); assert False
|
|
70
|
+
except Blocked: pass
|
|
71
|
+
assert led.verify_chain()
|
|
72
|
+
print("hubspot adapter offline check: ok")
|
verify/intercom.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Verify reader for Intercom conversations. Stdlib only.
|
|
2
|
+
|
|
3
|
+
Env (never in code or chat):
|
|
4
|
+
INTERCOM_VERIFY_TOKEN access token from an Intercom app granted only "Read conversations" (Verify: read-only)
|
|
5
|
+
|
|
6
|
+
Checks a support agent's claim "conversation closed with a reply".
|
|
7
|
+
Docs:
|
|
8
|
+
https://developers.intercom.com/docs/references/rest-api/api.intercom.io/conversations/retrieveconversation
|
|
9
|
+
https://developers.intercom.com/docs/build-an-integration/learn-more/rest-apis/api-versioning
|
|
10
|
+
https://developers.intercom.com/docs/build-an-integration/learn-more/authentication
|
|
11
|
+
https://developers.intercom.com/docs/references/rest-api/errors/rate-limiting
|
|
12
|
+
"""
|
|
13
|
+
import json, os, tempfile, urllib.request, urllib.error
|
|
14
|
+
from .core import Blocked, Ledger, verify
|
|
15
|
+
|
|
16
|
+
API_VERSION = "2.17" # pinned; bump deliberately after reading the changelog
|
|
17
|
+
|
|
18
|
+
CONTRACT = {
|
|
19
|
+
"id": "conversation.closed_with_reply.intercom", "version": "1.0.0", "owner": "support",
|
|
20
|
+
"target_system": "intercom", "subject_ref": "conversation:{conversation}", "subject_present": "conversation.exists",
|
|
21
|
+
"preconditions": [],
|
|
22
|
+
"postconditions": [{"path": "conversation.state", "op": "eq", "value": "closed"},
|
|
23
|
+
{"path": "conversation.admin_replies", "op": "gt", "value": 0}],
|
|
24
|
+
"forbidden": [{"path": "conversation.snoozed", "op": "eq", "value": True}],
|
|
25
|
+
"required_level": "L2", "settle_window_s": 1.0, "max_attempts": 3,
|
|
26
|
+
"scope": ["conversation.exists", "conversation.state", "conversation.admin_replies", "conversation.snoozed"],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ic_http(token):
|
|
31
|
+
def http(method, path):
|
|
32
|
+
req = urllib.request.Request(f"https://api.intercom.io{path}", method=method, headers={
|
|
33
|
+
"Authorization": f"Bearer {token}", "Accept": "application/json", "Intercom-Version": API_VERSION})
|
|
34
|
+
try:
|
|
35
|
+
with urllib.request.urlopen(req, timeout=20) as r: return r.status, json.load(r)
|
|
36
|
+
except urllib.error.HTTPError as e:
|
|
37
|
+
if e.code == 404: return 404, {}
|
|
38
|
+
ra = e.headers.get("Retry-After")
|
|
39
|
+
raise Blocked(f"intercom HTTP {e.code}", retry_after=float(ra) if ra else None)
|
|
40
|
+
except urllib.error.URLError as e:
|
|
41
|
+
raise Blocked(f"intercom unreachable: {e.reason}")
|
|
42
|
+
return http
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def make_reader(http):
|
|
46
|
+
def reader(_unused, claim):
|
|
47
|
+
st, d = http("GET", f"/conversations/{claim['conversation']}?display_as=plaintext")
|
|
48
|
+
parts = d.get("conversation_parts", {}).get("conversation_parts", []) if st == 200 else []
|
|
49
|
+
replies = sum(1 for p in parts if p.get("part_type") == "comment"
|
|
50
|
+
and (p.get("author") or {}).get("type") in ("admin", "bot"))
|
|
51
|
+
raw = {"conversation.exists": st == 200, "conversation.state": d.get("state"),
|
|
52
|
+
"conversation.admin_replies": replies if st == 200 else None,
|
|
53
|
+
"conversation.snoozed": (d.get("state") == "snoozed" or bool(d.get("snoozed_until"))) if st == 200 else None}
|
|
54
|
+
return raw, json.dumps(raw, sort_keys=True)
|
|
55
|
+
return reader
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def fake_intercom(convs):
|
|
59
|
+
"""convs: id -> (state, snoozed_until, [(part_type, author_type), ...])"""
|
|
60
|
+
def http(method, path):
|
|
61
|
+
if method != "GET": raise Blocked("intercom HTTP 403: read-only token")
|
|
62
|
+
c = convs.get(path.split("/")[2].split("?")[0])
|
|
63
|
+
if c is None: return 404, {}
|
|
64
|
+
if c == "429": raise Blocked("intercom HTTP 429", retry_after=0)
|
|
65
|
+
return 200, {"state": c[0], "snoozed_until": c[1], "conversation_parts": {"conversation_parts": [
|
|
66
|
+
{"part_type": pt, "author": {"type": at}} for pt, at in c[2]]}}
|
|
67
|
+
return http
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check():
|
|
71
|
+
convs = {"1": ("closed", None, [("comment", "user"), ("comment", "admin"), ("close", "admin")]),
|
|
72
|
+
"2": ("open", None, [("comment", "admin")]),
|
|
73
|
+
"3": ("closed", None, [("comment", "user"), ("close", "admin"), ("note", "admin")]),
|
|
74
|
+
"4": ("closed", 1900000000, [("comment", "bot")]), "5": "429"}
|
|
75
|
+
c = dict(CONTRACT, settle_window_s=0, max_attempts=1)
|
|
76
|
+
led, reader = Ledger(os.path.join(tempfile.mkdtemp(), "l.db")), make_reader(fake_intercom(convs))
|
|
77
|
+
got = [verify(led, None, {"conversation": i}, c, reader)[1] for i in ("1", "2", "3", "4", "9", "5")]
|
|
78
|
+
assert got == ["pass", "fail", "fail", "fail", "not_observed", "blocked"], got
|
|
79
|
+
try: fake_intercom(convs)("POST", "/conversations/1/reply"); assert False
|
|
80
|
+
except Blocked: pass
|
|
81
|
+
assert led.verify_chain()
|
|
82
|
+
print("intercom adapter offline check: ok")
|
verify/local.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Local demo: SQLite stands in for Stripe + Zendesk. No accounts, no network."""
|
|
2
|
+
import sqlite3, json, os, uuid, shutil, tempfile
|
|
3
|
+
from .core import Ledger, Blocked, verify as _verify
|
|
4
|
+
|
|
5
|
+
# ---------------- system of record (stands in for Stripe + Zendesk) ----------------
|
|
6
|
+
def make_sor(path):
|
|
7
|
+
db = sqlite3.connect(path)
|
|
8
|
+
db.executescript("""
|
|
9
|
+
CREATE TABLE charge(id TEXT PRIMARY KEY, status TEXT, amount INT, amount_refunded INT DEFAULT 0);
|
|
10
|
+
CREATE TABLE refund(id TEXT PRIMARY KEY, charge_id TEXT, amount INT);
|
|
11
|
+
CREATE TABLE ticket(id TEXT PRIMARY KEY, charge_id TEXT, status TEXT);
|
|
12
|
+
CREATE TABLE comment(ticket_id TEXT, public INT, author TEXT);
|
|
13
|
+
INSERT INTO charge VALUES ('ch_1','succeeded',5000,0),('ch_2','succeeded',5000,0),('ch_3','succeeded',5000,0),('ch_4','failed',5000,0);
|
|
14
|
+
INSERT INTO ticket VALUES ('t_1','ch_1','open'),('t_2','ch_2','open'),('t_3','ch_3','open'),('t_4','ch_4','open');
|
|
15
|
+
""")
|
|
16
|
+
db.commit(); db.close()
|
|
17
|
+
|
|
18
|
+
# ---------------- the agent (with injected faults) ----------------
|
|
19
|
+
def agent_handle(sor_path, ticket, fault=None):
|
|
20
|
+
"""Writes with the agent's own read-write connection. Always *claims* success."""
|
|
21
|
+
db = sqlite3.connect(sor_path)
|
|
22
|
+
charge_id = db.execute("SELECT charge_id FROM ticket WHERE id=?", (ticket,)).fetchone()[0]
|
|
23
|
+
amount = db.execute("SELECT amount FROM charge WHERE id=?", (charge_id,)).fetchone()[0]
|
|
24
|
+
refund_id = "re_" + ticket[2:] + "_" + uuid.uuid4().hex[:4]
|
|
25
|
+
if fault != "timeout": # timeout: tool call died, nothing written
|
|
26
|
+
amt = amount // 2 if fault == "half" else amount
|
|
27
|
+
db.execute("INSERT INTO refund VALUES (?,?,?)", (refund_id, charge_id, amt))
|
|
28
|
+
db.execute("UPDATE charge SET amount_refunded=amount_refunded+? WHERE id=?", (amt, charge_id))
|
|
29
|
+
db.execute("UPDATE ticket SET status='solved' WHERE id=?", (ticket,))
|
|
30
|
+
db.execute("INSERT INTO comment VALUES (?,1,'agent')", (ticket,))
|
|
31
|
+
db.commit(); db.close()
|
|
32
|
+
return {"ticket": ticket, "charge_id": charge_id, "refund_id": refund_id, "amount": amount,
|
|
33
|
+
"agent_says": "Refund issued ✅"}
|
|
34
|
+
|
|
35
|
+
# ---------------- contract (doc 02, JSON form) ----------------
|
|
36
|
+
CONTRACT = {
|
|
37
|
+
"id": "refund.full.with_reply", "version": "1.0.0", "owner": "support-ops",
|
|
38
|
+
"target_system": "demo_sor", "subject_ref": "refund:{refund_id}",
|
|
39
|
+
"preconditions": [{"path": "charge.status", "op": "eq", "value": "succeeded"}],
|
|
40
|
+
"postconditions": [{"path": "refund.amount", "op": "eq", "value": "{amount}"},
|
|
41
|
+
{"path": "charge.amount_refunded", "op": "eq", "value": "{amount}"},
|
|
42
|
+
{"path": "ticket.status", "op": "eq", "value": "solved"},
|
|
43
|
+
{"path": "ticket.public_agent_replies", "op": "gt", "value": 0}],
|
|
44
|
+
"forbidden": [{"path": "charge.refund_count", "op": "gt", "value": 1}],
|
|
45
|
+
"required_level": "L3", "settle_window_s": 0.05, "max_attempts": 3,
|
|
46
|
+
"scope": ["charge.status", "charge.amount_refunded", "charge.refund_count", "refund.amount",
|
|
47
|
+
"ticket.status", "ticket.public_agent_replies"],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def readback(sor_path, claim):
|
|
51
|
+
"""Separate credential analogue: SQLite read-only URI. Returns only scoped fields."""
|
|
52
|
+
if not os.path.exists(sor_path): raise Blocked("system of record unreachable")
|
|
53
|
+
try:
|
|
54
|
+
db = sqlite3.connect(f"file:{sor_path}?mode=ro", uri=True)
|
|
55
|
+
c = db.execute("SELECT status, amount_refunded FROM charge WHERE id=?", (claim["charge_id"],)).fetchone()
|
|
56
|
+
n = db.execute("SELECT count(*) FROM refund WHERE charge_id=?", (claim["charge_id"],)).fetchone()[0]
|
|
57
|
+
r = db.execute("SELECT amount FROM refund WHERE id=?", (claim["refund_id"],)).fetchone()
|
|
58
|
+
t = db.execute("SELECT status FROM ticket WHERE id=?", (claim["ticket"],)).fetchone()
|
|
59
|
+
k = db.execute("SELECT count(*) FROM comment WHERE ticket_id=? AND public=1 AND author='agent'", (claim["ticket"],)).fetchone()[0]
|
|
60
|
+
db.close()
|
|
61
|
+
except sqlite3.Error as e:
|
|
62
|
+
raise Blocked(str(e))
|
|
63
|
+
raw = {"charge.status": c and c[0], "charge.amount_refunded": c and c[1], "charge.refund_count": n,
|
|
64
|
+
"refund.amount": r and r[0], "ticket.status": t and t[0], "ticket.public_agent_replies": k}
|
|
65
|
+
return {k: v for k, v in raw.items() if k in CONTRACT["scope"]}, json.dumps(raw, sort_keys=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def verify(led, sor, claim):
|
|
69
|
+
return _verify(led, sor, claim, CONTRACT, readback)
|
|
70
|
+
|
|
71
|
+
# ---------------- demo ----------------
|
|
72
|
+
def demo(out="demo_out"):
|
|
73
|
+
shutil.rmtree(out, ignore_errors=True); os.makedirs(out)
|
|
74
|
+
sor, led = os.path.join(out, "system_of_record.db"), Ledger(os.path.join(out, "ledger.db"))
|
|
75
|
+
make_sor(sor)
|
|
76
|
+
plan = [("t_1", None), ("t_2", "half"), ("t_3", "timeout")]
|
|
77
|
+
claims = [agent_handle(sor, t, f) for t, f in plan]
|
|
78
|
+
print("\n== What the agent says ==")
|
|
79
|
+
for c in claims: print(f" {c['ticket']}: {c['agent_says']}")
|
|
80
|
+
print(f" Agent success rate: {len(claims)}/{len(claims)}\n\n== What Verify observed in the system of record ==")
|
|
81
|
+
results = [verify(led, sor, c) for c in claims]
|
|
82
|
+
for c, (rid, v, lvl, why) in zip(claims, results):
|
|
83
|
+
print(f" {c['ticket']} {v.upper():13} {lvl} run={rid}" + (f"\n - " + "\n - ".join(why) if why else ""))
|
|
84
|
+
ok = sum(v == "pass" for _, v, *_ in results)
|
|
85
|
+
print(f"\n Verified success rate: {ok}/{len(results)}")
|
|
86
|
+
print(f" Ledger hash chain intact: {led.verify_chain()} ({out}/ledger.db)\n")
|
|
87
|
+
return results
|
|
88
|
+
|
|
89
|
+
def check():
|
|
90
|
+
d = tempfile.mkdtemp(); sor = os.path.join(d, "sor.db"); make_sor(sor); led = Ledger(os.path.join(d, "l.db"))
|
|
91
|
+
v = lambda c: verify(led, sor, c)[1]
|
|
92
|
+
assert v(agent_handle(sor, "t_1")) == "pass"
|
|
93
|
+
assert v(agent_handle(sor, "t_2", "half")) == "fail"
|
|
94
|
+
assert v(agent_handle(sor, "t_3", "timeout")) == "not_observed"
|
|
95
|
+
assert v(agent_handle(sor, "t_4")) == "inapplicable" # charge failed: refund should never run
|
|
96
|
+
assert verify(led, os.path.join(d, "gone.db"), {"ticket": "t_1", "charge_id": "ch_1", "refund_id": "re_1", "amount": 5000})[1] == "blocked"
|
|
97
|
+
dup = agent_handle(sor, "t_1") # second refund on same charge
|
|
98
|
+
assert v(dup) == "fail"
|
|
99
|
+
assert led.verify_chain()
|
|
100
|
+
led.db.execute("UPDATE event SET payload_json='{}' WHERE seq=1"); led.db.commit()
|
|
101
|
+
assert not led.verify_chain(), "tamper must be detected"
|
|
102
|
+
print("all 5 verdicts + duplicate refund + tamper detection: ok")
|
|
103
|
+
|