bylaw-python 0.4.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,561 @@
1
+ # Ledgix ALCV — Enforcement Layer
2
+ # Decorator and context manager for intercepting tool calls
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import contextvars
8
+ import functools
9
+ import importlib
10
+ import inspect
11
+ import pkgutil
12
+ import sys
13
+ import types
14
+ from pathlib import Path
15
+ from typing import Any, Callable, TypeVar
16
+
17
+ from .client import LedgixClient
18
+ from .config import VaultConfig
19
+ from .exceptions import ClearanceDeniedError, ReviewPendingError
20
+ from .manifest import Manifest, ManifestRule, load_manifest
21
+ from .models import ClearanceRequest, ClearanceResponse
22
+ from .pending import PendingApproval
23
+
24
+ F = TypeVar("F", bound=Callable[..., Any])
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Global singleton & context variable
28
+ # ---------------------------------------------------------------------------
29
+
30
+ _default_client: LedgixClient | None = None
31
+ _current_clearance: contextvars.ContextVar[ClearanceResponse | None] = contextvars.ContextVar(
32
+ "_ledgix_clearance", default=None
33
+ )
34
+ _manifest: Manifest | None = None
35
+
36
+
37
+ def configure(config: VaultConfig | None = None, **kwargs: Any) -> LedgixClient:
38
+ """Configure the global Ledgix client.
39
+
40
+ Call this once at application startup. All subsequent calls to
41
+ :func:`enforce` will use this client automatically.
42
+
43
+ Keyword arguments are forwarded to :class:`~bylaw_python.VaultConfig`
44
+ when no explicit *config* object is provided::
45
+
46
+ import bylaw_python as ledgix
47
+
48
+ ledgix.configure(agent_id="finance-agent")
49
+
50
+ Args:
51
+ config: Optional pre-built :class:`~bylaw_python.VaultConfig`.
52
+ **kwargs: Config overrides passed to ``VaultConfig`` when *config* is
53
+ ``None``.
54
+
55
+ Returns:
56
+ The newly created :class:`~bylaw_python.LedgixClient`.
57
+ """
58
+ global _default_client
59
+ if config is None:
60
+ config = VaultConfig(**kwargs)
61
+ _default_client = LedgixClient(config)
62
+ return _default_client
63
+
64
+
65
+ def _get_default_client() -> LedgixClient:
66
+ """Return the global client, raising if :func:`configure` was never called."""
67
+ if _default_client is None:
68
+ raise RuntimeError(
69
+ "No Ledgix client configured. Call ledgix.configure() at startup "
70
+ "before using @ledgix.enforce()."
71
+ )
72
+ return _default_client
73
+
74
+
75
+ def current_clearance() -> ClearanceResponse | None:
76
+ """Return the :class:`~bylaw_python.ClearanceResponse` for the current call.
77
+
78
+ Returns ``None`` when called outside an :func:`enforce`-wrapped function.
79
+ """
80
+ return _current_clearance.get()
81
+
82
+
83
+ def current_token() -> str | None:
84
+ """Return the A-JWT token for the current call.
85
+
86
+ Returns ``None`` when called outside an :func:`enforce`-wrapped function or
87
+ when the clearance did not include a token.
88
+ """
89
+ clearance = _current_clearance.get()
90
+ if clearance is None:
91
+ return None
92
+ return clearance.token
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Manifest-driven auto-instrumentation
97
+ # ---------------------------------------------------------------------------
98
+
99
+ def auto_instrument(
100
+ module: types.ModuleType,
101
+ manifest: str | Path | dict[str, Any] | Manifest | None = None,
102
+ recurse: bool = False,
103
+ ) -> list[str]:
104
+ """Scan modules and wrap matching functions according to a manifest.
105
+
106
+ Call once at startup after :func:`configure`::
107
+
108
+ import tools
109
+ import bylaw_python as ledgix
110
+
111
+ ledgix.configure(agent_id="my-agent")
112
+ ledgix.auto_instrument(tools) # reads ledgix.yaml from CWD
113
+
114
+ Or point at a specific manifest::
115
+
116
+ ledgix.auto_instrument(tools, manifest="config/ledgix.yaml")
117
+ ledgix.auto_instrument(tools, manifest={"enforce": [
118
+ {"tool": "stripe_*", "policy_id": "financial-high-risk"},
119
+ ]})
120
+
121
+ Only functions *defined* in the scanned module are wrapped — functions
122
+ imported into it are skipped. If you have tools outside the scanned
123
+ modules use the :func:`tool` decorator as an escape hatch.
124
+
125
+ .. warning::
126
+ Call ``auto_instrument`` before other modules import the tool functions.
127
+ Monkey-patching updates the module namespace, but existing references
128
+ held by already-imported modules will not be retroactively wrapped.
129
+
130
+ Args:
131
+ module: Module or package (when *recurse* is ``True``) to scan.
132
+ manifest: Path to a manifest file, an inline ``dict``, a pre-built
133
+ :class:`~bylaw_python.Manifest`, or ``None`` to auto-discover
134
+ ``ledgix.yaml`` / ``ledgix.yml`` / ``ledgix.json`` in the CWD.
135
+ recurse: If ``True``, also scan sub-packages of any package module.
136
+
137
+ Returns:
138
+ Sorted list of qualified names that were wrapped,
139
+ e.g. ``["tools.stripe_payment", "tools.issue_refund"]``.
140
+ """
141
+ global _manifest
142
+
143
+ if isinstance(manifest, Manifest):
144
+ _manifest = manifest
145
+ else:
146
+ _manifest = load_manifest(manifest)
147
+
148
+ return sorted(_instrument_module(module, _manifest, recurse=recurse))
149
+
150
+
151
+ def _instrument_module(
152
+ module: types.ModuleType,
153
+ manifest: Manifest,
154
+ *,
155
+ recurse: bool,
156
+ ) -> list[str]:
157
+ """Walk *module* and monkey-patch functions that match a manifest rule."""
158
+ wrapped: list[str] = []
159
+ mod_name = module.__name__
160
+
161
+ for attr_name, obj in inspect.getmembers(module, inspect.isfunction):
162
+ if attr_name.startswith("_"):
163
+ continue
164
+ # Skip functions that were imported into this module from elsewhere.
165
+ if obj.__module__ != mod_name:
166
+ continue
167
+ rule = manifest.match(attr_name)
168
+ if rule is None:
169
+ continue
170
+ setattr(module, attr_name, _wrap_with_rule(obj, attr_name, rule))
171
+ wrapped.append(f"{mod_name}.{attr_name}")
172
+
173
+ if recurse and hasattr(module, "__path__"):
174
+ for _, submod_name, _ in pkgutil.walk_packages(
175
+ module.__path__, prefix=mod_name + "."
176
+ ):
177
+ submod = sys.modules.get(submod_name)
178
+ if submod is None:
179
+ submod = importlib.import_module(submod_name)
180
+ wrapped.extend(_instrument_module(submod, manifest, recurse=False))
181
+
182
+ return wrapped
183
+
184
+
185
+ def _wrap_with_rule(func: F, name: str, rule: ManifestRule) -> F:
186
+ """Apply :func:`enforce` to *func* using settings from *rule*."""
187
+ return enforce(
188
+ tool_name=name,
189
+ policy_id=rule.policy_id,
190
+ context=rule.context if rule.context else None,
191
+ )(func)
192
+
193
+
194
+ def tool(
195
+ func: F | None = None,
196
+ *,
197
+ tool_name: str | None = None,
198
+ policy_id: str | None = None,
199
+ context: dict[str, Any] | None = None,
200
+ # Phase 2 — GDPR Article 30 processing-register matching.
201
+ data_categories: list[str] | None = None,
202
+ purpose: str | None = None,
203
+ processing_register_ref: str | None = None,
204
+ # Phase 6 — dataset lineage.
205
+ dataset_ref: str | None = None,
206
+ ) -> F | Callable[[F], F]:
207
+ """Decorator to enforce Vault clearance on a single function.
208
+
209
+ Use this as an escape hatch for functions that live outside the modules
210
+ passed to :func:`auto_instrument`. If a manifest has been loaded its
211
+ rules are applied first; explicit keyword arguments always take precedence.
212
+
213
+ Works with or without call parentheses::
214
+
215
+ @ledgix.tool
216
+ def my_fn(amount: float):
217
+ token = ledgix.current_token()
218
+ ...
219
+
220
+ @ledgix.tool(policy_id="financial-high-risk")
221
+ def stripe_charge(amount: float, customer_id: str):
222
+ token = ledgix.current_token()
223
+ ...
224
+
225
+ Args:
226
+ func: The function to wrap (populated automatically when the decorator
227
+ is used without parentheses).
228
+ tool_name: Override the tool name used in the clearance request
229
+ (defaults to ``func.__name__``).
230
+ policy_id: Policy ID override (manifest match used when omitted).
231
+ context: Extra key/value pairs forwarded to the clearance request.
232
+ data_categories: Personal-data categories this action will touch
233
+ (Phase 2 register matching).
234
+ purpose: Purpose of processing (Phase 2 register matching).
235
+ processing_register_ref: Optional UUID hint of the matching register.
236
+ dataset_ref: Logical dataset ref this action reads/writes (Phase 6).
237
+ """
238
+ def decorator(f: F) -> F:
239
+ resolved_name = tool_name or f.__name__
240
+ resolved_policy = policy_id
241
+ resolved_context: dict[str, Any] = dict(context or {})
242
+
243
+ # Apply manifest rule if available, explicit kwargs take precedence.
244
+ if _manifest is not None:
245
+ rule = _manifest.match(resolved_name)
246
+ if rule is not None:
247
+ if resolved_policy is None:
248
+ resolved_policy = rule.policy_id
249
+ resolved_context = {**rule.context, **resolved_context}
250
+
251
+ return enforce(
252
+ tool_name=resolved_name,
253
+ policy_id=resolved_policy,
254
+ context=resolved_context or None,
255
+ data_categories=data_categories,
256
+ purpose=purpose,
257
+ processing_register_ref=processing_register_ref,
258
+ dataset_ref=dataset_ref,
259
+ )(f)
260
+
261
+ if func is not None:
262
+ # @ledgix.tool — called without parentheses
263
+ return decorator(func)
264
+ # @ledgix.tool(...) — called with arguments
265
+ return decorator
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # New low-code decorator: enforce()
270
+ # ---------------------------------------------------------------------------
271
+
272
+ def enforce(
273
+ *,
274
+ tool_name: str | None = None,
275
+ policy_id: str | None = None,
276
+ context: dict[str, Any] | None = None,
277
+ on_review_pending: Callable[[PendingApproval], None] | None = None,
278
+ # Phase 2 — GDPR Article 30 processing-register matching.
279
+ data_categories: list[str] | None = None,
280
+ purpose: str | None = None,
281
+ processing_register_ref: str | None = None,
282
+ # Phase 6 — dataset lineage.
283
+ dataset_ref: str | None = None,
284
+ ) -> Callable[[F], F]:
285
+ """Decorator that enforces Vault clearance before a function executes.
286
+
287
+ Requires :func:`configure` to have been called at startup. The A-JWT
288
+ token is stored in a context variable and can be retrieved inside the
289
+ decorated function via :func:`current_token`::
290
+
291
+ import bylaw_python as ledgix
292
+
293
+ ledgix.configure(agent_id="finance-agent")
294
+
295
+ @ledgix.enforce(tool_name="stripe_refund")
296
+ def process_refund(amount: float, reason: str):
297
+ token = ledgix.current_token()
298
+ stripe.refund(amount=amount, metadata={"vault_token": token})
299
+
300
+ Works with both sync and async functions. Unlike :func:`vault_enforce`,
301
+ no ``_clearance`` kwarg is injected — the function signature is untouched.
302
+
303
+ Args:
304
+ on_review_pending: Optional callback invoked when ``review_mode="detach"``
305
+ and the Vault returns a ``pending_review`` response. Receives a
306
+ :class:`~bylaw_python.PendingApproval` handle. After the callback
307
+ returns, :class:`~bylaw_python.ReviewPendingError` is re-raised so
308
+ the calling framework can abort the current turn.
309
+ """
310
+
311
+ def decorator(func: F) -> F:
312
+ resolved_name = tool_name or func.__name__
313
+
314
+ if inspect.iscoroutinefunction(func):
315
+
316
+ @functools.wraps(func)
317
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
318
+ client = _get_default_client()
319
+ request = ClearanceRequest(
320
+ tool_name=resolved_name,
321
+ tool_args=_extract_tool_args(func, args, kwargs),
322
+ agent_id=client.config.agent_id,
323
+ session_id=client.config.session_id,
324
+ context={**(context or {}), **({"policy_id": policy_id} if policy_id else {})},
325
+ data_categories=data_categories,
326
+ purpose=purpose,
327
+ processing_register_ref=processing_register_ref,
328
+ dataset_ref=dataset_ref,
329
+ )
330
+ try:
331
+ clearance = await client.arequest_clearance(request)
332
+ except ReviewPendingError as exc:
333
+ if on_review_pending is not None:
334
+ on_review_pending(exc.pending_approval)
335
+ raise
336
+ token = _current_clearance.set(clearance)
337
+ try:
338
+ return await func(*args, **kwargs)
339
+ finally:
340
+ _current_clearance.reset(token)
341
+
342
+ return async_wrapper # type: ignore[return-value]
343
+
344
+ else:
345
+
346
+ @functools.wraps(func)
347
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
348
+ client = _get_default_client()
349
+ request = ClearanceRequest(
350
+ tool_name=resolved_name,
351
+ tool_args=_extract_tool_args(func, args, kwargs),
352
+ agent_id=client.config.agent_id,
353
+ session_id=client.config.session_id,
354
+ context={**(context or {}), **({"policy_id": policy_id} if policy_id else {})},
355
+ data_categories=data_categories,
356
+ purpose=purpose,
357
+ processing_register_ref=processing_register_ref,
358
+ dataset_ref=dataset_ref,
359
+ )
360
+ try:
361
+ clearance = client.request_clearance(request)
362
+ except ReviewPendingError as exc:
363
+ if on_review_pending is not None:
364
+ on_review_pending(exc.pending_approval)
365
+ raise
366
+ token = _current_clearance.set(clearance)
367
+ try:
368
+ return func(*args, **kwargs)
369
+ finally:
370
+ _current_clearance.reset(token)
371
+
372
+ return sync_wrapper # type: ignore[return-value]
373
+
374
+ return decorator
375
+
376
+
377
+ # ---------------------------------------------------------------------------
378
+ # Original explicit decorator: vault_enforce() (unchanged)
379
+ # ---------------------------------------------------------------------------
380
+
381
+ class VaultContext:
382
+ """Context manager that requests clearance before executing a block.
383
+
384
+ Sync usage::
385
+
386
+ with VaultContext(client, "stripe_refund", {"amount": 45}) as ctx:
387
+ # ctx.clearance contains the ClearanceResponse
388
+ execute_refund(ctx.clearance.token)
389
+
390
+ Async usage::
391
+
392
+ async with VaultContext(client, "stripe_refund", {"amount": 45}) as ctx:
393
+ execute_refund(ctx.clearance.token)
394
+ """
395
+
396
+ def __init__(
397
+ self,
398
+ client: LedgixClient,
399
+ tool_name: str,
400
+ tool_args: dict[str, Any] | None = None,
401
+ *,
402
+ context: dict[str, Any] | None = None,
403
+ policy_id: str | None = None,
404
+ data_categories: list[str] | None = None,
405
+ purpose: str | None = None,
406
+ processing_register_ref: str | None = None,
407
+ dataset_ref: str | None = None,
408
+ ) -> None:
409
+ self.client = client
410
+ self.tool_name = tool_name
411
+ self.tool_args = tool_args or {}
412
+ self.context = context or {}
413
+ self.policy_id = policy_id
414
+ self.data_categories = data_categories
415
+ self.purpose = purpose
416
+ self.processing_register_ref = processing_register_ref
417
+ self.dataset_ref = dataset_ref
418
+ self.clearance: ClearanceResponse | None = None
419
+
420
+ def _build_request(self) -> ClearanceRequest:
421
+ ctx = {**self.context}
422
+ if self.policy_id:
423
+ ctx["policy_id"] = self.policy_id
424
+ return ClearanceRequest(
425
+ tool_name=self.tool_name,
426
+ tool_args=self.tool_args,
427
+ agent_id=self.client.config.agent_id,
428
+ session_id=self.client.config.session_id,
429
+ context=ctx,
430
+ data_categories=self.data_categories,
431
+ purpose=self.purpose,
432
+ processing_register_ref=self.processing_register_ref,
433
+ dataset_ref=self.dataset_ref,
434
+ )
435
+
436
+ # Sync context manager
437
+ def __enter__(self) -> VaultContext:
438
+ request = self._build_request()
439
+ self.clearance = self.client.request_clearance(request)
440
+ return self
441
+
442
+ def __exit__(self, *args: Any) -> None:
443
+ pass
444
+
445
+ # Async context manager
446
+ async def __aenter__(self) -> VaultContext:
447
+ request = self._build_request()
448
+ self.clearance = await self.client.arequest_clearance(request)
449
+ return self
450
+
451
+ async def __aexit__(self, *args: Any) -> None:
452
+ pass
453
+
454
+
455
+ def vault_enforce(
456
+ client: LedgixClient,
457
+ *,
458
+ tool_name: str | None = None,
459
+ policy_id: str | None = None,
460
+ context: dict[str, Any] | None = None,
461
+ data_categories: list[str] | None = None,
462
+ purpose: str | None = None,
463
+ processing_register_ref: str | None = None,
464
+ dataset_ref: str | None = None,
465
+ ) -> Callable[[F], F]:
466
+ """Decorator that enforces Vault clearance before a function executes.
467
+
468
+ Works with both sync and async functions automatically.
469
+
470
+ Usage::
471
+
472
+ @vault_enforce(client, tool_name="stripe_refund")
473
+ def process_refund(amount: float, reason: str):
474
+ # This only runs if the Vault approves
475
+ stripe.refund(amount=amount, reason=reason)
476
+
477
+ @vault_enforce(client, tool_name="stripe_refund")
478
+ async def async_process_refund(amount: float, reason: str):
479
+ await stripe.refund(amount=amount, reason=reason)
480
+
481
+ The decorated function receives an injected ``_clearance`` keyword
482
+ argument containing the ``ClearanceResponse`` (with the A-JWT token).
483
+
484
+ .. note::
485
+ Prefer :func:`enforce` for new code — it requires no changes to the
486
+ function signature and uses the global client set by :func:`configure`.
487
+ """
488
+
489
+ def decorator(func: F) -> F:
490
+ resolved_name = tool_name or func.__name__
491
+
492
+ if inspect.iscoroutinefunction(func):
493
+
494
+ @functools.wraps(func)
495
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
496
+ request = ClearanceRequest(
497
+ tool_name=resolved_name,
498
+ tool_args=_extract_tool_args(func, args, kwargs),
499
+ agent_id=client.config.agent_id,
500
+ session_id=client.config.session_id,
501
+ context={**(context or {}), **({"policy_id": policy_id} if policy_id else {})},
502
+ data_categories=data_categories,
503
+ purpose=purpose,
504
+ processing_register_ref=processing_register_ref,
505
+ dataset_ref=dataset_ref,
506
+ )
507
+ clearance = await client.arequest_clearance(request)
508
+ kwargs["_clearance"] = clearance
509
+ return await func(*args, **kwargs)
510
+
511
+ return async_wrapper # type: ignore[return-value]
512
+
513
+ else:
514
+
515
+ @functools.wraps(func)
516
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
517
+ request = ClearanceRequest(
518
+ tool_name=resolved_name,
519
+ tool_args=_extract_tool_args(func, args, kwargs),
520
+ agent_id=client.config.agent_id,
521
+ session_id=client.config.session_id,
522
+ context={**(context or {}), **({"policy_id": policy_id} if policy_id else {})},
523
+ data_categories=data_categories,
524
+ purpose=purpose,
525
+ processing_register_ref=processing_register_ref,
526
+ dataset_ref=dataset_ref,
527
+ )
528
+ clearance = client.request_clearance(request)
529
+ kwargs["_clearance"] = clearance
530
+ return func(*args, **kwargs)
531
+
532
+ return sync_wrapper # type: ignore[return-value]
533
+
534
+ return decorator
535
+
536
+
537
+ def _extract_tool_args(
538
+ func: Callable[..., Any],
539
+ args: tuple[Any, ...],
540
+ kwargs: dict[str, Any],
541
+ ) -> dict[str, Any]:
542
+ """Best-effort extraction of function arguments as a dict for the clearance request.
543
+
544
+ Skips ``self``, private parameters (prefixed with ``_``), ``*args``, and
545
+ ``**kwargs`` captures so only named, user-visible parameters are included.
546
+ """
547
+ try:
548
+ sig = inspect.signature(func)
549
+ bound = sig.bind_partial(*args, **kwargs)
550
+ bound.apply_defaults()
551
+ result: dict[str, Any] = {}
552
+ for name, value in bound.arguments.items():
553
+ if name.startswith("_") or name == "self":
554
+ continue
555
+ param = sig.parameters[name]
556
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
557
+ continue
558
+ result[name] = value
559
+ return result
560
+ except (TypeError, ValueError):
561
+ return {k: v for k, v in kwargs.items() if not k.startswith("_")}
@@ -0,0 +1,104 @@
1
+ # Ledgix ALCV — Exceptions
2
+ # All custom exceptions for the SDK
3
+
4
+ from __future__ import annotations
5
+
6
+
7
+ class LedgixError(Exception):
8
+ """Base exception for all Ledgix SDK errors."""
9
+ pass
10
+
11
+
12
+ class ClearanceDeniedError(LedgixError):
13
+ """Raised when the Vault denies a tool-call clearance request.
14
+
15
+ Attributes:
16
+ reason: Human-readable denial reason from the Vault.
17
+ request_id: The Vault's unique ID for this clearance request.
18
+ """
19
+
20
+ def __init__(self, reason: str, request_id: str | None = None) -> None:
21
+ self.reason = reason
22
+ self.request_id = request_id
23
+ super().__init__(f"Clearance denied: {reason}")
24
+
25
+
26
+ class ManualReviewTimeoutError(LedgixError):
27
+ """Raised when a pending manual review decision does not resolve before timeout."""
28
+
29
+ def __init__(self, request_id: str | None = None) -> None:
30
+ self.request_id = request_id
31
+ suffix = f" ({request_id})" if request_id else ""
32
+ super().__init__(f"Manual review timed out{suffix}")
33
+
34
+
35
+ class VaultConnectionError(LedgixError):
36
+ """Raised when the SDK cannot reach the Vault server."""
37
+
38
+ def __init__(self, message: str = "Unable to connect to the Vault server") -> None:
39
+ super().__init__(message)
40
+
41
+
42
+ class TokenVerificationError(LedgixError):
43
+ """Raised when A-JWT verification fails (bad signature, expired, etc.)."""
44
+
45
+ def __init__(self, message: str = "Token verification failed") -> None:
46
+ super().__init__(message)
47
+
48
+
49
+ class PolicyRegistrationError(LedgixError):
50
+ """Raised when a policy registration request fails."""
51
+
52
+ def __init__(self, message: str = "Policy registration failed") -> None:
53
+ super().__init__(message)
54
+
55
+
56
+ class ReplayDetectedError(TokenVerificationError):
57
+ """Raised when an A-JWT jti has already been consumed by this SDK instance.
58
+
59
+ Each A-JWT is single-use. Presenting the same token twice in the same
60
+ process raises this error so callers cannot accidentally reuse a spent
61
+ clearance. The SDK tracks jtis for the token's remaining TTL plus a
62
+ 30-second clock-skew buffer.
63
+ """
64
+
65
+ def __init__(self, jti: str | None = None) -> None:
66
+ self.jti = jti
67
+ suffix = f" (jti={jti})" if jti else ""
68
+ super(TokenVerificationError, self).__init__(f"A-JWT replay detected{suffix}")
69
+
70
+
71
+ class QueueSaturatedError(LedgixError):
72
+ """Raised when Vault repeatedly responds with HTTP 429 (queue near capacity).
73
+
74
+ Vault emits 429 + ``Retry-After`` from its proactive backpressure check
75
+ (Scale & Reliability §2.1). The SDK honors the header and does NOT count
76
+ these against the normal ``max_retries`` budget — they're cooperative
77
+ backoff, not failures. After ``max_consecutive_429`` waves with no
78
+ success, however, the SDK gives up with this error so callers can fail
79
+ fast instead of looping indefinitely while the Vault is melting.
80
+
81
+ Attributes:
82
+ attempts: How many consecutive 429s were received before giving up.
83
+ last_retry_after: The last ``Retry-After`` value the server emitted (seconds).
84
+ """
85
+
86
+ def __init__(self, attempts: int, last_retry_after: float | None = None) -> None:
87
+ self.attempts = attempts
88
+ self.last_retry_after = last_retry_after
89
+ suffix = f" (last Retry-After={last_retry_after}s)" if last_retry_after is not None else ""
90
+ super().__init__(
91
+ f"Vault clearance queue saturated after {attempts} consecutive 429 responses{suffix}"
92
+ )
93
+
94
+
95
+ class ReviewPendingError(LedgixError):
96
+ """Raised in ``review_mode="detach"`` when a clearance enters pending-review.
97
+
98
+ The attached :attr:`pending_approval` handle lets the caller poll or cancel
99
+ the review without blocking the current thread/coroutine.
100
+ """
101
+
102
+ def __init__(self, pending_approval: "Any") -> None: # noqa: F821
103
+ self.pending_approval = pending_approval
104
+ super().__init__(f"Clearance pending review (request_id={pending_approval.request_id})")