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,152 @@
1
+ # Ledgix ALCV — Manifest Layer
2
+ # Schema, loading, and pattern matching for config-driven auto-instrumentation.
3
+
4
+ from __future__ import annotations
5
+
6
+ import fnmatch
7
+ import json
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ MANIFEST_FILENAMES = ("ledgix.yaml", "ledgix.yml", "ledgix.json")
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ManifestRule:
17
+ """A single enforcement rule declared in the manifest.
18
+
19
+ Attributes:
20
+ tool: Glob pattern matched against function names (e.g. ``"stripe_*"``).
21
+ policy_id: Policy to enforce for matching tools.
22
+ context: Extra key/value pairs forwarded to the clearance request context.
23
+ """
24
+
25
+ tool: str
26
+ policy_id: str | None = None
27
+ context: dict[str, Any] = field(default_factory=dict)
28
+
29
+ def matches(self, name: str) -> bool:
30
+ """Return ``True`` if *name* matches this rule's glob pattern."""
31
+ return fnmatch.fnmatch(name, self.tool)
32
+
33
+
34
+ @dataclass
35
+ class Manifest:
36
+ """Parsed enforcement manifest.
37
+
38
+ Contains an ordered list of :class:`ManifestRule` objects. Rules are
39
+ evaluated in declaration order — the first match wins.
40
+ """
41
+
42
+ rules: list[ManifestRule]
43
+ source: str = "<inline>"
44
+
45
+ def match(self, name: str) -> ManifestRule | None:
46
+ """Return the first rule whose pattern matches *name*, or ``None``."""
47
+ for rule in self.rules:
48
+ if rule.matches(name):
49
+ return rule
50
+ return None
51
+
52
+ def __repr__(self) -> str:
53
+ return f"Manifest(rules={len(self.rules)}, source={self.source!r})"
54
+
55
+
56
+ def load_manifest(
57
+ source: str | Path | dict[str, Any] | None = None,
58
+ ) -> Manifest:
59
+ """Load an enforcement manifest from a file, an inline dict, or auto-discovery.
60
+
61
+ Supported file formats:
62
+
63
+ * **YAML** (``.yaml`` / ``.yml``) — requires ``pyyaml`` (``pip install pyyaml``
64
+ or ``pip install 'bylaw-python[yaml]'``)
65
+ * **JSON** (``.json``) — no extra dependencies
66
+
67
+ When *source* is ``None`` the function searches the current working
68
+ directory for ``ledgix.yaml``, ``ledgix.yml``, then ``ledgix.json`` in
69
+ that order.
70
+
71
+ Manifest schema::
72
+
73
+ enforce:
74
+ - tool: "stripe_*"
75
+ policy_id: "financial-high-risk"
76
+ - tool: "db_write*"
77
+ policy_id: "data-mutation"
78
+ context:
79
+ risk_level: "high"
80
+ - tool: "*" # catch-all (optional)
81
+ policy_id: "default"
82
+
83
+ Args:
84
+ source: File path, inline ``dict``, or ``None`` for auto-discovery.
85
+
86
+ Returns:
87
+ Parsed :class:`Manifest`.
88
+
89
+ Raises:
90
+ FileNotFoundError: If *source* is ``None`` and no manifest file exists,
91
+ or if an explicit path does not exist.
92
+ ImportError: If a YAML file is given but ``pyyaml`` is not installed.
93
+ ValueError: If the file extension is not ``.yaml``, ``.yml``, or ``.json``.
94
+ """
95
+ if source is None:
96
+ path = _find_default_manifest()
97
+ data = _parse_file(path)
98
+ src_label = str(path)
99
+ elif isinstance(source, dict):
100
+ data = source
101
+ src_label = "<inline>"
102
+ else:
103
+ path = Path(source)
104
+ if not path.exists():
105
+ raise FileNotFoundError(f"Ledgix manifest not found: {path}")
106
+ data = _parse_file(path)
107
+ src_label = str(path)
108
+
109
+ rules = [
110
+ ManifestRule(
111
+ tool=entry["tool"],
112
+ policy_id=entry.get("policy_id"),
113
+ context=entry.get("context") or {},
114
+ )
115
+ for entry in data.get("enforce", [])
116
+ ]
117
+ return Manifest(rules=rules, source=src_label)
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Internal helpers
122
+ # ---------------------------------------------------------------------------
123
+
124
+ def _find_default_manifest() -> Path:
125
+ cwd = Path.cwd()
126
+ for name in MANIFEST_FILENAMES:
127
+ candidate = cwd / name
128
+ if candidate.exists():
129
+ return candidate
130
+ raise FileNotFoundError(
131
+ "No Ledgix manifest found in the current directory. "
132
+ f"Create one of: {', '.join(MANIFEST_FILENAMES)}"
133
+ )
134
+
135
+
136
+ def _parse_file(path: Path) -> dict[str, Any]:
137
+ if path.suffix in (".yaml", ".yml"):
138
+ try:
139
+ import yaml # type: ignore[import-untyped]
140
+ except ImportError as exc:
141
+ raise ImportError(
142
+ "PyYAML is required to load YAML manifests. "
143
+ "Install it with: pip install pyyaml "
144
+ "or: pip install 'bylaw-python[yaml]'"
145
+ ) from exc
146
+ return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
147
+ if path.suffix == ".json":
148
+ return json.loads(path.read_text(encoding="utf-8"))
149
+ raise ValueError(
150
+ f"Unsupported manifest format: {path.suffix!r}. "
151
+ "Use .yaml, .yml, or .json."
152
+ )
bylaw_python/models.py ADDED
@@ -0,0 +1,330 @@
1
+ # Ledgix ALCV — Data Models
2
+ # Pydantic models for Vault API request/response payloads
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Literal
7
+
8
+ from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator, model_validator
9
+
10
+ _MISSING = object()
11
+
12
+
13
+ class ClearanceRequest(BaseModel):
14
+ """Payload sent to the Vault's ``/request-clearance`` endpoint."""
15
+
16
+ tool_name: str = Field(..., description="Name of the tool the agent wants to invoke")
17
+ tool_args: dict[str, Any] = Field(
18
+ default_factory=dict,
19
+ description="Arguments the agent will pass to the tool",
20
+ )
21
+ agent_id: str = Field(default="default-agent", description="Identifier for the calling agent")
22
+ session_id: str = Field(default="", description="Session grouping identifier")
23
+ context: dict[str, Any] = Field(
24
+ default_factory=dict,
25
+ description="Additional context for the Vault's policy judge (e.g. conversation history)",
26
+ )
27
+ human_principal: str | None = Field(
28
+ default=None,
29
+ description="Advisory OIDC sub of the human on whose behalf the agent acts",
30
+ )
31
+ parent_jti: str | None = Field(
32
+ default=None,
33
+ description="JTI of the parent A-JWT; present on delegated sub-agent requests",
34
+ )
35
+ destination_uri: str | None = Field(
36
+ default=None,
37
+ description="Canonical URI the action will be sent to (e.g. https://api.openai.com/v1/chat/completions)",
38
+ )
39
+ destination_provider: str | None = Field(
40
+ default=None,
41
+ description="Canonical provider key (e.g. openai, stripe, anthropic, aws-bedrock)",
42
+ )
43
+ destination_account_ref: str | None = Field(
44
+ default=None,
45
+ description="Account/org/workspace ref within the provider (e.g. Stripe acct id, Slack team id)",
46
+ )
47
+ # Phase 2 — GDPR Article 30 processing-register matching.
48
+ # When supplied, the Vault's pre-LLM validator chain checks for an active
49
+ # processing register that covers (data_categories ⊇ requested,
50
+ # purpose ∈ register.purposes, recipient ∈ register.recipients). Unmatched
51
+ # requests are denied with reason_code='processing_no_register_match'.
52
+ data_categories: list[str] | None = Field(
53
+ default=None,
54
+ description="Personal-data categories this action will touch (e.g. ['customer_email','transaction_amount'])",
55
+ )
56
+ purpose: str | None = Field(
57
+ default=None,
58
+ description="Purpose of processing (e.g. 'fraud_detection', 'billing'); must be in matched register's purposes",
59
+ )
60
+ processing_register_ref: str | None = Field(
61
+ default=None,
62
+ description="Optional UUID hint of which register this action anchors to; Vault still does authoritative match",
63
+ )
64
+ # Phase 6 — dataset lineage. When supplied, dataset sheets auto-derive
65
+ # row counts, schema fingerprints, and consent-basis breakdowns from
66
+ # ledger replay scoped to events with this ref.
67
+ dataset_ref: str | None = Field(
68
+ default=None,
69
+ description="Logical dataset reference this action reads/writes (e.g. 'prod_customer_support_kb', S3 path, table name)",
70
+ )
71
+
72
+
73
+ ConfidenceBucket = Literal["extra_high", "high", "medium", "low", "none"]
74
+ DecisionStatus = Literal["approved", "denied", "approved_pending_review"]
75
+
76
+
77
+ class ClearanceResponse(BaseModel):
78
+ """Response from the Vault's ``/request-clearance`` endpoint.
79
+
80
+ As of v1.0 the wire format is bucket-only. The legacy ``approved``,
81
+ ``confidence``, and ``minimum_confidence_score`` fields have been
82
+ removed; consumers read ``decision_status`` and ``confidence_bucket``
83
+ instead. See ``docs/MIGRATION_0.4.md`` for the migration guide.
84
+ """
85
+
86
+ status: str = Field(default="denied", description="Vault lifecycle: processing, approved, denied, or pending_review")
87
+ decision_status: DecisionStatus = Field(
88
+ default="denied",
89
+ description="Categorical decision: approved | denied | approved_pending_review",
90
+ )
91
+ requires_manual_review: bool = Field(default=False, description="Whether the request is pending human review")
92
+ token: str | None = Field(default=None, description="Signed A-JWT if approved, None if denied")
93
+ reason: str = Field(default="", description="Human-readable explanation of the decision")
94
+ request_id: str = Field(default="", description="Vault-assigned unique ID for this request")
95
+ confidence_bucket: ConfidenceBucket = Field(
96
+ default="none",
97
+ description="Categorical confidence: extra_high | high | medium | low | none",
98
+ )
99
+ minimum_confidence_bucket: ConfidenceBucket = Field(
100
+ default="high",
101
+ description="Client-configured minimum confidence bucket for auto approval",
102
+ )
103
+ policy_version_id: str | None = Field(
104
+ default=None,
105
+ description="UUID of the policy version the decision was evaluated against",
106
+ )
107
+ policy_content_hash: str | None = Field(
108
+ default=None,
109
+ description="Content hash of the policy version the decision was evaluated against",
110
+ )
111
+ reason_code: str | None = Field(
112
+ default=None,
113
+ description="Machine-readable denial code, e.g. 'spend_cap_exceeded'",
114
+ )
115
+
116
+ @property
117
+ def is_approved(self) -> bool:
118
+ """Convenience: True iff the policy permits the action.
119
+
120
+ Returns True for both ``approved`` and ``approved_pending_review``.
121
+ Use this in place of the legacy ``approved`` boolean. Note that
122
+ ``approved_pending_review`` does NOT mean the agent can proceed
123
+ immediately — it means the policy permits the action subject to
124
+ human review.
125
+ """
126
+ return self.decision_status in ("approved", "approved_pending_review")
127
+
128
+
129
+ class PolicyRegistration(BaseModel):
130
+ """Payload for registering a policy with the Vault."""
131
+
132
+ policy_id: str = Field(..., description="Unique identifier for the policy")
133
+ description: str = Field(default="", description="Human-readable description of the policy")
134
+ rules: list[str] = Field(
135
+ default_factory=list,
136
+ description="List of plain-English rules (e.g. 'Refunds must not exceed $100')",
137
+ )
138
+ tools: list[str] = Field(
139
+ default_factory=list,
140
+ description="Tool names this policy applies to (empty = all tools)",
141
+ )
142
+
143
+
144
+ class PolicyRegistrationResponse(BaseModel):
145
+ """Response from the Vault's ``/register-policy`` endpoint."""
146
+
147
+ policy_id: str = Field(..., description="Confirmed policy ID")
148
+ status: str = Field(default="registered", description="Registration status")
149
+ message: str = Field(default="", description="Additional information")
150
+
151
+
152
+ class LedgerEntry(BaseModel):
153
+ """Ledger entry returned by the Vault's ledger endpoints."""
154
+
155
+ model_config = ConfigDict(populate_by_name=True)
156
+
157
+ seq: int
158
+ event_uuid: str
159
+ request_id: str
160
+ agent_id: str = ""
161
+ policy_id: str = ""
162
+ policy_version_id: str = ""
163
+ policy_content_hash: str = ""
164
+ intent_hash: str = ""
165
+ tool_name: str
166
+ tool_args: dict[str, Any] = Field(default_factory=dict)
167
+ raw_tool_args: Any = Field(default_factory=lambda: _MISSING, exclude=True)
168
+ action_category: str = ""
169
+ action_metadata: dict[str, Any] = Field(default_factory=dict)
170
+ raw_action_metadata: Any = Field(default_factory=lambda: _MISSING, exclude=True)
171
+ reason: str = ""
172
+ citations: list[dict[str, Any]] = Field(default_factory=list)
173
+ raw_citations: Any = Field(default_factory=lambda: _MISSING, exclude=True)
174
+ evidence_chunks: list[dict[str, Any]] = Field(default_factory=list)
175
+ raw_evidence_chunks: Any = Field(default_factory=lambda: _MISSING, exclude=True)
176
+ # Legacy float kept for canonical_version=1 hash verification of old rows.
177
+ # New rows under canonical_version=2 also carry confidence_bucket and
178
+ # decision_status as their canonical signal.
179
+ confidence: float = Field(default=0.0, ge=0.0, le=1.0, description="Legacy bucket midpoint; prefer confidence_bucket")
180
+ confidence_bucket: ConfidenceBucket | None = Field(
181
+ default=None,
182
+ description="Categorical confidence; populated for canonical_version>=2 events",
183
+ )
184
+ decision_status: DecisionStatus | None = Field(
185
+ default=None,
186
+ description="Categorical decision; populated for canonical_version>=2 events",
187
+ )
188
+ approved: bool = Field(
189
+ default=False,
190
+ description="Legacy boolean; derived for new rows. Prefer decision_status.",
191
+ )
192
+ accepted_at: str = Field(validation_alias=AliasChoices("accepted_at", "decided_at"))
193
+ canonical_version: int = 1
194
+ event_hash: str
195
+ leaf_hash: str
196
+ leaf_index: int | None = None
197
+ checkpoint_id: int | None = None
198
+ receipt_algorithm: str = Field(
199
+ default="",
200
+ validation_alias=AliasChoices("receipt_algorithm", "signature_algorithm"),
201
+ )
202
+ receipt_key_id: str = Field(
203
+ default="",
204
+ validation_alias=AliasChoices("receipt_key_id", "signer_key_id"),
205
+ )
206
+ receipt_signature: str = Field(
207
+ default="",
208
+ validation_alias=AliasChoices("receipt_signature", "row_signature"),
209
+ )
210
+ receipt_payload: str = Field(default="")
211
+
212
+ @model_validator(mode="before")
213
+ @classmethod
214
+ def _capture_raw_verification_fields(cls, value: Any) -> Any:
215
+ if not isinstance(value, dict):
216
+ return value
217
+ data = dict(value)
218
+ if "raw_tool_args" not in data and "tool_args" in data:
219
+ data["raw_tool_args"] = data.get("tool_args")
220
+ if "raw_action_metadata" not in data and "action_metadata" in data:
221
+ data["raw_action_metadata"] = data.get("action_metadata")
222
+ if "raw_citations" not in data and "citations" in data:
223
+ data["raw_citations"] = data.get("citations")
224
+ if "raw_evidence_chunks" not in data and "evidence_chunks" in data:
225
+ data["raw_evidence_chunks"] = data.get("evidence_chunks")
226
+ return data
227
+
228
+ @field_validator("tool_args", "action_metadata", mode="before")
229
+ @classmethod
230
+ def _normalize_nullable_dicts(cls, value: Any) -> Any:
231
+ if value is None:
232
+ return {}
233
+ return value
234
+
235
+ @field_validator("citations", "evidence_chunks", mode="before")
236
+ @classmethod
237
+ def _normalize_nullable_lists(cls, value: Any) -> Any:
238
+ if value is None:
239
+ return []
240
+ return value
241
+
242
+
243
+ class LedgerCheckpoint(BaseModel):
244
+ """Signed checkpoint returned by the Vault."""
245
+
246
+ model_config = ConfigDict(populate_by_name=True)
247
+
248
+ checkpoint_id: int
249
+ microblock_id: int = 0
250
+ tree_size: int
251
+ root_hash: str = Field(validation_alias=AliasChoices("root_hash", "head_row_hash"))
252
+ checkpoint_hash: str = Field(
253
+ validation_alias=AliasChoices("checkpoint_hash", "manifest_hash"),
254
+ )
255
+ prev_checkpoint_hash: str = Field(
256
+ default="",
257
+ validation_alias=AliasChoices("prev_checkpoint_hash", "prev_manifest_hash"),
258
+ )
259
+ signature_algorithm: str = Field(
260
+ default="",
261
+ validation_alias=AliasChoices("signature_algorithm", "signature_algorithm"),
262
+ )
263
+ signer_key_id: str = ""
264
+ checkpoint_signature: str = Field(
265
+ default="",
266
+ validation_alias=AliasChoices("checkpoint_signature", "manifest_signature"),
267
+ )
268
+ checkpoint_payload: str = Field(
269
+ default="",
270
+ validation_alias=AliasChoices("checkpoint_payload", "manifest_payload"),
271
+ )
272
+ signed_at: str = Field(validation_alias=AliasChoices("signed_at", "generated_at", "period_start"))
273
+ mmd_seconds: int = 30
274
+ export_target: str = ""
275
+ export_uri: str = ""
276
+ export_status: str = ""
277
+ exported_at: str | None = None
278
+
279
+
280
+ LedgerManifest = LedgerCheckpoint
281
+
282
+
283
+ class LedgerKeyVersion(BaseModel):
284
+ key_id: str
285
+ algorithm: str
286
+ public_jwk: str = ""
287
+ active_from: str
288
+ retired_at: str | None = None
289
+ attestation_payload: str = ""
290
+ attestation_signature: str = ""
291
+ attestation_key_id: str = ""
292
+ attestation_status: str = ""
293
+
294
+
295
+ class InclusionProof(BaseModel):
296
+ event_uuid: str
297
+ request_id: str
298
+ event_hash: str
299
+ leaf_hash: str
300
+ leaf_index: int
301
+ tree_size: int
302
+ path: list[str] = Field(default_factory=list)
303
+ checkpoint: LedgerCheckpoint
304
+
305
+
306
+ class ConsistencyProof(BaseModel):
307
+ from_checkpoint: LedgerCheckpoint
308
+ to_checkpoint: LedgerCheckpoint
309
+ path: list[str] = Field(default_factory=list)
310
+
311
+
312
+ class LedgerProofBundle(BaseModel):
313
+ event: LedgerEntry
314
+ inclusion: InclusionProof
315
+ consistency: ConsistencyProof | None = None
316
+ keys: list[LedgerKeyVersion] = Field(default_factory=list)
317
+
318
+
319
+ class LedgerVerificationResult(BaseModel):
320
+ """Result of independent offline ledger verification."""
321
+
322
+ intact: bool
323
+ verified_entries: int
324
+ verified_checkpoints: int = 0
325
+ verified_manifests: int
326
+ latest_leaf_hash: str | None = None
327
+ latest_checkpoint_hash: str | None = None
328
+ latest_manifest_hash: str | None = None
329
+ coverage_note: str | None = None
330
+ error: str | None = None
@@ -0,0 +1,128 @@
1
+ # Ledgix ALCV — PendingApproval
2
+ # Handle for detached manual-review decisions
3
+
4
+ from __future__ import annotations
5
+
6
+ import time
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ if TYPE_CHECKING:
10
+ from .client import LedgixClient
11
+ from .models import ClearanceResponse
12
+
13
+
14
+ class PendingApproval:
15
+ """Handle for a clearance request that entered ``pending_review`` status.
16
+
17
+ Obtained when ``review_mode="detach"`` is set on :class:`~bylaw_python.VaultConfig`
18
+ and the Vault returns a ``pending_review`` response.
19
+
20
+ Usage (async)::
21
+
22
+ try:
23
+ result = await client.arequest_clearance(request)
24
+ except ReviewPendingError as exc:
25
+ pending = exc.pending_approval
26
+ # store pending.request_id, come back later
27
+ result = await pending.wait_async(timeout=1800)
28
+
29
+ Usage (sync)::
30
+
31
+ try:
32
+ result = client.request_clearance(request)
33
+ except ReviewPendingError as exc:
34
+ result = exc.pending_approval.wait(timeout=1800)
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ request_id: str,
40
+ client: "LedgixClient",
41
+ initial_response: "ClearanceResponse",
42
+ ) -> None:
43
+ self._request_id = request_id
44
+ self._client = client
45
+ self._initial_response = initial_response
46
+
47
+ @property
48
+ def request_id(self) -> str:
49
+ """The Vault's unique ID for this clearance request."""
50
+ return self._request_id
51
+
52
+ def wait(self, timeout: float | None = None) -> "ClearanceResponse":
53
+ """Block until the reviewer decides, then return the :class:`ClearanceResponse`.
54
+
55
+ Args:
56
+ timeout: Maximum seconds to wait. Defaults to the client's
57
+ ``review_timeout`` config value.
58
+
59
+ Raises:
60
+ ManualReviewTimeoutError: If no decision arrives within *timeout*.
61
+ ClearanceDeniedError: If the reviewer denies the request.
62
+ """
63
+ from .exceptions import ClearanceDeniedError, ManualReviewTimeoutError
64
+ from .models import ClearanceResponse as CR
65
+
66
+ deadline = time.monotonic() + (timeout if timeout is not None else self._client.config.review_timeout)
67
+ poll = self._client.config.review_poll_interval
68
+
69
+ while time.monotonic() < deadline:
70
+ time.sleep(poll)
71
+ response = self._client._get_sync_client().get(
72
+ f"/clearance-status/{self._request_id}"
73
+ )
74
+ response.raise_for_status()
75
+ clearance = CR.model_validate(response.json())
76
+ if clearance.status not in {"processing", "pending_review"}:
77
+ if not clearance.is_approved:
78
+ raise ClearanceDeniedError(
79
+ reason=clearance.reason,
80
+ request_id=clearance.request_id,
81
+ )
82
+ return clearance
83
+
84
+ raise ManualReviewTimeoutError(self._request_id)
85
+
86
+ async def wait_async(self, timeout: float | None = None) -> "ClearanceResponse":
87
+ """Async variant of :meth:`wait`."""
88
+ import asyncio
89
+
90
+ from .exceptions import ClearanceDeniedError, ManualReviewTimeoutError
91
+ from .models import ClearanceResponse as CR
92
+
93
+ deadline = time.monotonic() + (timeout if timeout is not None else self._client.config.review_timeout)
94
+ poll = self._client.config.review_poll_interval
95
+
96
+ while time.monotonic() < deadline:
97
+ await asyncio.sleep(poll)
98
+ response = await self._client._get_async_client().get(
99
+ f"/clearance-status/{self._request_id}"
100
+ )
101
+ response.raise_for_status()
102
+ clearance = CR.model_validate(response.json())
103
+ if clearance.status not in {"processing", "pending_review"}:
104
+ if not clearance.is_approved:
105
+ raise ClearanceDeniedError(
106
+ reason=clearance.reason,
107
+ request_id=clearance.request_id,
108
+ )
109
+ return clearance
110
+
111
+ raise ManualReviewTimeoutError(self._request_id)
112
+
113
+ def cancel(self) -> None:
114
+ """Cancel the pending review by posting a denial decision.
115
+
116
+ Records a ``review.cancelled_by_agent`` entry in the Vault ledger.
117
+ """
118
+ self._client._get_sync_client().post(
119
+ f"/reviews/{self._request_id}/decision",
120
+ json={"approved": False, "review_reason": "cancelled by agent"},
121
+ )
122
+
123
+ async def acancel(self) -> None:
124
+ """Async variant of :meth:`cancel`."""
125
+ await self._client._get_async_client().post(
126
+ f"/reviews/{self._request_id}/decision",
127
+ json={"approved": False, "review_reason": "cancelled by agent"},
128
+ )
@@ -0,0 +1,44 @@
1
+ # Ledgix ALCV — Webhook verification helper
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+
8
+
9
+ def verify_webhook(body: bytes | str, signature: str, secret: str) -> bool:
10
+ """Verify the HMAC-SHA256 signature on an inbound Ledgix webhook.
11
+
12
+ The Vault signs every delivery with ``X-Ledgix-Signature: sha256=<hex>``.
13
+ Pass the raw request body, that header value, and your endpoint's signing
14
+ secret to verify authenticity before processing the event.
15
+
16
+ Args:
17
+ body: Raw request body (bytes or str). Use the unparsed body exactly
18
+ as received — do not re-encode from a parsed JSON object.
19
+ signature: Value of the ``X-Ledgix-Signature`` header.
20
+ secret: Signing secret for this webhook endpoint (from the dashboard).
21
+
22
+ Returns:
23
+ ``True`` if the signature is valid, ``False`` otherwise.
24
+
25
+ Example::
26
+
27
+ from flask import request
28
+ import bylaw_python as ledgix
29
+
30
+ @app.route("/webhook", methods=["POST"])
31
+ def handle_webhook():
32
+ if not ledgix.verify_webhook(request.data, request.headers["X-Ledgix-Signature"], SECRET):
33
+ return "Forbidden", 403
34
+ event = request.get_json()
35
+ ...
36
+ """
37
+ if isinstance(body, str):
38
+ body = body.encode("utf-8")
39
+
40
+ if signature.startswith("sha256="):
41
+ signature = signature[len("sha256="):]
42
+
43
+ expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
44
+ return hmac.compare_digest(expected, signature)