resume-guard 1.0.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TextForAgents LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: resume-guard
3
+ Version: 1.0.0
4
+ Summary: Resume-effect reconciliation for checkpointed agent runs: checkpoint-keyed effect ledger, read-only probe, Ed25519-signed resume verdicts.
5
+ Author: TextForAgents LLC
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: cryptography>=41
11
+ Dynamic: license-file
12
+
13
+ # resume_guard
14
+
15
+ Resume-effect reconciliation for checkpointed agent runs — by TextForAgents LLC.
16
+
17
+ Before a checkpointed agent run resumes, ask what already landed. An effect
18
+ ledger keyed to checkpoint identity, a read-only probe of the target system
19
+ when the ledger is silent, and an Ed25519-signed verdict — SAFE /
20
+ RESUME-WITH-SKIPS / DO-NOT-RESUME — so a resumed node never re-sends the paid
21
+ call, re-books the booking, or records an approval against work the human
22
+ never saw.
23
+
24
+ ## Quickstart
25
+
26
+ ```python
27
+ from resume_guard import EffectLedger, TargetSystem, Reconciler, GuardedEffects
28
+
29
+ ledger = EffectLedger("effects.db")
30
+ target = TargetSystem("target.json")
31
+ rec = Reconciler(ledger, {"paid_job": target})
32
+ guard = GuardedEffects(ledger, rec)
33
+
34
+ receipt, how = guard.run_effect("run-1", "ns-1", "book_and_pay",
35
+ "submit-job", "paid_job",
36
+ lambda eid: target.submit(eid, {"job": "gpu-job-1"}))
37
+ # kill the process here; on resume the same call returns ("...", "injected")
38
+ # and the effect function is never called again.
39
+ verdict = rec.verdict("run-1", "ns-1", "book_and_pay",
40
+ [{"effect_key": "submit-job", "kind": "paid_job"}])
41
+ assert verdict["verdict"]["state"] == "RESUME-WITH-SKIPS"
42
+ assert Reconciler.verify(verdict)
43
+ print("exactly one execution:", target.submit_calls == 1)
44
+ ```
45
+
46
+ Hosted service: Solo $9/mo per operator (first month free, one-time trial) ·
47
+ Team $29/mo · per-verdict API $0.005 (x402). Support: support@textforagents.com
@@ -0,0 +1,35 @@
1
+ # resume_guard
2
+
3
+ Resume-effect reconciliation for checkpointed agent runs — by TextForAgents LLC.
4
+
5
+ Before a checkpointed agent run resumes, ask what already landed. An effect
6
+ ledger keyed to checkpoint identity, a read-only probe of the target system
7
+ when the ledger is silent, and an Ed25519-signed verdict — SAFE /
8
+ RESUME-WITH-SKIPS / DO-NOT-RESUME — so a resumed node never re-sends the paid
9
+ call, re-books the booking, or records an approval against work the human
10
+ never saw.
11
+
12
+ ## Quickstart
13
+
14
+ ```python
15
+ from resume_guard import EffectLedger, TargetSystem, Reconciler, GuardedEffects
16
+
17
+ ledger = EffectLedger("effects.db")
18
+ target = TargetSystem("target.json")
19
+ rec = Reconciler(ledger, {"paid_job": target})
20
+ guard = GuardedEffects(ledger, rec)
21
+
22
+ receipt, how = guard.run_effect("run-1", "ns-1", "book_and_pay",
23
+ "submit-job", "paid_job",
24
+ lambda eid: target.submit(eid, {"job": "gpu-job-1"}))
25
+ # kill the process here; on resume the same call returns ("...", "injected")
26
+ # and the effect function is never called again.
27
+ verdict = rec.verdict("run-1", "ns-1", "book_and_pay",
28
+ [{"effect_key": "submit-job", "kind": "paid_job"}])
29
+ assert verdict["verdict"]["state"] == "RESUME-WITH-SKIPS"
30
+ assert Reconciler.verify(verdict)
31
+ print("exactly one execution:", target.submit_calls == 1)
32
+ ```
33
+
34
+ Hosted service: Solo $9/mo per operator (first month free, one-time trial) ·
35
+ Team $29/mo · per-verdict API $0.005 (x402). Support: support@textforagents.com
@@ -0,0 +1,16 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "resume-guard"
7
+ version = "1.0.0"
8
+ description = "Resume-effect reconciliation for checkpointed agent runs: checkpoint-keyed effect ledger, read-only probe, Ed25519-signed resume verdicts."
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.10"
12
+ dependencies = ["cryptography>=41"]
13
+ authors = [{name = "TextForAgents LLC"}]
14
+
15
+ [tool.setuptools.packages.find]
16
+ include = ["resume_guard*"]
@@ -0,0 +1,21 @@
1
+ """Resume-effect reconciliation (EXP-023, P23) — production build.
2
+
3
+ Effect ledger keyed to checkpoint identity + signed resume verdicts.
4
+ """
5
+ from .ledger import EffectLedger, derive_effect_id
6
+ from .target import TargetSystem, TargetUnreachable, TargetAmbiguous
7
+ from .reconciler import Reconciler, TIER_PROBE_CAPS
8
+ from .guard import GuardedEffects
9
+ from .audit import audit_journal
10
+ from .probes import IdempotencyProbe, DictStoreProbe, HttpReadOnlyProbe
11
+ from .adapters import LangGraphAdapter, CopilotJournalReader
12
+ from .keys import generate_key, load_key, pinned_set, key_id_for
13
+
14
+ __all__ = [
15
+ "EffectLedger", "derive_effect_id", "TargetSystem",
16
+ "TargetUnreachable", "TargetAmbiguous", "Reconciler",
17
+ "TIER_PROBE_CAPS", "GuardedEffects", "audit_journal",
18
+ "IdempotencyProbe", "DictStoreProbe", "HttpReadOnlyProbe",
19
+ "LangGraphAdapter", "CopilotJournalReader",
20
+ "generate_key", "load_key", "pinned_set", "key_id_for",
21
+ ]
@@ -0,0 +1,60 @@
1
+ """Per-framework adapters (packet 04 hardening item 2).
2
+
3
+ Adapters translate framework checkpoint shapes into the framework-
4
+ neutral core's (run_id, checkpoint_ns, node) identity. No framework
5
+ types and no framework verdict logic live in the core; adapters hold
6
+ only shape translation.
7
+ """
8
+ import json
9
+
10
+
11
+ class LangGraphAdapter:
12
+ """LangGraph checkpointer hook shape (proven by harness_langgraph.py).
13
+
14
+ LangGraph checkpoints carry config.configurable.thread_id and
15
+ checkpoint metadata; a checkpointer hook receives (config,
16
+ checkpoint) pairs. This adapter extracts the neutral identity
17
+ from those plain dict shapes — it imports nothing from langgraph.
18
+ """
19
+ def identity_from_config(self, config, node):
20
+ cfg = (config or {}).get("configurable", {})
21
+ run_id = cfg.get("thread_id") or cfg.get("run_id") or ""
22
+ ns = cfg.get("checkpoint_ns") or ""
23
+ if not run_id:
24
+ raise ValueError("langgraph config missing thread_id")
25
+ return run_id, ns, node
26
+
27
+ def identity_from_checkpoint(self, checkpoint, node):
28
+ ck = checkpoint or {}
29
+ run_id = ck.get("id") or ck.get("thread_id") or ""
30
+ ns = ck.get("checkpoint_ns") or ck.get("ns") or ""
31
+ if not run_id:
32
+ raise ValueError("checkpoint missing id")
33
+ return str(run_id), str(ns), node
34
+
35
+ def declared_from_checkpoint(self, checkpoint, node):
36
+ """Effects a node declared, read from checkpoint channel values."""
37
+ vals = (checkpoint or {}).get("channel_values", {}) or {}
38
+ raw = vals.get("declared_effects", [])
39
+ return [{"effect_key": e["effect_key"], "kind": e["kind"]}
40
+ for e in raw if e.get("node", node) == node]
41
+
42
+
43
+ class CopilotJournalReader:
44
+ """Copilot dynamic-workflow journal reader: parses a journal
45
+ (JSON-lines text, list of dicts, or file-like lines) into the
46
+ journal-completeness audit's [{node, effect_key, kind}] shape."""
47
+ def read(self, journal):
48
+ entries = []
49
+ if isinstance(journal, str):
50
+ lines = [ln for ln in journal.splitlines() if ln.strip()]
51
+ items = [json.loads(ln) for ln in lines]
52
+ else:
53
+ items = list(journal)
54
+ for it in items:
55
+ node = it.get("node") or it.get("step") or it.get("name")
56
+ key = it.get("effect_key") or it.get("key") or it.get("idempotency_key")
57
+ kind = it.get("kind") or it.get("effect_kind") or "paid_job"
58
+ if node and key:
59
+ entries.append({"node": node, "effect_key": key, "kind": kind})
60
+ return entries
@@ -0,0 +1,68 @@
1
+ """Journal-completeness audit — the crash-side inverse (CARD-022
2
+ evidence 2): a runtime refused resume because the run never settled
3
+ at a controlled checkpoint, even though its journal held complete
4
+ records of the steps that finished.
5
+
6
+ The audit certifies each journaled step independently: a step is
7
+ certified iff its effect is proven landed by the ledger or by a
8
+ target probe. A signed certificate is issued only when EVERY step
9
+ certifies; otherwise the report names the uncertified steps and no
10
+ certificate is issued (partial credit is reported, never certified).
11
+ """
12
+ import json
13
+ import time
14
+
15
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
16
+ Ed25519PrivateKey)
17
+
18
+
19
+ def _canonical(obj):
20
+ return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
21
+
22
+
23
+ def audit_journal(ledger, targets, run_id, checkpoint_ns, journal,
24
+ signing_key=None):
25
+ """journal: [{node, effect_key, kind}] — steps the killed run's
26
+ journal claims completed. Returns a report dict; 'certificate'
27
+ is present only on full certification."""
28
+ key = signing_key or Ed25519PrivateKey.generate()
29
+ steps = []
30
+ all_ok = True
31
+ for entry in journal:
32
+ eid_row = ledger.get_by_identity(run_id, checkpoint_ns,
33
+ entry["node"],
34
+ entry["effect_key"])
35
+ status = "uncertified"
36
+ evidence = None
37
+ if eid_row and eid_row["status"] == "LANDED":
38
+ status, evidence = "certified", "ledger:landed"
39
+ else:
40
+ target = targets.get(entry["kind"])
41
+ if target is not None:
42
+ try:
43
+ from .ledger import derive_effect_id
44
+ eid = derive_effect_id(run_id, checkpoint_ns,
45
+ entry["node"],
46
+ entry["effect_key"])
47
+ if target.lookup(eid) is not None:
48
+ status, evidence = "certified", "probe:landed"
49
+ except Exception as e:
50
+ evidence = "probe_failed: %s" % e
51
+ if evidence is None:
52
+ evidence = "no evidence of landing"
53
+ if status != "certified":
54
+ all_ok = False
55
+ steps.append({"node": entry["node"],
56
+ "effect_key": entry["effect_key"],
57
+ "status": status, "evidence": evidence})
58
+ report = {"run_id": run_id, "checkpoint_ns": checkpoint_ns,
59
+ "audited": time.time(), "steps": steps,
60
+ "certified": all_ok}
61
+ if all_ok:
62
+ sig = key.sign(_canonical(report))
63
+ from .keys import key_id_for
64
+ pub = key.public_key().public_bytes_raw()
65
+ report["certificate"] = {
66
+ "signature": sig.hex(), "key_id": key_id_for(pub),
67
+ "public_key": pub.hex()}
68
+ return report
@@ -0,0 +1,56 @@
1
+ """Wrapper SDK: the code a node actually calls.
2
+
3
+ run_effect() is the whole integration surface:
4
+ - First execution: claim INTENT (durable) -> execute -> record
5
+ LANDED (durable) -> return the fresh result.
6
+ - On resume (node re-executes from its first line, per LangGraph
7
+ semantics): the ledger already holds LANDED, so the recorded
8
+ result is injected and the effect function is NEVER called again.
9
+ - Concurrent duplicate resume: the atomic intent claim means only
10
+ one caller executes; the other reconciles against the ledger /
11
+ probe instead of executing.
12
+ """
13
+ import time
14
+
15
+ from .ledger import derive_effect_id
16
+
17
+
18
+ class EffectAlreadyInFlight(Exception):
19
+ pass
20
+
21
+
22
+ class GuardedEffects:
23
+ def __init__(self, ledger, reconciler=None):
24
+ self.ledger = ledger
25
+ self.reconciler = reconciler
26
+
27
+ def run_effect(self, run_id, checkpoint_ns, node, effect_key, kind,
28
+ fn, wait_seconds=5.0):
29
+ eid = derive_effect_id(run_id, checkpoint_ns, node, effect_key)
30
+ row = self.ledger.get(eid)
31
+ if row and row["status"] == "LANDED":
32
+ return row["result"], "injected"
33
+ eid2, existing = self.ledger.claim_intent(
34
+ run_id, checkpoint_ns, node, effect_key, kind)
35
+ if existing is not None:
36
+ if existing["status"] == "LANDED":
37
+ return existing["result"], "injected"
38
+ # Another caller holds INTENT. Two cases look identical
39
+ # here — a LIVE concurrent resume mid-execution, and a
40
+ # crash between intent and landed — and they demand
41
+ # opposite handling, so the guard never executes on
42
+ # probe-absence (audit fix, EXP-023 session 1: a
43
+ # probe-takeover path let 6 of 8 concurrent resumes
44
+ # execute). Wait for the live holder to land; if nobody
45
+ # lands it, raise — crash recovery is the reconciler's
46
+ # verdict decision, not a guard takeover.
47
+ deadline = time.time() + wait_seconds
48
+ while time.time() < deadline:
49
+ row = self.ledger.get(eid)
50
+ if row and row["status"] == "LANDED":
51
+ return row["result"], "injected"
52
+ time.sleep(0.01)
53
+ raise EffectAlreadyInFlight(eid)
54
+ result = fn(eid)
55
+ self.ledger.record_landed(eid, result)
56
+ return result, "executed"
@@ -0,0 +1,40 @@
1
+ """Production signing-key management (packet 04 hardening item 1).
2
+
3
+ The production Ed25519 private key is generated on the droplet and
4
+ held ONLY in the product's own env/secrets file (mode 0600) as
5
+ RESUME_GUARD_SIGNING_KEY_HEX, together with RESUME_GUARD_KEY_ID.
6
+ Rotation: generate a new key, move the old {key_id: public_key_hex}
7
+ into RESUME_GUARD_PREVIOUS_KEYS (JSON), and operators pin by key_id —
8
+ verdicts signed under a retired key-id still verify against the
9
+ pinned set, and a key that silently changes fails verification
10
+ because the pinned public key will not match.
11
+ """
12
+ import hashlib
13
+ import json
14
+
15
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
16
+ Ed25519PrivateKey)
17
+
18
+
19
+ def key_id_for(public_bytes):
20
+ return "ed25519-" + hashlib.sha256(public_bytes).hexdigest()[:16]
21
+
22
+
23
+ def generate_key():
24
+ key = Ed25519PrivateKey.generate()
25
+ pub = key.public_key().public_bytes_raw()
26
+ return {"private_key_hex": key.private_bytes_raw().hex(),
27
+ "public_key_hex": pub.hex(), "key_id": key_id_for(pub)}
28
+
29
+
30
+ def load_key(private_key_hex):
31
+ key = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(private_key_hex))
32
+ pub = key.public_key().public_bytes_raw()
33
+ return key, key_id_for(pub), pub.hex()
34
+
35
+
36
+ def pinned_set(current_key_id, current_public_hex, previous_json=""):
37
+ pinned = {current_key_id: current_public_hex}
38
+ if previous_json:
39
+ pinned.update(json.loads(previous_json))
40
+ return pinned
@@ -0,0 +1,147 @@
1
+ """Effect ledger keyed to checkpoint identity.
2
+
3
+ Durability is the point: the framework default (async checkpoint
4
+ writes) is exactly what loses the record that a paid call happened
5
+ (CARD-022, 3/3 in the repro). This ledger uses sqlite WAL with
6
+ synchronous=FULL, and every state transition is committed before the
7
+ caller proceeds — INTENT is durable *before* the effect executes,
8
+ LANDED is durable before the node continues.
9
+
10
+ Concurrency shape (audit fix, EXP-023 session 1): resumes happen in
11
+ DIFFERENT processes in real deployments, so the database file — not
12
+ an in-process object — is the shared state. Every operation opens
13
+ its own connection (WAL + busy_timeout serializes writers at the DB
14
+ level), and the effects PRIMARY KEY is the atomic claim. The first
15
+ implementation shared one connection across threads; the stress
16
+ harness showed statement-level crosstalk on the shared connection
17
+ (InterfaceError, and a loser's connection-wide rollback() deleting
18
+ the winner's uncommitted INTENT — 86/200 trials double-executed).
19
+ Per-operation connections: 0/200.
20
+ """
21
+ import hashlib
22
+ import json
23
+ import sqlite3
24
+ import time
25
+
26
+
27
+ def derive_effect_id(run_id, checkpoint_ns, node, effect_key):
28
+ raw = "|".join([run_id, checkpoint_ns, node, effect_key])
29
+ return hashlib.sha256(raw.encode()).hexdigest()
30
+
31
+
32
+ class LedgerCorrupt(Exception):
33
+ pass
34
+
35
+
36
+ class EffectLedger:
37
+ def __init__(self, path):
38
+ self.path = path
39
+ try:
40
+ db = self._connect()
41
+ try:
42
+ db.execute(
43
+ "CREATE TABLE IF NOT EXISTS effects ("
44
+ " effect_id TEXT PRIMARY KEY,"
45
+ " run_id TEXT, checkpoint_ns TEXT, node TEXT,"
46
+ " effect_key TEXT, kind TEXT, status TEXT,"
47
+ " result_json TEXT, source TEXT, updated REAL)")
48
+ db.execute(
49
+ "CREATE TABLE IF NOT EXISTS checkpoints ("
50
+ " run_id TEXT, checkpoint_ns TEXT,"
51
+ " discovery_pin TEXT, updated REAL,"
52
+ " PRIMARY KEY (run_id, checkpoint_ns))")
53
+ db.commit()
54
+ finally:
55
+ db.close()
56
+ except sqlite3.DatabaseError as e:
57
+ raise LedgerCorrupt(str(e)) from e
58
+
59
+ def _connect(self):
60
+ db = sqlite3.connect(self.path, timeout=10.0)
61
+ db.execute("PRAGMA journal_mode=WAL")
62
+ db.execute("PRAGMA synchronous=FULL")
63
+ db.execute("PRAGMA busy_timeout=10000")
64
+ return db
65
+
66
+ def close(self):
67
+ pass # per-operation connections; nothing held open
68
+
69
+ # -- checkpoint discovery pin (EXCHANGE-034) ----------------------
70
+ def pin_discovery(self, run_id, checkpoint_ns, discovery_pin):
71
+ db = self._connect()
72
+ try:
73
+ db.execute(
74
+ "INSERT OR REPLACE INTO checkpoints VALUES (?,?,?,?)",
75
+ (run_id, checkpoint_ns, discovery_pin, time.time()))
76
+ db.commit()
77
+ finally:
78
+ db.close()
79
+
80
+ def discovery_pin(self, run_id, checkpoint_ns):
81
+ db = self._connect()
82
+ try:
83
+ cur = db.execute(
84
+ "SELECT discovery_pin FROM checkpoints WHERE run_id=? AND checkpoint_ns=?",
85
+ (run_id, checkpoint_ns))
86
+ row = cur.fetchone()
87
+ return row[0] if row else None
88
+ finally:
89
+ db.close()
90
+
91
+ # -- effect lifecycle ----------------------------------------------
92
+ def claim_intent(self, run_id, checkpoint_ns, node, effect_key, kind):
93
+ """Atomically claim the right to execute this effect.
94
+
95
+ Returns (effect_id, existing_row_or_None). The PRIMARY KEY
96
+ insert on a fresh connection IS the claim: exactly one
97
+ caller (thread or process) wins it; everyone else gets the
98
+ existing row and must NOT execute.
99
+ """
100
+ eid = derive_effect_id(run_id, checkpoint_ns, node, effect_key)
101
+ db = self._connect()
102
+ try:
103
+ try:
104
+ db.execute(
105
+ "INSERT INTO effects VALUES (?,?,?,?,?,?, 'INTENT', NULL, 'ledger', ?)",
106
+ (eid, run_id, checkpoint_ns, node, effect_key, kind,
107
+ time.time()))
108
+ db.commit()
109
+ return eid, None
110
+ except sqlite3.IntegrityError:
111
+ db.rollback()
112
+ finally:
113
+ db.close()
114
+ return eid, self.get(eid)
115
+
116
+ def record_landed(self, effect_id, result, source="ledger"):
117
+ db = self._connect()
118
+ try:
119
+ db.execute(
120
+ "UPDATE effects SET status='LANDED', result_json=?, source=?, updated=? WHERE effect_id=?",
121
+ (json.dumps(result, sort_keys=True), source, time.time(),
122
+ effect_id))
123
+ db.commit()
124
+ finally:
125
+ db.close()
126
+
127
+ def get(self, effect_id):
128
+ db = self._connect()
129
+ try:
130
+ cur = db.execute(
131
+ "SELECT effect_id, run_id, checkpoint_ns, node, effect_key,"
132
+ " kind, status, result_json, source FROM effects WHERE effect_id=?",
133
+ (effect_id,))
134
+ row = cur.fetchone()
135
+ finally:
136
+ db.close()
137
+ if row is None:
138
+ return None
139
+ return {"effect_id": row[0], "run_id": row[1],
140
+ "checkpoint_ns": row[2], "node": row[3],
141
+ "effect_key": row[4], "kind": row[5], "status": row[6],
142
+ "result": json.loads(row[7]) if row[7] else None,
143
+ "source": row[8]}
144
+
145
+ def get_by_identity(self, run_id, checkpoint_ns, node, effect_key):
146
+ return self.get(derive_effect_id(run_id, checkpoint_ns, node,
147
+ effect_key))
@@ -0,0 +1,59 @@
1
+ """Read-only probe framework (packet 04 hardening item 3).
2
+
3
+ A probe can ONLY look up an effect by idempotency key. By
4
+ construction there is no submit/execute/run method on any probe
5
+ class, so a probe can never cause an effect (lab invariant: 50
6
+ probes -> 0 submissions). Effect classes whose only lookup is not
7
+ read-only get NO probe here; the reconciler holds those as
8
+ DO-NOT-RESUME on a silent ledger.
9
+ """
10
+ import json
11
+ import urllib.request
12
+
13
+ from .target import TargetUnreachable, TargetAmbiguous
14
+
15
+
16
+ class IdempotencyProbe:
17
+ """Base probe: subclasses implement _fetch(key) -> receipt|None.
18
+ The public surface is lookup() only."""
19
+ def lookup(self, idempotency_key):
20
+ return self._fetch(idempotency_key)
21
+
22
+ def _fetch(self, idempotency_key): # pragma: no cover
23
+ raise NotImplementedError
24
+
25
+
26
+ class DictStoreProbe(IdempotencyProbe):
27
+ """Probe over a durable JSON store keyed by idempotency key —
28
+ the generic shape for booking/charge/submission status stores."""
29
+ def __init__(self, store):
30
+ self._store = store # mapping, treated read-only
31
+
32
+ def _fetch(self, idempotency_key):
33
+ rec = self._store.get(idempotency_key)
34
+ return dict(rec) if rec else None
35
+
36
+
37
+ class HttpReadOnlyProbe(IdempotencyProbe):
38
+ """Probe that performs an HTTP GET ONLY (urllib with an explicit
39
+ GET; no other method exists in this class). Non-200 -> unreachable,
40
+ 409/ambiguous payload -> ambiguous: both fail closed upstream."""
41
+ def __init__(self, base_url, timeout=5.0):
42
+ self.base_url = base_url.rstrip("/")
43
+ self.timeout = timeout
44
+
45
+ def _fetch(self, idempotency_key):
46
+ url = self.base_url + "/" + idempotency_key
47
+ req = urllib.request.Request(url, method="GET")
48
+ try:
49
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
50
+ data = json.loads(r.read().decode())
51
+ except urllib.error.HTTPError as e:
52
+ if e.code == 404:
53
+ return None
54
+ raise TargetUnreachable("probe HTTP %s" % e.code) from e
55
+ except Exception as e:
56
+ raise TargetUnreachable(str(e)) from e
57
+ if isinstance(data, dict) and data.get("ambiguous"):
58
+ raise TargetAmbiguous("target cannot confirm or deny")
59
+ return data
@@ -0,0 +1,194 @@
1
+ """The reconciler: before a node re-executes, decide whether it may.
2
+
3
+ Verdict states:
4
+ SAFE — no declared effect landed; re-execution is safe.
5
+ RESUME-WITH-SKIPS— some effects already landed; inject their recorded
6
+ results instead of re-running them.
7
+ DO-NOT-RESUME — ambiguous effect state, probe failure, discovery
8
+ drift, or corrupt ledger; a human gets the
9
+ evidence packet.
10
+
11
+ Evidence order per effect: (1) the effect ledger; (2) if the ledger
12
+ is silent or holds only INTENT, a probe of the target system keyed by
13
+ the effect's idempotency key (EXCHANGE-031). A probe that finds the
14
+ effect backfills the ledger (source='probe') so the next resume is
15
+ ledger-cheap. Probing never executes anything.
16
+
17
+ Precondition (EXCHANGE-034): if the checkpoint carries a discovery
18
+ pin and the current discovery digest differs, any verdict escalates
19
+ to DO-NOT-RESUME — the recorded effects were produced under different
20
+ instructions than the ones now framing the agent.
21
+
22
+ Verdicts are Ed25519-signed over their canonical JSON; verify()
23
+ rejects any tampering.
24
+ """
25
+ import json
26
+ import time
27
+
28
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
29
+ Ed25519PrivateKey)
30
+ from cryptography.exceptions import InvalidSignature
31
+
32
+ from .ledger import derive_effect_id, LedgerCorrupt
33
+ from .target import TargetUnreachable, TargetAmbiguous
34
+
35
+
36
+ def _canonical(obj):
37
+ return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
38
+
39
+
40
+ TIER_PROBE_CAPS = {"solo": 20, "team": 100}
41
+
42
+
43
+ def key_id_for(public_bytes):
44
+ import hashlib
45
+ return "ed25519-" + hashlib.sha256(public_bytes).hexdigest()[:16]
46
+
47
+
48
+ class Reconciler:
49
+ def __init__(self, ledger, targets, signing_key=None, key_id=None):
50
+ """targets: {kind: TargetSystem-like with .lookup(key)}"""
51
+ self.ledger = ledger
52
+ self.targets = targets
53
+ self.key = signing_key or Ed25519PrivateKey.generate()
54
+ self.key_id = key_id or key_id_for(
55
+ self.key.public_key().public_bytes_raw())
56
+
57
+ # ---------------------------------------------------------------
58
+ def verdict(self, run_id, checkpoint_ns, node, declared_effects,
59
+ current_discovery_digest=None, tier=None, probe_cap=None):
60
+ """declared_effects: [{effect_key, kind}] for the node about
61
+ to re-execute."""
62
+ body = {"run_id": run_id, "checkpoint_ns": checkpoint_ns,
63
+ "node": node, "issued": time.time(), "effects": [],
64
+ "state": None, "reasons": []}
65
+ try:
66
+ pin = self.ledger.discovery_pin(run_id, checkpoint_ns)
67
+ except Exception as e: # corrupt ledger: fail closed
68
+ body["state"] = "DO-NOT-RESUME"
69
+ body["reasons"].append("ledger_unreadable: %s" % e)
70
+ return self._sign(body)
71
+ if pin is not None and current_discovery_digest is not None \
72
+ and pin != current_discovery_digest:
73
+ body["state"] = "DO-NOT-RESUME"
74
+ body["reasons"].append("discovery_drift: checkpoint pin "
75
+ "does not match current discovery "
76
+ "digest (EXCHANGE-034)")
77
+ for eff in declared_effects:
78
+ body["effects"].append(
79
+ {"effect_key": eff["effect_key"], "action": "hold"})
80
+ return self._sign(body)
81
+
82
+ # Flat-tier probe caps (packet 03/04): ledger hits are free;
83
+ # only effects needing a probe count against the cap. Effects
84
+ # beyond the cap are NOT probed: they are named as
85
+ # DO-NOT-RESUME evidence (fail-closed, exactly as lab-designed).
86
+ if probe_cap is None and tier is not None:
87
+ probe_cap = TIER_PROBE_CAPS.get(str(tier).lower())
88
+ probes_used = [0]
89
+
90
+ def _one(eff):
91
+ eid = derive_effect_id(run_id, checkpoint_ns, node,
92
+ eff["effect_key"])
93
+ try:
94
+ row = self.ledger.get(eid)
95
+ except Exception:
96
+ row = None
97
+ needs_probe = not (row and row["status"] == "LANDED")
98
+ if needs_probe and probe_cap is not None:
99
+ if probes_used[0] >= probe_cap:
100
+ return {"effect_key": eff["effect_key"],
101
+ "kind": eff["kind"], "effect_id": eid,
102
+ "action": "hold",
103
+ "evidence": "probe_cap_exceeded: unprobed "
104
+ "remainder is DO-NOT-RESUME evidence "
105
+ "(cap=%d)" % probe_cap}
106
+ probes_used[0] += 1
107
+ return self._reconcile_one(run_id, checkpoint_ns, node, eff)
108
+
109
+ any_skip = False
110
+ any_ambiguous = False
111
+ for eff in declared_effects:
112
+ entry = _one(eff)
113
+ body["effects"].append(entry)
114
+ if entry["action"] == "skip":
115
+ any_skip = True
116
+ elif entry["action"] == "hold":
117
+ any_ambiguous = True
118
+ if any_ambiguous:
119
+ body["state"] = "DO-NOT-RESUME"
120
+ body["reasons"].append("ambiguous effect state — see "
121
+ "per-effect evidence")
122
+ elif any_skip:
123
+ body["state"] = "RESUME-WITH-SKIPS"
124
+ else:
125
+ body["state"] = "SAFE"
126
+ return self._sign(body)
127
+
128
+ def _reconcile_one(self, run_id, checkpoint_ns, node, eff):
129
+ key = eff["effect_key"]
130
+ kind = eff["kind"]
131
+ eid = derive_effect_id(run_id, checkpoint_ns, node, key)
132
+ out = {"effect_key": key, "kind": kind, "effect_id": eid}
133
+ try:
134
+ row = self.ledger.get(eid)
135
+ except Exception as e:
136
+ out.update(action="hold", evidence="ledger_unreadable: %s" % e)
137
+ return out
138
+ if row and row["status"] == "LANDED":
139
+ out.update(action="skip", evidence="ledger:landed",
140
+ result=row["result"])
141
+ return out
142
+ # Ledger silent or INTENT-only: probe the target (EXCHANGE-031).
143
+ target = self.targets.get(kind)
144
+ if target is None:
145
+ out.update(action="hold",
146
+ evidence="no probe surface for kind %s" % kind)
147
+ return out
148
+ try:
149
+ receipt = target.lookup(eid)
150
+ except (TargetUnreachable, TargetAmbiguous) as e:
151
+ out.update(action="hold", evidence="probe_failed: %s" % e)
152
+ return out
153
+ if receipt is not None:
154
+ # Ground truth: it landed. Backfill so the next verdict
155
+ # is ledger-cheap, then skip with the probed result.
156
+ if row is None:
157
+ self.ledger.claim_intent(run_id, checkpoint_ns, node,
158
+ key, kind)
159
+ self.ledger.record_landed(eid, receipt, source="probe")
160
+ out.update(action="skip", evidence="probe:landed",
161
+ result=receipt)
162
+ return out
163
+ out.update(action="rerun", evidence="probe:absent")
164
+ return out
165
+
166
+ # ---------------------------------------------------------------
167
+ def _sign(self, body):
168
+ sig = self.key.sign(_canonical(body))
169
+ return {"verdict": body, "signature": sig.hex(), "key_id": self.key_id,
170
+ "public_key": self.key.public_key().public_bytes_raw().hex()}
171
+
172
+ @staticmethod
173
+ def verify(signed, pinned=None):
174
+ """Verify a signed verdict. If `pinned` is given ({key_id:
175
+ public_key_hex} — the operator's pinned key set, which is how
176
+ rotation is handled: old key-ids stay verifiable), the
177
+ verdict's key_id must be in the set and its public_key must
178
+ match the pinned value."""
179
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
180
+ Ed25519PublicKey)
181
+ try:
182
+ if pinned is not None:
183
+ kid = signed.get("key_id")
184
+ if kid not in pinned:
185
+ return False
186
+ if pinned[kid] != signed["public_key"]:
187
+ return False
188
+ pub = Ed25519PublicKey.from_public_bytes(
189
+ bytes.fromhex(signed["public_key"]))
190
+ pub.verify(bytes.fromhex(signed["signature"]),
191
+ _canonical(signed["verdict"]))
192
+ return True
193
+ except (InvalidSignature, Exception):
194
+ return False
@@ -0,0 +1,71 @@
1
+ """Durable test-double target system (paid job submission API shape).
2
+
3
+ Stands in for the external systems agent nodes act on (submit a paid
4
+ job, book, charge). It is a *double*, not a mock of our own code: it
5
+ keeps its own durable store, dedupes submissions by idempotency key
6
+ the way a well-behaved provider API does, and exposes a read-only
7
+ lookup by idempotency key — the probe surface EXCHANGE-031 requires.
8
+ Fault modes (unreachable / ambiguous) are switchable for adversarial
9
+ tests. Nothing here is a production system.
10
+ """
11
+ import json
12
+ import os
13
+ import threading
14
+
15
+
16
+ class TargetUnreachable(Exception):
17
+ pass
18
+
19
+
20
+ class TargetAmbiguous(Exception):
21
+ pass
22
+
23
+
24
+ class TargetSystem:
25
+ def __init__(self, path):
26
+ self.path = path
27
+ self._lock = threading.Lock()
28
+ self.mode = "ok" # ok | unreachable | ambiguous
29
+ self.submit_calls = 0
30
+ self.probe_calls = 0
31
+ self._store = {}
32
+ if os.path.exists(path):
33
+ with open(path) as f:
34
+ self._store = json.load(f)
35
+
36
+ def _persist(self):
37
+ tmp = self.path + ".tmp"
38
+ with open(tmp, "w") as f:
39
+ json.dump(self._store, f)
40
+ f.flush()
41
+ os.fsync(f.fileno())
42
+ os.replace(tmp, self.path)
43
+
44
+ def submit(self, idempotency_key, payload):
45
+ """A side effect. Deduped by idempotency key at the provider,
46
+ but every raw call is counted so tests can detect a guard
47
+ layer that failed to prevent re-execution."""
48
+ with self._lock:
49
+ if self.mode == "unreachable":
50
+ raise TargetUnreachable("target unreachable")
51
+ self.submit_calls += 1
52
+ if idempotency_key in self._store:
53
+ return dict(self._store[idempotency_key])
54
+ receipt = {"receipt_id": "rcpt-" + idempotency_key[:12],
55
+ "idempotency_key": idempotency_key,
56
+ "payload": payload, "status": "submitted"}
57
+ self._store[idempotency_key] = receipt
58
+ self._persist()
59
+ return dict(receipt)
60
+
61
+ def lookup(self, idempotency_key):
62
+ """The probe: read-only. Returns the receipt, None if the
63
+ effect never landed, raises on unreachable/ambiguous."""
64
+ with self._lock:
65
+ if self.mode == "unreachable":
66
+ raise TargetUnreachable("target unreachable")
67
+ if self.mode == "ambiguous":
68
+ raise TargetAmbiguous("target cannot confirm or deny")
69
+ self.probe_calls += 1
70
+ rec = self._store.get(idempotency_key)
71
+ return dict(rec) if rec else None
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: resume-guard
3
+ Version: 1.0.0
4
+ Summary: Resume-effect reconciliation for checkpointed agent runs: checkpoint-keyed effect ledger, read-only probe, Ed25519-signed resume verdicts.
5
+ Author: TextForAgents LLC
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: cryptography>=41
11
+ Dynamic: license-file
12
+
13
+ # resume_guard
14
+
15
+ Resume-effect reconciliation for checkpointed agent runs — by TextForAgents LLC.
16
+
17
+ Before a checkpointed agent run resumes, ask what already landed. An effect
18
+ ledger keyed to checkpoint identity, a read-only probe of the target system
19
+ when the ledger is silent, and an Ed25519-signed verdict — SAFE /
20
+ RESUME-WITH-SKIPS / DO-NOT-RESUME — so a resumed node never re-sends the paid
21
+ call, re-books the booking, or records an approval against work the human
22
+ never saw.
23
+
24
+ ## Quickstart
25
+
26
+ ```python
27
+ from resume_guard import EffectLedger, TargetSystem, Reconciler, GuardedEffects
28
+
29
+ ledger = EffectLedger("effects.db")
30
+ target = TargetSystem("target.json")
31
+ rec = Reconciler(ledger, {"paid_job": target})
32
+ guard = GuardedEffects(ledger, rec)
33
+
34
+ receipt, how = guard.run_effect("run-1", "ns-1", "book_and_pay",
35
+ "submit-job", "paid_job",
36
+ lambda eid: target.submit(eid, {"job": "gpu-job-1"}))
37
+ # kill the process here; on resume the same call returns ("...", "injected")
38
+ # and the effect function is never called again.
39
+ verdict = rec.verdict("run-1", "ns-1", "book_and_pay",
40
+ [{"effect_key": "submit-job", "kind": "paid_job"}])
41
+ assert verdict["verdict"]["state"] == "RESUME-WITH-SKIPS"
42
+ assert Reconciler.verify(verdict)
43
+ print("exactly one execution:", target.submit_calls == 1)
44
+ ```
45
+
46
+ Hosted service: Solo $9/mo per operator (first month free, one-time trial) ·
47
+ Team $29/mo · per-verdict API $0.005 (x402). Support: support@textforagents.com
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.py
5
+ resume_guard/__init__.py
6
+ resume_guard/adapters.py
7
+ resume_guard/audit.py
8
+ resume_guard/guard.py
9
+ resume_guard/keys.py
10
+ resume_guard/ledger.py
11
+ resume_guard/probes.py
12
+ resume_guard/reconciler.py
13
+ resume_guard/target.py
14
+ resume_guard.egg-info/PKG-INFO
15
+ resume_guard.egg-info/SOURCES.txt
16
+ resume_guard.egg-info/dependency_links.txt
17
+ resume_guard.egg-info/requires.txt
18
+ resume_guard.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ cryptography>=41
@@ -0,0 +1 @@
1
+ resume_guard
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ from setuptools import setup; setup()