apsimo-hostworker 0.2.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,1815 @@
1
+ """Reference :class:`~apsimo_hostworker.store.ActionStore` on local SQLite.
2
+
3
+ This is the governed-action SUBSET only: actions, immutable receipts and
4
+ events, dead letters, leases, the owner-authorized dispatch transaction, and
5
+ the bounded GET-only observation machinery. It deliberately omits the
6
+ general-purpose queue features of the host system it was extracted from
7
+ (callback outboxes, message-delivery lifecycles, policy-grant dispatch);
8
+ adding them back here would widen the surface the conformance suite must
9
+ defend.
10
+
11
+ Durability posture: WAL journal, ``synchronous=FULL``, ``BEGIN IMMEDIATE``
12
+ transactions, owner-only 0600 database files, and SQL triggers that make
13
+ receipts, events, and action identity immutable even against buggy code in
14
+ this very module.
15
+
16
+ Every transactional invariant this store upholds is specified in
17
+ :mod:`apsimo_hostworker.store` (I1-I11) and exercised by
18
+ :mod:`apsimo_hostworker.conformance`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import hmac
25
+ import json
26
+ import math
27
+ import os
28
+ import sqlite3
29
+ import stat
30
+ import threading
31
+ import uuid
32
+ from contextlib import contextmanager
33
+ from typing import Any, Mapping, Sequence
34
+
35
+ from .catalog import GRANT_AUTHORIZABLE_TOOL_NAMES
36
+ from .contract import canonical_json_utf8, sha256_json_utf8
37
+ from .gate import GRANT_BINDING_METHOD, GateAuthorization
38
+ from .store import (
39
+ ALLOWED_TRANSITIONS,
40
+ ActionIdempotencyConflict,
41
+ ActionLeaseConflict,
42
+ ActionNotFound,
43
+ ActionStoreError,
44
+ ActionTransitionError,
45
+ GATE_RECEIPT_KIND,
46
+ OBSERVATION_RECEIPT_KIND,
47
+ RECOVERY_RECEIPT_KIND,
48
+ STATE_ACCEPTED,
49
+ STATE_COMPLETED,
50
+ STATE_DISPATCHED,
51
+ STATE_FAILED,
52
+ STATE_GATED,
53
+ STATE_PROPOSED,
54
+ STATE_VERIFIED,
55
+ TERMINAL_STATES,
56
+ )
57
+
58
+ RECOVERY_SCHEMA = "ColonyDispatchObservationRecoveryV1"
59
+ OBSERVATION_SCHEMA = "ColonyDispatchObservationAttemptV1"
60
+
61
+ _RESERVED_RECEIPT_KINDS = frozenset(
62
+ {GATE_RECEIPT_KIND, RECOVERY_RECEIPT_KIND, OBSERVATION_RECEIPT_KIND}
63
+ )
64
+
65
+ _ALL_STATES = (
66
+ STATE_PROPOSED,
67
+ STATE_GATED,
68
+ STATE_DISPATCHED,
69
+ STATE_ACCEPTED,
70
+ STATE_VERIFIED,
71
+ STATE_COMPLETED,
72
+ STATE_FAILED,
73
+ )
74
+
75
+ _LEASEABLE_STATES = frozenset({STATE_GATED, STATE_ACCEPTED, STATE_VERIFIED})
76
+
77
+ _GATE_CLOCK_SKEW_SECONDS = 30.0
78
+
79
+
80
+ class SqliteActionStore:
81
+ """SQLite-backed governed-action lifecycle store."""
82
+
83
+ SCHEMA_VERSION = 1
84
+
85
+ def __init__(self, path: str, *, clock=None, busy_timeout_ms: int = 5000):
86
+ import time as _time
87
+
88
+ self.path = os.path.abspath(os.path.expanduser(path))
89
+ self._clock = clock or _time.time
90
+ self._lock = threading.RLock()
91
+
92
+ parent = os.path.dirname(self.path)
93
+ if parent:
94
+ os.makedirs(parent, mode=0o700, exist_ok=True)
95
+ # SQLite creates the database, WAL, and shared-memory files lazily.
96
+ # A restrictive creation mask prevents payloads and receipts from
97
+ # being briefly world-readable before the explicit chmod below.
98
+ previous_umask = os.umask(0o077)
99
+ try:
100
+ self._conn = sqlite3.connect(
101
+ self.path,
102
+ timeout=max(float(busy_timeout_ms) / 1000.0, 0.001),
103
+ isolation_level=None,
104
+ check_same_thread=False,
105
+ )
106
+ finally:
107
+ os.umask(previous_umask)
108
+ self._conn.row_factory = sqlite3.Row
109
+ existing_version = self._conn.execute("PRAGMA user_version").fetchone()[0]
110
+ if existing_version not in (0, self.SCHEMA_VERSION):
111
+ self.close()
112
+ raise ActionStoreError(
113
+ "unsupported action-store schema version %s" % existing_version
114
+ )
115
+ self._conn.execute("PRAGMA foreign_keys=ON")
116
+ self._conn.execute("PRAGMA journal_mode=WAL")
117
+ self._conn.execute("PRAGMA synchronous=FULL")
118
+ self._conn.execute("PRAGMA busy_timeout=%d" % int(busy_timeout_ms))
119
+ try:
120
+ self._create_schema()
121
+ self._secure_database_files()
122
+ except Exception:
123
+ self.close()
124
+ raise
125
+
126
+ # ------------------------------------------------------------- plumbing
127
+
128
+ def _secure_database_files(self):
129
+ private_mode = stat.S_IRUSR | stat.S_IWUSR
130
+ for candidate in (self.path, self.path + "-wal", self.path + "-shm"):
131
+ try:
132
+ os.chmod(candidate, private_mode)
133
+ except FileNotFoundError:
134
+ continue
135
+
136
+ def close(self):
137
+ with self._lock:
138
+ if self._conn is not None:
139
+ self._conn.close()
140
+ self._conn = None
141
+
142
+ def __enter__(self):
143
+ return self
144
+
145
+ def __exit__(self, exc_type, exc, traceback):
146
+ self.close()
147
+
148
+ @contextmanager
149
+ def _transaction(self):
150
+ with self._lock:
151
+ if self._conn is None:
152
+ raise ActionStoreError("action store is closed")
153
+ cursor = self._conn.cursor()
154
+ cursor.execute("BEGIN IMMEDIATE")
155
+ try:
156
+ yield cursor
157
+ except Exception:
158
+ self._conn.rollback()
159
+ raise
160
+ else:
161
+ self._conn.commit()
162
+ self._secure_database_files()
163
+ finally:
164
+ cursor.close()
165
+
166
+ def _create_schema(self):
167
+ states = ",".join("'%s'" % state for state in _ALL_STATES)
168
+ ddl = """
169
+ CREATE TABLE IF NOT EXISTS actions (
170
+ action_id TEXT PRIMARY KEY,
171
+ idempotency_key TEXT NOT NULL UNIQUE,
172
+ source TEXT NOT NULL,
173
+ source_ref TEXT,
174
+ action_type TEXT NOT NULL,
175
+ payload_json TEXT NOT NULL,
176
+ payload_sha256 TEXT NOT NULL,
177
+ state TEXT NOT NULL CHECK (state IN (%s)),
178
+ attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
179
+ max_attempts INTEGER NOT NULL CHECK (max_attempts = 1),
180
+ next_attempt_at REAL NOT NULL,
181
+ lease_owner TEXT,
182
+ lease_expires_at REAL,
183
+ last_error TEXT,
184
+ result_json TEXT,
185
+ created_at REAL NOT NULL,
186
+ updated_at REAL NOT NULL,
187
+ terminal_at REAL
188
+ );
189
+
190
+ CREATE INDEX IF NOT EXISTS idx_actions_ready
191
+ ON actions(state, next_attempt_at, lease_expires_at, created_at);
192
+ CREATE INDEX IF NOT EXISTS idx_actions_source_ref
193
+ ON actions(source, source_ref);
194
+
195
+ CREATE TABLE IF NOT EXISTS action_events (
196
+ event_id INTEGER PRIMARY KEY AUTOINCREMENT,
197
+ action_id TEXT NOT NULL REFERENCES actions(action_id),
198
+ event_type TEXT NOT NULL,
199
+ from_state TEXT,
200
+ to_state TEXT,
201
+ actor TEXT NOT NULL,
202
+ details_json TEXT NOT NULL,
203
+ created_at REAL NOT NULL
204
+ );
205
+ CREATE INDEX IF NOT EXISTS idx_action_events_action
206
+ ON action_events(action_id, event_id);
207
+
208
+ CREATE TABLE IF NOT EXISTS receipts (
209
+ receipt_id TEXT PRIMARY KEY,
210
+ action_id TEXT NOT NULL REFERENCES actions(action_id),
211
+ receipt_key TEXT NOT NULL,
212
+ kind TEXT NOT NULL,
213
+ status TEXT NOT NULL,
214
+ external_id TEXT,
215
+ evidence_json TEXT NOT NULL,
216
+ evidence_sha256 TEXT NOT NULL,
217
+ observed_at REAL NOT NULL,
218
+ created_at REAL NOT NULL,
219
+ UNIQUE(action_id, receipt_key)
220
+ );
221
+ CREATE INDEX IF NOT EXISTS idx_receipts_action
222
+ ON receipts(action_id, created_at);
223
+
224
+ CREATE TABLE IF NOT EXISTS dead_letters (
225
+ dead_letter_id TEXT PRIMARY KEY,
226
+ action_id TEXT NOT NULL UNIQUE REFERENCES actions(action_id),
227
+ reason TEXT NOT NULL,
228
+ action_snapshot_json TEXT NOT NULL,
229
+ created_at REAL NOT NULL
230
+ );
231
+
232
+ CREATE TRIGGER IF NOT EXISTS action_events_no_update
233
+ BEFORE UPDATE ON action_events BEGIN
234
+ SELECT RAISE(ABORT, 'action events are immutable');
235
+ END;
236
+ CREATE TRIGGER IF NOT EXISTS action_events_no_delete
237
+ BEFORE DELETE ON action_events BEGIN
238
+ SELECT RAISE(ABORT, 'action events are immutable');
239
+ END;
240
+ CREATE TRIGGER IF NOT EXISTS receipts_no_update
241
+ BEFORE UPDATE ON receipts BEGIN
242
+ SELECT RAISE(ABORT, 'receipts are immutable');
243
+ END;
244
+ CREATE TRIGGER IF NOT EXISTS receipts_no_delete
245
+ BEFORE DELETE ON receipts BEGIN
246
+ SELECT RAISE(ABORT, 'receipts are immutable');
247
+ END;
248
+ CREATE TRIGGER IF NOT EXISTS actions_identity_no_update
249
+ BEFORE UPDATE OF action_id,idempotency_key,source,source_ref,action_type,
250
+ payload_json,payload_sha256,max_attempts,created_at
251
+ ON actions BEGIN
252
+ SELECT RAISE(ABORT, 'action identity and payload are immutable');
253
+ END;
254
+ """ % states
255
+ with self._lock:
256
+ try:
257
+ self._conn.executescript(
258
+ "BEGIN IMMEDIATE;\n"
259
+ + ddl
260
+ + "\nPRAGMA user_version=%d;\nCOMMIT;" % self.SCHEMA_VERSION
261
+ )
262
+ except Exception:
263
+ self._conn.rollback()
264
+ raise
265
+
266
+ @staticmethod
267
+ def _validate_text(name, value):
268
+ if not isinstance(value, str) or not value.strip():
269
+ raise ActionStoreError("%s must be a non-empty string" % name)
270
+ return value.strip()
271
+
272
+ @staticmethod
273
+ def _json_load(value):
274
+ return json.loads(value) if value is not None else None
275
+
276
+ def _action_dict(self, row):
277
+ if row is None:
278
+ return None
279
+ result = dict(row)
280
+ raw_payload = result.pop("payload_json")
281
+ try:
282
+ payload = self._json_load(raw_payload)
283
+ canonical = canonical_json_utf8(payload)
284
+ except Exception as exc:
285
+ raise ActionStoreError("action payload is not canonical JSON") from exc
286
+ observed = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
287
+ if not hmac.compare_digest(observed, str(result.get("payload_sha256") or "")):
288
+ raise ActionStoreError(
289
+ "action payload digest does not match its immutable record"
290
+ )
291
+ result["payload"] = payload
292
+ result["result"] = self._json_load(result.pop("result_json"))
293
+ return result
294
+
295
+ def _receipt_dict(self, row):
296
+ result = dict(row)
297
+ result["evidence"] = self._json_load(result.pop("evidence_json"))
298
+ return result
299
+
300
+ @staticmethod
301
+ def _get_action_row(cursor, action_id):
302
+ row = cursor.execute(
303
+ "SELECT * FROM actions WHERE action_id=?", (action_id,)
304
+ ).fetchone()
305
+ if row is None:
306
+ raise ActionNotFound("action %s not found" % action_id)
307
+ return row
308
+
309
+ @staticmethod
310
+ def _require_lease(row, owner, now):
311
+ if not owner or row["lease_owner"] != owner:
312
+ raise ActionLeaseConflict(
313
+ "record is not leased by %s" % (owner or "<empty>")
314
+ )
315
+ expires = row["lease_expires_at"]
316
+ if expires is None or float(expires) <= now:
317
+ raise ActionLeaseConflict("lease for %s has expired" % owner)
318
+
319
+ @staticmethod
320
+ def _check_transition(from_state, to_state):
321
+ if to_state not in ALLOWED_TRANSITIONS.get(from_state, frozenset()):
322
+ raise ActionTransitionError(
323
+ "cannot transition %s -> %s" % (from_state, to_state)
324
+ )
325
+
326
+ @staticmethod
327
+ def _add_event(cursor, action_id, event_type, from_state, to_state, actor, details, now):
328
+ cursor.execute(
329
+ """INSERT INTO action_events
330
+ (action_id,event_type,from_state,to_state,actor,details_json,created_at)
331
+ VALUES (?,?,?,?,?,?,?)""",
332
+ (
333
+ action_id,
334
+ event_type,
335
+ from_state,
336
+ to_state,
337
+ actor,
338
+ canonical_json_utf8(details or {}),
339
+ now,
340
+ ),
341
+ )
342
+
343
+ def _transition(
344
+ self,
345
+ cursor,
346
+ row,
347
+ to_state,
348
+ actor,
349
+ event_type,
350
+ details,
351
+ now,
352
+ result=None,
353
+ error=None,
354
+ clear_lease=False,
355
+ next_attempt_at=None,
356
+ ):
357
+ from_state = row["state"]
358
+ self._check_transition(from_state, to_state)
359
+ terminal_at = now if to_state in TERMINAL_STATES else None
360
+ result_json = (
361
+ canonical_json_utf8(result) if result is not None else row["result_json"]
362
+ )
363
+ lease_owner = None if clear_lease else row["lease_owner"]
364
+ lease_expires = None if clear_lease else row["lease_expires_at"]
365
+ next_at = row["next_attempt_at"] if next_attempt_at is None else next_attempt_at
366
+ cursor.execute(
367
+ """UPDATE actions
368
+ SET state=?, result_json=?, last_error=?, next_attempt_at=?,
369
+ lease_owner=?, lease_expires_at=?, updated_at=?, terminal_at=?
370
+ WHERE action_id=?""",
371
+ (
372
+ to_state,
373
+ result_json,
374
+ error,
375
+ next_at,
376
+ lease_owner,
377
+ lease_expires,
378
+ now,
379
+ terminal_at,
380
+ row["action_id"],
381
+ ),
382
+ )
383
+ self._add_event(
384
+ cursor,
385
+ row["action_id"],
386
+ event_type,
387
+ from_state,
388
+ to_state,
389
+ actor,
390
+ details,
391
+ now,
392
+ )
393
+
394
+ def _dead_letter_action(self, cursor, action_id, reason, now):
395
+ row = self._get_action_row(cursor, action_id)
396
+ snapshot = self._action_dict(row)
397
+ cursor.execute(
398
+ """INSERT OR IGNORE INTO dead_letters
399
+ (dead_letter_id,action_id,reason,action_snapshot_json,created_at)
400
+ VALUES (?,?,?,?,?)""",
401
+ (str(uuid.uuid4()), action_id, reason, canonical_json_utf8(snapshot), now),
402
+ )
403
+
404
+ # ------------------------------------------------------------- ingress
405
+
406
+ def propose(
407
+ self,
408
+ idempotency_key,
409
+ source,
410
+ action_type,
411
+ payload,
412
+ source_ref=None,
413
+ action_id=None,
414
+ actor="ingress",
415
+ ):
416
+ """Insert one immutable governed action in ``proposed`` state.
417
+
418
+ ``max_attempts`` is pinned to 1 for every governed action — a schema
419
+ CHECK re-enforces it — so the one-mutation guarantee cannot be
420
+ configured away at ingress.
421
+ """
422
+
423
+ idempotency_key = self._validate_text("idempotency_key", idempotency_key)
424
+ source = self._validate_text("source", source)
425
+ action_type = self._validate_text("action_type", action_type)
426
+ if source_ref is not None and not isinstance(source_ref, str):
427
+ raise ActionStoreError("source_ref must be a string or None")
428
+ payload_json = canonical_json_utf8(payload)
429
+ payload_hash = hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
430
+ now = float(self._clock())
431
+ action_id = action_id or str(uuid.uuid4())
432
+
433
+ with self._transaction() as cursor:
434
+ existing = cursor.execute(
435
+ "SELECT * FROM actions WHERE idempotency_key=?", (idempotency_key,)
436
+ ).fetchone()
437
+ if existing is not None:
438
+ same = (
439
+ existing["source"] == source
440
+ and existing["source_ref"] == source_ref
441
+ and existing["action_type"] == action_type
442
+ and existing["payload_sha256"] == payload_hash
443
+ )
444
+ if not same:
445
+ raise ActionIdempotencyConflict(
446
+ "action idempotency key %s was reused with different content"
447
+ % idempotency_key
448
+ )
449
+ return self._action_dict(existing)
450
+
451
+ cursor.execute(
452
+ """INSERT INTO actions
453
+ (action_id,idempotency_key,source,source_ref,action_type,payload_json,
454
+ payload_sha256,state,attempt_count,max_attempts,next_attempt_at,
455
+ created_at,updated_at)
456
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
457
+ (
458
+ action_id,
459
+ idempotency_key,
460
+ source,
461
+ source_ref,
462
+ action_type,
463
+ payload_json,
464
+ payload_hash,
465
+ STATE_PROPOSED,
466
+ 0,
467
+ 1,
468
+ now,
469
+ now,
470
+ now,
471
+ ),
472
+ )
473
+ self._add_event(
474
+ cursor,
475
+ action_id,
476
+ "proposed",
477
+ None,
478
+ STATE_PROPOSED,
479
+ actor,
480
+ {
481
+ "source": source,
482
+ "source_ref": source_ref,
483
+ "action_type": action_type,
484
+ },
485
+ now,
486
+ )
487
+ return self._action_dict(self._get_action_row(cursor, action_id))
488
+
489
+ def _insert_receipt(
490
+ self,
491
+ cursor,
492
+ action_id,
493
+ receipt_key,
494
+ kind,
495
+ status_value,
496
+ evidence,
497
+ external_id,
498
+ observed_at,
499
+ now,
500
+ ):
501
+ receipt_key = self._validate_text("receipt_key", receipt_key)
502
+ kind = self._validate_text("kind", kind)
503
+ status_value = self._validate_text("status", status_value)
504
+ evidence_json = canonical_json_utf8(evidence)
505
+ evidence_hash = hashlib.sha256(evidence_json.encode("utf-8")).hexdigest()
506
+ existing = cursor.execute(
507
+ "SELECT * FROM receipts WHERE action_id=? AND receipt_key=?",
508
+ (action_id, receipt_key),
509
+ ).fetchone()
510
+ if existing is not None:
511
+ same = (
512
+ existing["kind"] == kind
513
+ and existing["status"] == status_value
514
+ and existing["external_id"] == external_id
515
+ and existing["evidence_sha256"] == evidence_hash
516
+ )
517
+ if not same:
518
+ raise ActionIdempotencyConflict(
519
+ "receipt key %s was reused with different evidence" % receipt_key
520
+ )
521
+ return self._receipt_dict(existing)
522
+ receipt_id = str(uuid.uuid4())
523
+ cursor.execute(
524
+ """INSERT INTO receipts
525
+ (receipt_id,action_id,receipt_key,kind,status,external_id,evidence_json,
526
+ evidence_sha256,observed_at,created_at)
527
+ VALUES (?,?,?,?,?,?,?,?,?,?)""",
528
+ (
529
+ receipt_id,
530
+ action_id,
531
+ receipt_key,
532
+ kind,
533
+ status_value,
534
+ external_id,
535
+ evidence_json,
536
+ evidence_hash,
537
+ float(observed_at if observed_at is not None else now),
538
+ now,
539
+ ),
540
+ )
541
+ return self._receipt_dict(
542
+ cursor.execute(
543
+ "SELECT * FROM receipts WHERE receipt_id=?", (receipt_id,)
544
+ ).fetchone()
545
+ )
546
+
547
+ def add_receipt(
548
+ self,
549
+ action_id,
550
+ receipt_key,
551
+ kind,
552
+ status_value,
553
+ evidence,
554
+ external_id=None,
555
+ observed_at=None,
556
+ ):
557
+ """Append one generic receipt. Reserved kinds are refused here: the
558
+ gate receipt is written by :meth:`gate` and the recovery/observation
559
+ receipts only by their transactional owners."""
560
+
561
+ kind = self._validate_text("kind", kind)
562
+ if kind in _RESERVED_RECEIPT_KINDS:
563
+ raise ActionStoreError(
564
+ "receipt kind %s is reserved for its transactional owner" % kind
565
+ )
566
+ now = float(self._clock())
567
+ with self._transaction() as cursor:
568
+ self._get_action_row(cursor, action_id)
569
+ return self._insert_receipt(
570
+ cursor,
571
+ action_id,
572
+ receipt_key,
573
+ kind,
574
+ status_value,
575
+ evidence,
576
+ external_id,
577
+ observed_at,
578
+ now,
579
+ )
580
+
581
+ def gate(
582
+ self,
583
+ action_id,
584
+ evidence,
585
+ receipt_key="owner-gate",
586
+ actor="gate",
587
+ external_id=None,
588
+ ):
589
+ """Attach the owner-approval gate receipt and move to ``gated``.
590
+
591
+ The store records the evidence verbatim; it does NOT judge it.
592
+ Judgment happens at point of use, inside
593
+ :meth:`begin_owner_authorized_dispatch` (invariant I4).
594
+ """
595
+
596
+ now = float(self._clock())
597
+ with self._transaction() as cursor:
598
+ row = self._get_action_row(cursor, action_id)
599
+ if row["state"] == STATE_GATED:
600
+ receipt = self._insert_receipt(
601
+ cursor,
602
+ action_id,
603
+ receipt_key,
604
+ GATE_RECEIPT_KIND,
605
+ "passed",
606
+ evidence,
607
+ external_id,
608
+ None,
609
+ now,
610
+ )
611
+ return self._action_dict(row), receipt
612
+ if row["state"] != STATE_PROPOSED:
613
+ raise ActionTransitionError("only proposed actions can be gated")
614
+ receipt = self._insert_receipt(
615
+ cursor,
616
+ action_id,
617
+ receipt_key,
618
+ GATE_RECEIPT_KIND,
619
+ "passed",
620
+ evidence,
621
+ external_id,
622
+ None,
623
+ now,
624
+ )
625
+ self._transition(
626
+ cursor,
627
+ row,
628
+ STATE_GATED,
629
+ actor,
630
+ "gate_passed",
631
+ {"receipt_key": receipt_key},
632
+ now,
633
+ )
634
+ return self._action_dict(self._get_action_row(cursor, action_id)), receipt
635
+
636
+ # --------------------------------------------------- leases & recovery
637
+
638
+ @classmethod
639
+ def _lease_scope(cls, *, source=None, source_prefix=None, action_type=None, action_ids=None):
640
+ if source is not None and source_prefix is not None:
641
+ raise ActionStoreError("source and source_prefix are mutually exclusive")
642
+ clauses = []
643
+ values = []
644
+ if source is not None:
645
+ clauses.append("source=?")
646
+ values.append(cls._validate_text("source", source))
647
+ if source_prefix is not None:
648
+ prefix = cls._validate_text("source_prefix", source_prefix)
649
+ clauses.append("substr(source,1,?)=?")
650
+ values.extend((len(prefix), prefix))
651
+ if action_type is not None:
652
+ clauses.append("action_type=?")
653
+ values.append(cls._validate_text("action_type", action_type))
654
+ if action_ids is not None:
655
+ ids = tuple(str(action_id) for action_id in action_ids)
656
+ if not ids:
657
+ clauses.append("0")
658
+ else:
659
+ clauses.append("action_id IN (%s)" % ",".join("?" for _ in ids))
660
+ values.extend(ids)
661
+ return (" AND " + " AND ".join(clauses) if clauses else ""), values
662
+
663
+ def _dispatch_observation_recovery(self, cursor, row):
664
+ """Return one valid immutable GET-only recovery contract, if present.
665
+
666
+ Ordinary dispatched actions keep the conservative lease-reaper
667
+ behavior (terminal ambiguity). Only a contract inserted atomically
668
+ by :meth:`begin_owner_authorized_dispatch` changes an expired
669
+ dispatch into read-only reconciliation work.
670
+ """
671
+
672
+ receipts = cursor.execute(
673
+ "SELECT * FROM receipts WHERE action_id=? AND kind=?",
674
+ (row["action_id"], RECOVERY_RECEIPT_KIND),
675
+ ).fetchall()
676
+ if len(receipts) != 1:
677
+ return None
678
+ receipt = receipts[0]
679
+ try:
680
+ evidence = json.loads(receipt["evidence_json"])
681
+ issued_at = float(evidence.get("issued_at"))
682
+ deadline = float(evidence.get("observation_deadline"))
683
+ except Exception:
684
+ return None
685
+ execution_digest = evidence.get("execution_digest")
686
+ fields = {
687
+ "schema",
688
+ "version",
689
+ "action_id",
690
+ "action_digest",
691
+ "execution_digest",
692
+ "issued_at",
693
+ "observation_deadline",
694
+ "max_observations",
695
+ }
696
+ if (
697
+ not isinstance(evidence, dict)
698
+ or set(evidence) != fields
699
+ or evidence.get("schema") != RECOVERY_SCHEMA
700
+ or isinstance(evidence.get("version"), bool)
701
+ or evidence.get("version") != 1
702
+ or evidence.get("action_id") != row["action_id"]
703
+ or not hmac.compare_digest(
704
+ str(evidence.get("action_digest") or ""),
705
+ str(row["payload_sha256"] or ""),
706
+ )
707
+ or not isinstance(execution_digest, str)
708
+ or len(execution_digest) != 64
709
+ or any(
710
+ character not in "0123456789abcdef"
711
+ for character in execution_digest
712
+ )
713
+ or receipt["receipt_key"]
714
+ != "dispatch-observation-recovery:" + execution_digest
715
+ or receipt["status"] != "observation_only"
716
+ or receipt["external_id"] != execution_digest
717
+ or not hmac.compare_digest(
718
+ str(receipt["evidence_sha256"] or ""), sha256_json_utf8(evidence)
719
+ )
720
+ or not math.isfinite(issued_at)
721
+ or not math.isfinite(deadline)
722
+ or issued_at <= 0
723
+ or deadline <= issued_at
724
+ or deadline - issued_at > 60 * 60
725
+ or float(receipt["created_at"]) != issued_at
726
+ or isinstance(evidence.get("max_observations"), bool)
727
+ or not isinstance(evidence.get("max_observations"), int)
728
+ or not 1 <= evidence["max_observations"] <= 100
729
+ ):
730
+ return None
731
+ return evidence
732
+
733
+ def _dispatch_observation_attempts(self, cursor, row, recovery):
734
+ rows = cursor.execute(
735
+ "SELECT * FROM receipts WHERE action_id=? AND kind=?",
736
+ (row["action_id"], OBSERVATION_RECEIPT_KIND),
737
+ ).fetchall()
738
+ by_attempt = {}
739
+ fields = {
740
+ "schema",
741
+ "version",
742
+ "action_id",
743
+ "action_digest",
744
+ "execution_digest",
745
+ "attempt",
746
+ "started_at",
747
+ }
748
+ for receipt in rows:
749
+ try:
750
+ evidence = json.loads(receipt["evidence_json"])
751
+ started_at = float(evidence.get("started_at"))
752
+ except Exception as error:
753
+ raise ActionTransitionError(
754
+ "dispatch observation journal is invalid"
755
+ ) from error
756
+ attempt = evidence.get("attempt")
757
+ execution_digest = recovery["execution_digest"]
758
+ if (
759
+ not isinstance(evidence, dict)
760
+ or set(evidence) != fields
761
+ or evidence.get("schema") != OBSERVATION_SCHEMA
762
+ or isinstance(evidence.get("version"), bool)
763
+ or evidence.get("version") != 1
764
+ or evidence.get("action_id") != row["action_id"]
765
+ or not hmac.compare_digest(
766
+ str(evidence.get("action_digest") or ""),
767
+ str(row["payload_sha256"] or ""),
768
+ )
769
+ or evidence.get("execution_digest") != execution_digest
770
+ or isinstance(attempt, bool)
771
+ or not isinstance(attempt, int)
772
+ or not 1 <= attempt <= recovery["max_observations"]
773
+ or receipt["receipt_key"]
774
+ != "dispatch-observation:%s:%03d" % (execution_digest, attempt)
775
+ or receipt["status"] != "started"
776
+ or receipt["external_id"] != execution_digest
777
+ or not hmac.compare_digest(
778
+ str(receipt["evidence_sha256"] or ""), sha256_json_utf8(evidence)
779
+ )
780
+ or not math.isfinite(started_at)
781
+ or started_at < float(recovery["issued_at"])
782
+ or started_at > float(recovery["observation_deadline"])
783
+ or float(receipt["created_at"]) != started_at
784
+ or attempt in by_attempt
785
+ ):
786
+ raise ActionTransitionError(
787
+ "dispatch observation journal is invalid"
788
+ )
789
+ by_attempt[attempt] = receipt
790
+ if set(by_attempt) != set(range(1, len(by_attempt) + 1)):
791
+ raise ActionTransitionError("dispatch observation journal is invalid")
792
+ return [by_attempt[index] for index in range(1, len(by_attempt) + 1)]
793
+
794
+ def _terminalize_dispatch_observation(
795
+ self, cursor, row, recovery, now, *, actor, reason, observations
796
+ ):
797
+ message = (
798
+ "governed action outcome remains explicitly ambiguous after "
799
+ "bounded GET-only reconciliation"
800
+ )
801
+ result = {
802
+ "status": "ambiguous",
803
+ "effect_state": "unknown",
804
+ "execution_digest": recovery["execution_digest"],
805
+ "observation_attempts": int(observations),
806
+ "reason": reason,
807
+ }
808
+ self._transition(
809
+ cursor,
810
+ row,
811
+ STATE_FAILED,
812
+ actor,
813
+ "dispatch_observation_ambiguous",
814
+ {
815
+ "reason": reason,
816
+ "observations": int(observations),
817
+ "execution_digest": recovery["execution_digest"],
818
+ },
819
+ now,
820
+ result=result,
821
+ error=message,
822
+ clear_lease=True,
823
+ )
824
+ self._dead_letter_action(cursor, row["action_id"], message, now)
825
+ return self._action_dict(self._get_action_row(cursor, row["action_id"]))
826
+
827
+ def _recover_expired_action_leases(
828
+ self,
829
+ cursor,
830
+ now,
831
+ *,
832
+ source=None,
833
+ source_prefix=None,
834
+ action_type=None,
835
+ action_ids=None,
836
+ ):
837
+ scope, scope_values = self._lease_scope(
838
+ source=source,
839
+ source_prefix=source_prefix,
840
+ action_type=action_type,
841
+ action_ids=action_ids,
842
+ )
843
+ dispatched = cursor.execute(
844
+ """SELECT * FROM actions
845
+ WHERE state=? AND lease_expires_at IS NOT NULL AND lease_expires_at<=?%s"""
846
+ % scope,
847
+ (STATE_DISPATCHED, now, *scope_values),
848
+ ).fetchall()
849
+ for row in dispatched:
850
+ recovery = self._dispatch_observation_recovery(cursor, row)
851
+ if recovery is not None:
852
+ try:
853
+ observations = len(
854
+ self._dispatch_observation_attempts(cursor, row, recovery)
855
+ )
856
+ except ActionTransitionError:
857
+ self._terminalize_dispatch_observation(
858
+ cursor,
859
+ row,
860
+ recovery,
861
+ now,
862
+ actor="lease-reaper",
863
+ reason="invalid_observation_journal",
864
+ observations=0,
865
+ )
866
+ continue
867
+ if (
868
+ now >= float(recovery["observation_deadline"])
869
+ or observations >= int(recovery["max_observations"])
870
+ ):
871
+ self._terminalize_dispatch_observation(
872
+ cursor,
873
+ row,
874
+ recovery,
875
+ now,
876
+ actor="lease-reaper",
877
+ reason="observation_bound_exhausted",
878
+ observations=observations,
879
+ )
880
+ continue
881
+ cursor.execute(
882
+ """UPDATE actions
883
+ SET lease_owner=NULL,lease_expires_at=NULL,updated_at=?
884
+ WHERE action_id=?""",
885
+ (now, row["action_id"]),
886
+ )
887
+ self._add_event(
888
+ cursor,
889
+ row["action_id"],
890
+ "dispatch_observation_lease_expired",
891
+ row["state"],
892
+ row["state"],
893
+ "lease-reaper",
894
+ {
895
+ "previous_owner": row["lease_owner"],
896
+ "mode": "get_only",
897
+ "observations": observations,
898
+ },
899
+ now,
900
+ )
901
+ continue
902
+ reason = "dispatch lease expired; external outcome is ambiguous"
903
+ self._transition(
904
+ cursor,
905
+ row,
906
+ STATE_FAILED,
907
+ "lease-reaper",
908
+ "ambiguous_dispatch_expired",
909
+ {"reason": reason, "previous_owner": row["lease_owner"]},
910
+ now,
911
+ error=reason,
912
+ clear_lease=True,
913
+ )
914
+ self._dead_letter_action(cursor, row["action_id"], reason, now)
915
+
916
+ recoverable = cursor.execute(
917
+ """SELECT * FROM actions
918
+ WHERE state IN (?,?,?) AND lease_expires_at IS NOT NULL
919
+ AND lease_expires_at<=?%s""" % scope,
920
+ (
921
+ STATE_GATED,
922
+ STATE_ACCEPTED,
923
+ STATE_VERIFIED,
924
+ now,
925
+ *scope_values,
926
+ ),
927
+ ).fetchall()
928
+ for row in recoverable:
929
+ cursor.execute(
930
+ """UPDATE actions SET lease_owner=NULL,lease_expires_at=NULL,updated_at=?
931
+ WHERE action_id=?""",
932
+ (now, row["action_id"]),
933
+ )
934
+ self._add_event(
935
+ cursor,
936
+ row["action_id"],
937
+ "lease_expired",
938
+ row["state"],
939
+ row["state"],
940
+ "lease-reaper",
941
+ {"previous_owner": row["lease_owner"]},
942
+ now,
943
+ )
944
+
945
+ def recover_expired_leases(
946
+ self, *, source=None, source_prefix=None, action_type=None, action_ids=None
947
+ ):
948
+ now = float(self._clock())
949
+ with self._transaction() as cursor:
950
+ self._recover_expired_action_leases(
951
+ cursor,
952
+ now,
953
+ source=source,
954
+ source_prefix=source_prefix,
955
+ action_type=action_type,
956
+ action_ids=action_ids,
957
+ )
958
+
959
+ def lease_next(
960
+ self,
961
+ owner,
962
+ *,
963
+ lease_seconds=60.0,
964
+ states=(STATE_GATED, STATE_ACCEPTED, STATE_VERIFIED),
965
+ source=None,
966
+ source_prefix=None,
967
+ action_type=None,
968
+ action_ids=None,
969
+ ):
970
+ owner = self._validate_text("owner", owner)
971
+ lease_seconds = float(lease_seconds)
972
+ if not math.isfinite(lease_seconds) or lease_seconds <= 0:
973
+ raise ActionStoreError("lease_seconds must be positive")
974
+ states = tuple(str(state) for state in states)
975
+ if not states or any(state not in _LEASEABLE_STATES for state in states):
976
+ raise ActionStoreError(
977
+ "only gated, accepted, or verified actions are leaseable"
978
+ )
979
+ if action_ids is not None:
980
+ action_ids = tuple(str(action_id) for action_id in action_ids)
981
+ if not action_ids:
982
+ return None
983
+ now = float(self._clock())
984
+ state_placeholders = ",".join("?" for _ in states)
985
+ action_filter, scoped_values = self._lease_scope(
986
+ source=source,
987
+ source_prefix=source_prefix,
988
+ action_type=action_type,
989
+ action_ids=action_ids,
990
+ )
991
+ values = list(states) + [now, now]
992
+ values.extend(scoped_values)
993
+ with self._transaction() as cursor:
994
+ self._recover_expired_action_leases(
995
+ cursor,
996
+ now,
997
+ source=source,
998
+ source_prefix=source_prefix,
999
+ action_type=action_type,
1000
+ action_ids=action_ids,
1001
+ )
1002
+ row = cursor.execute(
1003
+ """SELECT * FROM actions
1004
+ WHERE state IN (%s) AND next_attempt_at<=?
1005
+ AND (lease_owner IS NULL OR lease_expires_at<=?)%s
1006
+ ORDER BY next_attempt_at, created_at, action_id LIMIT 1"""
1007
+ % (state_placeholders, action_filter),
1008
+ tuple(values),
1009
+ ).fetchone()
1010
+ if row is None:
1011
+ return None
1012
+ expires = now + lease_seconds
1013
+ cursor.execute(
1014
+ """UPDATE actions SET lease_owner=?,lease_expires_at=?,updated_at=?
1015
+ WHERE action_id=?""",
1016
+ (owner, expires, now, row["action_id"]),
1017
+ )
1018
+ self._add_event(
1019
+ cursor,
1020
+ row["action_id"],
1021
+ "leased",
1022
+ row["state"],
1023
+ row["state"],
1024
+ owner,
1025
+ {"lease_expires_at": expires},
1026
+ now,
1027
+ )
1028
+ return self._action_dict(self._get_action_row(cursor, row["action_id"]))
1029
+
1030
+ def lease_dispatched_observation(
1031
+ self,
1032
+ owner,
1033
+ *,
1034
+ lease_seconds=60.0,
1035
+ source=None,
1036
+ source_prefix=None,
1037
+ action_type=None,
1038
+ action_ids=None,
1039
+ ):
1040
+ """Lease only dispatched rows carrying a durable GET-only contract.
1041
+
1042
+ This method can never make an ordinary dispatched row retryable and
1043
+ can never transition a row back to ``gated``. Exhausted contracts
1044
+ are terminalized as explicitly ambiguous without another effect.
1045
+ """
1046
+
1047
+ owner = self._validate_text("owner", owner)
1048
+ lease_seconds = float(lease_seconds)
1049
+ if not math.isfinite(lease_seconds) or lease_seconds <= 0:
1050
+ raise ActionStoreError("lease_seconds must be positive")
1051
+ if action_ids is not None:
1052
+ action_ids = tuple(str(action_id) for action_id in action_ids)
1053
+ if not action_ids:
1054
+ return None
1055
+ now = float(self._clock())
1056
+ scope, scope_values = self._lease_scope(
1057
+ source=source,
1058
+ source_prefix=source_prefix,
1059
+ action_type=action_type,
1060
+ action_ids=action_ids,
1061
+ )
1062
+ with self._transaction() as cursor:
1063
+ self._recover_expired_action_leases(
1064
+ cursor,
1065
+ now,
1066
+ source=source,
1067
+ source_prefix=source_prefix,
1068
+ action_type=action_type,
1069
+ action_ids=action_ids,
1070
+ )
1071
+ rows = cursor.execute(
1072
+ """SELECT * FROM actions
1073
+ WHERE state=? AND next_attempt_at<=?
1074
+ AND (lease_owner IS NULL OR lease_expires_at<=?)%s
1075
+ ORDER BY next_attempt_at,created_at,action_id""" % scope,
1076
+ (STATE_DISPATCHED, now, now, *scope_values),
1077
+ ).fetchall()
1078
+ for row in rows:
1079
+ recovery = self._dispatch_observation_recovery(cursor, row)
1080
+ if recovery is None:
1081
+ continue
1082
+ try:
1083
+ observations = len(
1084
+ self._dispatch_observation_attempts(cursor, row, recovery)
1085
+ )
1086
+ except ActionTransitionError:
1087
+ self._terminalize_dispatch_observation(
1088
+ cursor,
1089
+ row,
1090
+ recovery,
1091
+ now,
1092
+ actor=owner,
1093
+ reason="invalid_observation_journal",
1094
+ observations=0,
1095
+ )
1096
+ continue
1097
+ if (
1098
+ now >= float(recovery["observation_deadline"])
1099
+ or observations >= int(recovery["max_observations"])
1100
+ ):
1101
+ self._terminalize_dispatch_observation(
1102
+ cursor,
1103
+ row,
1104
+ recovery,
1105
+ now,
1106
+ actor=owner,
1107
+ reason="observation_bound_exhausted",
1108
+ observations=observations,
1109
+ )
1110
+ continue
1111
+ expires = now + lease_seconds
1112
+ cursor.execute(
1113
+ """UPDATE actions SET lease_owner=?,lease_expires_at=?,updated_at=?
1114
+ WHERE action_id=?""",
1115
+ (owner, expires, now, row["action_id"]),
1116
+ )
1117
+ self._add_event(
1118
+ cursor,
1119
+ row["action_id"],
1120
+ "dispatch_observation_leased",
1121
+ row["state"],
1122
+ row["state"],
1123
+ owner,
1124
+ {"lease_expires_at": expires, "mode": "get_only"},
1125
+ now,
1126
+ )
1127
+ return self._action_dict(
1128
+ self._get_action_row(cursor, row["action_id"])
1129
+ )
1130
+ return None
1131
+
1132
+ # -------------------------------------------- owner-authorized dispatch
1133
+
1134
+ def begin_owner_authorized_dispatch(
1135
+ self,
1136
+ action_id,
1137
+ owner,
1138
+ *,
1139
+ gate_receipt_key,
1140
+ expected_source,
1141
+ expected_source_ref,
1142
+ expected_action_type,
1143
+ expected_payload,
1144
+ expected_approval_id,
1145
+ expected_decision_id,
1146
+ expected_execution_digest,
1147
+ observation_window_seconds,
1148
+ max_observations,
1149
+ gate_validator,
1150
+ ):
1151
+ """Atomically consume one exact, still-live owner approval gate.
1152
+
1153
+ Implements invariants I4 and I5: the caller-supplied
1154
+ ``gate_validator`` runs INSIDE this transaction against the durable
1155
+ receipts re-read here, the store re-checks the structural gate
1156
+ bindings itself, and the GET-only recovery contract is inserted in
1157
+ the same transaction as the ``gated -> dispatched`` transition.
1158
+ """
1159
+
1160
+ gate_receipt_key = self._validate_text("gate_receipt_key", gate_receipt_key)
1161
+ expected_source = self._validate_text("expected_source", expected_source)
1162
+ expected_source_ref = self._validate_text(
1163
+ "expected_source_ref", expected_source_ref
1164
+ )
1165
+ expected_action_type = self._validate_text(
1166
+ "expected_action_type", expected_action_type
1167
+ )
1168
+ expected_approval_id = self._validate_text(
1169
+ "expected_approval_id", expected_approval_id
1170
+ )
1171
+ expected_decision_id = self._validate_text(
1172
+ "expected_decision_id", expected_decision_id
1173
+ )
1174
+ expected_execution_digest = self._validate_text(
1175
+ "expected_execution_digest", expected_execution_digest
1176
+ )
1177
+ if not callable(gate_validator):
1178
+ raise ActionStoreError("gate validator must be callable")
1179
+ try:
1180
+ observation_window = float(observation_window_seconds)
1181
+ except (TypeError, ValueError, OverflowError) as error:
1182
+ raise ActionStoreError("observation window is invalid") from error
1183
+ if (
1184
+ len(expected_execution_digest) != 64
1185
+ or any(
1186
+ character not in "0123456789abcdef"
1187
+ for character in expected_execution_digest
1188
+ )
1189
+ or not math.isfinite(observation_window)
1190
+ or not 1 <= observation_window <= 60 * 60
1191
+ or isinstance(max_observations, bool)
1192
+ or not isinstance(max_observations, int)
1193
+ or not 1 <= max_observations <= 100
1194
+ ):
1195
+ raise ActionStoreError("dispatch observation contract is invalid")
1196
+ expected_payload_json = canonical_json_utf8(expected_payload)
1197
+ expected_payload_digest = hashlib.sha256(
1198
+ expected_payload_json.encode("utf-8")
1199
+ ).hexdigest()
1200
+ now = float(self._clock())
1201
+ with self._transaction() as cursor:
1202
+ row = self._get_action_row(cursor, action_id)
1203
+ self._require_lease(row, owner, now)
1204
+ if row["state"] != STATE_GATED:
1205
+ raise ActionTransitionError("only gated actions can be dispatched")
1206
+ if float(row["next_attempt_at"]) > now:
1207
+ raise ActionTransitionError("action retry backoff has not elapsed")
1208
+ if (
1209
+ row["source"] != expected_source
1210
+ or row["source_ref"] != expected_source_ref
1211
+ or row["action_type"] != expected_action_type
1212
+ or row["payload_json"] != expected_payload_json
1213
+ or not hmac.compare_digest(
1214
+ str(row["payload_sha256"] or ""), expected_payload_digest
1215
+ )
1216
+ ):
1217
+ raise ActionTransitionError(
1218
+ "owner authorization does not bind this action payload"
1219
+ )
1220
+ gates = cursor.execute(
1221
+ "SELECT * FROM receipts WHERE action_id=? AND kind=?",
1222
+ (action_id, GATE_RECEIPT_KIND),
1223
+ ).fetchall()
1224
+ if len(gates) != 1 or gates[0]["receipt_key"] != gate_receipt_key:
1225
+ raise ActionTransitionError(
1226
+ "exactly one owner authorization gate is required"
1227
+ )
1228
+ receipt = gates[0]
1229
+ # Structural in-transaction re-check: shape-agnostic bindings the
1230
+ # store enforces itself, in addition to the semantic validator.
1231
+ try:
1232
+ evidence = json.loads(receipt["evidence_json"])
1233
+ decided_at = float(evidence.get("decided_at_epoch"))
1234
+ expires_at = float(evidence.get("expires_at_epoch"))
1235
+ except Exception as error:
1236
+ raise ActionTransitionError(
1237
+ "owner authorization receipt is invalid"
1238
+ ) from error
1239
+ if (
1240
+ not isinstance(evidence, dict)
1241
+ or receipt["status"] != "passed"
1242
+ or receipt["external_id"] != expected_approval_id
1243
+ or not hmac.compare_digest(
1244
+ str(receipt["evidence_sha256"] or ""),
1245
+ sha256_json_utf8(evidence),
1246
+ )
1247
+ or evidence.get("decision") != "approved"
1248
+ or evidence.get("authority") != "owner"
1249
+ or evidence.get("decision_id") != expected_decision_id
1250
+ or evidence.get("approval_id") != expected_approval_id
1251
+ or evidence.get("action_id") != action_id
1252
+ or not hmac.compare_digest(
1253
+ str(evidence.get("action_digest") or ""),
1254
+ expected_payload_digest,
1255
+ )
1256
+ or isinstance(evidence.get("revision"), bool)
1257
+ or evidence.get("revision") != 1
1258
+ or not math.isfinite(decided_at)
1259
+ or not math.isfinite(expires_at)
1260
+ or decided_at <= 0
1261
+ or decided_at > now + _GATE_CLOCK_SKEW_SECONDS
1262
+ or expires_at <= now
1263
+ or expires_at <= decided_at
1264
+ ):
1265
+ raise ActionTransitionError(
1266
+ "owner authorization receipt does not bind this action"
1267
+ )
1268
+ # Backstop independent of validator and worker configuration: a
1269
+ # grant-bound receipt never dispatches a tool the catalog does
1270
+ # not explicitly allow on standing-grant authority.
1271
+ if evidence.get("binding_method") == GRANT_BINDING_METHOD:
1272
+ intent_value = (
1273
+ expected_payload.get("intent")
1274
+ if isinstance(expected_payload, dict)
1275
+ else None
1276
+ )
1277
+ grant_tool_name = (
1278
+ intent_value.get("tool_name")
1279
+ if isinstance(intent_value, dict)
1280
+ else None
1281
+ )
1282
+ if grant_tool_name not in GRANT_AUTHORIZABLE_TOOL_NAMES:
1283
+ raise ActionTransitionError(
1284
+ "bounded grant authority never covers this tool"
1285
+ )
1286
+ # THE in-transaction semantic re-validation (invariant I4): the
1287
+ # validator sees the durable projections re-read in THIS
1288
+ # transaction and the store's own clock — never the caller's
1289
+ # earlier view.
1290
+ action_projection = self._action_dict(row)
1291
+ receipt_projections = [
1292
+ self._receipt_dict(item)
1293
+ for item in cursor.execute(
1294
+ "SELECT * FROM receipts WHERE action_id=? "
1295
+ "ORDER BY created_at,receipt_id",
1296
+ (action_id,),
1297
+ ).fetchall()
1298
+ ]
1299
+ try:
1300
+ authorization = gate_validator(
1301
+ action_projection, receipt_projections, now
1302
+ )
1303
+ except ActionStoreError:
1304
+ raise
1305
+ except Exception as error:
1306
+ raise ActionTransitionError(
1307
+ "owner gate validator rejected this action"
1308
+ ) from error
1309
+ if (
1310
+ not isinstance(authorization, GateAuthorization)
1311
+ or authorization.expired
1312
+ or authorization.receipt_key != gate_receipt_key
1313
+ or authorization.approval_id != expected_approval_id
1314
+ or authorization.decision_id != expected_decision_id
1315
+ ):
1316
+ raise ActionTransitionError(
1317
+ "owner gate validator did not authorize this dispatch"
1318
+ )
1319
+ existing_recovery = cursor.execute(
1320
+ "SELECT 1 FROM receipts WHERE action_id=? AND kind=?",
1321
+ (action_id, RECOVERY_RECEIPT_KIND),
1322
+ ).fetchone()
1323
+ if existing_recovery is not None:
1324
+ raise ActionTransitionError(
1325
+ "dispatch observation contract already exists"
1326
+ )
1327
+ recovery = {
1328
+ "schema": RECOVERY_SCHEMA,
1329
+ "version": 1,
1330
+ "action_id": action_id,
1331
+ "action_digest": expected_payload_digest,
1332
+ "execution_digest": expected_execution_digest,
1333
+ "issued_at": now,
1334
+ "observation_deadline": now + observation_window,
1335
+ "max_observations": max_observations,
1336
+ }
1337
+ self._insert_receipt(
1338
+ cursor,
1339
+ action_id,
1340
+ "dispatch-observation-recovery:" + expected_execution_digest,
1341
+ RECOVERY_RECEIPT_KIND,
1342
+ "observation_only",
1343
+ recovery,
1344
+ expected_execution_digest,
1345
+ now,
1346
+ now,
1347
+ )
1348
+ attempt = int(row["attempt_count"]) + 1
1349
+ if attempt > int(row["max_attempts"]):
1350
+ raise ActionTransitionError("action attempt budget is exhausted")
1351
+ self._check_transition(row["state"], STATE_DISPATCHED)
1352
+ cursor.execute(
1353
+ """UPDATE actions SET state=?,attempt_count=?,last_error=NULL,updated_at=?
1354
+ WHERE action_id=?""",
1355
+ (STATE_DISPATCHED, attempt, now, row["action_id"]),
1356
+ )
1357
+ self._add_event(
1358
+ cursor,
1359
+ row["action_id"],
1360
+ "dispatched",
1361
+ row["state"],
1362
+ STATE_DISPATCHED,
1363
+ owner,
1364
+ {"attempt": attempt},
1365
+ now,
1366
+ )
1367
+ return self._action_dict(self._get_action_row(cursor, action_id))
1368
+
1369
+ # ------------------------------------------------ dispatched observation
1370
+
1371
+ def begin_dispatched_observation(self, action_id, owner, execution_digest):
1372
+ """Durably consume one bounded GET attempt before external observation."""
1373
+
1374
+ execution_digest = self._validate_text("execution_digest", execution_digest)
1375
+ now = float(self._clock())
1376
+ with self._transaction() as cursor:
1377
+ row = self._get_action_row(cursor, action_id)
1378
+ self._require_lease(row, owner, now)
1379
+ if row["state"] != STATE_DISPATCHED:
1380
+ raise ActionTransitionError(
1381
+ "only dispatched actions can begin effect observation"
1382
+ )
1383
+ recovery = self._dispatch_observation_recovery(cursor, row)
1384
+ if (
1385
+ recovery is None
1386
+ or not hmac.compare_digest(
1387
+ str(recovery["execution_digest"]), execution_digest
1388
+ )
1389
+ ):
1390
+ raise ActionTransitionError(
1391
+ "dispatch observation contract does not bind this action"
1392
+ )
1393
+ try:
1394
+ observations = self._dispatch_observation_attempts(
1395
+ cursor, row, recovery
1396
+ )
1397
+ except ActionTransitionError:
1398
+ action = self._terminalize_dispatch_observation(
1399
+ cursor,
1400
+ row,
1401
+ recovery,
1402
+ now,
1403
+ actor=owner,
1404
+ reason="invalid_observation_journal",
1405
+ observations=0,
1406
+ )
1407
+ return action, None
1408
+ if (
1409
+ now >= float(recovery["observation_deadline"])
1410
+ or len(observations) >= int(recovery["max_observations"])
1411
+ ):
1412
+ action = self._terminalize_dispatch_observation(
1413
+ cursor,
1414
+ row,
1415
+ recovery,
1416
+ now,
1417
+ actor=owner,
1418
+ reason="observation_bound_exhausted",
1419
+ observations=len(observations),
1420
+ )
1421
+ return action, None
1422
+ attempt = len(observations) + 1
1423
+ evidence = {
1424
+ "schema": OBSERVATION_SCHEMA,
1425
+ "version": 1,
1426
+ "action_id": row["action_id"],
1427
+ "action_digest": row["payload_sha256"],
1428
+ "execution_digest": execution_digest,
1429
+ "attempt": attempt,
1430
+ "started_at": now,
1431
+ }
1432
+ receipt_key = "dispatch-observation:%s:%03d" % (
1433
+ execution_digest,
1434
+ attempt,
1435
+ )
1436
+ receipt = self._insert_receipt(
1437
+ cursor,
1438
+ row["action_id"],
1439
+ receipt_key,
1440
+ OBSERVATION_RECEIPT_KIND,
1441
+ "started",
1442
+ evidence,
1443
+ execution_digest,
1444
+ now,
1445
+ now,
1446
+ )
1447
+ self._add_event(
1448
+ cursor,
1449
+ row["action_id"],
1450
+ "dispatch_observation_started",
1451
+ row["state"],
1452
+ row["state"],
1453
+ owner,
1454
+ {"attempt": attempt, "mode": "get_only"},
1455
+ now,
1456
+ )
1457
+ return self._action_dict(row), receipt
1458
+
1459
+ def defer_dispatched_observation(
1460
+ self,
1461
+ action_id,
1462
+ owner,
1463
+ execution_digest,
1464
+ observation_receipt_key,
1465
+ reason,
1466
+ delay_seconds,
1467
+ ):
1468
+ """Record an unresolved GET and either defer or end as ambiguous."""
1469
+
1470
+ execution_digest = self._validate_text("execution_digest", execution_digest)
1471
+ observation_receipt_key = self._validate_text(
1472
+ "observation_receipt_key", observation_receipt_key
1473
+ )
1474
+ reason = self._validate_text("reason", str(reason))[:2000]
1475
+ delay = float(delay_seconds)
1476
+ if not math.isfinite(delay) or not 0 < delay <= 24 * 60 * 60:
1477
+ raise ActionStoreError("defer delay must be between zero and one day")
1478
+ now = float(self._clock())
1479
+ with self._transaction() as cursor:
1480
+ row = self._get_action_row(cursor, action_id)
1481
+ self._require_lease(row, owner, now)
1482
+ if row["state"] != STATE_DISPATCHED:
1483
+ raise ActionTransitionError(
1484
+ "only dispatched observations can be deferred"
1485
+ )
1486
+ recovery = self._dispatch_observation_recovery(cursor, row)
1487
+ if (
1488
+ recovery is None
1489
+ or not hmac.compare_digest(
1490
+ str(recovery["execution_digest"]), execution_digest
1491
+ )
1492
+ ):
1493
+ raise ActionTransitionError(
1494
+ "dispatch observation contract does not bind this action"
1495
+ )
1496
+ observations = self._dispatch_observation_attempts(cursor, row, recovery)
1497
+ if (
1498
+ not observations
1499
+ or observations[-1]["receipt_key"] != observation_receipt_key
1500
+ ):
1501
+ raise ActionTransitionError(
1502
+ "dispatch observation attempt is not the current attempt"
1503
+ )
1504
+ if (
1505
+ now >= float(recovery["observation_deadline"])
1506
+ or len(observations) >= int(recovery["max_observations"])
1507
+ ):
1508
+ return self._terminalize_dispatch_observation(
1509
+ cursor,
1510
+ row,
1511
+ recovery,
1512
+ now,
1513
+ actor=owner,
1514
+ reason="observation_bound_exhausted",
1515
+ observations=len(observations),
1516
+ )
1517
+ next_at = now + delay
1518
+ message = "governed dispatch observation unresolved (%d/%d)" % (
1519
+ len(observations),
1520
+ recovery["max_observations"],
1521
+ )
1522
+ cursor.execute(
1523
+ """UPDATE actions SET next_attempt_at=?,lease_owner=NULL,
1524
+ lease_expires_at=NULL,last_error=?,updated_at=? WHERE action_id=?""",
1525
+ (next_at, message, now, action_id),
1526
+ )
1527
+ self._add_event(
1528
+ cursor,
1529
+ action_id,
1530
+ "dispatch_observation_deferred",
1531
+ row["state"],
1532
+ row["state"],
1533
+ owner,
1534
+ {
1535
+ "reason": reason,
1536
+ "retry_at": next_at,
1537
+ "observation": len(observations),
1538
+ "mode": "get_only",
1539
+ },
1540
+ now,
1541
+ )
1542
+ return self._action_dict(self._get_action_row(cursor, action_id))
1543
+
1544
+ # ------------------------------------------------------- forward states
1545
+
1546
+ def defer_leased(self, action_id, owner, reason, delay_seconds, *, event_type="deferred"):
1547
+ """Release a lease without changing lifecycle state or effect count.
1548
+
1549
+ Only for work safe to repeat in its current state: a gated action
1550
+ whose gate is still unconsumed, or read-only verification after an
1551
+ external mutation produced immutable acceptance evidence.
1552
+ """
1553
+
1554
+ reason = self._validate_text("reason", str(reason))[:2000]
1555
+ delay = float(delay_seconds)
1556
+ if not math.isfinite(delay) or not 0 < delay <= 24 * 60 * 60:
1557
+ raise ActionStoreError("defer delay must be between zero and one day")
1558
+ event_type = self._validate_text("event_type", event_type)
1559
+ now = float(self._clock())
1560
+ with self._transaction() as cursor:
1561
+ row = self._get_action_row(cursor, action_id)
1562
+ self._require_lease(row, owner, now)
1563
+ if row["state"] not in (STATE_GATED, STATE_ACCEPTED, STATE_VERIFIED):
1564
+ raise ActionTransitionError("action state cannot be deferred safely")
1565
+ next_at = now + delay
1566
+ cursor.execute(
1567
+ """UPDATE actions SET next_attempt_at=?,lease_owner=NULL,
1568
+ lease_expires_at=NULL,last_error=?,updated_at=? WHERE action_id=?""",
1569
+ (next_at, reason, now, action_id),
1570
+ )
1571
+ self._add_event(
1572
+ cursor,
1573
+ action_id,
1574
+ event_type,
1575
+ row["state"],
1576
+ row["state"],
1577
+ owner,
1578
+ {"reason": reason, "retry_at": next_at},
1579
+ now,
1580
+ )
1581
+ return self._action_dict(self._get_action_row(cursor, action_id))
1582
+
1583
+ def accept(
1584
+ self,
1585
+ action_id,
1586
+ owner,
1587
+ receipt_key,
1588
+ kind,
1589
+ evidence,
1590
+ *,
1591
+ external_id=None,
1592
+ result=None,
1593
+ ):
1594
+ kind = self._validate_text("kind", kind)
1595
+ if kind in _RESERVED_RECEIPT_KINDS:
1596
+ raise ActionStoreError(
1597
+ "receipt kind %s is reserved for its transactional owner" % kind
1598
+ )
1599
+ now = float(self._clock())
1600
+ with self._transaction() as cursor:
1601
+ row = self._get_action_row(cursor, action_id)
1602
+ self._require_lease(row, owner, now)
1603
+ if row["state"] != STATE_DISPATCHED:
1604
+ raise ActionTransitionError("only dispatched actions can be accepted")
1605
+ receipt = self._insert_receipt(
1606
+ cursor,
1607
+ action_id,
1608
+ receipt_key,
1609
+ kind,
1610
+ "accepted",
1611
+ evidence,
1612
+ external_id,
1613
+ None,
1614
+ now,
1615
+ )
1616
+ self._transition(
1617
+ cursor,
1618
+ row,
1619
+ STATE_ACCEPTED,
1620
+ owner,
1621
+ "accepted",
1622
+ {"receipt_key": receipt_key, "kind": kind},
1623
+ now,
1624
+ result=result,
1625
+ )
1626
+ return self._action_dict(self._get_action_row(cursor, action_id)), receipt
1627
+
1628
+ def verify(
1629
+ self,
1630
+ action_id,
1631
+ owner,
1632
+ receipt_key,
1633
+ evidence,
1634
+ *,
1635
+ qualifying_receipt_keys,
1636
+ kind="verification",
1637
+ ):
1638
+ kind = self._validate_text("kind", kind)
1639
+ if kind in _RESERVED_RECEIPT_KINDS:
1640
+ raise ActionStoreError(
1641
+ "receipt kind %s is reserved for its transactional owner" % kind
1642
+ )
1643
+ qualifying_receipt_keys = tuple(qualifying_receipt_keys or ())
1644
+ if not qualifying_receipt_keys:
1645
+ raise ActionStoreError(
1646
+ "verification requires at least one qualifying receipt"
1647
+ )
1648
+ now = float(self._clock())
1649
+ with self._transaction() as cursor:
1650
+ row = self._get_action_row(cursor, action_id)
1651
+ self._require_lease(row, owner, now)
1652
+ if row["state"] != STATE_ACCEPTED:
1653
+ raise ActionTransitionError("only accepted actions can be verified")
1654
+ placeholders = ",".join("?" for _ in qualifying_receipt_keys)
1655
+ found = cursor.execute(
1656
+ "SELECT receipt_key FROM receipts WHERE action_id=? AND receipt_key IN (%s)"
1657
+ % placeholders,
1658
+ (action_id,) + qualifying_receipt_keys,
1659
+ ).fetchall()
1660
+ found_keys = {item["receipt_key"] for item in found}
1661
+ missing = set(qualifying_receipt_keys) - found_keys
1662
+ if missing:
1663
+ raise ActionNotFound(
1664
+ "qualifying receipts not found: %s" % sorted(missing)
1665
+ )
1666
+ receipt = self._insert_receipt(
1667
+ cursor,
1668
+ action_id,
1669
+ receipt_key,
1670
+ kind,
1671
+ "verified",
1672
+ evidence,
1673
+ None,
1674
+ None,
1675
+ now,
1676
+ )
1677
+ self._transition(
1678
+ cursor,
1679
+ row,
1680
+ STATE_VERIFIED,
1681
+ owner,
1682
+ "verified",
1683
+ {
1684
+ "receipt_key": receipt_key,
1685
+ "qualifying_receipt_keys": list(qualifying_receipt_keys),
1686
+ },
1687
+ now,
1688
+ )
1689
+ return self._action_dict(self._get_action_row(cursor, action_id)), receipt
1690
+
1691
+ def complete(self, action_id, owner, result):
1692
+ now = float(self._clock())
1693
+ with self._transaction() as cursor:
1694
+ row = self._get_action_row(cursor, action_id)
1695
+ self._require_lease(row, owner, now)
1696
+ if row["state"] != STATE_VERIFIED:
1697
+ raise ActionTransitionError(
1698
+ "an action must be verified before completion"
1699
+ )
1700
+ self._transition(
1701
+ cursor,
1702
+ row,
1703
+ STATE_COMPLETED,
1704
+ owner,
1705
+ "completed",
1706
+ {"result_sha256": sha256_json_utf8(result)},
1707
+ now,
1708
+ result=result,
1709
+ clear_lease=True,
1710
+ )
1711
+ return self._action_dict(self._get_action_row(cursor, action_id))
1712
+
1713
+ def fail_attempt(self, action_id, owner, error, retryable):
1714
+ """Terminalize as failed. ``retryable`` is recorded, never honored
1715
+ with a second mutation (invariants I6/I10)."""
1716
+
1717
+ error = self._validate_text("error", str(error))[:2000]
1718
+ now = float(self._clock())
1719
+ with self._transaction() as cursor:
1720
+ row = self._get_action_row(cursor, action_id)
1721
+ self._require_lease(row, owner, now)
1722
+ state = row["state"]
1723
+ if state not in (
1724
+ STATE_GATED,
1725
+ STATE_DISPATCHED,
1726
+ STATE_ACCEPTED,
1727
+ STATE_VERIFIED,
1728
+ ):
1729
+ raise ActionTransitionError("action in %s cannot fail" % state)
1730
+ reason = error
1731
+ if retryable and state != STATE_DISPATCHED:
1732
+ reason = "%s (not retried after %s: outcome may be ambiguous)" % (
1733
+ error,
1734
+ state,
1735
+ )
1736
+ elif retryable:
1737
+ reason = "%s (governed actions are never re-dispatched)" % error
1738
+ self._transition(
1739
+ cursor,
1740
+ row,
1741
+ STATE_FAILED,
1742
+ owner,
1743
+ "failed",
1744
+ {
1745
+ "error": reason,
1746
+ "attempt": row["attempt_count"],
1747
+ "retryable_requested": bool(retryable),
1748
+ },
1749
+ now,
1750
+ error=reason,
1751
+ clear_lease=True,
1752
+ )
1753
+ self._dead_letter_action(cursor, action_id, reason, now)
1754
+ return self._action_dict(self._get_action_row(cursor, action_id))
1755
+
1756
+ # ------------------------------------------------------------ read side
1757
+
1758
+ def get_action(self, action_id):
1759
+ with self._lock:
1760
+ if self._conn is None:
1761
+ raise ActionStoreError("action store is closed")
1762
+ row = self._conn.execute(
1763
+ "SELECT * FROM actions WHERE action_id=?", (action_id,)
1764
+ ).fetchone()
1765
+ if row is None:
1766
+ raise ActionNotFound("action %s not found" % action_id)
1767
+ return self._action_dict(row)
1768
+
1769
+ def list_receipts(self, action_id):
1770
+ with self._lock:
1771
+ if self._conn is None:
1772
+ raise ActionStoreError("action store is closed")
1773
+ rows = self._conn.execute(
1774
+ "SELECT * FROM receipts WHERE action_id=? ORDER BY created_at,receipt_id",
1775
+ (action_id,),
1776
+ ).fetchall()
1777
+ return [self._receipt_dict(row) for row in rows]
1778
+
1779
+ def list_events(self, action_id):
1780
+ with self._lock:
1781
+ if self._conn is None:
1782
+ raise ActionStoreError("action store is closed")
1783
+ rows = self._conn.execute(
1784
+ "SELECT * FROM action_events WHERE action_id=? ORDER BY event_id",
1785
+ (action_id,),
1786
+ ).fetchall()
1787
+ result = []
1788
+ for row in rows:
1789
+ item = dict(row)
1790
+ item["details"] = self._json_load(item.pop("details_json"))
1791
+ result.append(item)
1792
+ return result
1793
+
1794
+ def list_dead_letters(self):
1795
+ with self._lock:
1796
+ if self._conn is None:
1797
+ raise ActionStoreError("action store is closed")
1798
+ rows = self._conn.execute(
1799
+ "SELECT * FROM dead_letters ORDER BY created_at,dead_letter_id"
1800
+ ).fetchall()
1801
+ result = []
1802
+ for row in rows:
1803
+ item = dict(row)
1804
+ item["action_snapshot"] = self._json_load(
1805
+ item.pop("action_snapshot_json")
1806
+ )
1807
+ result.append(item)
1808
+ return result
1809
+
1810
+
1811
+ __all__ = (
1812
+ "OBSERVATION_SCHEMA",
1813
+ "RECOVERY_SCHEMA",
1814
+ "SqliteActionStore",
1815
+ )