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
@@ -38,6 +38,12 @@ _DELIVERY = _load_module(
38
38
  "blun_website_localization_composed_cms_source_delivery",
39
39
  _ROOT / "integrations" / "website_localization_cms_source_delivery.py",
40
40
  )
41
+ _HTTP = _load_module(
42
+ "blun_website_localization_composed_cms_source_delivery_http",
43
+ _ROOT
44
+ / "integrations"
45
+ / "website_localization_cms_source_delivery_http.py",
46
+ )
41
47
 
42
48
 
43
49
  class DurableCMSSourceDeliveryRuntimeBlocked(RuntimeError):
@@ -68,6 +74,8 @@ def _delivery_failure(error: Exception) -> DurableCMSSourceDeliveryRuntimeBlocke
68
74
  return _blocked("source_delivery_runtime.idempotency_collision")
69
75
  if code == "source_delivery.status_not_found":
70
76
  return _blocked("source_delivery_runtime.status_not_found")
77
+ if code == "source_delivery.source_status_unavailable":
78
+ return _blocked("source_delivery_runtime.source_status_unavailable")
71
79
  return _blocked("source_delivery_runtime.outbox_blocked")
72
80
 
73
81
 
@@ -207,6 +215,175 @@ def _prepare_database_file(database_path: str) -> Callable[[], None]:
207
215
  return guard
208
216
 
209
217
 
218
+ def preflight_existing_durable_cms_source_delivery(
219
+ database: str | os.PathLike[str],
220
+ client: Any,
221
+ ) -> None:
222
+ """Validate an existing durable generation locally without changing it."""
223
+
224
+ path = _database_path(database)
225
+ if path == ":memory:":
226
+ return
227
+ _validate_database_parent(path)
228
+ try:
229
+ identity = _validate_database_file(path)
230
+ except FileNotFoundError:
231
+ return
232
+
233
+ delivery_digest = getattr(client, "expected_capabilities_sha256", None)
234
+ legacy_delivery_digest = getattr(
235
+ client, "legacy_expected_capabilities_sha256", delivery_digest,
236
+ )
237
+ runtime_digest = getattr(
238
+ client, "expected_runtime_capabilities_sha256", None,
239
+ )
240
+ rendering_digest = getattr(
241
+ client, "expected_commercial_rendering_registry_sha256", None,
242
+ )
243
+ terminal_receiver_digest = getattr(
244
+ client, "expected_terminal_receiver_capabilities_sha256", None,
245
+ )
246
+ if not all(
247
+ isinstance(value, str) and _DELIVERY.SHA256.fullmatch(value) is not None
248
+ for value in (
249
+ delivery_digest, legacy_delivery_digest, runtime_digest,
250
+ rendering_digest,
251
+ terminal_receiver_digest,
252
+ )
253
+ ):
254
+ raise _blocked("source_delivery_runtime.configuration_invalid")
255
+ expected_binding = _DELIVERY._capability_binding_row(
256
+ delivery_digest, runtime_digest, rendering_digest,
257
+ terminal_receiver_digest,
258
+ )
259
+ legacy_binding = _DELIVERY._legacy_capability_binding_row(
260
+ legacy_delivery_digest, runtime_digest, rendering_digest,
261
+ )
262
+
263
+ connection = None
264
+ try:
265
+ connection = sqlite3.connect(
266
+ Path(path).as_uri() + "?mode=ro",
267
+ uri=True,
268
+ timeout=0,
269
+ isolation_level=None,
270
+ )
271
+ connection.row_factory = sqlite3.Row
272
+ _validate_database_file(path, identity)
273
+ binding_objects = connection.execute(
274
+ "SELECT type, name, tbl_name, sql FROM sqlite_master "
275
+ "WHERE name = ?",
276
+ (_DELIVERY.CAPABILITY_BINDING_TABLE,),
277
+ ).fetchall()
278
+
279
+ meta_columns = tuple(
280
+ row["name"] for row in connection.execute(
281
+ "PRAGMA table_info(cms_source_delivery_meta)"
282
+ ).fetchall()
283
+ )
284
+ outbox_columns = tuple(
285
+ row["name"] for row in connection.execute(
286
+ "PRAGMA table_info(cms_source_delivery_outbox)"
287
+ ).fetchall()
288
+ )
289
+ empty_file = not binding_objects and not meta_columns and not outbox_columns
290
+ if empty_file:
291
+ unexpected = connection.execute(
292
+ "SELECT 1 FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' "
293
+ "LIMIT 1"
294
+ ).fetchone()
295
+ if unexpected is None:
296
+ return
297
+
298
+ if (
299
+ meta_columns != _DELIVERY._META_COLUMNS
300
+ or outbox_columns != _DELIVERY._COLUMNS
301
+ ):
302
+ raise _blocked("source_delivery_runtime.database_schema_altered")
303
+ meta = connection.execute(
304
+ "SELECT singleton, schema_version FROM cms_source_delivery_meta"
305
+ ).fetchall()
306
+ if (
307
+ len(meta) != 1
308
+ or tuple(meta[0]) != (1, _DELIVERY.SCHEMA_VERSION)
309
+ ):
310
+ raise _blocked("source_delivery_runtime.database_schema_altered")
311
+
312
+ has_work = connection.execute(
313
+ "SELECT 1 FROM cms_source_delivery_outbox LIMIT 1"
314
+ ).fetchone()
315
+ if not binding_objects:
316
+ if has_work is not None:
317
+ raise _blocked("source_delivery_runtime.database_generation_unbound")
318
+ return
319
+ if len(binding_objects) != 1:
320
+ raise _blocked("source_delivery_runtime.database_generation_invalid")
321
+ binding_object = tuple(binding_objects[0])
322
+ current_object = (
323
+ "table",
324
+ _DELIVERY.CAPABILITY_BINDING_TABLE,
325
+ _DELIVERY.CAPABILITY_BINDING_TABLE,
326
+ _DELIVERY.CAPABILITY_BINDING_SQL,
327
+ )
328
+ legacy_object = (
329
+ "table",
330
+ _DELIVERY.CAPABILITY_BINDING_TABLE,
331
+ _DELIVERY.CAPABILITY_BINDING_TABLE,
332
+ _DELIVERY.LEGACY_CAPABILITY_BINDING_SQL,
333
+ )
334
+ if binding_object not in {current_object, legacy_object}:
335
+ raise _blocked("source_delivery_runtime.database_generation_invalid")
336
+ columns = tuple(
337
+ (row[1], row[2], row[3], row[5])
338
+ for row in connection.execute(
339
+ f"PRAGMA table_info({_DELIVERY.CAPABILITY_BINDING_TABLE})"
340
+ ).fetchall()
341
+ )
342
+ if binding_object == legacy_object:
343
+ rows = connection.execute(
344
+ f"SELECT singleton, schema, database_role, "
345
+ f"delivery_capabilities_sha256, runtime_capabilities_sha256, "
346
+ f"commercial_rendering_registry_sha256, binding_sha256 "
347
+ f"FROM {_DELIVERY.CAPABILITY_BINDING_TABLE}"
348
+ ).fetchall()
349
+ if (
350
+ columns != _DELIVERY.LEGACY_CAPABILITY_BINDING_COLUMNS
351
+ or len(rows) != 1
352
+ ):
353
+ raise _blocked(
354
+ "source_delivery_runtime.database_generation_invalid"
355
+ )
356
+ if tuple(rows[0]) != legacy_binding:
357
+ raise _blocked(
358
+ "source_delivery_runtime.database_generation_mismatch"
359
+ )
360
+ if has_work is not None:
361
+ raise _blocked(
362
+ "source_delivery_runtime.database_generation_unbound"
363
+ )
364
+ _validate_database_file(path, identity)
365
+ return
366
+ rows = connection.execute(
367
+ f"SELECT singleton, schema, database_role, "
368
+ f"delivery_capabilities_sha256, runtime_capabilities_sha256, "
369
+ f"commercial_rendering_registry_sha256, "
370
+ f"terminal_receiver_capabilities_sha256, binding_sha256 "
371
+ f"FROM {_DELIVERY.CAPABILITY_BINDING_TABLE}"
372
+ ).fetchall()
373
+ if columns != _DELIVERY.CAPABILITY_BINDING_COLUMNS or len(rows) != 1:
374
+ raise _blocked("source_delivery_runtime.database_generation_invalid")
375
+ if tuple(rows[0]) != expected_binding:
376
+ raise _blocked("source_delivery_runtime.database_generation_mismatch")
377
+ _validate_database_file(path, identity)
378
+ except DurableCMSSourceDeliveryRuntimeBlocked:
379
+ raise
380
+ except sqlite3.Error:
381
+ raise _blocked("source_delivery_runtime.database_preflight_failed") from None
382
+ finally:
383
+ if connection is not None:
384
+ connection.close()
385
+
386
+
210
387
  def _duration(value: Any, code: str, *, maximum: float = 86_400) -> float:
211
388
  if (
212
389
  isinstance(value, bool)
@@ -253,6 +430,7 @@ class DurableCMSSourceDeliveryRuntime:
253
430
  *,
254
431
  worker_id: str,
255
432
  lease_seconds: float,
433
+ http_authenticator: Callable[[dict[str, Any]], Any] | None = None,
256
434
  ):
257
435
  self._connection = connection
258
436
  self._guard = guard
@@ -266,6 +444,13 @@ class DurableCMSSourceDeliveryRuntime:
266
444
  self._worker_thread: threading.Thread | None = None
267
445
  self._worker_state = "unmanaged"
268
446
  self._worker_error_code: str | None = None
447
+ self.http = (
448
+ None
449
+ if http_authenticator is None
450
+ else _HTTP.CMSSourceDeliveryHTTPApplication(
451
+ self, http_authenticator,
452
+ )
453
+ )
269
454
 
270
455
  def __repr__(self) -> str:
271
456
  return f"DurableCMSSourceDeliveryRuntime(state={self.state!r})"
@@ -350,9 +535,40 @@ class DurableCMSSourceDeliveryRuntime:
350
535
  def status(self, operation: str, request_id: str) -> Any:
351
536
  return self._call("status", operation, request_id)
352
537
 
538
+ def source_status(
539
+ self, event_id: str, site_id: str, payload_sha256: str,
540
+ ) -> Mapping[str, Any]:
541
+ """Read the source lifecycle through the exact accepted outbox row."""
542
+
543
+ return self._call(
544
+ "source_status", event_id, site_id, payload_sha256,
545
+ )
546
+
547
+ def source_readiness(self) -> Mapping[str, Any]:
548
+ """Read the immutable downstream source-service readiness contract."""
549
+
550
+ return self._call("source_readiness")
551
+
552
+ def source_health(self) -> Mapping[str, Any]:
553
+ """Read the immutable downstream source-service health contract."""
554
+
555
+ return self._call("source_health")
556
+
353
557
  def health(self) -> Any:
354
558
  return self._call("health")
355
559
 
560
+ def capability_binding(self) -> dict[str, Any]:
561
+ """Return the locally verified durable outbox generation."""
562
+
563
+ return self._call("capability_binding")
564
+
565
+ @property
566
+ def expected_capabilities_sha256(self) -> str:
567
+ """Return the immutable source-service contract pinned by the outbox."""
568
+
569
+ self._assert_owner()
570
+ return self._outbox.capabilities_sha256
571
+
356
572
  def run_once(self) -> Any:
357
573
  return self._call(
358
574
  "run_once",
@@ -593,9 +809,12 @@ def open_durable_cms_source_delivery(
593
809
  lease_seconds: float | int = 600,
594
810
  base_delay_seconds: float | int = 5,
595
811
  max_delay_seconds: float | int = 300,
812
+ http_authenticator: Callable[[dict[str, Any]], Any] | None = None,
596
813
  ) -> DurableCMSSourceDeliveryRuntime:
597
814
  """Validate configuration, then open one guarded durable outbox."""
598
815
 
816
+ if http_authenticator is not None and not callable(http_authenticator):
817
+ raise _blocked("source_delivery_runtime.configuration_invalid")
599
818
  path = _database_path(database)
600
819
  timeout = _sqlite_timeout(sqlite_timeout_seconds)
601
820
  outbox_options = {
@@ -635,6 +854,7 @@ def open_durable_cms_source_delivery(
635
854
  outbox,
636
855
  worker_id=worker_id,
637
856
  lease_seconds=lease,
857
+ http_authenticator=http_authenticator,
638
858
  )
639
859
 
640
860
 
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env python3
2
+ """Bind the source-delivery sidecar client to the durable website outbox.
3
+
4
+ The website outbox and the sidecar intentionally own different retry loops.
5
+ This adapter keeps their budgets separate: the outbox retries durable sidecar
6
+ acceptance, while ``sidecar_delivery_max_attempts`` controls the sidecar's
7
+ delivery to the source service and the per-call ``max_attempts`` controls the
8
+ source service itself.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import math
16
+ import re
17
+ from typing import Any, Mapping
18
+
19
+
20
+ ADAPTER_SCHEMA = "blun.cms-source-delivery-sidecar-outbox-adapter.v3"
21
+ LEGACY_ADAPTER_SCHEMA = "blun.cms-source-delivery-sidecar-outbox-adapter.v2"
22
+ SHA256 = re.compile(r"^[a-f0-9]{64}$")
23
+ TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
24
+ ERROR_CODE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
25
+ CHANGE_SCHEMA = "blun.cms-content-change.v2"
26
+ CANCELLATION_SCHEMA = "blun.cms-content-cancellation.v1"
27
+ TOMBSTONE_SCHEMA = "blun.cms-content-tombstone.v1"
28
+ CHANGE_RESPONSE_SCHEMA = "blun.cms-source-change-enqueue-response.v3"
29
+ REMOVAL_RESPONSE_SCHEMA = "blun.cms-source-removal-enqueue-response.v3"
30
+ SIDECAR_QUEUE_RESPONSE_SCHEMA = "blun.cms-source-delivery-queue-response.v1"
31
+ QUEUE_FIELDS = {
32
+ "operation", "request_id", "event_id", "site_id", "payload_sha256",
33
+ "remote_capabilities_sha256", "status", "attempts",
34
+ "delivery_max_attempts", "source_max_attempts", "next_attempt_at",
35
+ "lease_expires_at", "lease_expired", "last_error_code",
36
+ "response_sha256",
37
+ }
38
+
39
+
40
+ class CMSSourceDeliverySidecarAdapterBlocked(RuntimeError):
41
+ """Content-free failure compatible with the website delivery outbox."""
42
+
43
+ cms_source_client_failure = True
44
+
45
+ def __init__(self, code: str, *, retryable: bool):
46
+ if not isinstance(code, str) or ERROR_CODE.fullmatch(code) is None:
47
+ code = "source_delivery_sidecar_adapter.blocked"
48
+ retryable = False
49
+ if not isinstance(retryable, bool):
50
+ retryable = False
51
+ super().__init__(code)
52
+ self.code = code
53
+ self.retryable = retryable
54
+
55
+
56
+ def _attempts(value: Any) -> int:
57
+ if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 20:
58
+ raise CMSSourceDeliverySidecarAdapterBlocked(
59
+ "source_delivery_sidecar_adapter.attempts_invalid", retryable=False,
60
+ )
61
+ return value
62
+
63
+
64
+ def _canonical(value: Any) -> bytes:
65
+ try:
66
+ return json.dumps(
67
+ value, ensure_ascii=False, allow_nan=False, sort_keys=True,
68
+ separators=(",", ":"),
69
+ ).encode("utf-8")
70
+ except (TypeError, ValueError, RecursionError):
71
+ raise CMSSourceDeliverySidecarAdapterBlocked(
72
+ "source_delivery_sidecar_adapter.request_invalid", retryable=False,
73
+ ) from None
74
+
75
+
76
+ def _failure(error: Exception) -> CMSSourceDeliverySidecarAdapterBlocked:
77
+ code = getattr(error, "code", None)
78
+ retryable = getattr(error, "retryable", None)
79
+ if (
80
+ getattr(error, "cms_source_delivery_client_failure", False) is True
81
+ and isinstance(code, str)
82
+ and ERROR_CODE.fullmatch(code) is not None
83
+ and isinstance(retryable, bool)
84
+ ):
85
+ return CMSSourceDeliverySidecarAdapterBlocked(code, retryable=retryable)
86
+ return CMSSourceDeliverySidecarAdapterBlocked(
87
+ "source_delivery_sidecar_adapter.client_failure", retryable=False,
88
+ )
89
+
90
+
91
+ class CMSSourceDeliverySidecarOutboxAdapter:
92
+ """Project validated sidecar acceptance into the durable outbox contract."""
93
+
94
+ def __init__(
95
+ self,
96
+ client: Any,
97
+ *,
98
+ sidecar_delivery_max_attempts: int = 5,
99
+ runtime_capabilities_sha256: str | None = None,
100
+ commercial_rendering_registry_sha256: str | None = None,
101
+ terminal_receiver_capabilities_sha256: str | None = None,
102
+ ):
103
+ sidecar_digest = getattr(client, "expected_capabilities_sha256", None)
104
+ remote_digest = getattr(client, "expected_remote_capabilities_sha256", None)
105
+ timeout = getattr(client, "timeout", None)
106
+ if (
107
+ not callable(getattr(client, "submit_change", None))
108
+ or not callable(getattr(client, "submit_removal", None))
109
+ or not isinstance(sidecar_digest, str)
110
+ or SHA256.fullmatch(sidecar_digest) is None
111
+ or not isinstance(remote_digest, str)
112
+ or SHA256.fullmatch(remote_digest) is None
113
+ or len({
114
+ runtime_capabilities_sha256 is None,
115
+ commercial_rendering_registry_sha256 is None,
116
+ terminal_receiver_capabilities_sha256 is None,
117
+ }) != 1
118
+ or runtime_capabilities_sha256 is not None and (
119
+ not isinstance(runtime_capabilities_sha256, str)
120
+ or SHA256.fullmatch(runtime_capabilities_sha256) is None
121
+ or not isinstance(commercial_rendering_registry_sha256, str)
122
+ or SHA256.fullmatch(commercial_rendering_registry_sha256) is None
123
+ or not isinstance(terminal_receiver_capabilities_sha256, str)
124
+ or SHA256.fullmatch(terminal_receiver_capabilities_sha256) is None
125
+ )
126
+ or isinstance(timeout, bool)
127
+ or not isinstance(timeout, (int, float))
128
+ or not math.isfinite(float(timeout))
129
+ or not 0 < float(timeout) <= 300
130
+ ):
131
+ raise CMSSourceDeliverySidecarAdapterBlocked(
132
+ "source_delivery_sidecar_adapter.client_invalid", retryable=False,
133
+ )
134
+ delivery_attempts = _attempts(sidecar_delivery_max_attempts)
135
+ binding = {
136
+ "schema": ADAPTER_SCHEMA,
137
+ "sidecar_capabilities_sha256": sidecar_digest,
138
+ "remote_capabilities_sha256": remote_digest,
139
+ "sidecar_delivery_max_attempts": delivery_attempts,
140
+ "runtime_capabilities_sha256": runtime_capabilities_sha256,
141
+ "commercial_rendering_registry_sha256": (
142
+ commercial_rendering_registry_sha256
143
+ ),
144
+ "terminal_receiver_capabilities_sha256": (
145
+ terminal_receiver_capabilities_sha256
146
+ ),
147
+ }
148
+ legacy_binding = {
149
+ "schema": LEGACY_ADAPTER_SCHEMA,
150
+ "sidecar_capabilities_sha256": sidecar_digest,
151
+ "remote_capabilities_sha256": remote_digest,
152
+ "sidecar_delivery_max_attempts": delivery_attempts,
153
+ "runtime_capabilities_sha256": runtime_capabilities_sha256,
154
+ "commercial_rendering_registry_sha256": (
155
+ commercial_rendering_registry_sha256
156
+ ),
157
+ }
158
+ self.client = client
159
+ self.sidecar_capabilities_sha256 = sidecar_digest
160
+ self.remote_capabilities_sha256 = remote_digest
161
+ self.sidecar_delivery_max_attempts = delivery_attempts
162
+ self.expected_runtime_capabilities_sha256 = (
163
+ runtime_capabilities_sha256
164
+ )
165
+ self.expected_commercial_rendering_registry_sha256 = (
166
+ commercial_rendering_registry_sha256
167
+ )
168
+ self.expected_terminal_receiver_capabilities_sha256 = (
169
+ terminal_receiver_capabilities_sha256
170
+ )
171
+ self.legacy_expected_capabilities_sha256 = hashlib.sha256(
172
+ _canonical(legacy_binding)
173
+ ).hexdigest()
174
+ self.expected_capabilities_sha256 = hashlib.sha256(
175
+ _canonical(binding)
176
+ ).hexdigest()
177
+ self.timeout = float(timeout)
178
+
179
+ def __repr__(self) -> str:
180
+ return "CMSSourceDeliverySidecarOutboxAdapter(configured=True)"
181
+
182
+ @staticmethod
183
+ def _identity(value: Mapping[str, Any]) -> tuple[str, str, str, str]:
184
+ if not isinstance(value, Mapping):
185
+ raise CMSSourceDeliverySidecarAdapterBlocked(
186
+ "source_delivery_sidecar_adapter.request_invalid", retryable=False,
187
+ )
188
+ schema = value.get("schema")
189
+ site_id = value.get("site_id")
190
+ event_id = value.get("event_id")
191
+ if schema == CHANGE_SCHEMA:
192
+ operation, request_id = "change", event_id
193
+ elif schema == CANCELLATION_SCHEMA:
194
+ operation, request_id = "cancellation", value.get("cancellation_id")
195
+ elif schema == TOMBSTONE_SCHEMA:
196
+ operation, request_id = "tombstone", value.get("tombstone_id")
197
+ else:
198
+ operation, request_id = "", None
199
+ if (
200
+ operation == ""
201
+ or not all(
202
+ isinstance(item, str) and TOKEN.fullmatch(item) is not None
203
+ for item in (site_id, event_id, request_id)
204
+ )
205
+ ):
206
+ raise CMSSourceDeliverySidecarAdapterBlocked(
207
+ "source_delivery_sidecar_adapter.request_invalid", retryable=False,
208
+ )
209
+ return operation, request_id, event_id, site_id
210
+
211
+ def _submit(
212
+ self, value: Mapping[str, Any], *, max_attempts: int, change: bool,
213
+ ) -> Mapping[str, Any]:
214
+ source_attempts = _attempts(max_attempts)
215
+ operation, request_id, event_id, site_id = self._identity(value)
216
+ if change != (operation == "change"):
217
+ raise CMSSourceDeliverySidecarAdapterBlocked(
218
+ "source_delivery_sidecar_adapter.request_invalid", retryable=False,
219
+ )
220
+ payload_hash = hashlib.sha256(_canonical(dict(value))).hexdigest()
221
+ try:
222
+ if change:
223
+ response = self.client.submit_change(
224
+ value, source_max_attempts=source_attempts,
225
+ delivery_max_attempts=self.sidecar_delivery_max_attempts,
226
+ )
227
+ else:
228
+ response = self.client.submit_removal(
229
+ value, source_max_attempts=source_attempts,
230
+ delivery_max_attempts=self.sidecar_delivery_max_attempts,
231
+ )
232
+ except Exception as error:
233
+ raise _failure(error) from error
234
+
235
+ queue = response.get("queue") if isinstance(response, Mapping) else None
236
+ status = queue.get("status") if isinstance(queue, Mapping) else None
237
+ attempts = queue.get("attempts") if isinstance(queue, Mapping) else None
238
+ next_attempt = (
239
+ queue.get("next_attempt_at") if isinstance(queue, Mapping) else None
240
+ )
241
+ lease_expires = (
242
+ queue.get("lease_expires_at") if isinstance(queue, Mapping) else None
243
+ )
244
+ last_error = (
245
+ queue.get("last_error_code") if isinstance(queue, Mapping) else None
246
+ )
247
+ response_hash = (
248
+ queue.get("response_sha256") if isinstance(queue, Mapping) else None
249
+ )
250
+ if (
251
+ not isinstance(response, Mapping)
252
+ or set(response) != {"schema", "queue", "capabilities_sha256"}
253
+ or response.get("schema") != SIDECAR_QUEUE_RESPONSE_SCHEMA
254
+ or response.get("capabilities_sha256") != self.sidecar_capabilities_sha256
255
+ or not isinstance(queue, Mapping)
256
+ or set(queue) != QUEUE_FIELDS
257
+ or queue.get("operation") != operation
258
+ or queue.get("request_id") != request_id
259
+ or queue.get("event_id") != event_id
260
+ or queue.get("site_id") != site_id
261
+ or queue.get("payload_sha256") != payload_hash
262
+ or queue.get("remote_capabilities_sha256") != self.remote_capabilities_sha256
263
+ or queue.get("source_max_attempts") != source_attempts
264
+ or queue.get("delivery_max_attempts") != self.sidecar_delivery_max_attempts
265
+ or status not in {"pending", "leased", "retry_wait", "succeeded", "failed"}
266
+ or isinstance(attempts, bool)
267
+ or not isinstance(attempts, int)
268
+ or not 0 <= attempts <= self.sidecar_delivery_max_attempts
269
+ or isinstance(next_attempt, bool)
270
+ or not isinstance(next_attempt, (int, float))
271
+ or not math.isfinite(float(next_attempt))
272
+ or float(next_attempt) < 0
273
+ or not isinstance(queue.get("lease_expired"), bool)
274
+ or (status == "leased") != (lease_expires is not None)
275
+ or lease_expires is not None and (
276
+ isinstance(lease_expires, bool)
277
+ or not isinstance(lease_expires, (int, float))
278
+ or not math.isfinite(float(lease_expires))
279
+ or float(lease_expires) < 0
280
+ )
281
+ or last_error is not None and (
282
+ not isinstance(last_error, str)
283
+ or ERROR_CODE.fullmatch(last_error) is None
284
+ )
285
+ or response_hash is not None and (
286
+ not isinstance(response_hash, str)
287
+ or SHA256.fullmatch(response_hash) is None
288
+ )
289
+ or (status == "succeeded") != (response_hash is not None)
290
+ or status in {"retry_wait", "failed"} and last_error is None
291
+ ):
292
+ raise CMSSourceDeliverySidecarAdapterBlocked(
293
+ "source_delivery_sidecar_adapter.response_invalid", retryable=False,
294
+ )
295
+
296
+ # This private projection records sidecar acceptance only. It is never
297
+ # exposed as source-service status; downstream state remains separate.
298
+ projected = {
299
+ "schema": CHANGE_RESPONSE_SCHEMA if change else REMOVAL_RESPONSE_SCHEMA,
300
+ "operation": operation,
301
+ "request_id": request_id,
302
+ "event_id": event_id,
303
+ "payload_sha256": payload_hash,
304
+ "status": "pending",
305
+ "attempts": 0,
306
+ "max_attempts": source_attempts,
307
+ "capabilities_sha256": self.expected_capabilities_sha256,
308
+ }
309
+ if self.expected_runtime_capabilities_sha256 is not None:
310
+ projected["capability_binding"] = {
311
+ "schema": "blun.cms-source-capability-binding.v2",
312
+ "status": "verified",
313
+ "capabilities_sha256": (
314
+ self.expected_runtime_capabilities_sha256
315
+ ),
316
+ "commercial_rendering_registry_sha256": (
317
+ self.expected_commercial_rendering_registry_sha256
318
+ ),
319
+ "database_roles": ["changes", "removals", "lifecycle"],
320
+ }
321
+ return projected
322
+
323
+ def submit_change(
324
+ self, value: Mapping[str, Any], *, max_attempts: int = 5,
325
+ ) -> Mapping[str, Any]:
326
+ return self._submit(value, max_attempts=max_attempts, change=True)
327
+
328
+ def submit_removal(
329
+ self, value: Mapping[str, Any], *, max_attempts: int = 5,
330
+ ) -> Mapping[str, Any]:
331
+ return self._submit(value, max_attempts=max_attempts, change=False)