blun-king-cli 9.1.597 → 9.1.600

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.
Files changed (106) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +17 -1
  3. package/agent-spine-plugin/CHANGELOG.md +1 -1
  4. package/agent-spine-plugin/docs/host-integration.md +1 -15
  5. package/agent-spine-plugin/docs/preflight-recall.md +1 -1
  6. package/agent-spine-plugin/scripts/check-install.js +1 -6
  7. package/agent-spine-plugin/scripts/hermetic-process.js +8 -0
  8. package/agent-spine-plugin/scripts/release-check.js +2 -3
  9. package/agent-spine-plugin/scripts/run-tests-hermetic.js +8 -11
  10. package/agent-spine-plugin/src/hook.js +40 -40
  11. package/agent-spine-plugin/src/lib/briefing.js +6 -3
  12. package/agent-spine-plugin/src/lib/hook-context.js +63 -64
  13. package/agent-spine-plugin/src/lib/hook-output.js +115 -94
  14. package/agent-spine-plugin/src/lib/hook-timeline.js +20 -11
  15. package/agent-spine-plugin/src/lib/host-instruction-budget.js +8 -14
  16. package/agent-spine-plugin/src/lib/mcp-timeline-tools.js +0 -7
  17. package/agent-spine-plugin/src/lib/pre-answer-timeline-recall.js +104 -0
  18. package/agent-spine-plugin/src/lib/session-timeline-codex.js +0 -4
  19. package/agent-spine-plugin/src/lib/session-timeline-enrollment-source.js +0 -3
  20. package/agent-spine-plugin/src/lib/session-timeline-enrollment-storage.js +0 -4
  21. package/agent-spine-plugin/src/lib/session-timeline-enrollment-transport.js +0 -2
  22. package/agent-spine-plugin/src/lib/session-timeline-enrollment.js +0 -22
  23. package/agent-spine-plugin/src/lib/session-timeline-host-origin.js +0 -4
  24. package/agent-spine-plugin/src/lib/session-timeline-prior.js +42 -50
  25. package/agent-spine-plugin/src/lib/session-timeline-provider.js +0 -3
  26. package/agent-spine-plugin/src/lib/session-timeline-query.js +0 -1
  27. package/agent-spine-plugin/src/lib/session-timeline-sid-acl.js +0 -4
  28. package/agent-spine-plugin/src/lib/session-timeline-source.js +0 -3
  29. package/agent-spine-plugin/src/lib/session-timeline-transport.js +0 -8
  30. package/agent-spine-plugin/src/lib/session-timeline-windows-acl.js +0 -3
  31. package/agent-spine-plugin/src/lib/session-timeline.js +370 -408
  32. package/agent-spine-plugin/src/lib/source-roots.js +26 -16
  33. package/agent-spine-plugin/src/lib/timeline-user-feedback.js +0 -1
  34. package/bin/core-bootstrap.js +18 -10
  35. package/bin/launcher-mode.js +5 -0
  36. package/bin/launcher-runtime.js +27 -0
  37. package/bin/verify-bundled-agent-sources.cjs +28 -2
  38. package/blun.mjs +798 -143
  39. package/bundled-agent-sources.json +192 -155
  40. package/package.json +1 -1
  41. package/standard-skills/translate-native/README.md +1334 -1
  42. package/standard-skills/translate-native/SKILL.md +1 -1
  43. package/standard-skills/translate-native/VERSION +1 -1
  44. package/standard-skills/translate-native/docs/PREMORTEM.md +901 -0
  45. package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION.md +208 -41
  46. package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_API.md +1319 -77
  47. package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_EVIDENCE_HTTP.md +66 -11
  48. package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_HTTP_PROVIDER.md +19 -1
  49. package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_RECEIPT_VERIFIER_HTTP.md +24 -6
  50. package/standard-skills/translate-native/integrations/commercial_localization_profile.py +23 -0
  51. package/standard-skills/translate-native/integrations/website_localization.py +234 -10
  52. package/standard-skills/translate-native/integrations/website_localization_benchmark.py +39 -3
  53. package/standard-skills/translate-native/integrations/website_localization_cms.py +512 -10
  54. package/standard-skills/translate-native/integrations/website_localization_cms_client.py +116 -0
  55. package/standard-skills/translate-native/integrations/website_localization_cms_receiver.py +101 -11
  56. package/standard-skills/translate-native/integrations/website_localization_cms_receiver_runtime.py +12 -1
  57. package/standard-skills/translate-native/integrations/website_localization_cms_receiver_store.py +186 -24
  58. package/standard-skills/translate-native/integrations/website_localization_cms_source_client.py +64 -1
  59. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery.py +528 -5
  60. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_auth.py +918 -0
  61. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_auth_runtime.py +609 -0
  62. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_client.py +885 -0
  63. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_http.py +1244 -0
  64. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_runtime.py +220 -0
  65. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_sidecar_adapter.py +331 -0
  66. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_capabilities_http.py +444 -0
  67. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_client.py +673 -0
  68. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch.py +1317 -0
  69. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_client.py +826 -0
  70. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_http.py +1152 -0
  71. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_openapi.py +896 -0
  72. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_runtime.py +621 -0
  73. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_http.py +1180 -0
  74. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_runtime.py +1479 -0
  75. package/standard-skills/translate-native/integrations/website_localization_cms_source_http.py +133 -17
  76. package/standard-skills/translate-native/integrations/website_localization_cms_source_runtime.py +319 -4
  77. package/standard-skills/translate-native/integrations/website_localization_cms_source_service.py +72 -2
  78. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_http.py +13 -0
  79. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver.py +56 -13
  80. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver_openapi.py +335 -0
  81. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver_runtime.py +66 -1
  82. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_processing_monitor.py +73 -7
  83. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_receiver_client.py +93 -9
  84. package/standard-skills/translate-native/integrations/website_localization_evidence_http.py +221 -11
  85. package/standard-skills/translate-native/integrations/website_localization_health.py +6 -0
  86. package/standard-skills/translate-native/integrations/website_localization_http_provider.py +1 -1
  87. package/standard-skills/translate-native/integrations/website_localization_quality_profiles.py +350 -0
  88. package/standard-skills/translate-native/integrations/website_localization_queue.py +126 -56
  89. package/standard-skills/translate-native/integrations/website_localization_receipt_verifier_http.py +166 -13
  90. package/standard-skills/translate-native/integrations/website_localization_release.py +423 -17
  91. package/standard-skills/translate-native/integrations/website_localization_release_coordinator.py +168 -13
  92. package/standard-skills/translate-native/integrations/website_localization_runner.py +10 -0
  93. package/standard-skills/translate-native/integrations/website_localization_worker.py +213 -9
  94. package/standard-skills/translate-native/provenance.json +2 -2
  95. package/standard-skills/translate-native/references/commercial-localization.md +96 -17
  96. package/standard-skills/translate-native/scripts/blun_language_guard.py +3 -1
  97. package/standard-skills/translate-native/scripts/check_commercial_review.py +69 -9
  98. package/standard-skills/translate-native/scripts/commercial_localization_profile.py +829 -31
  99. package/standard-tools/language-guard/VERSION +1 -1
  100. package/standard-tools/language-guard/blun_language_guard.py +3 -1
  101. package/standard-tools/language-guard/check_commercial_review.py +69 -9
  102. package/standard-tools/language-guard/commercial_localization_profile.py +829 -31
  103. package/standard-tools/language-guard/provenance.json +2 -2
  104. package/standard-tools/manifest.json +1 -1
  105. package/worker-host.mjs +215 -82
  106. package/agent-spine-plugin/scripts/check-install-king.js +0 -37
@@ -0,0 +1,885 @@
1
+ #!/usr/bin/env python3
2
+ """Contract-pinned HTTPS client for the website-source delivery sidecar.
3
+
4
+ CMS and website backends use this adapter to persist one immutable change,
5
+ cancellation, or tombstone without sharing a Python runtime with the outbox.
6
+ Every operation verifies the live sidecar contract, uses one fixed origin and
7
+ one transport attempt, and validates the response against both the sidecar and
8
+ downstream source-service capability pins. Retry scheduling remains external.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import importlib.util
15
+ import json
16
+ import math
17
+ import re
18
+ import socket
19
+ import sys
20
+ import unicodedata
21
+ import urllib.error
22
+ import urllib.parse
23
+ import urllib.request
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any, Callable, Mapping, Protocol
27
+
28
+
29
+ MAX_ENDPOINT_LENGTH = 2_048
30
+ MAX_HEADER_VALUE_LENGTH = 4_096
31
+ # Six transport-owned headers may be added after credential construction.
32
+ MAX_AUTHENTICATION_HEADERS = 58
33
+ SHA256 = re.compile(r"^[a-f0-9]{64}$")
34
+ TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
35
+ ERROR_CODE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
36
+ RESERVED_HEADERS = {
37
+ "accept", "connection", "content-length", "content-type", "host",
38
+ "idempotency-key", "transfer-encoding",
39
+ "x-localization-source-payload-sha256",
40
+ }
41
+ AUTH_CONTEXT_SCHEMA = (
42
+ "blun.cms-source-delivery-sidecar-client-auth-context.v1"
43
+ )
44
+
45
+
46
+ def _load_module(name: str, path: Path):
47
+ spec = importlib.util.spec_from_file_location(name, path)
48
+ if spec is None or spec.loader is None:
49
+ raise RuntimeError(f"cannot load sidecar client dependency: {path.name}")
50
+ module = importlib.util.module_from_spec(spec)
51
+ sys.modules[spec.name] = module
52
+ spec.loader.exec_module(module)
53
+ return module
54
+
55
+
56
+ _ROOT = Path(__file__).resolve().parents[1]
57
+ _HTTP = _load_module(
58
+ "blun_website_localization_cms_source_delivery_client_http",
59
+ _ROOT / "integrations" / "website_localization_cms_source_delivery_http.py",
60
+ )
61
+ _CMS = _load_module(
62
+ "blun_website_localization_cms_source_delivery_client_cms",
63
+ _ROOT / "integrations" / "website_localization_cms.py",
64
+ )
65
+
66
+
67
+ class CMSSourceDeliveryClientBlocked(RuntimeError):
68
+ """Stable content-free client failure with a retry decision."""
69
+
70
+ cms_source_delivery_client_failure = True
71
+
72
+ def __init__(self, code: str, *, retryable: bool):
73
+ if ERROR_CODE.fullmatch(code) is None or not isinstance(retryable, bool):
74
+ raise ValueError("source delivery client failure is invalid")
75
+ super().__init__(code)
76
+ self.code = code
77
+ self.retryable = retryable
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class HTTPResult:
82
+ status: int
83
+ headers: tuple[tuple[str, str], ...]
84
+ body: bytes
85
+
86
+
87
+ class HTTPTransport(Protocol):
88
+ def request(
89
+ self,
90
+ method: str,
91
+ url: str,
92
+ headers: Mapping[str, str],
93
+ body: bytes | None,
94
+ *,
95
+ timeout: float,
96
+ ) -> HTTPResult: ...
97
+
98
+
99
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
100
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
101
+ return None
102
+
103
+
104
+ class URLTransport:
105
+ """Perform one bounded HTTPS request without following redirects."""
106
+
107
+ def __init__(self):
108
+ self._opener = urllib.request.build_opener(_NoRedirect)
109
+
110
+ def request(self, method, url, headers, body, *, timeout):
111
+ request = urllib.request.Request(
112
+ url, data=body, headers=dict(headers), method=method,
113
+ )
114
+ try:
115
+ response = self._opener.open(request, timeout=timeout)
116
+ except urllib.error.HTTPError as error:
117
+ response = error
118
+ except (urllib.error.URLError, TimeoutError, socket.timeout, OSError):
119
+ _fail("network", retryable=True)
120
+ try:
121
+ raw = response.read(_HTTP.MAX_BODY_BYTES + 1)
122
+ return HTTPResult(
123
+ int(response.status), tuple(response.headers.items()), raw,
124
+ )
125
+ except (TimeoutError, socket.timeout, OSError):
126
+ _fail("network", retryable=True)
127
+ finally:
128
+ response.close()
129
+
130
+
131
+ def _fail(code: str, *, retryable: bool = False) -> None:
132
+ raise CMSSourceDeliveryClientBlocked(
133
+ "source_delivery_client." + code, retryable=retryable,
134
+ )
135
+
136
+
137
+ def _canonical(value: Any) -> bytes:
138
+ try:
139
+ raw = json.dumps(
140
+ value, ensure_ascii=False, allow_nan=False, sort_keys=True,
141
+ separators=(",", ":"),
142
+ ).encode("utf-8")
143
+ except (TypeError, ValueError, RecursionError):
144
+ _fail("request_invalid")
145
+ if not raw or len(raw) > _HTTP.MAX_BODY_BYTES:
146
+ _fail("request_invalid")
147
+ return raw
148
+
149
+
150
+ def _pairs(items: list[tuple[str, Any]]) -> dict[str, Any]:
151
+ result: dict[str, Any] = {}
152
+ for key, value in items:
153
+ if key in result:
154
+ raise ValueError("duplicate key")
155
+ result[key] = value
156
+ return result
157
+
158
+
159
+ def _constant(_value: str) -> None:
160
+ raise ValueError("non-finite number")
161
+
162
+
163
+ def _token(value: Any) -> bool:
164
+ return (
165
+ isinstance(value, str)
166
+ and TOKEN.fullmatch(value) is not None
167
+ and unicodedata.is_normalized("NFC", value)
168
+ )
169
+
170
+
171
+ def _sha256(value: Any) -> bool:
172
+ return isinstance(value, str) and SHA256.fullmatch(value) is not None
173
+
174
+
175
+ def _count(value: Any, *, minimum: int = 0, maximum: int = 1_000_000) -> bool:
176
+ return (
177
+ isinstance(value, int)
178
+ and not isinstance(value, bool)
179
+ and minimum <= value <= maximum
180
+ )
181
+
182
+
183
+ def _timestamp(value: Any, *, nullable: bool = False) -> bool:
184
+ return (
185
+ nullable and value is None
186
+ or isinstance(value, (int, float))
187
+ and not isinstance(value, bool)
188
+ and math.isfinite(float(value))
189
+ and float(value) >= 0
190
+ )
191
+
192
+
193
+ def _endpoint(value: Any, allow_loopback_http: bool) -> str:
194
+ if (
195
+ not isinstance(value, str)
196
+ or value != value.strip()
197
+ or not value
198
+ or not value.isascii()
199
+ or len(value) > MAX_ENDPOINT_LENGTH
200
+ or any(ord(character) <= 32 or ord(character) == 127 for character in value)
201
+ ):
202
+ raise ValueError("origin is invalid")
203
+ try:
204
+ parsed = urllib.parse.urlsplit(value)
205
+ hostname = parsed.hostname
206
+ port = parsed.port
207
+ except ValueError:
208
+ raise ValueError("origin is invalid") from None
209
+ secure = parsed.scheme == "https"
210
+ loopback = (
211
+ parsed.scheme == "http"
212
+ and allow_loopback_http
213
+ and isinstance(hostname, str)
214
+ and hostname.lower() in {"localhost", "127.0.0.1", "::1"}
215
+ )
216
+ if (
217
+ not (secure or loopback)
218
+ or not hostname
219
+ or parsed.username is not None
220
+ or parsed.password is not None
221
+ or parsed.path
222
+ or parsed.query
223
+ or parsed.fragment
224
+ or port is not None and not 1 <= port <= 65_535
225
+ or value != f"{parsed.scheme}://{parsed.netloc}"
226
+ ):
227
+ raise ValueError("origin must be one exact HTTPS origin")
228
+ return value
229
+
230
+
231
+ def _authentication_headers(provider, context) -> dict[str, str]:
232
+ try:
233
+ supplied = provider(dict(context))
234
+ if not isinstance(supplied, Mapping):
235
+ raise TypeError
236
+ items = tuple(supplied.items())
237
+ except Exception:
238
+ _fail("authentication")
239
+ if len(items) > MAX_AUTHENTICATION_HEADERS:
240
+ _fail("authentication")
241
+ result: dict[str, str] = {}
242
+ names: set[str] = set()
243
+ for name, value in items:
244
+ normalized = name.lower() if isinstance(name, str) else ""
245
+ if (
246
+ not isinstance(name, str)
247
+ or _HTTP.HEADER_NAME.fullmatch(name) is None
248
+ or normalized in RESERVED_HEADERS
249
+ or normalized in names
250
+ or not isinstance(value, str)
251
+ or not value
252
+ or len(value) > MAX_HEADER_VALUE_LENGTH
253
+ or "\r" in value
254
+ or "\n" in value
255
+ or any(ord(character) < 32 or ord(character) == 127 for character in value)
256
+ ):
257
+ _fail("authentication")
258
+ names.add(normalized)
259
+ result[name] = value
260
+ if not result:
261
+ _fail("authentication")
262
+ return result
263
+
264
+
265
+ def _response_headers(value: Any) -> dict[str, str]:
266
+ if not isinstance(value, tuple):
267
+ _fail("transport_invalid", retryable=True)
268
+ result: dict[str, str] = {}
269
+ for item in value:
270
+ if (
271
+ not isinstance(item, tuple)
272
+ or len(item) != 2
273
+ or not all(isinstance(part, str) for part in item)
274
+ ):
275
+ _fail("transport_invalid", retryable=True)
276
+ name, content = item[0].lower(), item[1].strip()
277
+ if name in result:
278
+ _fail("response_headers")
279
+ result[name] = content
280
+ return result
281
+
282
+
283
+ def _json_response(result: Any, allowed_statuses: set[int]) -> dict[str, Any]:
284
+ if (
285
+ not isinstance(result, HTTPResult)
286
+ or isinstance(result.status, bool)
287
+ or not isinstance(result.status, int)
288
+ or not 100 <= result.status <= 599
289
+ ):
290
+ _fail("transport_invalid", retryable=True)
291
+ if result.status not in allowed_statuses:
292
+ if 300 <= result.status <= 399:
293
+ _fail("redirect")
294
+ retryable = result.status in {408, 425, 429} or result.status >= 500
295
+ _fail("http_status", retryable=retryable)
296
+ headers = _response_headers(result.headers)
297
+ content_type = headers.get("content-type", "").lower().replace(" ", "")
298
+ if content_type not in {"application/json", "application/json;charset=utf-8"}:
299
+ _fail("response_content_type")
300
+ if (
301
+ not isinstance(result.body, bytes)
302
+ or not result.body
303
+ or len(result.body) > _HTTP.MAX_BODY_BYTES
304
+ ):
305
+ _fail("response_size")
306
+ declared = headers.get("content-length")
307
+ if declared is not None and (
308
+ not declared.isascii()
309
+ or not declared.isdecimal()
310
+ or int(declared) != len(result.body)
311
+ ):
312
+ _fail("response_size")
313
+ try:
314
+ text = result.body.decode("utf-8")
315
+ if text.startswith("\ufeff"):
316
+ raise ValueError
317
+ value = json.loads(
318
+ text, object_pairs_hook=_pairs, parse_constant=_constant,
319
+ )
320
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError):
321
+ _fail("response_json")
322
+ if not isinstance(value, dict):
323
+ _fail("response_binding")
324
+ return value
325
+
326
+
327
+ def _copy_payload(value: Any, operation: str) -> tuple[dict[str, Any], bytes]:
328
+ if not isinstance(value, Mapping):
329
+ _fail("request_invalid")
330
+ raw = _canonical(dict(value))
331
+ try:
332
+ copied = json.loads(raw.decode("utf-8"), object_pairs_hook=_pairs)
333
+ if operation == "change":
334
+ _CMS.WebsiteLocalizationCMSBridge._validated_event(None, copied)
335
+ elif copied.get("schema") == _CMS.CANCELLATION_SCHEMA:
336
+ _CMS.WebsiteLocalizationCMSBridge._validated_cancellation(None, copied)
337
+ elif copied.get("schema") == _CMS.TOMBSTONE_SCHEMA:
338
+ _CMS.WebsiteLocalizationCMSBridge._validated_tombstone(None, copied)
339
+ else:
340
+ raise ValueError
341
+ except Exception:
342
+ _fail("request_invalid")
343
+ return copied, raw
344
+
345
+
346
+ def _identity(payload: Mapping[str, Any], operation: str) -> tuple[str, str, str]:
347
+ try:
348
+ site_id = payload["site_id"]
349
+ event_id = payload["event_id"]
350
+ if operation == "change":
351
+ request_id = event_id
352
+ elif payload["schema"] == _CMS.CANCELLATION_SCHEMA:
353
+ request_id = payload["cancellation_id"]
354
+ else:
355
+ request_id = payload["tombstone_id"]
356
+ except (KeyError, TypeError):
357
+ _fail("request_invalid")
358
+ if not all(_token(value) for value in (site_id, event_id, request_id)):
359
+ _fail("request_invalid")
360
+ return site_id, event_id, request_id
361
+
362
+
363
+ def _status_payload(
364
+ value: Any,
365
+ *,
366
+ expected_operation: str,
367
+ expected_request_id: str,
368
+ expected_event_id: str,
369
+ expected_site_id: str,
370
+ expected_payload_sha256: str,
371
+ expected_remote_capabilities_sha256: str,
372
+ ) -> dict[str, Any]:
373
+ fields = {
374
+ "operation", "request_id", "event_id", "site_id", "payload_sha256",
375
+ "remote_capabilities_sha256", "status", "attempts",
376
+ "delivery_max_attempts", "source_max_attempts", "next_attempt_at",
377
+ "lease_expires_at", "lease_expired", "last_error_code",
378
+ "response_sha256",
379
+ }
380
+ if not isinstance(value, Mapping) or set(value) != fields:
381
+ _fail("status_binding")
382
+ operation = value.get("operation")
383
+ status = value.get("status")
384
+ lease_expires = value.get("lease_expires_at")
385
+ response_hash = value.get("response_sha256")
386
+ error = value.get("last_error_code")
387
+ valid = (
388
+ operation == expected_operation
389
+ and operation in _HTTP.OPERATIONS
390
+ and value.get("request_id") == expected_request_id
391
+ and value.get("event_id") == expected_event_id
392
+ and value.get("site_id") == expected_site_id
393
+ and value.get("payload_sha256") == expected_payload_sha256
394
+ and value.get("remote_capabilities_sha256")
395
+ == expected_remote_capabilities_sha256
396
+ and all(
397
+ _token(value.get(name))
398
+ for name in ("request_id", "event_id", "site_id")
399
+ )
400
+ and status in _HTTP.STATUSES
401
+ and _count(value.get("attempts"), maximum=20)
402
+ and _count(value.get("delivery_max_attempts"), minimum=1, maximum=20)
403
+ and _count(value.get("source_max_attempts"), minimum=1, maximum=20)
404
+ and value["attempts"] <= value["delivery_max_attempts"]
405
+ and _timestamp(value.get("next_attempt_at"))
406
+ and _timestamp(lease_expires, nullable=True)
407
+ and isinstance(value.get("lease_expired"), bool)
408
+ and (error is None or isinstance(error, str) and ERROR_CODE.fullmatch(error))
409
+ and (response_hash is None or _sha256(response_hash))
410
+ and (status == "leased") == (lease_expires is not None)
411
+ and (status == "succeeded") == (response_hash is not None)
412
+ and (status not in {"retry_wait", "failed"} or error is not None)
413
+ )
414
+ if not valid:
415
+ _fail("status_binding")
416
+ return dict(value)
417
+
418
+
419
+ class _PayloadView:
420
+ def __init__(self, value: Mapping[str, Any]):
421
+ self.value = value
422
+
423
+ def as_payload(self) -> Mapping[str, Any]:
424
+ return self.value
425
+
426
+
427
+ class CMSSourceDeliverySidecarHTTPClient:
428
+ """Operate one sidecar only through its freshly verified contract."""
429
+
430
+ def __init__(
431
+ self,
432
+ origin: str,
433
+ expected_capabilities_sha256: str,
434
+ expected_remote_capabilities_sha256: str,
435
+ authentication_headers: Callable[[Mapping[str, Any]], Mapping[str, str]],
436
+ *,
437
+ transport: HTTPTransport | None = None,
438
+ timeout: float | int = 30,
439
+ allow_loopback_http: bool = False,
440
+ ):
441
+ if not isinstance(allow_loopback_http, bool):
442
+ raise TypeError("allow_loopback_http must be boolean")
443
+ self.origin = _endpoint(origin, allow_loopback_http)
444
+ if not _sha256(expected_capabilities_sha256):
445
+ raise ValueError("expected sidecar capability digest is invalid")
446
+ if not _sha256(expected_remote_capabilities_sha256):
447
+ raise ValueError("expected remote capability digest is invalid")
448
+ if not callable(authentication_headers):
449
+ raise TypeError("authentication_headers must be callable")
450
+ if (
451
+ isinstance(timeout, bool)
452
+ or not isinstance(timeout, (int, float))
453
+ or not math.isfinite(float(timeout))
454
+ or not 0 < float(timeout) <= 300
455
+ ):
456
+ raise ValueError("timeout is outside the supported range")
457
+ self.expected_capabilities_sha256 = expected_capabilities_sha256
458
+ self.expected_remote_capabilities_sha256 = (
459
+ expected_remote_capabilities_sha256
460
+ )
461
+ self.authentication_headers = authentication_headers
462
+ self.transport = URLTransport() if transport is None else transport
463
+ if not callable(getattr(self.transport, "request", None)):
464
+ raise TypeError("transport must provide request")
465
+ self.timeout = float(timeout)
466
+
467
+ def __repr__(self) -> str:
468
+ return "CMSSourceDeliverySidecarHTTPClient(configured=True)"
469
+
470
+ def _request(self, method, path, body, allowed_statuses, context, headers=None):
471
+ body_hash = hashlib.sha256(body or b"").hexdigest()
472
+ authentication = {
473
+ "schema": AUTH_CONTEXT_SCHEMA,
474
+ "method": method,
475
+ "origin": self.origin,
476
+ "path": path,
477
+ "scope": _HTTP.SCOPES[path],
478
+ "body_sha256": body_hash,
479
+ **context,
480
+ }
481
+ request_headers = _authentication_headers(
482
+ self.authentication_headers, authentication,
483
+ )
484
+ request_headers["Accept"] = "application/json"
485
+ if body is not None:
486
+ request_headers["Content-Type"] = "application/json; charset=utf-8"
487
+ if headers:
488
+ request_headers.update(headers)
489
+ try:
490
+ result = self.transport.request(
491
+ method, self.origin + path, request_headers, body,
492
+ timeout=self.timeout,
493
+ )
494
+ except CMSSourceDeliveryClientBlocked:
495
+ raise
496
+ except Exception:
497
+ _fail("network", retryable=True)
498
+ return result, _json_response(result, allowed_statuses)
499
+
500
+ def capabilities(self) -> Mapping[str, Any]:
501
+ _result, response = self._request(
502
+ "GET", _HTTP.CAPABILITIES_PATH, None, {200}, {},
503
+ )
504
+ try:
505
+ expected = _HTTP._capabilities_payload()
506
+ except Exception:
507
+ _fail("local_contract_invalid")
508
+ if (
509
+ set(response) != {"schema", "capabilities"}
510
+ or response.get("schema") != _HTTP.CAPABILITIES_RESPONSE_SCHEMA
511
+ or response.get("capabilities") != expected
512
+ or expected.get("sha256") != self.expected_capabilities_sha256
513
+ ):
514
+ _fail("capabilities_binding")
515
+ return response
516
+
517
+ def _contract(self, operation: str) -> Mapping[str, Any]:
518
+ return self.capabilities()["capabilities"]["operations"][operation]
519
+
520
+ def submit_change(
521
+ self,
522
+ change: Mapping[str, Any],
523
+ *,
524
+ source_max_attempts: int = 5,
525
+ delivery_max_attempts: int = 5,
526
+ ) -> Mapping[str, Any]:
527
+ payload, payload_body = _copy_payload(change, "change")
528
+ site_id, event_id, request_id = _identity(payload, "change")
529
+ return self._enqueue(
530
+ "change", payload, payload_body, site_id, event_id, request_id,
531
+ source_max_attempts, delivery_max_attempts,
532
+ )
533
+
534
+ def submit_removal(
535
+ self,
536
+ removal: Mapping[str, Any],
537
+ *,
538
+ source_max_attempts: int = 5,
539
+ delivery_max_attempts: int = 5,
540
+ ) -> Mapping[str, Any]:
541
+ payload, payload_body = _copy_payload(removal, "removal")
542
+ operation = (
543
+ "cancellation"
544
+ if payload["schema"] == _CMS.CANCELLATION_SCHEMA
545
+ else "tombstone"
546
+ )
547
+ site_id, event_id, request_id = _identity(payload, operation)
548
+ return self._enqueue(
549
+ operation, payload, payload_body, site_id, event_id, request_id,
550
+ source_max_attempts, delivery_max_attempts,
551
+ )
552
+
553
+ def _enqueue(
554
+ self,
555
+ operation,
556
+ payload,
557
+ payload_body,
558
+ site_id,
559
+ event_id,
560
+ request_id,
561
+ source_max_attempts,
562
+ delivery_max_attempts,
563
+ ) -> Mapping[str, Any]:
564
+ if not _count(source_max_attempts, minimum=1, maximum=20):
565
+ _fail("request_invalid")
566
+ if not _count(delivery_max_attempts, minimum=1, maximum=20):
567
+ _fail("request_invalid")
568
+ contract_name = "change" if operation == "change" else "removal"
569
+ contract = self._contract(contract_name)
570
+ envelope_key = "change" if operation == "change" else "removal"
571
+ request = {
572
+ "schema": contract["request_schema"],
573
+ envelope_key: payload,
574
+ "source_max_attempts": source_max_attempts,
575
+ "delivery_max_attempts": delivery_max_attempts,
576
+ }
577
+ body = _canonical(request)
578
+ payload_hash = hashlib.sha256(payload_body).hexdigest()
579
+ _result, response = self._request(
580
+ contract["method"], contract["path"], body,
581
+ {contract["success_status"]},
582
+ {
583
+ "site_id": site_id,
584
+ "event_id": event_id,
585
+ "request_id": request_id,
586
+ "payload_sha256": payload_hash,
587
+ },
588
+ {
589
+ "Idempotency-Key": request_id,
590
+ "X-Localization-Source-Payload-Sha256": payload_hash,
591
+ },
592
+ )
593
+ if (
594
+ set(response) != {"schema", "queue", "capabilities_sha256"}
595
+ or response.get("schema") != contract["response_schema"]
596
+ or response.get("capabilities_sha256")
597
+ != self.expected_capabilities_sha256
598
+ ):
599
+ _fail("enqueue_binding")
600
+ try:
601
+ status = _status_payload(
602
+ response["queue"],
603
+ expected_operation=operation,
604
+ expected_request_id=request_id,
605
+ expected_event_id=event_id,
606
+ expected_site_id=site_id,
607
+ expected_payload_sha256=payload_hash,
608
+ expected_remote_capabilities_sha256=(
609
+ self.expected_remote_capabilities_sha256
610
+ ),
611
+ )
612
+ except CMSSourceDeliveryClientBlocked:
613
+ _fail("enqueue_binding")
614
+ if (
615
+ status["source_max_attempts"] != source_max_attempts
616
+ or status["delivery_max_attempts"] != delivery_max_attempts
617
+ ):
618
+ _fail("enqueue_binding")
619
+ return response
620
+
621
+ def status(
622
+ self,
623
+ operation: str,
624
+ request_id: str,
625
+ event_id: str,
626
+ site_id: str,
627
+ payload_sha256: str,
628
+ ) -> Mapping[str, Any]:
629
+ if (
630
+ operation not in _HTTP.OPERATIONS
631
+ or not all(_token(value) for value in (request_id, event_id, site_id))
632
+ or not _sha256(payload_sha256)
633
+ ):
634
+ _fail("request_invalid")
635
+ contract = self._contract("status")
636
+ request = {
637
+ "schema": contract["request_schema"],
638
+ "operation": operation,
639
+ "request_id": request_id,
640
+ "site_id": site_id,
641
+ }
642
+ body = _canonical(request)
643
+ _result, response = self._request(
644
+ contract["method"], contract["path"], body,
645
+ {contract["success_status"]},
646
+ {
647
+ "site_id": site_id,
648
+ "event_id": event_id,
649
+ "request_id": request_id,
650
+ "payload_sha256": payload_sha256,
651
+ },
652
+ )
653
+ if (
654
+ set(response) != {"schema", "status", "capabilities_sha256"}
655
+ or response.get("schema") != contract["response_schema"]
656
+ or response.get("capabilities_sha256")
657
+ != self.expected_capabilities_sha256
658
+ ):
659
+ _fail("status_binding")
660
+ _status_payload(
661
+ response["status"],
662
+ expected_operation=operation,
663
+ expected_request_id=request_id,
664
+ expected_event_id=event_id,
665
+ expected_site_id=site_id,
666
+ expected_payload_sha256=payload_sha256,
667
+ expected_remote_capabilities_sha256=(
668
+ self.expected_remote_capabilities_sha256
669
+ ),
670
+ )
671
+ return response
672
+
673
+ def source_status(
674
+ self,
675
+ event_id: str,
676
+ site_id: str,
677
+ payload_sha256: str,
678
+ ) -> Mapping[str, Any]:
679
+ """Read the complete source lifecycle through the accepted sidecar row."""
680
+
681
+ if (
682
+ not _token(event_id)
683
+ or not _token(site_id)
684
+ or not _sha256(payload_sha256)
685
+ ):
686
+ _fail("request_invalid")
687
+ contract = self._contract("source_status")
688
+ request = {
689
+ "schema": contract["request_schema"],
690
+ "event_id": event_id,
691
+ "site_id": site_id,
692
+ "payload_sha256": payload_sha256,
693
+ }
694
+ body = _canonical(request)
695
+ _result, response = self._request(
696
+ contract["method"],
697
+ contract["path"],
698
+ body,
699
+ {contract["success_status"]},
700
+ {
701
+ "site_id": site_id,
702
+ "event_id": event_id,
703
+ "request_id": event_id,
704
+ "payload_sha256": payload_sha256,
705
+ },
706
+ )
707
+ if (
708
+ set(response) != {
709
+ "schema", "source_status", "source_capabilities_sha256",
710
+ "capabilities_sha256", "source_capability_binding",
711
+ }
712
+ or response.get("schema") != contract["response_schema"]
713
+ or response.get("capabilities_sha256")
714
+ != self.expected_capabilities_sha256
715
+ or response.get("source_capabilities_sha256")
716
+ != self.expected_remote_capabilities_sha256
717
+ ):
718
+ _fail("source_status_binding")
719
+ try:
720
+ normalized = _HTTP._source_status_response(
721
+ {
722
+ "schema": _HTTP._SOURCE_HTTP.STATUS_RESPONSE_SCHEMA,
723
+ "status": response["source_status"],
724
+ "capability_binding": response[
725
+ "source_capability_binding"
726
+ ],
727
+ "capabilities_sha256": (
728
+ response["source_capabilities_sha256"]
729
+ ),
730
+ },
731
+ expected_event_id=event_id,
732
+ expected_site_id=site_id,
733
+ expected_capabilities_sha256=(
734
+ self.expected_remote_capabilities_sha256
735
+ ),
736
+ )
737
+ except Exception:
738
+ _fail("source_status_binding")
739
+ if (
740
+ normalized["status"] != response["source_status"]
741
+ or normalized["capability_binding"]
742
+ != response["source_capability_binding"]
743
+ ):
744
+ _fail("source_status_binding")
745
+ return response
746
+
747
+ def source_readiness(self) -> Mapping[str, Any]:
748
+ """Read the downstream processing readiness through the sidecar."""
749
+
750
+ contract = self._contract("source_readiness")
751
+ result, response = self._request(
752
+ contract["method"], contract["path"], None, {200, 503}, {},
753
+ )
754
+ if (
755
+ set(response) != {
756
+ "schema", "source_readiness", "source_capabilities_sha256",
757
+ "capabilities_sha256", "source_capability_binding",
758
+ }
759
+ or response.get("schema") != contract["response_schema"]
760
+ or response.get("capabilities_sha256")
761
+ != self.expected_capabilities_sha256
762
+ or response.get("source_capabilities_sha256")
763
+ != self.expected_remote_capabilities_sha256
764
+ ):
765
+ _fail("source_readiness_binding")
766
+ try:
767
+ normalized = _HTTP._source_readiness_response(
768
+ {
769
+ "schema": _HTTP._SOURCE_HTTP.READINESS_RESPONSE_SCHEMA,
770
+ "readiness": response["source_readiness"],
771
+ "capability_binding": response[
772
+ "source_capability_binding"
773
+ ],
774
+ "capabilities_sha256": (
775
+ response["source_capabilities_sha256"]
776
+ ),
777
+ },
778
+ expected_capabilities_sha256=(
779
+ self.expected_remote_capabilities_sha256
780
+ ),
781
+ )
782
+ except Exception:
783
+ _fail("source_readiness_binding")
784
+ if (
785
+ normalized["readiness"] != response["source_readiness"]
786
+ or normalized["capability_binding"]
787
+ != response["source_capability_binding"]
788
+ or (result.status == 200)
789
+ != (normalized["readiness"]["status"] == "ready")
790
+ ):
791
+ _fail("source_readiness_binding")
792
+ return response
793
+
794
+ def source_health(self) -> Mapping[str, Any]:
795
+ """Read the downstream queue health through the sidecar."""
796
+
797
+ contract = self._contract("source_health")
798
+ result, response = self._request(
799
+ contract["method"], contract["path"], None, {200, 503}, {},
800
+ )
801
+ if (
802
+ set(response) != {
803
+ "schema", "source_health", "source_capabilities_sha256",
804
+ "capabilities_sha256", "source_capability_binding",
805
+ }
806
+ or response.get("schema") != contract["response_schema"]
807
+ or response.get("capabilities_sha256")
808
+ != self.expected_capabilities_sha256
809
+ or response.get("source_capabilities_sha256")
810
+ != self.expected_remote_capabilities_sha256
811
+ ):
812
+ _fail("source_health_binding")
813
+ try:
814
+ normalized = _HTTP._source_health_response(
815
+ {
816
+ "schema": _HTTP._SOURCE_HTTP.HEALTH_RESPONSE_SCHEMA,
817
+ "health": response["source_health"],
818
+ "capability_binding": response[
819
+ "source_capability_binding"
820
+ ],
821
+ "capabilities_sha256": (
822
+ response["source_capabilities_sha256"]
823
+ ),
824
+ },
825
+ expected_capabilities_sha256=(
826
+ self.expected_remote_capabilities_sha256
827
+ ),
828
+ )
829
+ except Exception:
830
+ _fail("source_health_binding")
831
+ if (
832
+ normalized["health"] != response["source_health"]
833
+ or normalized["capability_binding"]
834
+ != response["source_capability_binding"]
835
+ or (result.status == 503)
836
+ != (normalized["health"]["status"] == "blocked")
837
+ ):
838
+ _fail("source_health_binding")
839
+ return response
840
+
841
+ def health(self) -> Mapping[str, Any]:
842
+ contract = self._contract("health")
843
+ result, response = self._request(
844
+ contract["method"], contract["path"], None, {200, 503}, {},
845
+ )
846
+ if (
847
+ set(response) != {"schema", "health", "capabilities_sha256"}
848
+ or response.get("schema") != contract["response_schema"]
849
+ or response.get("capabilities_sha256")
850
+ != self.expected_capabilities_sha256
851
+ ):
852
+ _fail("health_binding", retryable=result.status == 503)
853
+ try:
854
+ normalized = _HTTP._health_payload(_PayloadView(response["health"]))
855
+ except Exception:
856
+ _fail("health_binding", retryable=result.status == 503)
857
+ if (
858
+ normalized != response["health"]
859
+ or (result.status == 503) != (normalized["status"] == "blocked")
860
+ ):
861
+ _fail("health_binding", retryable=result.status == 503)
862
+ return response
863
+
864
+ def readiness(self) -> Mapping[str, Any]:
865
+ contract = self._contract("readiness")
866
+ result, response = self._request(
867
+ contract["method"], contract["path"], None, {200, 503}, {},
868
+ )
869
+ if (
870
+ set(response) != {"schema", "readiness", "capabilities_sha256"}
871
+ or response.get("schema") != contract["response_schema"]
872
+ or response.get("capabilities_sha256")
873
+ != self.expected_capabilities_sha256
874
+ ):
875
+ _fail("readiness_binding", retryable=result.status == 503)
876
+ try:
877
+ normalized = _HTTP._readiness_payload(response["readiness"])
878
+ except Exception:
879
+ _fail("readiness_binding", retryable=result.status == 503)
880
+ if (
881
+ normalized != response["readiness"]
882
+ or (result.status == 200) != (normalized["status"] == "ready")
883
+ ):
884
+ _fail("readiness_binding", retryable=result.status == 503)
885
+ return response