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
@@ -2,13 +2,16 @@
2
2
  """Owned, durable runtime for the source side of the CMS localization API.
3
3
 
4
4
  The composition root validates the complete service configuration before it
5
- opens three private SQLite databases. One runtime belongs to one process and
6
- serializes every local state transition. Separate processes may safely open
7
- the same files because the underlying outboxes and monitor use durable leases.
5
+ opens three private SQLite databases. An explicit production preflight can
6
+ also verify both CMS capability pins before persistence and durably bind the
7
+ verified pair plus each database role. One runtime belongs to one process and
8
+ serializes every local state transition. Separate processes may safely open the
9
+ same files because the underlying outboxes and monitor use durable leases.
8
10
  """
9
11
 
10
12
  from __future__ import annotations
11
13
 
14
+ import hashlib
12
15
  import importlib.util
13
16
  import math
14
17
  import os
@@ -42,6 +45,30 @@ _HTTP = _load_module(
42
45
  )
43
46
 
44
47
 
48
+ CAPABILITY_BINDING_SCHEMA = "blun.cms-source-runtime-capability-binding.v1"
49
+ CAPABILITY_BINDING_TABLE = "cms_source_runtime_capability_binding"
50
+ CAPABILITY_DATABASE_ROLES = ("changes", "removals", "lifecycle")
51
+ CAPABILITY_BINDING_SQL = (
52
+ "CREATE TABLE cms_source_runtime_capability_binding ("
53
+ "singleton INTEGER PRIMARY KEY CHECK (singleton = 1), "
54
+ "schema TEXT NOT NULL CHECK ("
55
+ "schema = 'blun.cms-source-runtime-capability-binding.v1'), "
56
+ "database_role TEXT NOT NULL CHECK ("
57
+ "database_role IN ('changes', 'removals', 'lifecycle')), "
58
+ "capabilities_sha256 TEXT NOT NULL, "
59
+ "commercial_rendering_registry_sha256 TEXT NOT NULL, "
60
+ "binding_sha256 TEXT NOT NULL)"
61
+ )
62
+ CAPABILITY_BINDING_COLUMNS = (
63
+ ("singleton", "INTEGER", 0, 1),
64
+ ("schema", "TEXT", 1, 0),
65
+ ("database_role", "TEXT", 1, 0),
66
+ ("capabilities_sha256", "TEXT", 1, 0),
67
+ ("commercial_rendering_registry_sha256", "TEXT", 1, 0),
68
+ ("binding_sha256", "TEXT", 1, 0),
69
+ )
70
+
71
+
45
72
  class DurableCMSSourceRuntimeBlocked(RuntimeError):
46
73
  """Stable runtime failure that contains no path, credential, or content."""
47
74
 
@@ -240,6 +267,204 @@ def _preflight_service(
240
267
  connection.close()
241
268
 
242
269
 
270
+ def _sha256(value: Any) -> str | None:
271
+ if (
272
+ isinstance(value, str)
273
+ and len(value) == 64
274
+ and all(character in "0123456789abcdef" for character in value)
275
+ ):
276
+ return value
277
+ return None
278
+
279
+
280
+ def _preflight_capabilities(client: Any) -> tuple[str, str]:
281
+ """Verify both deployment pins through one bounded capability request."""
282
+
283
+ try:
284
+ capabilities_sha256 = _sha256(
285
+ getattr(client, "capabilities_sha256", None),
286
+ )
287
+ rendering_sha256 = _sha256(
288
+ getattr(client, "commercial_rendering_registry_sha256", None),
289
+ )
290
+ capabilities = getattr(client, "capabilities", None)
291
+ except Exception as error:
292
+ raise _blocked("source_runtime.capability_preflight_failed") from error
293
+ if (
294
+ capabilities_sha256 is None
295
+ or rendering_sha256 is None
296
+ or not callable(capabilities)
297
+ ):
298
+ raise _blocked("source_runtime.capability_pins_required")
299
+ try:
300
+ response = capabilities()
301
+ except Exception as error:
302
+ if getattr(error, "code", None) in {
303
+ "capabilities_pin_mismatch",
304
+ "commercial_rendering_registry_pin_mismatch",
305
+ }:
306
+ raise _blocked("source_runtime.capability_pin_mismatch") from error
307
+ raise _blocked("source_runtime.capability_preflight_failed") from error
308
+ try:
309
+ capability_payload = response["capabilities"]
310
+ client_type = _SERVICE._DISPATCH._CLIENT.CMSLocalizationHTTPClient
311
+ valid = (
312
+ isinstance(response, Mapping)
313
+ and client_type._valid_capabilities(response)
314
+ and capability_payload["sha256"] == capabilities_sha256
315
+ and capability_payload["commercial_rendering_registry"]["sha256"]
316
+ == rendering_sha256
317
+ )
318
+ except Exception:
319
+ valid = False
320
+ if not valid:
321
+ raise _blocked("source_runtime.capability_preflight_failed")
322
+ return capabilities_sha256, rendering_sha256
323
+
324
+
325
+ def _capability_binding_row(
326
+ role: str,
327
+ binding: tuple[str, str],
328
+ ) -> tuple[Any, ...]:
329
+ capabilities_sha256, rendering_sha256 = binding
330
+ digest = hashlib.sha256("\x00".join((
331
+ CAPABILITY_BINDING_SCHEMA,
332
+ role,
333
+ capabilities_sha256,
334
+ rendering_sha256,
335
+ )).encode("utf-8")).hexdigest()
336
+ return (
337
+ 1,
338
+ CAPABILITY_BINDING_SCHEMA,
339
+ role,
340
+ capabilities_sha256,
341
+ rendering_sha256,
342
+ digest,
343
+ )
344
+
345
+
346
+ def _capability_table_exists(connection: sqlite3.Connection) -> bool:
347
+ rows = connection.execute(
348
+ "SELECT type, name, tbl_name, sql FROM sqlite_master WHERE name = ?",
349
+ (CAPABILITY_BINDING_TABLE,),
350
+ ).fetchall()
351
+ if not rows:
352
+ return False
353
+ if len(rows) != 1 or tuple(rows[0]) != (
354
+ "table",
355
+ CAPABILITY_BINDING_TABLE,
356
+ CAPABILITY_BINDING_TABLE,
357
+ CAPABILITY_BINDING_SQL,
358
+ ):
359
+ raise _blocked("source_runtime.capability_database_invalid")
360
+ return True
361
+
362
+
363
+ def _validate_capability_database(
364
+ connection: sqlite3.Connection,
365
+ role: str,
366
+ binding: tuple[str, str],
367
+ ) -> None:
368
+ try:
369
+ if not _capability_table_exists(connection):
370
+ raise _blocked("source_runtime.capability_database_unbound")
371
+ columns = tuple(
372
+ (row[1], row[2], row[3], row[5])
373
+ for row in connection.execute(
374
+ f"PRAGMA table_info({CAPABILITY_BINDING_TABLE})"
375
+ ).fetchall()
376
+ )
377
+ rows = connection.execute(
378
+ f"SELECT singleton, schema, database_role, capabilities_sha256, "
379
+ f"commercial_rendering_registry_sha256, binding_sha256 "
380
+ f"FROM {CAPABILITY_BINDING_TABLE}"
381
+ ).fetchall()
382
+ except DurableCMSSourceRuntimeBlocked:
383
+ raise
384
+ except sqlite3.Error as error:
385
+ raise _blocked("source_runtime.capability_database_invalid") from error
386
+ if columns != CAPABILITY_BINDING_COLUMNS or len(rows) != 1:
387
+ raise _blocked("source_runtime.capability_database_invalid")
388
+ if tuple(rows[0]) != _capability_binding_row(role, binding):
389
+ raise _blocked("source_runtime.capability_database_mismatch")
390
+
391
+
392
+ def _unbound_database_has_work(connection: sqlite3.Connection) -> bool:
393
+ try:
394
+ tables = connection.execute(
395
+ "SELECT name FROM sqlite_master "
396
+ "WHERE type = 'table' AND name LIKE 'cms_source_%' "
397
+ "AND name <> ? ORDER BY name",
398
+ (CAPABILITY_BINDING_TABLE,),
399
+ ).fetchall()
400
+ for (name,) in tables:
401
+ if (
402
+ not isinstance(name, str)
403
+ or not name.startswith("cms_source_")
404
+ or any(
405
+ character not in "abcdefghijklmnopqrstuvwxyz_"
406
+ for character in name
407
+ )
408
+ ):
409
+ raise _blocked("source_runtime.capability_database_invalid")
410
+ if name.endswith("_meta"):
411
+ continue
412
+ if connection.execute(
413
+ f'SELECT 1 FROM "{name}" LIMIT 1'
414
+ ).fetchone() is not None:
415
+ return True
416
+ except DurableCMSSourceRuntimeBlocked:
417
+ raise
418
+ except sqlite3.Error as error:
419
+ raise _blocked("source_runtime.capability_database_invalid") from error
420
+ return False
421
+
422
+
423
+ def _precheck_capability_databases(
424
+ connections: tuple[sqlite3.Connection, ...],
425
+ binding: tuple[str, str],
426
+ ) -> None:
427
+ for role, connection in zip(CAPABILITY_DATABASE_ROLES, connections):
428
+ try:
429
+ exists = _capability_table_exists(connection)
430
+ except sqlite3.Error as error:
431
+ raise _blocked(
432
+ "source_runtime.capability_database_invalid",
433
+ ) from error
434
+ if exists:
435
+ _validate_capability_database(connection, role, binding)
436
+ elif _unbound_database_has_work(connection):
437
+ raise _blocked("source_runtime.capability_database_unbound")
438
+
439
+
440
+ def _bind_capability_databases(
441
+ connections: tuple[sqlite3.Connection, ...],
442
+ binding: tuple[str, str],
443
+ ) -> None:
444
+ for role, connection in zip(CAPABILITY_DATABASE_ROLES, connections):
445
+ try:
446
+ connection.execute("BEGIN IMMEDIATE")
447
+ if not _capability_table_exists(connection):
448
+ connection.execute(CAPABILITY_BINDING_SQL)
449
+ connection.execute(
450
+ f"INSERT OR IGNORE INTO {CAPABILITY_BINDING_TABLE} VALUES "
451
+ "(?, ?, ?, ?, ?, ?)",
452
+ _capability_binding_row(role, binding),
453
+ )
454
+ _validate_capability_database(connection, role, binding)
455
+ connection.commit()
456
+ except DurableCMSSourceRuntimeBlocked:
457
+ if connection.in_transaction:
458
+ connection.rollback()
459
+ raise
460
+ except sqlite3.Error as error:
461
+ if connection.in_transaction:
462
+ connection.rollback()
463
+ raise _blocked(
464
+ "source_runtime.capability_database_invalid",
465
+ ) from error
466
+
467
+
243
468
  class DurableCMSSourceRuntime:
244
469
  """One process-owned and thread-safe source-CMS service runtime."""
245
470
 
@@ -249,10 +474,12 @@ class DurableCMSSourceRuntime:
249
474
  guards: tuple[Callable[[], None], Callable[[], None], Callable[[], None]],
250
475
  service: Any,
251
476
  http_authenticator: Callable[[dict[str, Any]], Any] | None,
477
+ capability_binding: tuple[str, str] | None,
252
478
  ):
253
479
  self._connections = connections
254
480
  self._guards = guards
255
481
  self._service = service
482
+ self._capability_binding = capability_binding
256
483
  self._lock = threading.RLock()
257
484
  self._closed = False
258
485
  self._owner_pid = os.getpid()
@@ -288,11 +515,46 @@ class DurableCMSSourceRuntime:
288
515
  for guard in self._guards:
289
516
  guard()
290
517
 
518
+ def _guard_capability_binding(self) -> None:
519
+ if self._capability_binding is None:
520
+ return
521
+ expected_capabilities, expected_rendering = self._capability_binding
522
+ try:
523
+ current_capabilities = _sha256(getattr(
524
+ self._service.client,
525
+ "capabilities_sha256",
526
+ None,
527
+ ))
528
+ current_rendering = _sha256(getattr(
529
+ self._service.client,
530
+ "commercial_rendering_registry_sha256",
531
+ None,
532
+ ))
533
+ except Exception as error:
534
+ raise _blocked(
535
+ "source_runtime.capability_binding_changed",
536
+ ) from error
537
+ if (
538
+ current_capabilities != expected_capabilities
539
+ or current_rendering != expected_rendering
540
+ ):
541
+ raise _blocked("source_runtime.capability_binding_changed")
542
+ for role, connection in zip(
543
+ CAPABILITY_DATABASE_ROLES,
544
+ self._connections,
545
+ ):
546
+ _validate_capability_database(
547
+ connection,
548
+ role,
549
+ self._capability_binding,
550
+ )
551
+
291
552
  def _call(self, name: str, *args: Any, **kwargs: Any) -> Any:
292
553
  self._assert_owner()
293
554
  with self._lock:
294
555
  if self._closed:
295
556
  raise _blocked("source_runtime.closed")
557
+ self._guard_capability_binding()
296
558
  self._guard_databases()
297
559
  try:
298
560
  result = getattr(self._service, name)(*args, **kwargs)
@@ -321,6 +583,31 @@ class DurableCMSSourceRuntime:
321
583
  def health(self) -> Any:
322
584
  return self._call("health")
323
585
 
586
+ def capability_binding(self) -> dict[str, Any]:
587
+ """Return the content-free startup capability verification state."""
588
+
589
+ self._assert_owner()
590
+ with self._lock:
591
+ if self._closed:
592
+ raise _blocked("source_runtime.closed")
593
+ self._guard_capability_binding()
594
+ if self._capability_binding is None:
595
+ return {
596
+ "schema": "blun.cms-source-capability-binding.v2",
597
+ "status": "not_configured",
598
+ "capabilities_sha256": None,
599
+ "commercial_rendering_registry_sha256": None,
600
+ "database_roles": [],
601
+ }
602
+ capabilities_sha256, rendering_sha256 = self._capability_binding
603
+ return {
604
+ "schema": "blun.cms-source-capability-binding.v2",
605
+ "status": "verified",
606
+ "capabilities_sha256": capabilities_sha256,
607
+ "commercial_rendering_registry_sha256": rendering_sha256,
608
+ "database_roles": list(CAPABILITY_DATABASE_ROLES),
609
+ }
610
+
324
611
  @staticmethod
325
612
  def _loop_delays(
326
613
  active_delay_seconds: float | int,
@@ -402,6 +689,7 @@ class DurableCMSSourceRuntime:
402
689
  return
403
690
  if self._worker_state in {"stopping", "failed"}:
404
691
  raise _blocked("source_runtime.worker_blocked")
692
+ self._guard_capability_binding()
405
693
  self._guard_databases()
406
694
  stop = threading.Event()
407
695
  thread = threading.Thread(
@@ -554,6 +842,7 @@ def open_durable_cms_source(
554
842
  lifecycle_worker_id: str,
555
843
  terminal_notifier: Callable[[Mapping[str, Any]], Any] | None = None,
556
844
  terminal_status_reader: Callable[[str, str], Any] | None = None,
845
+ terminal_receiver_capabilities_sha256: str | None = None,
557
846
  notification_worker_id: str | None = None,
558
847
  http_authenticator: Callable[[dict[str, Any]], Any] | None = None,
559
848
  clock: Callable[[], float | int] = time.time,
@@ -576,21 +865,33 @@ def open_durable_cms_source(
576
865
  terminal_processing_poll_interval_seconds: float | int = 30,
577
866
  terminal_processing_base_delay_seconds: float | int = 5,
578
867
  terminal_processing_max_delay_seconds: float | int = 300,
868
+ capability_preflight: bool = False,
579
869
  ) -> DurableCMSSourceRuntime:
580
- """Validate and open the complete durable source-side CMS worker."""
870
+ """Validate and open the complete durable source-side CMS worker.
871
+
872
+ Set ``capability_preflight`` to require both client deployment pins and one
873
+ successful, exact capability read before any persistent database exists.
874
+ """
581
875
 
582
876
  paths = tuple(_database_path(value) for value in (
583
877
  change_database, removal_database, lifecycle_database,
584
878
  ))
585
879
  timeout = _sqlite_timeout(sqlite_timeout_seconds)
880
+ if not isinstance(capability_preflight, bool):
881
+ raise _blocked("source_runtime.capability_preflight_invalid")
586
882
  if http_authenticator is not None and not callable(http_authenticator):
587
883
  raise _blocked("source_runtime.http_authenticator_invalid")
884
+ if http_authenticator is not None and not capability_preflight:
885
+ raise _blocked("source_runtime.capability_preflight_required")
588
886
  service_options = {
589
887
  "change_worker_id": change_worker_id,
590
888
  "removal_worker_id": removal_worker_id,
591
889
  "lifecycle_worker_id": lifecycle_worker_id,
592
890
  "terminal_notifier": terminal_notifier,
593
891
  "terminal_status_reader": terminal_status_reader,
892
+ "terminal_receiver_capabilities_sha256": (
893
+ terminal_receiver_capabilities_sha256
894
+ ),
594
895
  "notification_worker_id": notification_worker_id,
595
896
  "clock": clock,
596
897
  "change_lease_seconds": change_lease_seconds,
@@ -620,6 +921,9 @@ def open_durable_cms_source(
620
921
  }
621
922
  _preflight_service(client, service_options)
622
923
  _validate_paths(paths)
924
+ capability_binding = (
925
+ _preflight_capabilities(client) if capability_preflight else None
926
+ )
623
927
  guards = tuple(_prepare_database_file(path) for path in paths)
624
928
  connections: list[sqlite3.Connection] = []
625
929
  try:
@@ -632,11 +936,21 @@ def open_durable_cms_source(
632
936
  ))
633
937
  for guard in guards:
634
938
  guard()
939
+ if capability_binding is not None:
940
+ _precheck_capability_databases(
941
+ tuple(connections),
942
+ capability_binding,
943
+ )
635
944
  service = _SERVICE.CMSLocalizationSourceService(
636
945
  *connections,
637
946
  client,
638
947
  **service_options,
639
948
  )
949
+ if capability_binding is not None:
950
+ _bind_capability_databases(
951
+ tuple(connections),
952
+ capability_binding,
953
+ )
640
954
  for guard in guards:
641
955
  guard()
642
956
  except Exception as error:
@@ -650,6 +964,7 @@ def open_durable_cms_source(
650
964
  raise _blocked("source_runtime.initialization_failed") from error
651
965
  return DurableCMSSourceRuntime(
652
966
  tuple(connections), guards, service, http_authenticator,
967
+ capability_binding,
653
968
  )
654
969
 
655
970
 
@@ -23,8 +23,8 @@ from typing import Any, Callable, Mapping
23
23
 
24
24
 
25
25
  SCHEMA = "blun.cms-source-service-tick.v1"
26
- HEALTH_SCHEMA = "blun.cms-source-service-health.v3"
27
- STATUS_SCHEMA = "blun.cms-source-service-status.v3"
26
+ HEALTH_SCHEMA = "blun.cms-source-service-health.v4"
27
+ STATUS_SCHEMA = "blun.cms-source-service-status.v4"
28
28
  TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
29
29
  ERROR_CODE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
30
30
 
@@ -96,6 +96,7 @@ class CMSSourceServiceHealth:
96
96
  lifecycle: Mapping[str, Any]
97
97
  notifications: Mapping[str, Any]
98
98
  terminal_processing: Mapping[str, Any]
99
+ terminal_receiver_capabilities_sha256: str | None
99
100
  error_code: str | None = None
100
101
 
101
102
  def as_payload(self) -> dict[str, Any]:
@@ -114,6 +115,9 @@ class CMSSourceServiceHealth:
114
115
  "lifecycle": dict(self.lifecycle),
115
116
  "notifications": dict(self.notifications),
116
117
  "terminal_processing": dict(self.terminal_processing),
118
+ "terminal_receiver_capabilities_sha256": (
119
+ self.terminal_receiver_capabilities_sha256
120
+ ),
117
121
  "error_code": self.error_code,
118
122
  }
119
123
 
@@ -147,6 +151,7 @@ class CMSSourceServiceStatus:
147
151
  notification_attempts: int
148
152
  notification_max_attempts: int | None
149
153
  notification_error_code: str | None
154
+ terminal_receiver_capabilities_sha256: str | None
150
155
  terminal_processing_state: str
151
156
  terminal_processing_poll_attempts: int
152
157
  terminal_processing_failures: int
@@ -186,6 +191,9 @@ class CMSSourceServiceStatus:
186
191
  "notification_attempts": self.notification_attempts,
187
192
  "notification_max_attempts": self.notification_max_attempts,
188
193
  "notification_error_code": self.notification_error_code,
194
+ "terminal_receiver_capabilities_sha256": (
195
+ self.terminal_receiver_capabilities_sha256
196
+ ),
189
197
  "terminal_processing_state": self.terminal_processing_state,
190
198
  "terminal_processing_poll_attempts": (
191
199
  self.terminal_processing_poll_attempts
@@ -263,6 +271,7 @@ class CMSLocalizationSourceService:
263
271
  lifecycle_worker_id: str,
264
272
  terminal_notifier: Callable[[Mapping[str, Any]], Any] | None = None,
265
273
  terminal_status_reader: Callable[[str, str], Any] | None = None,
274
+ terminal_receiver_capabilities_sha256: str | None = None,
266
275
  notification_worker_id: str | None = None,
267
276
  clock: Callable[[], float | int] = time.time,
268
277
  change_lease_seconds: float | int = 600,
@@ -305,6 +314,33 @@ class CMSLocalizationSourceService:
305
314
  terminal_notifier is None or not callable(terminal_status_reader)
306
315
  ):
307
316
  raise CMSSourceServiceBlocked("source_service.processing_monitor_invalid")
317
+ discovered_receiver_capabilities = None
318
+ if terminal_status_reader is not None:
319
+ try:
320
+ discovered_receiver_capabilities = getattr(
321
+ terminal_notifier, "expected_capabilities_sha256", None,
322
+ )
323
+ except Exception as error:
324
+ raise CMSSourceServiceBlocked(
325
+ "source_service.processing_capabilities_invalid"
326
+ ) from error
327
+ if terminal_receiver_capabilities_sha256 is None:
328
+ terminal_receiver_capabilities_sha256 = (
329
+ discovered_receiver_capabilities
330
+ )
331
+ if (
332
+ not _PROCESSING._sha(terminal_receiver_capabilities_sha256)
333
+ or discovered_receiver_capabilities is not None
334
+ and discovered_receiver_capabilities
335
+ != terminal_receiver_capabilities_sha256
336
+ ):
337
+ raise CMSSourceServiceBlocked(
338
+ "source_service.processing_capabilities_invalid"
339
+ )
340
+ elif terminal_receiver_capabilities_sha256 is not None:
341
+ raise CMSSourceServiceBlocked(
342
+ "source_service.processing_capabilities_invalid"
343
+ )
308
344
  if not callable(clock):
309
345
  raise CMSSourceServiceBlocked("source_service.clock_invalid")
310
346
  timeout = getattr(client, "timeout", None)
@@ -379,6 +415,13 @@ class CMSLocalizationSourceService:
379
415
  self.client = client
380
416
  self.terminal_notifier = terminal_notifier
381
417
  self.terminal_status_reader = terminal_status_reader
418
+ self.terminal_receiver_capabilities_sha256 = (
419
+ terminal_receiver_capabilities_sha256
420
+ )
421
+ self.terminal_receiver_capabilities_exposed = (
422
+ terminal_status_reader is not None
423
+ and discovered_receiver_capabilities is not None
424
+ )
382
425
 
383
426
  # All configuration is validated before the first schema write.
384
427
  for value, code in (
@@ -436,6 +479,7 @@ class CMSLocalizationSourceService:
436
479
  if terminal_status_reader is None
437
480
  else _PROCESSING.DurableTerminalProcessingMonitor(
438
481
  lifecycle_connection,
482
+ terminal_receiver_capabilities_sha256,
439
483
  poll_interval_seconds=terminal_processing_poll_interval_seconds,
440
484
  base_delay_seconds=terminal_processing_base_delay_seconds,
441
485
  max_delay_seconds=terminal_processing_max_delay_seconds,
@@ -459,6 +503,24 @@ class CMSLocalizationSourceService:
459
503
  raise CMSSourceServiceBlocked("source_service.clock_invalid")
460
504
  return float(value)
461
505
 
506
+ def _guard_terminal_receiver_capabilities(self) -> None:
507
+ if self.terminal_processing is None:
508
+ return
509
+ try:
510
+ current = getattr(
511
+ self.terminal_notifier, "expected_capabilities_sha256", None,
512
+ )
513
+ except Exception as error:
514
+ raise CMSSourceServiceBlocked(
515
+ "source_service.processing_capabilities_changed"
516
+ ) from error
517
+ if self.terminal_receiver_capabilities_exposed and (
518
+ current != self.terminal_receiver_capabilities_sha256
519
+ ):
520
+ raise CMSSourceServiceBlocked(
521
+ "source_service.processing_capabilities_changed"
522
+ )
523
+
462
524
  def enqueue_change(
463
525
  self,
464
526
  change: Mapping[str, Any],
@@ -481,6 +543,7 @@ class CMSLocalizationSourceService:
481
543
 
482
544
  def status(self, event_id: str, site_id: str) -> CMSSourceServiceStatus:
483
545
  """Return a content-free snapshot without repairing or leasing work."""
546
+ self._guard_terminal_receiver_capabilities()
484
547
  event_id = _identifier(event_id, "source_service.status_invalid")
485
548
  site_id = _identifier(site_id, "source_service.status_invalid")
486
549
  now = self._now()
@@ -629,6 +692,9 @@ class CMSLocalizationSourceService:
629
692
  notification_error_code=(
630
693
  None if notification is None else notification.last_error_code
631
694
  ),
695
+ terminal_receiver_capabilities_sha256=(
696
+ self.terminal_receiver_capabilities_sha256
697
+ ),
632
698
  terminal_processing_state=terminal_processing_state,
633
699
  terminal_processing_poll_attempts=(
634
700
  0 if terminal_processing is None
@@ -820,6 +886,7 @@ class CMSLocalizationSourceService:
820
886
  def run_once(self) -> CMSSourceTickOutcome:
821
887
  """Advance at most one external operation and one local handoff."""
822
888
  try:
889
+ self._guard_terminal_receiver_capabilities()
823
890
  now = self._now()
824
891
  except Exception as error:
825
892
  return self._outcome(
@@ -1037,6 +1104,7 @@ class CMSLocalizationSourceService:
1037
1104
 
1038
1105
  def health(self) -> CMSSourceServiceHealth:
1039
1106
  try:
1107
+ self._guard_terminal_receiver_capabilities()
1040
1108
  now = self._now()
1041
1109
  changes = _health_payload(self.changes.health(now=now))
1042
1110
  removals = _health_payload(self.removals.health(now=now))
@@ -1080,6 +1148,7 @@ class CMSLocalizationSourceService:
1080
1148
  {},
1081
1149
  {},
1082
1150
  {},
1151
+ self.terminal_receiver_capabilities_sha256,
1083
1152
  _safe_code(error, "source_service.health_blocked"),
1084
1153
  )
1085
1154
  blocked = any(
@@ -1121,5 +1190,6 @@ class CMSLocalizationSourceService:
1121
1190
  lifecycle,
1122
1191
  notifications,
1123
1192
  terminal_processing,
1193
+ self.terminal_receiver_capabilities_sha256,
1124
1194
  error_code,
1125
1195
  )
@@ -18,6 +18,7 @@ from typing import Any, Callable, Mapping, Protocol
18
18
  NOTIFICATION_SCHEMA = "blun.cms-source-terminal-notification.v1"
19
19
  ACK_SCHEMA = "blun.cms-source-terminal-notification-ack.v1"
20
20
  AUTH_SCHEMA = "blun.cms-source-terminal-notification-http-auth.v1"
21
+ CAPABILITIES_PRECONDITION_HEADER = "X-Localization-Capabilities-SHA256"
21
22
  MAX_BODY_BYTES = 16_384
22
23
  MAX_RESPONSE_BYTES = 8_192
23
24
  MAX_ENDPOINT_LENGTH = 2_048
@@ -35,6 +36,7 @@ RESERVED_HEADERS = {
35
36
  "idempotency-key", "transfer-encoding",
36
37
  "x-localization-terminal-notification-id",
37
38
  "x-localization-terminal-notification-sha256",
39
+ "x-localization-capabilities-sha256",
38
40
  }
39
41
  NOTIFICATION_FIELDS = {
40
42
  "schema", "notification_id", "event_id", "site_id", "plan_id",
@@ -326,6 +328,7 @@ class HTTPTerminalNotifierAdapter:
326
328
  def __init__(
327
329
  self,
328
330
  endpoint: str,
331
+ expected_capabilities_sha256: str,
329
332
  authentication_headers: Callable[
330
333
  [Mapping[str, Any]], Mapping[str, str]
331
334
  ],
@@ -339,6 +342,11 @@ class HTTPTerminalNotifierAdapter:
339
342
  self.endpoint, self.origin, self.path = _endpoint(
340
343
  endpoint, allow_loopback_http,
341
344
  )
345
+ if (
346
+ not isinstance(expected_capabilities_sha256, str)
347
+ or SHA256.fullmatch(expected_capabilities_sha256) is None
348
+ ):
349
+ raise ValueError("expected capability digest is invalid")
342
350
  if not callable(authentication_headers):
343
351
  raise TypeError("authentication_headers must be callable")
344
352
  if (
@@ -349,6 +357,7 @@ class HTTPTerminalNotifierAdapter:
349
357
  ):
350
358
  raise ValueError("timeout is outside the supported range")
351
359
  self.authentication_headers = authentication_headers
360
+ self.expected_capabilities_sha256 = expected_capabilities_sha256
352
361
  self.transport = URLTransport() if transport is None else transport
353
362
  if not callable(getattr(self.transport, "post", None)):
354
363
  raise TypeError("transport must provide post")
@@ -368,6 +377,7 @@ class HTTPTerminalNotifierAdapter:
368
377
  "event_id": payload["event_id"],
369
378
  "site_id": payload["site_id"],
370
379
  "body_sha256": body_sha256,
380
+ "capabilities_sha256": self.expected_capabilities_sha256,
371
381
  }
372
382
  headers = _authentication_headers(
373
383
  self.authentication_headers, authentication,
@@ -378,6 +388,9 @@ class HTTPTerminalNotifierAdapter:
378
388
  "Idempotency-Key": payload["notification_id"],
379
389
  "X-Localization-Terminal-Notification-Id": payload["notification_id"],
380
390
  "X-Localization-Terminal-Notification-Sha256": body_sha256,
391
+ CAPABILITIES_PRECONDITION_HEADER: (
392
+ self.expected_capabilities_sha256
393
+ ),
381
394
  })
382
395
  try:
383
396
  result = self.transport.post(