conseqa 0.1.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.
conseqa/__init__.py ADDED
@@ -0,0 +1,67 @@
1
+ """Conseqa for Python: verify what your agent actually changed.
2
+
3
+ from conseqa import Conseqa, load_contract_package
4
+
5
+ package = load_contract_package("dist/conseqa")
6
+ refund = package.contract(
7
+ "refund.processed", 1,
8
+ bind_result=lambda reply: {"refundId": reply["id"]},
9
+ )
10
+ conseqa = Conseqa(package, sidecar_token=os.environ["CONSEQA_SIDECAR_TOKEN"])
11
+ reply = conseqa.protect(
12
+ refund,
13
+ action_key="ticket-8042-refund",
14
+ intent={"paymentId": "pay_1001", "amountMinor": 2500, "currency": "USD"},
15
+ execute=lambda call: create_refund(reference=call.provider_correlation_key),
16
+ )
17
+ """
18
+
19
+ from ._canonical import canonical_json, payload_digest
20
+ from ._client import Conseqa, ExecuteContext
21
+ from ._package import (
22
+ Classification,
23
+ Contract,
24
+ ContractPackage,
25
+ IntentContext,
26
+ ambiguous,
27
+ calculate_build_hash,
28
+ load_contract_package,
29
+ not_executed,
30
+ rejected,
31
+ )
32
+ from ._runner import HttpRunnerTransport
33
+ from ._version import __version__
34
+ from .errors import (
35
+ ConseqaError,
36
+ ContractPackageIntegrityError,
37
+ DuplicateActionError,
38
+ ProtectionUnavailableError,
39
+ RunnerRejectedError,
40
+ RunnerUnavailableError,
41
+ UnregisteredContractError,
42
+ )
43
+
44
+ __all__ = [
45
+ "Classification",
46
+ "Conseqa",
47
+ "ConseqaError",
48
+ "Contract",
49
+ "ContractPackage",
50
+ "ContractPackageIntegrityError",
51
+ "DuplicateActionError",
52
+ "ExecuteContext",
53
+ "HttpRunnerTransport",
54
+ "IntentContext",
55
+ "ProtectionUnavailableError",
56
+ "RunnerRejectedError",
57
+ "RunnerUnavailableError",
58
+ "UnregisteredContractError",
59
+ "__version__",
60
+ "ambiguous",
61
+ "calculate_build_hash",
62
+ "canonical_json",
63
+ "load_contract_package",
64
+ "not_executed",
65
+ "payload_digest",
66
+ "rejected",
67
+ ]
conseqa/_canonical.py ADDED
@@ -0,0 +1,119 @@
1
+ """Canonical JSON and hashing, byte for byte what the JavaScript packages produce.
2
+
3
+ The runner and the hosted service compare digests made by either SDK, so the same value
4
+ must give the same text here as `canonicalJson` gives in `@conseqa/contracts`: keys sorted
5
+ by UTF-16 code unit, no whitespace, strings escaped as `JSON.stringify` escapes them, and
6
+ numbers written as JavaScript writes them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import math
14
+ from decimal import Decimal
15
+ from typing import Any, Union
16
+
17
+ #: The largest whole number JavaScript carries exactly. A larger one would be rounded on
18
+ #: its way through the runner, so the digest made here would describe a different value.
19
+ MAX_SAFE_INTEGER = 2**53 - 1
20
+
21
+
22
+ def _utf16_units(key: str) -> bytes:
23
+ return key.encode("utf-16-be", "surrogatepass")
24
+
25
+
26
+ def _string(value: str, path: str) -> str:
27
+ for character in value:
28
+ if 0xD800 <= ord(character) <= 0xDFFF:
29
+ raise TypeError(f"Text with an unpaired surrogate at {path}")
30
+ # json.dumps escapes exactly what JSON.stringify escapes: the quote, the backslash and
31
+ # the control characters below U+0020, with the same short forms and lowercase hex.
32
+ return json.dumps(value, ensure_ascii=False)
33
+
34
+
35
+ def _number(value: float, path: str) -> str:
36
+ """A double the way ECMAScript's Number::toString writes it."""
37
+ if math.isnan(value) or math.isinf(value):
38
+ raise TypeError(f"Canonical JSON cannot contain NaN or Infinity (at {path})")
39
+ if value == 0:
40
+ return "0"
41
+ # repr() gives the shortest digits that read back as the same double, as JavaScript does.
42
+ sign, digits, exponent = Decimal(repr(value)).as_tuple()
43
+ text = "".join(str(digit) for digit in digits).lstrip("0") or "0"
44
+ stripped = text.rstrip("0")
45
+ exponent = int(exponent) + (len(text) - len(stripped))
46
+ text = stripped
47
+ count = len(text)
48
+ point = count + exponent # how many digits stand before the decimal point
49
+ if count <= point <= 21:
50
+ body = text + "0" * (point - count)
51
+ elif 0 < point <= 21:
52
+ body = f"{text[:point]}.{text[point:]}"
53
+ elif -6 < point <= 0:
54
+ body = "0." + "0" * (-point) + text
55
+ else:
56
+ power = point - 1
57
+ mantissa = text if count == 1 else f"{text[0]}.{text[1:]}"
58
+ body = f"{mantissa}e{'+' if power >= 0 else '-'}{abs(power)}"
59
+ return f"-{body}" if sign else body
60
+
61
+
62
+ def canonical_json(value: Any) -> str:
63
+ """Stable JSON text for a JSON value. Anything that is not plain JSON is refused."""
64
+ seen: set[int] = set()
65
+
66
+ def visit(candidate: Any, path: str) -> str:
67
+ if candidate is None:
68
+ return "null"
69
+ if candidate is True:
70
+ return "true"
71
+ if candidate is False:
72
+ return "false"
73
+ if isinstance(candidate, str):
74
+ return _string(candidate, path)
75
+ if isinstance(candidate, int):
76
+ if abs(candidate) > MAX_SAFE_INTEGER:
77
+ raise TypeError(
78
+ f"Whole number at {path} is too large to carry exactly; send it as text"
79
+ )
80
+ return str(candidate)
81
+ if isinstance(candidate, float):
82
+ return _number(candidate, path)
83
+ if isinstance(candidate, (list, tuple, dict)):
84
+ if id(candidate) in seen:
85
+ raise TypeError(f"Circular value at {path}")
86
+ seen.add(id(candidate))
87
+ try:
88
+ if isinstance(candidate, dict):
89
+ for key in candidate:
90
+ if not isinstance(key, str):
91
+ raise TypeError(f"Key that is not text at {path}")
92
+ return (
93
+ "{"
94
+ + ",".join(
95
+ f"{_string(key, path)}:{visit(candidate[key], f'{path}.{key}')}"
96
+ for key in sorted(candidate, key=_utf16_units)
97
+ )
98
+ + "}"
99
+ )
100
+ return (
101
+ "["
102
+ + ",".join(visit(item, f"{path}[{index}]") for index, item in enumerate(candidate))
103
+ + "]"
104
+ )
105
+ finally:
106
+ seen.discard(id(candidate))
107
+ raise TypeError(f"Value that is not JSON at {path}: {type(candidate).__name__}")
108
+
109
+ return visit(value, "$")
110
+
111
+
112
+ def sha256_hex(value: Union[str, bytes]) -> str:
113
+ data = value.encode("utf-8") if isinstance(value, str) else value
114
+ return hashlib.sha256(data).hexdigest()
115
+
116
+
117
+ def payload_digest(intent: Any) -> str:
118
+ """The digest of an intent, as every SDK and the runner compute it."""
119
+ return sha256_hex(canonical_json(intent))
conseqa/_client.py ADDED
@@ -0,0 +1,512 @@
1
+ """`Conseqa.protect`: record what a call is meant to do, make the call, report how it ended.
2
+
3
+ This follows the JavaScript SDK step for step, and sends the runner the same requests:
4
+
5
+ 1. Register the intent. A required action that cannot be registered is not made.
6
+ 2. Record that the call is about to start.
7
+ 3. Make the call. Whatever it returns or raises is what `protect` returns or raises.
8
+ 4. Report how it ended: what it returned and what identifies the result, or what the
9
+ exception proves.
10
+
11
+ The runner does the rest: it reads the system of record and decides the verdict.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import inspect
18
+ import threading
19
+ import uuid
20
+ from dataclasses import dataclass
21
+ from datetime import datetime, timezone
22
+ from typing import Any, Callable, Dict, Mapping, Optional, Union
23
+
24
+ from ._canonical import canonical_json, payload_digest
25
+ from ._package import Classification, Contract, ContractPackage, IntentContext
26
+ from ._runner import HttpRunnerTransport
27
+ from ._telemetry import OperationTelemetry, SignalHandler
28
+ from ._version import __version__
29
+ from .errors import (
30
+ DuplicateActionError,
31
+ ProtectionUnavailableError,
32
+ RunnerRejectedError,
33
+ UnregisteredContractError,
34
+ )
35
+
36
+ JsonObject = Dict[str, Any]
37
+ SDK_NAME = "conseqa-python"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ExecuteContext:
42
+ """What the protected call is given."""
43
+
44
+ #: The reference to send with the call, so its record can be found by it afterwards.
45
+ provider_correlation_key: str
46
+
47
+
48
+ def _iso(moment: datetime) -> str:
49
+ if moment.tzinfo is None:
50
+ raise ValueError("A time without a zone is ambiguous; use a timezone-aware datetime")
51
+ text = moment.astimezone(timezone.utc).isoformat(timespec="milliseconds")
52
+ return text.replace("+00:00", "Z")
53
+
54
+
55
+ def _now() -> datetime:
56
+ return datetime.now(timezone.utc)
57
+
58
+
59
+ def _new_id() -> str:
60
+ return str(uuid.uuid4())
61
+
62
+
63
+ @dataclass
64
+ class _Run:
65
+ contract: Contract
66
+ mode: str
67
+ action_key: str
68
+ intent_id: str
69
+ registration: JsonObject
70
+ context: ExecuteContext
71
+ telemetry: OperationTelemetry
72
+
73
+
74
+ class Conseqa:
75
+ """The client an application keeps for as long as it runs."""
76
+
77
+ def __init__(
78
+ self,
79
+ package: ContractPackage,
80
+ *,
81
+ runner_url: str = "http://127.0.0.1:4319",
82
+ sidecar_token: Optional[str] = None,
83
+ runner: Any = None,
84
+ runtime_heartbeat_interval: float = 30.0,
85
+ instance_id: Optional[str] = None,
86
+ on_signal: Optional[SignalHandler] = None,
87
+ clock: Optional[Callable[[], datetime]] = None,
88
+ id_factory: Optional[Callable[[], str]] = None,
89
+ ) -> None:
90
+ """
91
+ `package` comes from `load_contract_package`. `runner_url` and `sidecar_token` say
92
+ where the runner listens and what it expects; `runner` replaces them with a
93
+ transport of your own. `runtime_heartbeat_interval` is how often, in seconds, the
94
+ application tells the runner which contract build it runs; 0 switches that off.
95
+ """
96
+ if not isinstance(package, ContractPackage):
97
+ raise TypeError("package must come from load_contract_package")
98
+ if runner is not None and sidecar_token is not None:
99
+ raise TypeError("sidecar_token cannot be used with a runner transport of your own")
100
+ if instance_id is not None:
101
+ try:
102
+ instance_id = str(uuid.UUID(instance_id))
103
+ except (ValueError, AttributeError, TypeError) as error:
104
+ raise TypeError(
105
+ "instance_id must be an opaque UUID, not a host name or a person"
106
+ ) from error
107
+ if runtime_heartbeat_interval < 0:
108
+ raise TypeError("runtime_heartbeat_interval must be zero or positive")
109
+ self._package = package
110
+ self._runner = runner or HttpRunnerTransport(runner_url, sidecar_token=sidecar_token)
111
+ self._on_signal = on_signal
112
+ self._clock = clock or _now
113
+ self._new_id = id_factory or _new_id
114
+ self.instance_id = instance_id or _new_id()
115
+ self._heartbeat_interval = float(runtime_heartbeat_interval)
116
+ self._closed = threading.Event()
117
+ self._heartbeat_thread: Optional[threading.Thread] = None
118
+ if self._heartbeat_interval > 0 and hasattr(self._runner, "report_runtime_instance"):
119
+ self._heartbeat_thread = threading.Thread(
120
+ target=self._heartbeats, name="conseqa-heartbeat", daemon=True
121
+ )
122
+ self._heartbeat_thread.start()
123
+
124
+ # ---------------------------------------------------------------- lifecycle of the client
125
+
126
+ def close(self) -> None:
127
+ """Stops the background report of which build this application runs."""
128
+ self._closed.set()
129
+
130
+ def __enter__(self) -> "Conseqa":
131
+ return self
132
+
133
+ def __exit__(self, *_exc: Any) -> None:
134
+ self.close()
135
+
136
+ def _heartbeats(self) -> None:
137
+ while not self._closed.is_set():
138
+ try:
139
+ self._runner.report_runtime_instance(
140
+ {
141
+ "instanceId": self.instance_id,
142
+ "buildHash": self._package.build_hash,
143
+ "packageName": self._package.package_name,
144
+ "packageVersion": self._package.package_version,
145
+ "observedAt": _iso(self._clock()),
146
+ }
147
+ )
148
+ except Exception:
149
+ pass # a diagnostic report must never get in the way of the application
150
+ self._closed.wait(self._heartbeat_interval)
151
+
152
+ # ------------------------------------------------------------------------------- protect
153
+
154
+ def protect(
155
+ self,
156
+ contract: Contract,
157
+ *,
158
+ action_key: str,
159
+ intent: Mapping[str, Any],
160
+ execute: Callable[[ExecuteContext], Any],
161
+ mode: str = "required",
162
+ deadline_at: Union[datetime, str, None] = None,
163
+ ) -> Any:
164
+ """Makes one protected call and returns what it returned.
165
+
166
+ `action_key` is one stable key for the business operation, such as the ticket or
167
+ order it belongs to. `intent` is what the call is meant to do, in the shape of the
168
+ contract's intent schema. `execute` is the call you already make; it is given the
169
+ reference to send with it. With `mode="required"` the call is not made unless the
170
+ intent was recorded first; with `"best_effort"` it goes ahead anyway and shows as
171
+ an unprotected gap.
172
+ """
173
+ if inspect.iscoroutinefunction(execute):
174
+ raise TypeError("execute is a coroutine function; use protect_async")
175
+ run = self._begin(contract, action_key, intent, mode, deadline_at)
176
+ with run.telemetry.active():
177
+ if not (self._register(run) and self._authorize(run)):
178
+ return self._unprotected(run, lambda: execute(run.context))
179
+ try:
180
+ result = execute(run.context)
181
+ except BaseException as error:
182
+ self._after_exception(run, error)
183
+ raise
184
+ if inspect.isawaitable(result):
185
+ # The call was never made: what came back is a promise to make it.
186
+ closer = getattr(result, "close", None)
187
+ if callable(closer):
188
+ closer()
189
+ self._report(
190
+ run,
191
+ self._event(
192
+ run,
193
+ 2,
194
+ "action.exception",
195
+ classification=Classification(
196
+ "not_executed", False, "awaitable_in_protect"
197
+ ).as_json(),
198
+ ),
199
+ )
200
+ run.telemetry.fail()
201
+ run.telemetry.end()
202
+ raise TypeError("execute returned an awaitable; use protect_async")
203
+ self._after_return(run, result)
204
+ return result
205
+
206
+ async def protect_async(
207
+ self,
208
+ contract: Contract,
209
+ *,
210
+ action_key: str,
211
+ intent: Mapping[str, Any],
212
+ execute: Callable[[ExecuteContext], Any],
213
+ mode: str = "required",
214
+ deadline_at: Union[datetime, str, None] = None,
215
+ ) -> Any:
216
+ """`protect` for an application that runs on asyncio. `execute` may be a coroutine."""
217
+ run = self._begin(contract, action_key, intent, mode, deadline_at)
218
+ with run.telemetry.active():
219
+ registered = await asyncio.to_thread(self._register, run)
220
+ if not (registered and await asyncio.to_thread(self._authorize, run)):
221
+
222
+ async def unprotected() -> Any:
223
+ outcome = execute(run.context)
224
+ return await outcome if inspect.isawaitable(outcome) else outcome
225
+
226
+ try:
227
+ result = await unprotected()
228
+ except BaseException:
229
+ run.telemetry.fail()
230
+ run.telemetry.end()
231
+ raise
232
+ run.telemetry.end()
233
+ return result
234
+ try:
235
+ result = execute(run.context)
236
+ if inspect.isawaitable(result):
237
+ result = await result
238
+ except BaseException as error:
239
+ # Shielded: a task cancelled in the middle of a call must still say so.
240
+ await asyncio.shield(asyncio.to_thread(self._after_exception, run, error))
241
+ raise
242
+ await asyncio.to_thread(self._after_return, run, result)
243
+ return result
244
+
245
+ # ------------------------------------------------------------------------------- observe
246
+
247
+ def observe(
248
+ self, operation: str, execute: Callable[[], Any], *, action_key: Optional[str] = None
249
+ ) -> Any:
250
+ """Runs a call without protecting it, and marks it as one that could have been.
251
+
252
+ Coverage is the share of an operation's runs that were protected. This is how the
253
+ runs that were not get counted. It needs the application to have OpenTelemetry.
254
+ """
255
+ del action_key # accepted for parity with the JavaScript SDK; never sent anywhere
256
+ telemetry = OperationTelemetry(operation, self._on_signal)
257
+ telemetry.emit(
258
+ "conseqa.unprotected.observed",
259
+ protection_level="NONE",
260
+ attributes={"explicitly_eligible": True},
261
+ )
262
+ with telemetry.active():
263
+ try:
264
+ result = execute()
265
+ except BaseException:
266
+ telemetry.fail()
267
+ telemetry.end()
268
+ raise
269
+ telemetry.end()
270
+ return result
271
+
272
+ # ----------------------------------------------------------------------------- the steps
273
+
274
+ def _begin(
275
+ self,
276
+ contract: Contract,
277
+ action_key: str,
278
+ intent: Mapping[str, Any],
279
+ mode: str,
280
+ deadline_at: Union[datetime, str, None],
281
+ ) -> _Run:
282
+ if not isinstance(contract, Contract) or contract._package is not self._package:
283
+ raise UnregisteredContractError(
284
+ "The contract did not come from the package this client was given"
285
+ )
286
+ if mode not in ("required", "best_effort"):
287
+ raise ValueError('mode must be "required" or "best_effort"')
288
+ if not isinstance(action_key, str) or not 1 <= len(action_key) <= 512:
289
+ raise ValueError("action_key must be text of 1 to 512 characters")
290
+ if not isinstance(intent, Mapping):
291
+ raise TypeError("intent must be a JSON object")
292
+ intent_body: JsonObject = dict(intent)
293
+ digest = payload_digest(intent_body) # also refuses anything that is not JSON
294
+ intent_id = self._new_id()
295
+ correlation = contract.correlation_key(
296
+ IntentContext(action_key=action_key, intent_id=intent_id, intent=intent_body)
297
+ )
298
+ if not isinstance(correlation, str) or not 1 <= len(correlation) <= 256:
299
+ raise ValueError("The correlation key must be text of 1 to 256 characters")
300
+ telemetry = OperationTelemetry(contract.operation, self._on_signal)
301
+ registration: JsonObject = {
302
+ "intentId": intent_id,
303
+ "actionKey": action_key,
304
+ "operation": contract.operation,
305
+ "protectionMode": mode,
306
+ "protectionLevel": contract.protection_level,
307
+ "contract": {
308
+ "name": contract.name,
309
+ "version": contract.version,
310
+ "buildHash": contract.build_hash,
311
+ },
312
+ "providerCorrelationKey": correlation,
313
+ "intent": intent_body,
314
+ "payloadDigest": digest,
315
+ "registeredAt": _iso(self._clock()),
316
+ "sdk": {"name": SDK_NAME, "version": __version__, "instanceId": self.instance_id},
317
+ }
318
+ if deadline_at is not None:
319
+ registration["deadlineAt"] = (
320
+ _iso(deadline_at)
321
+ if isinstance(deadline_at, datetime)
322
+ else _iso(datetime.fromisoformat(deadline_at.replace("Z", "+00:00")))
323
+ )
324
+ trace_context = telemetry.trace_context
325
+ if trace_context:
326
+ registration["traceContext"] = trace_context
327
+ return _Run(
328
+ contract=contract,
329
+ mode=mode,
330
+ action_key=action_key,
331
+ intent_id=intent_id,
332
+ registration=registration,
333
+ context=ExecuteContext(provider_correlation_key=correlation),
334
+ telemetry=telemetry,
335
+ )
336
+
337
+ def _event(self, run: _Run, sequence: int, kind: str, **fields: Any) -> JsonObject:
338
+ return {
339
+ "eventId": self._new_id(),
340
+ "intentId": run.intent_id,
341
+ "sequence": sequence,
342
+ "occurredAt": _iso(self._clock()),
343
+ "type": kind,
344
+ **fields,
345
+ }
346
+
347
+ def _send(self, run: _Run, event: JsonObject) -> Optional[BaseException]:
348
+ """Sends one event. Returns what went wrong, or None."""
349
+ try:
350
+ self._runner.append_event(event)
351
+ return None
352
+ except Exception as error:
353
+ return error
354
+
355
+ def _register(self, run: _Run) -> bool:
356
+ """True when the intent is recorded. False when the call may go ahead unprotected."""
357
+ telemetry = run.telemetry
358
+ level = run.contract.protection_level
359
+ failure: Optional[BaseException] = None
360
+ acknowledged: Optional[JsonObject] = None
361
+ try:
362
+ acknowledged = self._runner.register_intent(run.registration)
363
+ if not acknowledged["contractHashValidated"]:
364
+ failure = RunnerRejectedError(
365
+ "Runner did not validate the exact contract build hash", 409
366
+ )
367
+ except Exception as error:
368
+ failure = error
369
+
370
+ if acknowledged and acknowledged["status"] == "duplicate":
371
+ telemetry.emit(
372
+ "conseqa.intent.duplicate", intent_id=acknowledged["intentId"], protection_level=level
373
+ )
374
+ telemetry.fail()
375
+ telemetry.end()
376
+ raise DuplicateActionError(
377
+ f"Action key {run.action_key} is already registered; "
378
+ "execution stopped before dispatch",
379
+ acknowledged["intentId"],
380
+ acknowledged["providerCorrelationKey"],
381
+ )
382
+ if acknowledged and failure is None and (
383
+ acknowledged["intentId"] != run.intent_id
384
+ or acknowledged["providerCorrelationKey"] != run.context.provider_correlation_key
385
+ ):
386
+ telemetry.fail()
387
+ telemetry.end()
388
+ raise ProtectionUnavailableError(
389
+ "Runner acknowledgement did not match the newly registered intent; execution stopped"
390
+ )
391
+ if failure is not None:
392
+ telemetry.emit(
393
+ "conseqa.protection.gap",
394
+ intent_id=run.intent_id,
395
+ protection_level="NONE",
396
+ attributes={"phase": "intent_registration", "mode": run.mode},
397
+ )
398
+ if run.mode == "required":
399
+ telemetry.fail()
400
+ telemetry.end()
401
+ raise ProtectionUnavailableError(
402
+ "Required Conseqa intent could not be durably registered"
403
+ ) from failure
404
+ return False
405
+ telemetry.emit(
406
+ "conseqa.intent.registered",
407
+ intent_id=run.intent_id,
408
+ protection_level=level,
409
+ attributes={"durable_via": "runner"},
410
+ )
411
+ return True
412
+
413
+ def _authorize(self, run: _Run) -> bool:
414
+ """Records that the call is about to start. False: go ahead unprotected."""
415
+ telemetry = run.telemetry
416
+ failure = self._send(run, self._event(run, 1, "dispatch.authorized"))
417
+ if failure is None:
418
+ telemetry.emit(
419
+ "conseqa.dispatch.authorized",
420
+ intent_id=run.intent_id,
421
+ protection_level=run.contract.protection_level,
422
+ attributes={"durable_via": "runner"},
423
+ )
424
+ return True
425
+ telemetry.emit(
426
+ "conseqa.protection.gap",
427
+ intent_id=run.intent_id,
428
+ protection_level="NONE",
429
+ attributes={"phase": "dispatch_authorization", "mode": run.mode},
430
+ )
431
+ if run.mode == "required":
432
+ self._send(
433
+ run,
434
+ self._event(
435
+ run,
436
+ 2,
437
+ "action.abandoned",
438
+ reason="Required dispatch authorization could not be durably recorded",
439
+ ),
440
+ )
441
+ telemetry.fail()
442
+ telemetry.end()
443
+ raise ProtectionUnavailableError(
444
+ "Required Conseqa dispatch could not be durably authorized"
445
+ ) from failure
446
+ return False
447
+
448
+ def _unprotected(self, run: _Run, call: Callable[[], Any]) -> Any:
449
+ try:
450
+ result = call()
451
+ except BaseException:
452
+ run.telemetry.fail()
453
+ run.telemetry.end()
454
+ raise
455
+ run.telemetry.end()
456
+ return result
457
+
458
+ def _report(self, run: _Run, event: JsonObject) -> None:
459
+ if self._send(run, event) is not None:
460
+ run.telemetry.emit(
461
+ "conseqa.lifecycle.persistence_failed",
462
+ protection_level="PARTIAL",
463
+ attributes={"phase": event["type"]},
464
+ )
465
+
466
+ def _after_exception(self, run: _Run, error: BaseException) -> None:
467
+ try:
468
+ classification = run.contract.classify_exception(error)
469
+ if not isinstance(classification, Classification):
470
+ raise TypeError("classify_exception must return a Classification")
471
+ except Exception:
472
+ # A classifier that fails has told us nothing, which is what ambiguous means.
473
+ classification = Classification(
474
+ "ambiguous",
475
+ False,
476
+ "classification_failed",
477
+ "The contract exception classifier failed",
478
+ )
479
+ self._report(
480
+ run, self._event(run, 2, "action.exception", classification=classification.as_json())
481
+ )
482
+ run.telemetry.fail()
483
+ run.telemetry.end()
484
+
485
+ def _after_return(self, run: _Run, result: Any) -> None:
486
+ binding: Optional[JsonObject] = None
487
+ binding_failure: Optional[str] = None
488
+ try:
489
+ candidate = run.contract.bind_result(result)
490
+ if candidate is None:
491
+ binding_failure = "The provider result did not contain a usable binding"
492
+ else:
493
+ binding = dict(candidate)
494
+ canonical_json(binding) # a binding is a JSON object or it is nothing
495
+ except Exception:
496
+ binding = None
497
+ binding_failure = "The contract could not bind the provider result"
498
+
499
+ returned = self._event(run, 2, "action.returned", disposition="success")
500
+ if binding is not None:
501
+ returned["binding"] = binding
502
+ self._report(run, returned)
503
+ if binding_failure:
504
+ self._report(run, self._event(run, 3, "binding.failed", reason=binding_failure))
505
+ elif binding is not None:
506
+ run.telemetry.emit(
507
+ "conseqa.result.bound",
508
+ intent_id=run.intent_id,
509
+ protection_level=run.contract.protection_level,
510
+ )
511
+ run.telemetry.end()
512
+