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 ADDED
@@ -0,0 +1,5 @@
1
+ """Verify: never trust an agent's own "done". Read the system of record back."""
2
+ from .core import Blocked, Ledger, check, evaluate, verify
3
+
4
+ __all__ = ["Blocked", "Ledger", "check", "evaluate", "verify"]
5
+ __version__ = "0.4.1"
verify/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ import sys
2
+ from .cli import main
3
+ sys.exit(main())
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
+