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,446 @@
1
+ """The ActionStore protocol: durable state a governed host worker runs on.
2
+
3
+ This module is the CONTRACT. The numbered invariants below are not
4
+ descriptions of the reference implementation — they are the obligations any
5
+ conforming store must uphold, each one enforced by a named case in
6
+ :mod:`apsimo_hostworker.conformance`. A store that satisfies the method
7
+ signatures but violates an invariant WILL dispatch a second mutation, honor
8
+ a dead approval, or resurrect a consumed authorization under crash or
9
+ concurrency; the conformance suite exists to catch exactly that before a
10
+ host runs live.
11
+
12
+ Vocabulary
13
+ ==========
14
+ * *action* — one immutable proposed side effect: identity fields plus a
15
+ canonical-JSON payload pinned by ``payload_sha256`` (UTF-8 canonical-JSON
16
+ convention).
17
+ * *receipt* — one immutable, append-only evidence record attached to an
18
+ action, addressed by ``(action_id, receipt_key)``, its evidence pinned by
19
+ ``evidence_sha256``.
20
+ * *lease* — an exclusive, expiring claim (``owner``, ``lease_expires_at``)
21
+ required for every mutating call.
22
+ * *gate* — the receipt of ``kind == "gate"`` holding the owner-approval
23
+ evidence validated by :func:`apsimo_hostworker.gate.validate_owner_gate`.
24
+ * *recovery contract* — the immutable receipt of ``kind ==
25
+ "dispatch_recovery"`` that converts a dispatched action from "ambiguous,
26
+ fail on lease expiry" into "reconcile by bounded GET-only observation".
27
+
28
+ Lifecycle
29
+ =========
30
+ ``proposed -> gated -> dispatched -> accepted -> verified -> completed``
31
+ with ``failed`` reachable from every non-terminal state. ``dispatched``
32
+ NEVER returns to ``gated``: the governed subset pins every action to
33
+ ``max_attempts == 1``, so once a mutation may have been attempted the only
34
+ forward paths are acceptance or explicit ambiguity. (This is deliberately
35
+ stricter than a general-purpose action queue.)
36
+
37
+ TRANSACTIONAL INVARIANTS — THE CONTRACT
38
+ =======================================
39
+
40
+ I1. IMMUTABLE IDENTITY. ``action_id``, ``idempotency_key``, ``source``,
41
+ ``source_ref``, ``action_type``, the payload, ``payload_sha256``,
42
+ ``max_attempts``, and ``created_at`` never change after insertion.
43
+ ``get_action`` re-derives the payload digest from the stored payload and
44
+ refuses to return a row whose digest no longer matches.
45
+
46
+ I2. APPEND-ONLY RECEIPTS. Receipts are never updated or deleted. Writing
47
+ ``(action_id, receipt_key)`` again with byte-identical evidence, kind,
48
+ status, and external_id is an idempotent no-op returning the original;
49
+ with anything else it raises :class:`ActionIdempotencyConflict`.
50
+
51
+ I3. SINGLE-WRITER LEASES. Every mutating method verifies, inside its own
52
+ transaction, that the caller currently holds an unexpired lease on the
53
+ action; otherwise it raises :class:`ActionLeaseConflict` and writes
54
+ NOTHING. Lease checks use the store's clock, never the caller's.
55
+
56
+ I4. IN-TRANSACTION GATE RE-VALIDATION. ``begin_owner_authorized_dispatch``
57
+ takes the gate validator AS A PARAMETER and invokes it INSIDE the same
58
+ transaction that performs the ``gated -> dispatched`` transition,
59
+ passing the durable action projection, the durable receipt list re-read
60
+ within that transaction, and the store's own clock reading. A store
61
+ implementation therefore CANNOT forget to re-validate, and the evidence
62
+ validated is what is committed — not what the caller looked at earlier.
63
+ If the validator raises, returns anything but a
64
+ :class:`~apsimo_hostworker.gate.GateAuthorization`, returns one marked
65
+ ``expired``, or returns one whose ``receipt_key`` / ``approval_id`` /
66
+ ``decision_id`` differ from the caller's expectations, the transaction
67
+ aborts with no state change and no receipt written.
68
+
69
+ I5. ATOMIC RECOVERY CONTRACT. The ``gated -> dispatched`` transition and
70
+ the insertion of the immutable GET-only recovery contract (bound to the
71
+ exact ``execution_digest`` about to be PUT) commit in ONE transaction —
72
+ never one without the other. A second recovery contract for the same
73
+ action is refused. Together with I4 this is the one-mutation
74
+ guarantee's durable half: any crash after commit leaves an action that
75
+ can only ever be observed, and any crash before commit leaves an action
76
+ whose gate is still unconsumed and whose attempt count is unchanged.
77
+
78
+ I6. ONE MUTATION, EVER. ``attempt_count`` increments exactly once, inside
79
+ ``begin_owner_authorized_dispatch``. A ``dispatched`` action is never
80
+ returned by ``lease_next`` and never transitions back to ``gated``.
81
+ The ONLY way to lease it is ``lease_dispatched_observation``, and only
82
+ while a valid recovery contract with remaining budget exists; a
83
+ dispatched action WITHOUT a valid contract is terminalized as
84
+ explicitly ambiguous (``failed``, dead-lettered) when its lease
85
+ expires — it is never silently retried.
86
+
87
+ I7. BOUNDED OBSERVATION. ``begin_dispatched_observation`` durably journals
88
+ observation attempt N (an immutable receipt) BEFORE the caller performs
89
+ any network read, and refuses — terminalizing as ambiguous — once the
90
+ contract's ``max_observations`` or ``observation_deadline`` is reached.
91
+ A corrupt or forged observation journal terminalizes as ambiguous
92
+ rather than granting more attempts.
93
+
94
+ I8. MONOTONE LIFECYCLE. Only the transitions in
95
+ :data:`ALLOWED_TRANSITIONS` are possible, checked inside each
96
+ transaction; terminal states are frozen forever. Every transition
97
+ appends an immutable event record.
98
+
99
+ I9. VERIFIED MEANS DURABLY WITNESSED. ``verify`` refuses unless every
100
+ named qualifying receipt already exists on the same action in the same
101
+ store, so an action can never reach ``verified`` (and then
102
+ ``completed``) without its durable acceptance evidence.
103
+
104
+ I10. FAILURE IS TERMINAL AND EVIDENT. ``fail_attempt`` moves the action to
105
+ ``failed``, records the error, clears the lease, and dead-letters the
106
+ action in the same transaction. It never re-opens a dispatched action
107
+ for another mutation regardless of the ``retryable`` argument.
108
+
109
+ I11. THE STORE'S CLOCK JUDGES TIME. Expiries, lease deadlines, observation
110
+ deadlines, and the ``now`` handed to the gate validator all come from
111
+ the store's injected clock read inside the transaction. Caller-
112
+ supplied timestamps are data, never authority.
113
+
114
+ Error taxonomy
115
+ ==============
116
+ All refusals raise :class:`ActionStoreError` subclasses. Callers treat any
117
+ of them as "no mutation happened here" — which is only true because of
118
+ I3/I4/I8 (refusals roll back whole transactions).
119
+ """
120
+
121
+ from __future__ import annotations
122
+
123
+ from typing import Any, Callable, Mapping, Protocol, Sequence, runtime_checkable
124
+
125
+ from .gate import GateAuthorization
126
+
127
+
128
+ class ActionStoreError(RuntimeError):
129
+ """Base class: the store refused an operation and wrote nothing."""
130
+
131
+
132
+ class ActionNotFound(ActionStoreError):
133
+ """The requested durable record does not exist."""
134
+
135
+
136
+ class ActionIdempotencyConflict(ActionStoreError):
137
+ """An idempotency key or receipt key was reused for different content."""
138
+
139
+
140
+ class ActionLeaseConflict(ActionStoreError):
141
+ """The action is not currently leased by the requesting owner."""
142
+
143
+
144
+ class ActionTransitionError(ActionStoreError):
145
+ """The requested lifecycle transition or binding is not permitted."""
146
+
147
+
148
+ STATE_PROPOSED = "proposed"
149
+ STATE_GATED = "gated"
150
+ STATE_DISPATCHED = "dispatched"
151
+ STATE_ACCEPTED = "accepted"
152
+ STATE_VERIFIED = "verified"
153
+ STATE_COMPLETED = "completed"
154
+ STATE_FAILED = "failed"
155
+
156
+ ACTION_STATES = frozenset(
157
+ {
158
+ STATE_PROPOSED,
159
+ STATE_GATED,
160
+ STATE_DISPATCHED,
161
+ STATE_ACCEPTED,
162
+ STATE_VERIFIED,
163
+ STATE_COMPLETED,
164
+ STATE_FAILED,
165
+ }
166
+ )
167
+
168
+ TERMINAL_STATES = frozenset({STATE_COMPLETED, STATE_FAILED})
169
+
170
+ # NOTE: no dispatched -> gated edge (invariant I6). The general-purpose
171
+ # queue this was extracted from allows that edge for retryable work with a
172
+ # remaining attempt budget; the governed subset pins max_attempts == 1 and
173
+ # removes the edge entirely so no store bug can re-open a mutation.
174
+ ALLOWED_TRANSITIONS: dict[str, frozenset[str]] = {
175
+ STATE_PROPOSED: frozenset({STATE_GATED, STATE_FAILED}),
176
+ STATE_GATED: frozenset({STATE_DISPATCHED, STATE_FAILED}),
177
+ STATE_DISPATCHED: frozenset({STATE_ACCEPTED, STATE_FAILED}),
178
+ STATE_ACCEPTED: frozenset({STATE_VERIFIED, STATE_FAILED}),
179
+ STATE_VERIFIED: frozenset({STATE_COMPLETED, STATE_FAILED}),
180
+ STATE_COMPLETED: frozenset(),
181
+ STATE_FAILED: frozenset(),
182
+ }
183
+
184
+ # Receipt kinds with reserved semantics (I5/I7); stores must not let callers
185
+ # forge them through generic receipt insertion paths that skip their checks.
186
+ RECOVERY_RECEIPT_KIND = "dispatch_recovery"
187
+ OBSERVATION_RECEIPT_KIND = "dispatch_observation"
188
+ GATE_RECEIPT_KIND = "gate"
189
+
190
+ # The in-transaction gate validator (I4): called by the store as
191
+ # ``gate_validator(action, receipts, now)`` where ``action`` is the durable
192
+ # action projection, ``receipts`` the durable receipt list re-read inside
193
+ # the dispatch transaction, and ``now`` the store's clock reading. It must
194
+ # return a non-expired GateAuthorization or raise.
195
+ GateValidator = Callable[
196
+ [Mapping[str, Any], Sequence[Mapping[str, Any]], float], GateAuthorization
197
+ ]
198
+
199
+
200
+ @runtime_checkable
201
+ class ActionStore(Protocol):
202
+ """Durable action store contract — see the module docstring invariants.
203
+
204
+ Method docstrings state each method's obligations; the invariants I1-I11
205
+ above bind every method. ``action`` return values are plain mappings
206
+ with at least: ``action_id``, ``idempotency_key``, ``source``,
207
+ ``source_ref``, ``action_type``, ``payload``, ``payload_sha256``,
208
+ ``state``, ``attempt_count``, ``max_attempts``, ``next_attempt_at``,
209
+ ``lease_owner``, ``lease_expires_at``, ``last_error``, ``result``,
210
+ ``created_at``, ``updated_at``, ``terminal_at``. Receipt mappings carry
211
+ at least: ``action_id``, ``receipt_key``, ``kind``, ``status``,
212
+ ``external_id``, ``evidence``, ``evidence_sha256``, ``observed_at``,
213
+ ``created_at``.
214
+ """
215
+
216
+ def recover_expired_leases(
217
+ self,
218
+ *,
219
+ source: str | None = None,
220
+ source_prefix: str | None = None,
221
+ action_type: str | None = None,
222
+ action_ids: Sequence[str] | None = None,
223
+ ) -> None:
224
+ """Reap expired leases within the scope, atomically per action.
225
+
226
+ Expired ``gated`` / ``accepted`` / ``verified`` leases are simply
227
+ released (the work is safely repeatable in those states). An
228
+ expired ``dispatched`` lease is released ONLY when a valid recovery
229
+ contract with remaining observation budget exists; otherwise — or
230
+ when the contract is exhausted or its journal invalid — the action
231
+ is terminalized as explicitly ambiguous (I6/I7).
232
+ """
233
+ ...
234
+
235
+ def lease_next(
236
+ self,
237
+ owner: str,
238
+ *,
239
+ lease_seconds: float,
240
+ states: Sequence[str],
241
+ source: str | None = None,
242
+ source_prefix: str | None = None,
243
+ action_type: str | None = None,
244
+ action_ids: Sequence[str] | None = None,
245
+ ) -> Mapping[str, Any] | None:
246
+ """Atomically claim one ready action in ``states`` for ``owner``.
247
+
248
+ Only ``gated``, ``accepted``, and ``verified`` are leaseable here —
249
+ NEVER ``dispatched`` (I6). A row is ready when ``next_attempt_at``
250
+ has passed and no live lease exists. Returns ``None`` when nothing
251
+ is claimable.
252
+ """
253
+ ...
254
+
255
+ def lease_dispatched_observation(
256
+ self,
257
+ owner: str,
258
+ *,
259
+ lease_seconds: float,
260
+ source: str | None = None,
261
+ source_prefix: str | None = None,
262
+ action_type: str | None = None,
263
+ action_ids: Sequence[str] | None = None,
264
+ ) -> Mapping[str, Any] | None:
265
+ """Claim one dispatched action carrying a live recovery contract.
266
+
267
+ The ONLY way a dispatched action becomes leaseable, and only for
268
+ GET-only reconciliation (I6). Dispatched rows whose contract is
269
+ exhausted or invalid are terminalized as ambiguous instead of being
270
+ returned. Never returns an action to ``gated``.
271
+ """
272
+ ...
273
+
274
+ def begin_owner_authorized_dispatch(
275
+ self,
276
+ action_id: str,
277
+ owner: str,
278
+ *,
279
+ gate_receipt_key: str,
280
+ expected_source: str,
281
+ expected_source_ref: str,
282
+ expected_action_type: str,
283
+ expected_payload: Any,
284
+ expected_approval_id: str,
285
+ expected_decision_id: str,
286
+ expected_execution_digest: str,
287
+ observation_window_seconds: float,
288
+ max_observations: int,
289
+ gate_validator: GateValidator,
290
+ ) -> Mapping[str, Any]:
291
+ """Atomically consume one still-live owner gate and open the one
292
+ mutation attempt.
293
+
294
+ In ONE transaction (I4 + I5), the store must:
295
+
296
+ 1. verify the caller's live lease (I3) and ``state == gated`` with
297
+ ``next_attempt_at`` elapsed;
298
+ 2. verify the durable row matches EVERY ``expected_*`` binding —
299
+ source, source_ref, action_type, byte-identical canonical
300
+ payload, and payload digest — so the caller authorized what is
301
+ actually stored, not what it remembered;
302
+ 3. verify exactly one ``gate`` receipt exists and its
303
+ ``receipt_key`` equals ``gate_receipt_key``;
304
+ 4. re-read the durable receipts and CALL ``gate_validator(action,
305
+ receipts, now)`` with the store's clock; require a non-expired
306
+ :class:`~apsimo_hostworker.gate.GateAuthorization` whose
307
+ ``receipt_key``, ``approval_id``, and ``decision_id`` equal the
308
+ expected values (the store additionally re-checks the structural
309
+ gate bindings itself — two layers, both inside the transaction);
310
+ 5. insert the immutable GET-only recovery contract bound to
311
+ ``expected_execution_digest`` with the given observation window
312
+ and budget, refusing if one already exists;
313
+ 6. increment ``attempt_count`` (0 -> 1) and transition
314
+ ``gated -> dispatched``.
315
+
316
+ Any failure anywhere aborts the whole transaction: no transition,
317
+ no receipt, no attempt consumed. On return, the caller holds the
318
+ one permission that will ever exist to PUT this execution request.
319
+ """
320
+ ...
321
+
322
+ def begin_dispatched_observation(
323
+ self, action_id: str, owner: str, execution_digest: str
324
+ ) -> tuple[Mapping[str, Any], Mapping[str, Any] | None]:
325
+ """Durably consume one bounded GET attempt BEFORE observing (I7).
326
+
327
+ Requires a live lease, ``state == dispatched``, and a valid
328
+ recovery contract matching ``execution_digest``. Journals attempt
329
+ N as an immutable receipt and returns ``(action, receipt)``. When
330
+ the budget or deadline is exhausted — or the journal is invalid —
331
+ terminalizes as ambiguous and returns ``(action, None)``.
332
+ """
333
+ ...
334
+
335
+ def defer_dispatched_observation(
336
+ self,
337
+ action_id: str,
338
+ owner: str,
339
+ execution_digest: str,
340
+ observation_receipt_key: str,
341
+ reason: str,
342
+ delay_seconds: float,
343
+ ) -> Mapping[str, Any]:
344
+ """Record an unresolved GET and either defer or end as ambiguous.
345
+
346
+ ``observation_receipt_key`` must name the journal's CURRENT (last)
347
+ attempt — a stale caller cannot defer over a newer attempt. If the
348
+ contract still has budget, releases the lease with
349
+ ``next_attempt_at = now + delay_seconds``; otherwise terminalizes
350
+ as ambiguous (I7). Never re-dispatches.
351
+ """
352
+ ...
353
+
354
+ def accept(
355
+ self,
356
+ action_id: str,
357
+ owner: str,
358
+ receipt_key: str,
359
+ kind: str,
360
+ evidence: Any,
361
+ *,
362
+ external_id: str | None = None,
363
+ result: Any = None,
364
+ ) -> tuple[Mapping[str, Any], Mapping[str, Any]]:
365
+ """Record the endpoint's completed projection and move
366
+ ``dispatched -> accepted`` atomically with the acceptance receipt
367
+ (I2/I8). Requires a live lease."""
368
+ ...
369
+
370
+ def verify(
371
+ self,
372
+ action_id: str,
373
+ owner: str,
374
+ receipt_key: str,
375
+ evidence: Any,
376
+ *,
377
+ qualifying_receipt_keys: Sequence[str],
378
+ ) -> tuple[Mapping[str, Any], Mapping[str, Any]]:
379
+ """Move ``accepted -> verified`` only if every qualifying receipt
380
+ already exists durably on this action (I9). Requires a live
381
+ lease."""
382
+ ...
383
+
384
+ def complete(
385
+ self, action_id: str, owner: str, result: Any
386
+ ) -> Mapping[str, Any]:
387
+ """Move ``verified -> completed`` with the terminal result, clearing
388
+ the lease (I8). Requires a live lease."""
389
+ ...
390
+
391
+ def fail_attempt(
392
+ self, action_id: str, owner: str, error: str, retryable: bool
393
+ ) -> Mapping[str, Any]:
394
+ """Terminalize as ``failed`` with the error, dead-lettering in the
395
+ same transaction (I10). ``retryable`` is recorded but NEVER
396
+ re-opens a dispatched action. Requires a live lease."""
397
+ ...
398
+
399
+ def defer_leased(
400
+ self,
401
+ action_id: str,
402
+ owner: str,
403
+ reason: str,
404
+ delay_seconds: float,
405
+ *,
406
+ event_type: str = "deferred",
407
+ ) -> Mapping[str, Any]:
408
+ """Release the lease without changing state or attempt count, for
409
+ work safe to repeat in its current state (``gated`` before the gate
410
+ is consumed, ``accepted``/``verified`` read-only verification).
411
+ Refuses for ``dispatched`` — that path is
412
+ :meth:`defer_dispatched_observation` (I6)."""
413
+ ...
414
+
415
+ def list_receipts(self, action_id: str) -> Sequence[Mapping[str, Any]]:
416
+ """Return all receipts for the action in insertion order."""
417
+ ...
418
+
419
+ def get_action(self, action_id: str) -> Mapping[str, Any]:
420
+ """Return the action, re-verifying its payload digest (I1). Raises
421
+ :class:`ActionNotFound` if absent."""
422
+ ...
423
+
424
+
425
+ __all__ = (
426
+ "ACTION_STATES",
427
+ "ALLOWED_TRANSITIONS",
428
+ "ActionIdempotencyConflict",
429
+ "ActionLeaseConflict",
430
+ "ActionNotFound",
431
+ "ActionStore",
432
+ "ActionStoreError",
433
+ "ActionTransitionError",
434
+ "GATE_RECEIPT_KIND",
435
+ "GateValidator",
436
+ "OBSERVATION_RECEIPT_KIND",
437
+ "RECOVERY_RECEIPT_KIND",
438
+ "STATE_ACCEPTED",
439
+ "STATE_COMPLETED",
440
+ "STATE_DISPATCHED",
441
+ "STATE_FAILED",
442
+ "STATE_GATED",
443
+ "STATE_PROPOSED",
444
+ "STATE_VERIFIED",
445
+ "TERMINAL_STATES",
446
+ )