honestcode 0.5.0__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.
@@ -0,0 +1,777 @@
1
+ """The side-effect guard: the crash-safe wrapper around an agent's tool calls.
2
+
3
+ Three defects in the earlier implementation this derives from are fixed here
4
+ rather than carried over, and each is called out at the site where it matters:
5
+
6
+ 1. **The idempotency key was computed and discarded.** The earlier code wrote
7
+ ``let _idempotency_key = ...`` and then compared raw ``request_hash``
8
+ values in memory. Here the key is derived, persisted, and enforced by a
9
+ ``UNIQUE`` constraint, so it survives a restart — which is the only
10
+ situation in which idempotency is worth anything.
11
+ 2. **Failure was recorded as success.** It set ``status = Committed``,
12
+ *then* checked ``if !result.success { status = Failed }``, so a throwing
13
+ executor left a committed record and the next attempt served a receipt for
14
+ work that never happened. The order is inverted here.
15
+ 3. **The state machine had no gap between "not started" and "done".** This
16
+ module adds ``EXECUTING`` and commits it *before* the outside world is
17
+ touched, so a crash mid-call leaves evidence that the outcome is unknown
18
+ instead of evidence that nothing happened.
19
+
20
+ The public surface is two calls: :meth:`SideEffectGuard.run` for the common
21
+ case, and :meth:`SideEffectGuard.recover` after a restart.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ import json
28
+ import uuid
29
+ from contextlib import suppress
30
+ from dataclasses import dataclass
31
+ from typing import TYPE_CHECKING, Any, TypeVar
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Awaitable, Callable, Mapping
35
+
36
+ from honestcode.guards.store import (
37
+ COMMITTED,
38
+ COMPENSATED,
39
+ EXECUTING,
40
+ FAILED,
41
+ PENDING,
42
+ EffectRecord,
43
+ EffectStatus,
44
+ EffectStore,
45
+ StoreError,
46
+ )
47
+ from honestcode.guards.taxonomy import EffectClass, RecoveryStrategy, classify
48
+
49
+ __all__ = [
50
+ "ExecutionOutcome",
51
+ "GuardError",
52
+ "PreconditionFailed",
53
+ "EffectNotRunnable",
54
+ "RecoveryDecision",
55
+ "SideEffectGuard",
56
+ "durable_scope",
57
+ "precondition",
58
+ "strategy_for",
59
+ ]
60
+
61
+ T = TypeVar("T")
62
+
63
+
64
+ # ── errors ──────────────────────────────────────────────────────────────────
65
+
66
+
67
+ class GuardError(RuntimeError):
68
+ """Base class for every refusal this module makes."""
69
+
70
+
71
+ class PreconditionFailed(GuardError):
72
+ """A caller-supplied precondition refused the effect before it ran."""
73
+
74
+ def __init__(self, reason: str, *, precondition: str = "") -> None:
75
+ self.reason = reason
76
+ self.precondition = precondition
77
+ detail = f"precondition failed: {reason}" if reason else "precondition failed"
78
+ if precondition:
79
+ detail += f" ({precondition})"
80
+ super().__init__(detail)
81
+
82
+
83
+ class EffectNotRunnable(GuardError):
84
+ """The effect cannot be run in its current state.
85
+
86
+ Raised for a record that is already committed (retrieve its receipt
87
+ instead), already executing (another attempt owns it), or in an unknown
88
+ state after a crash.
89
+ """
90
+
91
+ def __init__(self, effect_id: str, status: EffectStatus, detail: str = "") -> None:
92
+ self.effect_id = effect_id
93
+ self.status = status
94
+ message = f"effect {effect_id} is {status}, not runnable"
95
+ if detail:
96
+ message += f": {detail}"
97
+ super().__init__(message)
98
+
99
+
100
+ # ── preconditions ───────────────────────────────────────────────────────────
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class Precondition:
105
+ """A named check that must pass before an effect is allowed to run.
106
+
107
+ Upstream's implementation was ``if !precond.is_empty() && precond == "deny"``
108
+ — a comparison against one magic string, which meant every other
109
+ precondition value was silently ignored. A precondition that is silently
110
+ ignored is worse than no precondition at all, because it is documented
111
+ protection that does not exist.
112
+
113
+ The contract here: ``check`` is called with the caller's context mapping and
114
+ returns truthy to permit the effect. ``reason`` is what a refused caller is
115
+ told, so it should name the actual limit — "budget exhausted", not "denied".
116
+ A precondition that raises is treated as a refusal, because a check that
117
+ cannot complete has not established that the effect is safe.
118
+ """
119
+
120
+ name: str
121
+ check: Callable[[Mapping[str, Any]], bool]
122
+ reason: str = ""
123
+
124
+ def evaluate(self, context: Mapping[str, Any]) -> None:
125
+ try:
126
+ allowed = bool(self.check(context))
127
+ except Exception as exc: # noqa: BLE001 - a failed check is not a pass
128
+ raise PreconditionFailed(f"precondition raised: {exc}", precondition=self.name) from exc
129
+ if not allowed:
130
+ raise PreconditionFailed(
131
+ self.reason or f"{self.name} refused the effect", precondition=self.name
132
+ )
133
+
134
+
135
+ def precondition(
136
+ name: str, reason: str = ""
137
+ ) -> Callable[[Callable[[Mapping[str, Any]], bool]], Precondition]:
138
+ """Decorator form: ``@precondition("budget", "budget exhausted")``."""
139
+
140
+ def wrap(fn: Callable[[Mapping[str, Any]], bool]) -> Precondition:
141
+ return Precondition(name=name, check=fn, reason=reason)
142
+
143
+ return wrap
144
+
145
+
146
+ # ── results ─────────────────────────────────────────────────────────────────
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class ExecutionOutcome:
151
+ """What :meth:`SideEffectGuard.run` returns.
152
+
153
+ ``executed`` distinguishes "your callable ran this time" from "we served a
154
+ receipt for a call that already happened". That distinction is the entire
155
+ product: a caller can log, bill, or branch on it, and cannot get it wrong by
156
+ accident.
157
+ """
158
+
159
+ record: EffectRecord
160
+ value: Any = None
161
+ executed: bool = False
162
+
163
+ @property
164
+ def effect_id(self) -> str:
165
+ return self.record.effect_id
166
+
167
+ @property
168
+ def status(self) -> EffectStatus:
169
+ return self.record.status
170
+
171
+ @property
172
+ def deduplicated(self) -> bool:
173
+ """True when this call adopted an existing effect instead of running one."""
174
+ return not self.executed
175
+
176
+ def to_dict(self) -> dict[str, Any]:
177
+ out = self.record.to_dict()
178
+ out["executed"] = self.executed
179
+ if self.value is not None:
180
+ out["value"] = self.value
181
+ return out
182
+
183
+
184
+ @dataclass(frozen=True)
185
+ class RecoveryDecision:
186
+ """What to do about an effect whose fate is unknown after a restart."""
187
+
188
+ record: EffectRecord
189
+ strategy: RecoveryStrategy
190
+ action: str
191
+ detail: str
192
+
193
+ def to_dict(self) -> dict[str, Any]:
194
+ return {
195
+ "effect_id": self.record.effect_id,
196
+ "status": self.record.status.bare,
197
+ "effect_class": self.record.effect_class.bare,
198
+ "strategy": self.strategy.bare,
199
+ "action": self.action,
200
+ "detail": self.detail,
201
+ }
202
+
203
+
204
+ # ── the guard ───────────────────────────────────────────────────────────────
205
+
206
+
207
+ def _hash_payload(payload: Any) -> str:
208
+ """Stable content hash for the effect's arguments.
209
+
210
+ ``sort_keys`` plus ``default=repr`` so a dict with mixed value types still
211
+ hashes deterministically — an unstable hash would make every retry look
212
+ like a new effect, which defeats the guard.
213
+ """
214
+ try:
215
+ blob = json.dumps(payload, sort_keys=True, default=repr, separators=(",", ":"))
216
+ except (TypeError, ValueError):
217
+ blob = repr(payload)
218
+ return hashlib.blake2b(blob.encode("utf-8"), digest_size=16).hexdigest()
219
+
220
+
221
+ def _idempotency_key(session_id: str, action_type: str, target: str, payload: Any) -> str:
222
+ """The key that makes an effect idempotent *within a session*.
223
+
224
+ Derived from *what the effect is*, not from when it was requested, so two
225
+ attempts at the same logical action produce the same key and the second one
226
+ is recognised. Upstream's ``{session_hex}:{request_hash}`` had the right
227
+ shape; it just never used it.
228
+
229
+ Session-scoped, so it answers "has this session already done this". For the
230
+ cross-restart question use :func:`_scope_key` via ``run(scope=...)``.
231
+ """
232
+ digest = hashlib.blake2b(
233
+ f"{action_type}\x00{target}\x00{_hash_payload(payload)}".encode(),
234
+ digest_size=16,
235
+ ).hexdigest()
236
+ return f"{session_id}:{digest}"
237
+
238
+
239
+ def durable_scope(run_id: str, *parts: str) -> str:
240
+ """Build the ``scope`` argument for :meth:`SideEffectGuard.run`.
241
+
242
+ ``run_id`` is the caller's durability boundary — the identifier that means
243
+ the same thing after a crash. A workflow run id, a job id, an order id:
244
+ whatever the caller can reproduce on the retry. It must *not* be the session
245
+ id, because a restart produces a new session and the whole point is to
246
+ recognise the retry as the same effect.
247
+
248
+ The remaining ``parts`` narrow the scope to one effect within that run.
249
+
250
+ **Scope is authoritative.** Passing a scope makes effect identity depend on
251
+ the scope and the effect's own arguments, and *not* on the session — so the
252
+ same scope in a different session resolves to the same record. That is what
253
+ makes a crash-and-retry safe. It also means the caller owns the isolation
254
+ question: if two independent agents must not share dedup state, they must
255
+ have different ``run_id``s.
256
+ """
257
+ if not run_id:
258
+ raise ValueError("run_id must not be empty")
259
+ suffix = _hash_payload(list(parts)) if parts else ""
260
+ return f"{run_id}:{suffix}" if suffix else run_id
261
+
262
+
263
+ def _scope_namespace(scope: str) -> str:
264
+ """The record-view namespace for a scoped effect.
265
+
266
+ Scoped effects are stored under a namespace derived from the scope rather
267
+ than from the session id, because the session is exactly what changes across
268
+ a restart. It is namespaced rather than left empty so one store can hold
269
+ both scoped and session-scoped effects without the two kinds colliding.
270
+ """
271
+ digest = hashlib.blake2b(scope.encode(), digest_size=16).hexdigest()
272
+ return f"scope-ns:{digest}"
273
+
274
+
275
+ def _scope_key(action_type: str, target: str, payload: Any) -> str:
276
+ """Key for a scoped effect. Identity comes from the effect itself; the
277
+ scope lives in the namespace, so the two together are the full identity."""
278
+ digest = hashlib.blake2b(
279
+ f"{action_type}\x00{target}\x00{_hash_payload(payload)}".encode(),
280
+ digest_size=16,
281
+ ).hexdigest()
282
+ return f"scope:{digest}"
283
+
284
+
285
+ class SideEffectGuard:
286
+ """Wraps instrumented tool calls so that repeating one is safe.
287
+
288
+ Usage::
289
+
290
+ guard = SideEffectGuard(session_id="sess-1", store=EffectStore("effects.db"))
291
+
292
+ outcome = guard.run(
293
+ "charge_card",
294
+ target="cus_123",
295
+ payload={"amount_cents": 100},
296
+ execute=lambda: stripe.charge(customer="cus_123", amount=100),
297
+ preconditions=[within_budget],
298
+ )
299
+
300
+ if outcome.deduplicated:
301
+ log.info("already charged, reusing receipt %s", outcome.effect_id)
302
+
303
+ One guard instance owns one session. Every method is safe to call
304
+ concurrently; the store serialises writes and the in-process lock covers the
305
+ read-modify-write sequences.
306
+ """
307
+
308
+ def __init__(
309
+ self,
310
+ *,
311
+ session_id: str,
312
+ store: EffectStore | None = None,
313
+ store_path: str = ":memory:",
314
+ approve_irreversible: bool = False,
315
+ ) -> None:
316
+ if not session_id:
317
+ raise ValueError("session_id must not be empty")
318
+ self.session_id = session_id
319
+ self.store = store if store is not None else EffectStore(store_path)
320
+ self.approve_irreversible = approve_irreversible
321
+
322
+ def close(self) -> None:
323
+ self.store.close()
324
+
325
+ # ── the main path ───────────────────────────────────────────────────────
326
+
327
+ def run(
328
+ self,
329
+ action_type: str,
330
+ *,
331
+ execute: Callable[[], T],
332
+ target: str = "",
333
+ payload: Any = None,
334
+ effect_class: EffectClass | None = None,
335
+ preconditions: tuple[Precondition, ...] = (),
336
+ context: Mapping[str, Any] | None = None,
337
+ approve_irreversible: bool | None = None,
338
+ scope: str | None = None,
339
+ ) -> ExecutionOutcome:
340
+ """Run *execute* at most once for this logical effect.
341
+
342
+ Returns an :class:`ExecutionOutcome` whose ``executed`` flag says
343
+ whether the callable ran. Raises :class:`PreconditionFailed` before
344
+ touching the outside world if any precondition refuses.
345
+
346
+ ``scope`` is the durability boundary. When omitted the key is derived
347
+ from the session, which is right for "has *this session* already done
348
+ this". Pass it when the effect must stay recognisable across sessions —
349
+ a crash and restart produces a new session id, and a key derived from
350
+ the session would therefore treat the retry as a brand-new effect, which
351
+ is exactly the case this guard exists for. See
352
+ :func:`durable_scope` for the usual construction.
353
+ """
354
+ resolved = effect_class or classify(action_type, target)
355
+ approved = (
356
+ self.approve_irreversible if approve_irreversible is None else approve_irreversible
357
+ )
358
+
359
+ record, created = self._claim(
360
+ action_type=action_type,
361
+ target=target,
362
+ payload=payload,
363
+ effect_class=resolved,
364
+ preconditions=preconditions,
365
+ context=context or {},
366
+ approve_irreversible=approved,
367
+ scope=scope,
368
+ )
369
+
370
+ if not created:
371
+ return self._adopt(record)
372
+
373
+ # The effect is claimed and durable. Failures past this point must not
374
+ # leave the record looking unstarted.
375
+ try:
376
+ self.store.set_status(record.effect_id, EXECUTING)
377
+ value = execute()
378
+ except Exception:
379
+ self._mark_failed(record.effect_id)
380
+ raise
381
+
382
+ committed = self.store.set_status(
383
+ record.effect_id,
384
+ COMMITTED,
385
+ response_hash=_hash_payload(_receipt_value(value)),
386
+ )
387
+ return ExecutionOutcome(record=committed, value=value, executed=True)
388
+
389
+ async def arun(
390
+ self,
391
+ action_type: str,
392
+ *,
393
+ execute: Callable[[], Awaitable[T]],
394
+ target: str = "",
395
+ payload: Any = None,
396
+ effect_class: EffectClass | None = None,
397
+ preconditions: tuple[Precondition, ...] = (),
398
+ context: Mapping[str, Any] | None = None,
399
+ approve_irreversible: bool | None = None,
400
+ scope: str | None = None,
401
+ ) -> ExecutionOutcome:
402
+ """Await *execute* at most once for this logical effect.
403
+
404
+ The same lifecycle as :meth:`run`, for callers that are already async.
405
+ The claim is written *before* the await, so a process that dies while the
406
+ coroutine is suspended leaves an ``executing`` record — the outcome is
407
+ recorded as unknown rather than as never-happened, which is what stops
408
+ the next attempt from repeating an external mutation.
409
+
410
+ Use this rather than :meth:`run` from async code. Because ``run`` is
411
+ synchronous it has to drive an awaitable to completion itself, which it
412
+ cannot do on a loop that is already running.
413
+ """
414
+ resolved = effect_class or classify(action_type, target)
415
+ approved = (
416
+ self.approve_irreversible if approve_irreversible is None else approve_irreversible
417
+ )
418
+
419
+ record, created = self._claim(
420
+ action_type=action_type,
421
+ target=target,
422
+ payload=payload,
423
+ effect_class=resolved,
424
+ preconditions=preconditions,
425
+ context=context or {},
426
+ approve_irreversible=approved,
427
+ scope=scope,
428
+ )
429
+
430
+ if not created:
431
+ return self._adopt(record)
432
+
433
+ try:
434
+ self.store.set_status(record.effect_id, EXECUTING)
435
+ value = await execute()
436
+ except Exception:
437
+ self._mark_failed(record.effect_id)
438
+ raise
439
+
440
+ committed = self.store.set_status(
441
+ record.effect_id,
442
+ COMMITTED,
443
+ response_hash=_hash_payload(_receipt_value(value)),
444
+ )
445
+ return ExecutionOutcome(record=committed, value=value, executed=True)
446
+
447
+ def _claim(
448
+ self,
449
+ *,
450
+ action_type: str,
451
+ target: str,
452
+ payload: Any,
453
+ effect_class: EffectClass,
454
+ preconditions: tuple[Precondition, ...],
455
+ context: Mapping[str, Any],
456
+ approve_irreversible: bool,
457
+ scope: str | None = None,
458
+ ) -> tuple[EffectRecord, bool]:
459
+ """Reserve the effect, or return the existing record for it.
460
+
461
+ Order matters: the durable claim happens *before* preconditions are
462
+ evaluated only in the sense that a duplicate is detected first. A
463
+ refusal raises before any record is written, so a precondition that
464
+ fails does not consume the idempotency key.
465
+ """
466
+ if scope:
467
+ # Scope is authoritative: the record lives in a namespace derived
468
+ # from the scope, so the same scope resolves to the same record
469
+ # regardless of which session is asking. Using the session id here
470
+ # would defeat the purpose, since a restart is precisely a new
471
+ # session id.
472
+ session_id = _scope_namespace(scope)
473
+ key = _scope_key(action_type, target, payload)
474
+ else:
475
+ session_id = self.session_id
476
+ key = _idempotency_key(self.session_id, action_type, target, payload)
477
+
478
+ existing = self.store.find_by_key(session_id, key)
479
+ if existing is not None and existing.is_active:
480
+ return existing, False
481
+
482
+ self._check_preconditions(effect_class, preconditions, context, approve_irreversible)
483
+
484
+ if existing is not None:
485
+ # A previous attempt failed or was compensated. Re-issuing a failed
486
+ # irreversible effect would be a second real-world event, so the
487
+ # caller is made to look at it rather than having it silently
488
+ # retried.
489
+ if existing.status == FAILED and effect_class is EffectClass.IRREVERSIBLE:
490
+ raise EffectNotRunnable(
491
+ existing.effect_id,
492
+ existing.status,
493
+ "an irreversible effect that already failed must be resolved by "
494
+ "hand — its partial effects cannot be assumed away",
495
+ )
496
+ # The transition is conditional on the status we just read: two
497
+ # concurrent retries both used to see FAILED, both reset it to
498
+ # PENDING, and both ran the effect. Only the caller whose UPDATE
499
+ # changes a row may execute.
500
+ if not self.store.transition_if_status(
501
+ existing.effect_id, (FAILED, COMPENSATED), PENDING
502
+ ):
503
+ winner = self.store.find_by_key(session_id, key)
504
+ if winner is None: # pragma: no cover - row cannot vanish
505
+ raise StoreError(f"effect key {key} vanished during retry")
506
+ return winner, False
507
+ return self.store.get(existing.effect_id) or existing, True
508
+
509
+ effect_id = f"se_{uuid.uuid4().hex}"
510
+ inserted = self.store.insert(
511
+ effect_id=effect_id,
512
+ session_id=session_id,
513
+ idempotency_key=key,
514
+ action_type=action_type,
515
+ target=target,
516
+ effect_class=effect_class,
517
+ payload_hash=_hash_payload(payload),
518
+ preconditions=tuple(p.name for p in preconditions),
519
+ scope=scope,
520
+ )
521
+ if inserted is None:
522
+ # Lost a race with a concurrent caller for the same effect. Adopt
523
+ # the winner's record; do not run.
524
+ winner = self.store.find_by_key(session_id, key)
525
+ if winner is None: # pragma: no cover - would mean the schema lied
526
+ raise StoreError(f"effect key {key} vanished after a uniqueness conflict")
527
+ return winner, False
528
+ return inserted, True
529
+
530
+ def _check_preconditions(
531
+ self,
532
+ effect_class: EffectClass,
533
+ preconditions: tuple[Precondition, ...],
534
+ context: Mapping[str, Any],
535
+ approve_irreversible: bool,
536
+ ) -> None:
537
+ if effect_class is EffectClass.IRREVERSIBLE and not approve_irreversible:
538
+ raise PreconditionFailed(
539
+ "irreversible effects require explicit approval "
540
+ "(pass approve_irreversible=True once the action has been reviewed)",
541
+ precondition="irreversible_approval",
542
+ )
543
+ for check in preconditions:
544
+ check.evaluate(context)
545
+
546
+ def _adopt(self, record: EffectRecord) -> ExecutionOutcome:
547
+ """Serve an existing effect instead of running it again."""
548
+ if record.status == COMMITTED:
549
+ return ExecutionOutcome(record=record, value=record.response_hash, executed=False)
550
+ if record.status == EXECUTING:
551
+ # Claimed before a crash. The outside world may already have been
552
+ # changed, so this is not a safe place to resume automatically.
553
+ raise EffectNotRunnable(
554
+ record.effect_id,
555
+ record.status,
556
+ "a previous attempt may have reached the outside world; call "
557
+ "recover() — or recover(scope=...) for a scoped effect — to get "
558
+ "a decision for this effect",
559
+ )
560
+ raise EffectNotRunnable(record.effect_id, record.status, "the effect is already claimed")
561
+
562
+ def _mark_failed(self, effect_id: str) -> None:
563
+ with suppress(StoreError): # store gone mid-flight
564
+ self.store.set_status(effect_id, FAILED)
565
+
566
+ # ── recovery ────────────────────────────────────────────────────────────
567
+
568
+ def recover(
569
+ self, session_id: str | None = None, *, scope: str | None = None
570
+ ) -> list[RecoveryDecision]:
571
+ """Decide what to do about every effect whose outcome is unknown.
572
+
573
+ Call this at session start, before any new work. Each decision names the
574
+ action that is safe for that effect's class, and the caller is expected
575
+ to carry it out or escalate — this method deliberately does not execute
576
+ anything, because the safe action for an irreversible effect is a human
577
+ looking at the vendor's dashboard.
578
+
579
+ Pass *scope* (the same value given to :meth:`run`) to recover effects
580
+ that were written under ``durable_scope``. Those records live in a
581
+ namespace derived from the scope, not under the current session id, so
582
+ the session-only query used to return nothing for exactly the
583
+ cross-restart case the scope exists to make safe.
584
+ """
585
+ if scope is not None:
586
+ records = self.store.unresolved(scope=scope)
587
+ else:
588
+ sid = session_id or self.session_id
589
+ records = self.store.unresolved(sid)
590
+ return [self.decide(record) for record in records]
591
+
592
+ def decide(self, record: EffectRecord) -> RecoveryDecision:
593
+ """Map one record to its recovery action.
594
+
595
+ The table is the port of upstream's ``recover_effect``, with the
596
+ strategy/action split kept so callers can group by class (``strategy``)
597
+ while still seeing the concrete instruction (``action``).
598
+ """
599
+ cls = record.effect_class
600
+ if record.status == COMMITTED:
601
+ return RecoveryDecision(
602
+ record,
603
+ RecoveryStrategy.REPLAY,
604
+ "use_cached",
605
+ "the effect already ran; serve the recorded response and do not call again",
606
+ )
607
+ if record.status == COMPENSATED:
608
+ return RecoveryDecision(
609
+ record,
610
+ strategy_for(cls),
611
+ "replay",
612
+ "the effect was undone; running it again is safe",
613
+ )
614
+ if record.status == FAILED:
615
+ if cls is EffectClass.IRREVERSIBLE:
616
+ return RecoveryDecision(
617
+ record,
618
+ RecoveryStrategy.QUERY_AND_CONFIRM,
619
+ "query_external",
620
+ "an irreversible effect that failed may have partially applied; "
621
+ "confirm against the external system before any retry",
622
+ )
623
+ return RecoveryDecision(
624
+ record, strategy_for(cls), "retry", "the effect raised before committing"
625
+ )
626
+ # PENDING or EXECUTING: the crash case.
627
+ if cls is EffectClass.IRREVERSIBLE:
628
+ return RecoveryDecision(
629
+ record,
630
+ RecoveryStrategy.QUERY_AND_CONFIRM,
631
+ "query_external",
632
+ "the outcome of an irreversible effect is unknown; confirm against "
633
+ "the external system before retrying — retrying blindly risks a "
634
+ "second real-world event",
635
+ )
636
+ if cls is EffectClass.REVERSIBLE:
637
+ if record.compensation is not None:
638
+ return RecoveryDecision(
639
+ record,
640
+ RecoveryStrategy.COMPENSATE,
641
+ "compensate_and_replay",
642
+ "compensation data is recorded; undo the partial change first",
643
+ )
644
+ return RecoveryDecision(
645
+ record,
646
+ RecoveryStrategy.REPLAY,
647
+ "replay",
648
+ "reversible with no compensation data; replaying converges on the intended state",
649
+ )
650
+ # PURE and IDEMPOTENT.
651
+ return RecoveryDecision(
652
+ record,
653
+ RecoveryStrategy.REPLAY,
654
+ "replay",
655
+ "safe to repeat without limit",
656
+ )
657
+
658
+ def resolve(
659
+ self,
660
+ effect_id: str,
661
+ status: EffectStatus,
662
+ *,
663
+ response_hash: str | None = None,
664
+ ) -> EffectRecord:
665
+ """Close out an effect by hand after a recovery decision.
666
+
667
+ This is the human-in-the-loop end of :meth:`recover`: an operator who
668
+ has checked the vendor dashboard calls this to record what they found.
669
+ """
670
+ return self.store.set_status(effect_id, status, response_hash=response_hash)
671
+
672
+ async def recovered_retry(
673
+ self,
674
+ effect_id: str,
675
+ *,
676
+ execute: Callable[[], Awaitable[T]],
677
+ approve_irreversible: bool = True,
678
+ ) -> ExecutionOutcome:
679
+ """Retry an effect whose outcome an operator has already resolved.
680
+
681
+ The missing step between :meth:`recover` and getting on with the work.
682
+ :meth:`recover` yields decisions; an operator acts on one; this carries
683
+ the effect through under the answer they reached.
684
+
685
+ What it refuses is the whole point:
686
+
687
+ * ``committed`` — returns the existing record without running. Re-running
688
+ a completed effect is the one thing this module exists to prevent.
689
+ * ``executing`` — raises. Nobody resolved it, and an unknown outcome is
690
+ exactly what must not be retried blind: it may already have reached
691
+ the outside world.
692
+ * ``pending`` — raises. :meth:`run` handles a fresh attempt; going
693
+ through this method would skip the guard's own bookkeeping.
694
+
695
+ ``approve_irreversible`` defaults to **True** here, unlike :meth:`run`.
696
+ That is deliberate and narrow: this method requires an ``effect_id``,
697
+ which only comes from a recovery review, so the approval already
698
+ happened in the real world and re-asking would be theatre. A caller who
699
+ did not do that review gains nothing, because they cannot name the effect.
700
+ """
701
+ record = self.store.get(effect_id)
702
+ if record is None:
703
+ raise StoreError(f"no such effect: {effect_id}")
704
+
705
+ if record.status == COMMITTED:
706
+ return ExecutionOutcome(record=record, value=record.response_hash, executed=False)
707
+ if record.status == EXECUTING:
708
+ raise EffectNotRunnable(
709
+ effect_id,
710
+ record.status,
711
+ "this effect has not been resolved — its outcome is still unknown. "
712
+ "Confirm against the external system with resolve() first",
713
+ )
714
+ if record.status == PENDING:
715
+ raise EffectNotRunnable(
716
+ effect_id,
717
+ record.status,
718
+ "use run() for a fresh attempt; this method is for an effect a "
719
+ "recovery review has already settled",
720
+ )
721
+
722
+ # FAILED or COMPENSATED: both mean "did not land", so retrying is safe.
723
+ self.store.set_status(effect_id, PENDING)
724
+ self.store.set_status(effect_id, EXECUTING)
725
+ try:
726
+ value = await execute()
727
+ except Exception:
728
+ self._mark_failed(effect_id)
729
+ raise
730
+
731
+ committed = self.store.set_status(
732
+ effect_id,
733
+ COMMITTED,
734
+ response_hash=_hash_payload(_receipt_value(value)),
735
+ )
736
+ return ExecutionOutcome(record=committed, value=value, executed=True)
737
+
738
+ # ── introspection ───────────────────────────────────────────────────────
739
+
740
+ def effects(
741
+ self, *, status: EffectStatus | None = None, scope: str | None = None
742
+ ) -> list[EffectRecord]:
743
+ if scope is not None:
744
+ return self.store.list_records(status=status, scope=scope)
745
+ return self.store.list_records(session_id=self.session_id, status=status)
746
+
747
+ def summary(self, *, scope: str | None = None) -> dict[str, int]:
748
+ if scope is not None:
749
+ return self.store.count_by_status(scope=scope)
750
+ return self.store.count_by_status(self.session_id)
751
+
752
+
753
+ def strategy_for(effect_class: EffectClass) -> RecoveryStrategy:
754
+ """Class → strategy. Port of ``SideEffectClass::recovery_strategy``."""
755
+ if effect_class in (EffectClass.PURE, EffectClass.IDEMPOTENT):
756
+ return RecoveryStrategy.REPLAY
757
+ if effect_class is EffectClass.REVERSIBLE:
758
+ return RecoveryStrategy.COMPENSATE
759
+ return RecoveryStrategy.QUERY_AND_CONFIRM
760
+
761
+
762
+ def _receipt_value(value: Any) -> Any:
763
+ """Normalise an executor's return value into something hashable.
764
+
765
+ The response hash is what proves two attempts produced the same result, so
766
+ it must be stable for equal values and different for different ones.
767
+ """
768
+ if value is None or isinstance(value, (str, int, float, bool)):
769
+ return value
770
+ if isinstance(value, (list, tuple)):
771
+ return [_receipt_value(v) for v in value]
772
+ if isinstance(value, dict):
773
+ return {str(k): _receipt_value(v) for k, v in value.items()}
774
+ to_dict = getattr(value, "to_dict", None)
775
+ if callable(to_dict):
776
+ return to_dict()
777
+ return repr(value)