blun-king-cli 9.1.587 → 9.1.588

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 (436) hide show
  1. package/CHANGELOG.md +11 -185
  2. package/LIESMICH.txt +51 -13
  3. package/README.md +44 -47
  4. package/agent-spine-plugin/.codex-plugin/plugin.json +16 -4
  5. package/agent-spine-plugin/CHANGELOG.md +37 -5
  6. package/agent-spine-plugin/README.md +3 -3
  7. package/agent-spine-plugin/blun.plugin.json +45 -10
  8. package/agent-spine-plugin/docs/artifact-evaluation.md +93 -0
  9. package/agent-spine-plugin/docs/host-integration.md +42 -27
  10. package/agent-spine-plugin/docs/preflight-recall.md +4 -2
  11. package/agent-spine-plugin/docs/session-timeline.md +97 -236
  12. package/agent-spine-plugin/docs/world-model.md +25 -0
  13. package/agent-spine-plugin/hooks/codex.json +1 -1
  14. package/agent-spine-plugin/hooks/hooks.json +1 -1
  15. package/agent-spine-plugin/package.json +1 -3
  16. package/agent-spine-plugin/scripts/check-hosts.js +3 -3
  17. package/agent-spine-plugin/scripts/release-check.js +10 -5
  18. package/agent-spine-plugin/scripts/run-checks.js +4 -1
  19. package/agent-spine-plugin/scripts/run-tests-hermetic.js +32 -6
  20. package/agent-spine-plugin/src/cli-learning.js +15 -0
  21. package/agent-spine-plugin/src/cli.js +2 -0
  22. package/agent-spine-plugin/src/hook.js +32 -32
  23. package/agent-spine-plugin/src/lib/action-lesson-recall.js +73 -8
  24. package/agent-spine-plugin/src/lib/briefing.js +146 -36
  25. package/agent-spine-plugin/src/lib/channel-continuity.js +19 -0
  26. package/agent-spine-plugin/src/lib/delivery-agent-usage.js +14 -7
  27. package/agent-spine-plugin/src/lib/gateway-group-response.js +128 -0
  28. package/agent-spine-plugin/src/lib/gateway-runs.js +24 -15
  29. package/agent-spine-plugin/src/lib/hook-briefing-use.js +13 -3
  30. package/agent-spine-plugin/src/lib/hook-context.js +16 -3
  31. package/agent-spine-plugin/src/lib/hook-output.js +129 -5
  32. package/agent-spine-plugin/src/lib/hook-timeline.js +5 -3
  33. package/agent-spine-plugin/src/lib/indexed-memory.js +2 -2
  34. package/agent-spine-plugin/src/lib/learning-artifact-evaluator.js +114 -0
  35. package/agent-spine-plugin/src/lib/learning-context.js +11 -4
  36. package/agent-spine-plugin/src/lib/learning-measurements.js +2 -2
  37. package/agent-spine-plugin/src/lib/mcp-runtime.js +89 -3
  38. package/agent-spine-plugin/src/lib/mcp-source-context.js +12 -2
  39. package/agent-spine-plugin/src/lib/mcp-timeline-tools.js +91 -8
  40. package/agent-spine-plugin/src/lib/mcp-world-tools.js +2 -2
  41. package/agent-spine-plugin/src/lib/owned-file-lock.js +20 -1
  42. package/agent-spine-plugin/src/lib/persona-runtime.js +2 -2
  43. package/agent-spine-plugin/src/lib/preflight-delivery-id.js +27 -0
  44. package/agent-spine-plugin/src/lib/preflight.js +4 -4
  45. package/agent-spine-plugin/src/lib/session-timeline-codex.js +15 -0
  46. package/agent-spine-plugin/src/lib/session-timeline-contract.js +12 -4
  47. package/agent-spine-plugin/src/lib/session-timeline-event-extract.js +36 -7
  48. package/agent-spine-plugin/src/lib/session-timeline-host-origin.js +13 -10
  49. package/agent-spine-plugin/src/lib/session-timeline-invocation.js +1 -1
  50. package/agent-spine-plugin/src/lib/session-timeline-king.js +14 -0
  51. package/agent-spine-plugin/src/lib/session-timeline-prior.js +18 -12
  52. package/agent-spine-plugin/src/lib/session-timeline-provider.js +5 -0
  53. package/agent-spine-plugin/src/lib/session-timeline-query.js +2 -0
  54. package/agent-spine-plugin/src/lib/session-timeline-results.js +35 -10
  55. package/agent-spine-plugin/src/lib/session-timeline-source-open.js +30 -0
  56. package/agent-spine-plugin/src/lib/session-timeline.js +122 -75
  57. package/agent-spine-plugin/src/lib/source-roots.js +3 -2
  58. package/agent-spine-plugin/src/lib/task-knowledge-context.js +22 -1
  59. package/agent-spine-plugin/src/lib/timeline-continuation-update.js +100 -0
  60. package/agent-spine-plugin/src/lib/timeline-tool-guard.js +30 -7
  61. package/agent-spine-plugin/src/lib/timeline-user-feedback.js +217 -0
  62. package/agent-spine-plugin/src/lib/timeline-world-capture.js +233 -0
  63. package/agent-spine-plugin/src/lib/world-knowledge.js +59 -2
  64. package/agent-spine-plugin/src/lib/world-model.js +64 -9
  65. package/agent-spine-plugin/src/worker.js +13 -1
  66. package/bin/blun.js +43 -28
  67. package/bin/core-bootstrap.js +5 -4
  68. package/bin/king.js +43 -28
  69. package/bin/launcher-mode.js +1 -10
  70. package/bin/launcher-runtime.js +128 -295
  71. package/bin/managed-node.js +0 -0
  72. package/bin/managed-plugin-selection.cjs +0 -1
  73. package/bin/native-module-repair.js +0 -0
  74. package/bin/node-runtime.js +0 -0
  75. package/bin/node-version.js +0 -0
  76. package/bin/plugin-bootstrap.js +56 -120
  77. package/bin/private-paths.js +11 -34
  78. package/bin/standard-tools-bootstrap.js +34 -114
  79. package/bin/turn-thinking-policy.cjs +3 -11
  80. package/bin/update-copy.js +200 -0
  81. package/bin/update-lease.js +0 -0
  82. package/bin/update-notice.js +136 -289
  83. package/bin/verify-agent-behavior.cjs +122 -0
  84. package/bin/verify-agent-components.cjs +104 -0
  85. package/bin/verify-bundled-agent-sources.cjs +57 -0
  86. package/blun.mjs +143076 -135288
  87. package/bundled-agent-sources.json +701 -0
  88. package/package.json +12 -15
  89. package/standard-skills/translate-native/README.md +1293 -0
  90. package/standard-skills/translate-native/SKILL.md +172 -22
  91. package/standard-skills/translate-native/VERSION +1 -1
  92. package/standard-skills/translate-native/agents/openai.yaml +18 -0
  93. package/standard-skills/translate-native/assets/icon.svg +8 -0
  94. package/standard-skills/translate-native/docs/BLUN_CODE_INTEGRATION.md +76 -0
  95. package/standard-skills/translate-native/docs/PREMORTEM.md +489 -0
  96. package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION.md +2035 -0
  97. package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_API.md +1302 -0
  98. package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_EVIDENCE_HTTP.md +136 -0
  99. package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_HEALTH_HTTP.md +130 -0
  100. package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_HTTP_PROVIDER.md +175 -0
  101. package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_RECEIPT_VERIFIER_HTTP.md +86 -0
  102. package/standard-skills/translate-native/integrations/AGENT_RULES.md +32 -0
  103. package/standard-skills/translate-native/integrations/adapters/blun-code-language-guard.js +514 -0
  104. package/standard-skills/translate-native/integrations/adapters/node-language-guard.js +230 -0
  105. package/standard-skills/translate-native/integrations/audit_log.py +327 -0
  106. package/standard-skills/translate-native/integrations/claude_language_hook.js +1536 -0
  107. package/standard-skills/translate-native/integrations/commercial_localization_profile.py +42 -0
  108. package/standard-skills/translate-native/integrations/delivery-policy.example.json +28 -0
  109. package/standard-skills/translate-native/integrations/enforced_delivery.py +543 -0
  110. package/standard-skills/translate-native/integrations/guard_service.py +435 -0
  111. package/standard-skills/translate-native/integrations/language_gateway.py +67 -0
  112. package/standard-skills/translate-native/integrations/mcp_auth_headers.py +198 -0
  113. package/standard-skills/translate-native/integrations/mcp_http_gateway.py +429 -0
  114. package/standard-skills/translate-native/integrations/non_language_html_entities.js +1485 -0
  115. package/standard-skills/translate-native/integrations/pre_output_guard.py +65 -0
  116. package/standard-skills/translate-native/integrations/task_router.py +101 -0
  117. package/standard-skills/translate-native/integrations/website_localization.py +401 -0
  118. package/standard-skills/translate-native/integrations/website_localization_api.py +581 -0
  119. package/standard-skills/translate-native/integrations/website_localization_benchmark.py +1885 -0
  120. package/standard-skills/translate-native/integrations/website_localization_benchmark_campaign.py +1772 -0
  121. package/standard-skills/translate-native/integrations/website_localization_benchmark_candidate.py +506 -0
  122. package/standard-skills/translate-native/integrations/website_localization_benchmark_http.py +400 -0
  123. package/standard-skills/translate-native/integrations/website_localization_benchmark_review_store.py +781 -0
  124. package/standard-skills/translate-native/integrations/website_localization_benchmark_reviewer_http.py +500 -0
  125. package/standard-skills/translate-native/integrations/website_localization_benchmark_runtime.py +1107 -0
  126. package/standard-skills/translate-native/integrations/website_localization_benchmark_suite.py +463 -0
  127. package/standard-skills/translate-native/integrations/website_localization_cms.py +2835 -0
  128. package/standard-skills/translate-native/integrations/website_localization_cms_client.py +875 -0
  129. package/standard-skills/translate-native/integrations/website_localization_cms_dispatch.py +805 -0
  130. package/standard-skills/translate-native/integrations/website_localization_cms_http.py +588 -0
  131. package/standard-skills/translate-native/integrations/website_localization_cms_lifecycle_monitor.py +991 -0
  132. package/standard-skills/translate-native/integrations/website_localization_cms_receiver.py +1441 -0
  133. package/standard-skills/translate-native/integrations/website_localization_cms_receiver_runtime.py +414 -0
  134. package/standard-skills/translate-native/integrations/website_localization_cms_receiver_store.py +1073 -0
  135. package/standard-skills/translate-native/integrations/website_localization_cms_removal_dispatch.py +865 -0
  136. package/standard-skills/translate-native/integrations/website_localization_cms_source_client.py +583 -0
  137. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery.py +964 -0
  138. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_runtime.py +665 -0
  139. package/standard-skills/translate-native/integrations/website_localization_cms_source_http.py +1153 -0
  140. package/standard-skills/translate-native/integrations/website_localization_cms_source_runtime.py +675 -0
  141. package/standard-skills/translate-native/integrations/website_localization_cms_source_service.py +1125 -0
  142. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification.py +674 -0
  143. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_http.py +444 -0
  144. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver.py +1469 -0
  145. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver_runtime.py +1142 -0
  146. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_processing_monitor.py +634 -0
  147. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_receiver_client.py +804 -0
  148. package/standard-skills/translate-native/integrations/website_localization_deepl_baseline.py +922 -0
  149. package/standard-skills/translate-native/integrations/website_localization_evidence_http.py +482 -0
  150. package/standard-skills/translate-native/integrations/website_localization_health.py +1541 -0
  151. package/standard-skills/translate-native/integrations/website_localization_health_http.py +372 -0
  152. package/standard-skills/translate-native/integrations/website_localization_http_provider.py +297 -0
  153. package/standard-skills/translate-native/integrations/website_localization_native_reference_http.py +479 -0
  154. package/standard-skills/translate-native/integrations/website_localization_native_reference_intake.py +363 -0
  155. package/standard-skills/translate-native/integrations/website_localization_native_reference_queue.py +1449 -0
  156. package/standard-skills/translate-native/integrations/website_localization_native_reference_store.py +420 -0
  157. package/standard-skills/translate-native/integrations/website_localization_quality_profiles.py +235 -0
  158. package/standard-skills/translate-native/integrations/website_localization_queue.py +671 -0
  159. package/standard-skills/translate-native/integrations/website_localization_receipt_verifier_http.py +516 -0
  160. package/standard-skills/translate-native/integrations/website_localization_release.py +928 -0
  161. package/standard-skills/translate-native/integrations/website_localization_release_coordinator.py +1008 -0
  162. package/standard-skills/translate-native/integrations/website_localization_runner.py +276 -0
  163. package/standard-skills/translate-native/integrations/website_localization_runtime.py +862 -0
  164. package/standard-skills/translate-native/integrations/website_localization_service.py +350 -0
  165. package/standard-skills/translate-native/integrations/website_localization_supervisor.py +511 -0
  166. package/standard-skills/translate-native/integrations/website_localization_worker.py +663 -0
  167. package/standard-skills/translate-native/provenance.json +3 -4
  168. package/standard-skills/translate-native/references/commercial-localization.md +177 -0
  169. package/standard-skills/translate-native/scripts/blun_language_guard.py +7 -1
  170. package/standard-skills/translate-native/scripts/check_commercial_review.py +80 -0
  171. package/standard-skills/translate-native/scripts/commercial_localization_profile.py +333 -0
  172. package/standard-tools/language-guard/LICENSE +21 -0
  173. package/standard-tools/language-guard/VERSION +1 -0
  174. package/standard-tools/language-guard/blun_language_guard.py +7 -1
  175. package/standard-tools/language-guard/check_commercial_review.py +80 -0
  176. package/standard-tools/language-guard/commercial_localization_profile.py +333 -0
  177. package/standard-tools/language-guard/language_gateway.py +62 -0
  178. package/standard-tools/language-guard/pre_output_guard.py +64 -0
  179. package/standard-tools/language-guard/provenance.json +4 -11
  180. package/standard-tools/manifest.json +34 -11
  181. package/telegram-plugin/commands/access.md +2 -10
  182. package/telegram-plugin/dist/bridge.mjs +64041 -687
  183. package/telegram-plugin/dist/mcp-server.mjs +72810 -9027
  184. package/telegram-plugin/dist/noise.mjs +28 -63511
  185. package/agent-spine-plugin/CONTRIBUTING.md +0 -52
  186. package/agent-spine-plugin/SECURITY.md +0 -47
  187. package/agent-spine-plugin/docs/assignment-continuation.md +0 -48
  188. package/agent-spine-plugin/docs/releasing.md +0 -85
  189. package/agent-spine-plugin/docs/structured-completion.md +0 -67
  190. package/bin/abort-listener-policy.cjs +0 -43
  191. package/bin/active-steer-priority-policy.cjs +0 -24
  192. package/bin/agent-api-http-adapter.mjs +0 -446
  193. package/bin/agent-api-private-http-server.mjs +0 -288
  194. package/bin/agent-api-runtime.mjs +0 -252
  195. package/bin/agent-api-service-environment.mjs +0 -236
  196. package/bin/agent-api-service-host.mjs +0 -209
  197. package/bin/agent-api-service-process.mjs +0 -171
  198. package/bin/agent-api-session-registry.mjs +0 -428
  199. package/bin/agent-api-tool-broker.cjs +0 -248
  200. package/bin/agent-api-turn-controller.mjs +0 -461
  201. package/bin/agent-api-usage-journal.cjs +0 -259
  202. package/bin/agent-resume-snapshot.cjs +0 -241
  203. package/bin/agentspine-king-goal-inbox.mjs +0 -111
  204. package/bin/agentspine-king-goal-intake.mjs +0 -106
  205. package/bin/approval-rejection-stop.cjs +0 -15
  206. package/bin/assistant-message-offload-policy.cjs +0 -284
  207. package/bin/baseline-skill-performance-policy.cjs +0 -39
  208. package/bin/bash-search-scope-policy.cjs +0 -49
  209. package/bin/codebase-search-runtime.cjs +0 -23
  210. package/bin/cognitive-action-checkpoint.cjs +0 -1104
  211. package/bin/cognitive-attention-delivery.cjs +0 -76
  212. package/bin/cognitive-attention-policy.cjs +0 -143
  213. package/bin/cognitive-attention-runtime.cjs +0 -91
  214. package/bin/cognitive-context-projection.cjs +0 -73
  215. package/bin/cognitive-cross-portal-acceptance.cjs +0 -443
  216. package/bin/cognitive-effective-view.cjs +0 -77
  217. package/bin/cognitive-focus-projection.cjs +0 -206
  218. package/bin/cognitive-focus-scope.cjs +0 -37
  219. package/bin/cognitive-goal-autostart-policy.cjs +0 -72
  220. package/bin/cognitive-goal-time-trigger-controller.cjs +0 -146
  221. package/bin/cognitive-memory-adapter.cjs +0 -282
  222. package/bin/cognitive-memory-command.cjs +0 -293
  223. package/bin/cognitive-memory-provider.cjs +0 -92
  224. package/bin/cognitive-salience-policy.cjs +0 -159
  225. package/bin/cognitive-state-store.cjs +0 -508
  226. package/bin/cognitive-turn-lifecycle.cjs +0 -624
  227. package/bin/cognitive-work-focus.cjs +0 -180
  228. package/bin/compaction-history-archive.cjs +0 -166
  229. package/bin/compaction-history-startup.cjs +0 -50
  230. package/bin/compaction-model-policy.cjs +0 -31
  231. package/bin/compaction-stage-policy.cjs +0 -21
  232. package/bin/compaction-transaction-policy.cjs +0 -122
  233. package/bin/config-write-dedup-policy.cjs +0 -27
  234. package/bin/context-budget-ledger.cjs +0 -31
  235. package/bin/context-doctor-policy.cjs +0 -70
  236. package/bin/context-insight-policy.cjs +0 -36
  237. package/bin/context-performance-policy.cjs +0 -19
  238. package/bin/context-pressure-policy.cjs +0 -20
  239. package/bin/cron-run-output.cjs +0 -45
  240. package/bin/cron-run-store.cjs +0 -145
  241. package/bin/curiosity-scout-policy.cjs +0 -49
  242. package/bin/default-model-output-budget-policy.cjs +0 -28
  243. package/bin/durable-task-resume-policy.cjs +0 -130
  244. package/bin/durable-task-resume-runtime.cjs +0 -117
  245. package/bin/durable-task-resume-store.cjs +0 -88
  246. package/bin/editable-tool-approval-policy.cjs +0 -540
  247. package/bin/editable-tool-approval-runtime.cjs +0 -99
  248. package/bin/effective-system-prompt-cache-policy.cjs +0 -33
  249. package/bin/error-memory-performance-policy.cjs +0 -113
  250. package/bin/file-observation-policy.cjs +0 -133
  251. package/bin/foreground-output-capture-policy.cjs +0 -41
  252. package/bin/generated-source-health.cjs +0 -142
  253. package/bin/glob-pattern-policy.cjs +0 -13
  254. package/bin/goal-completion-evidence-policy.cjs +0 -120
  255. package/bin/grep-output-limit-policy.cjs +0 -39
  256. package/bin/historical-media-projection-policy.cjs +0 -48
  257. package/bin/history-offload-pressure-policy.cjs +0 -33
  258. package/bin/html-to-research-markdown.cjs +0 -147
  259. package/bin/identity-context-policy.cjs +0 -764
  260. package/bin/identity-journal-policy.cjs +0 -107
  261. package/bin/input-draft-persistence.cjs +0 -77
  262. package/bin/king-tui-function-contract.json +0 -33
  263. package/bin/launcher-restart-policy.cjs +0 -150
  264. package/bin/live-response-repetition-guard.cjs +0 -196
  265. package/bin/llm-config-log-dedup-policy.cjs +0 -76
  266. package/bin/loop-event-record-policy.cjs +0 -174
  267. package/bin/managed-context-startup-policy.cjs +0 -27
  268. package/bin/media-activity-layout-policy.cjs +0 -34
  269. package/bin/media-auto-retrieval-policy.cjs +0 -90
  270. package/bin/media-result-policy.cjs +0 -59
  271. package/bin/micro-compaction-policy.cjs +0 -145
  272. package/bin/mistake-relevance-policy.cjs +0 -319
  273. package/bin/model-retry-progress-policy.cjs +0 -46
  274. package/bin/native-large-file-io.cjs +0 -42
  275. package/bin/native-runtime-cache.cjs +0 -76
  276. package/bin/natural-presence-policy.cjs +0 -28
  277. package/bin/noninteractive-shell-env-policy.cjs +0 -19
  278. package/bin/observer-hooks.cjs +0 -14
  279. package/bin/outbound-claim-provenance.cjs +0 -150
  280. package/bin/oversized-context-offload-policy.cjs +0 -86
  281. package/bin/pending-media-policy.cjs +0 -182
  282. package/bin/pending-token-estimate-policy.cjs +0 -41
  283. package/bin/personal-memory-consent-policy.cjs +0 -72
  284. package/bin/personal-memory-performance-policy.cjs +0 -12
  285. package/bin/personality-choice-policy.cjs +0 -101
  286. package/bin/personality-memory-adapter.cjs +0 -379
  287. package/bin/personality-mode.cjs +0 -46
  288. package/bin/personality-setup-policy.cjs +0 -197
  289. package/bin/proactive-compaction-policy.cjs +0 -25
  290. package/bin/profile-identity-resolution.cjs +0 -136
  291. package/bin/profile-runtime.cjs +0 -318
  292. package/bin/profile-tool-exclusion-policy.cjs +0 -37
  293. package/bin/programmatic-context-isolation.cjs +0 -25
  294. package/bin/programmatic-tool-runtime.mjs +0 -627
  295. package/bin/provider-idle-timeout-policy.cjs +0 -14
  296. package/bin/provider-model-refresh-deadline.cjs +0 -53
  297. package/bin/provider-model-refresh-policy.cjs +0 -107
  298. package/bin/rate-limit-recovery-policy.cjs +0 -47
  299. package/bin/read-batch-policy.cjs +0 -32
  300. package/bin/read-continuation-policy.cjs +0 -59
  301. package/bin/recurring-cron-history-policy.cjs +0 -124
  302. package/bin/relationship-continuity-policy.cjs +0 -143
  303. package/bin/relationship-curiosity-policy.cjs +0 -107
  304. package/bin/relationship-learning-policy.cjs +0 -168
  305. package/bin/release-artifact-freeze-policy.cjs +0 -30
  306. package/bin/reload-plugin-bootstrap.cjs +0 -18
  307. package/bin/reload-queue-policy.cjs +0 -38
  308. package/bin/repeated-assistant-response-policy.cjs +0 -232
  309. package/bin/repeated-injection-projection.cjs +0 -107
  310. package/bin/repeated-user-message-projection.cjs +0 -8
  311. package/bin/research-page-result.cjs +0 -74
  312. package/bin/retry-checkpoint-policy.cjs +0 -13
  313. package/bin/runtime-exit-ledger.cjs +0 -144
  314. package/bin/scoped-cron-run-policy.cjs +0 -358
  315. package/bin/session-checkpoint-policy.cjs +0 -25
  316. package/bin/session-compaction-policy.cjs +0 -84
  317. package/bin/session-replay-policy.cjs +0 -20
  318. package/bin/session-replay-window-policy.cjs +0 -40
  319. package/bin/session-resume-checkpoint.cjs +0 -254
  320. package/bin/session-scrollback-archive.cjs +0 -229
  321. package/bin/skill-activation-performance-policy.cjs +0 -69
  322. package/bin/skill-listing-performance-policy.cjs +0 -92
  323. package/bin/soul-organization-policy.cjs +0 -78
  324. package/bin/soul-preservation-policy.cjs +0 -20
  325. package/bin/startup-preferences.cjs +0 -131
  326. package/bin/streaming-flush-performance-policy.cjs +0 -28
  327. package/bin/structured-agent-swarm-output.cjs +0 -325
  328. package/bin/structured-subagent-output.cjs +0 -252
  329. package/bin/subagent-context-fork-policy.cjs +0 -155
  330. package/bin/subagent-max-tokens-handoff-policy.cjs +0 -69
  331. package/bin/subagent-parent-responsiveness.cjs +0 -19
  332. package/bin/subagent-skill-policy.cjs +0 -206
  333. package/bin/subagent-timeout-policy.cjs +0 -182
  334. package/bin/subagent-tool-policy.cjs +0 -60
  335. package/bin/subagent-usage-rollup-policy.cjs +0 -29
  336. package/bin/system-prompt-context-policy.cjs +0 -124
  337. package/bin/system-prompt-token-cache-policy.cjs +0 -60
  338. package/bin/telegram-addressed-focus.cjs +0 -55
  339. package/bin/telegram-addressed-priority.cjs +0 -12
  340. package/bin/telegram-approval-relay.cjs +0 -290
  341. package/bin/telegram-bot-priority.cjs +0 -17
  342. package/bin/telegram-console-status-policy.cjs +0 -174
  343. package/bin/telegram-context-projection-policy.cjs +0 -141
  344. package/bin/telegram-delivery-lifecycle.cjs +0 -125
  345. package/bin/telegram-direct-focus-policy.cjs +0 -273
  346. package/bin/telegram-mcp-compatibility.cjs +0 -49
  347. package/bin/telegram-media-delivery-policy.cjs +0 -42
  348. package/bin/telegram-private-conversation-policy.cjs +0 -185
  349. package/bin/telegram-queue-handoff-policy.cjs +0 -73
  350. package/bin/telegram-remote-status-policy.cjs +0 -120
  351. package/bin/telegram-session-queue-runtime.mjs +0 -306
  352. package/bin/telegram-text-chunk-policy.cjs +0 -63
  353. package/bin/telegram-truncated-reply-policy.cjs +0 -37
  354. package/bin/telegram-urgent-policy.cjs +0 -45
  355. package/bin/telemetry-spool-policy.cjs +0 -57
  356. package/bin/thinking-activity-status-policy.cjs +0 -132
  357. package/bin/thinking-only-guard.cjs +0 -80
  358. package/bin/todo-list-turn-policy.cjs +0 -131
  359. package/bin/tool-call-loop-policy.cjs +0 -51
  360. package/bin/tool-file-persistence.cjs +0 -141
  361. package/bin/tool-result-offload-policy.cjs +0 -359
  362. package/bin/tool-result-offload-telemetry.cjs +0 -12
  363. package/bin/tool-schema-token-cache-policy.cjs +0 -41
  364. package/bin/tool-stream-preview-policy.cjs +0 -9
  365. package/bin/tui-functional-contract.cjs +0 -55
  366. package/bin/turn-tool-performance-policy.cjs +0 -486
  367. package/bin/usage-cache-efficiency-policy.cjs +0 -26
  368. package/bin/user-home-path-policy.cjs +0 -13
  369. package/bin/user-message-offload-policy.cjs +0 -103
  370. package/bin/user-prompt-hook-origin-policy.cjs +0 -34
  371. package/bin/user-tool-record-policy.cjs +0 -7
  372. package/bin/validated-learning-insight-policy.cjs +0 -58
  373. package/bin/validated-learning-outcome-trace.cjs +0 -107
  374. package/bin/validated-learning-performance-policy.cjs +0 -53
  375. package/bin/validated-learning-signal.cjs +0 -463
  376. package/bin/windows-bash-dialect-policy.cjs +0 -25
  377. package/bin/windows-node-crash-dump.cjs +0 -110
  378. package/bin/write-continuation-policy.cjs +0 -69
  379. package/codebase-index/README.md +0 -82
  380. package/codebase-index/codebase_index.py +0 -470
  381. package/standard-skills/agent-browser/SKILL.md +0 -19
  382. package/standard-skills/agent-browser/references/runtime.md +0 -8
  383. package/standard-skills/blun-session-inspector/SKILL.md +0 -41
  384. package/standard-skills/blun-session-inspector/scripts/inspect-session.cjs +0 -437
  385. package/standard-skills/design-taste-frontend/SKILL.md +0 -1206
  386. package/standard-skills/full-output-enforcement/SKILL.md +0 -49
  387. package/standard-skills/high-end-visual-design/SKILL.md +0 -98
  388. package/standard-skills/image-to-code/SKILL.md +0 -1228
  389. package/standard-skills/industrial-brutalist-ui/SKILL.md +0 -92
  390. package/standard-skills/minimalist-ui/SKILL.md +0 -85
  391. package/standard-skills/motion-design-taste/SKILL.md +0 -74
  392. package/standard-skills/playwright-testing/SKILL.md +0 -19
  393. package/standard-skills/playwright-testing/references/runtime.md +0 -7
  394. package/standard-skills/premortem/SKILL.md +0 -148
  395. package/standard-skills/redesign-existing-projects/SKILL.md +0 -178
  396. package/standard-skills/research-evidence/SKILL.md +0 -39
  397. package/standard-skills/research-evidence/references/evidence-format.md +0 -104
  398. package/standard-skills/research-evidence/scripts/evidence-collection.cjs +0 -260
  399. package/standard-skills/research-evidence/scripts/score-report.cjs +0 -130
  400. package/standard-skills/screenshot-lesen/SKILL.md +0 -52
  401. package/standard-skills/stitch-design-taste/DESIGN.md +0 -121
  402. package/standard-skills/stitch-design-taste/SKILL.md +0 -184
  403. package/standard-skills/telegram-channel/SKILL.md +0 -18
  404. package/standard-skills/telegram-channel/references/runtime.md +0 -7
  405. package/standard-skills/venture-flywheel/SKILL.md +0 -32
  406. package/standard-skills/venture-flywheel/identity/project-identity.cjs +0 -146
  407. package/standard-skills/venture-flywheel/policy/capability-engine.cjs +0 -114
  408. package/standard-skills/venture-flywheel/policy/repository-trust.cjs +0 -229
  409. package/standard-skills/venture-flywheel/references/BEISPIELE-phase0.md +0 -146
  410. package/standard-skills/venture-flywheel/references/CAPABILITY-MAP.md +0 -34
  411. package/standard-skills/venture-flywheel/references/SPEC-phase0-identity-trust.md +0 -77
  412. package/standard-skills/venture-flywheel/references/SPEC-phase0-state-events.md +0 -93
  413. package/standard-skills/venture-flywheel/schemas/capability-decision.schema.json +0 -13
  414. package/standard-skills/venture-flywheel/schemas/execution-event.schema.json +0 -44
  415. package/standard-skills/venture-flywheel/schemas/project-identity.schema.json +0 -32
  416. package/standard-skills/venture-flywheel/schemas/repository-trust.schema.json +0 -57
  417. package/standard-skills/venture-flywheel/schemas/run-transition.schema.json +0 -59
  418. package/standard-skills/venture-flywheel/state/execution-event.cjs +0 -191
  419. package/standard-skills/venture-flywheel/state/task-state-machine.cjs +0 -190
  420. package/standard-skills/web-lesen/SKILL.md +0 -73
  421. package/standard-skills/web-lesen/scripts/crawl_public.py +0 -379
  422. package/standard-skills/windows-mcp/SKILL.md +0 -19
  423. package/standard-skills/windows-mcp/references/runtime.md +0 -9
  424. package/telegram-plugin/DELIVERY.md +0 -36
  425. package/telegram-plugin/bin/telegram-approval-relay.cjs +0 -290
  426. package/telegram-plugin/bin/telegram-console-status-policy.cjs +0 -175
  427. package/telegram-plugin/bin/telegram-delivery-lifecycle.cjs +0 -125
  428. package/telegram-plugin/bin/telegram-direct-reply-policy.cjs +0 -48
  429. package/telegram-plugin/bin/telegram-launcher-status-queue.cjs +0 -122
  430. package/telegram-plugin/bin/telegram-private-conversation-policy.cjs +0 -186
  431. package/telegram-plugin/bin/telegram-remote-status-policy.cjs +0 -121
  432. package/telegram-plugin/bin/telegram-reply-parts.cjs +0 -149
  433. package/telegram-plugin/bin/telegram-text-chunk-policy.cjs +0 -63
  434. package/telegram-plugin/bin/telegram-typing-keepalive.cjs +0 -89
  435. package/telegram-plugin/compat/mcp-server-fa511cd1.mjs +0 -73825
  436. /package/{bin → scripts}/fix-node-pty-perms.js +0 -0
@@ -0,0 +1,2835 @@
1
+ #!/usr/bin/env python3
2
+ """Signed CMS ingress and all-locales publication outbox.
3
+
4
+ The host owns the SQLite connections, signature authorities, and transport.
5
+ This module accepts one authenticated content-change event, resumes its
6
+ idempotent queue insertion, and publishes only a complete signed locale bundle.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import importlib.util
13
+ import json
14
+ import re
15
+ import secrets
16
+ import sqlite3
17
+ import sys
18
+ import time
19
+ import unicodedata
20
+ from contextlib import contextmanager
21
+ from dataclasses import dataclass, fields, is_dataclass
22
+ from pathlib import Path
23
+ from typing import Any, Callable, Iterator, Mapping, Protocol
24
+
25
+
26
+ SCHEMA_VERSION = 4
27
+ CHANGE_SCHEMA = "blun.cms-content-change.v2"
28
+ CANCELLATION_SCHEMA = "blun.cms-content-cancellation.v1"
29
+ TOMBSTONE_SCHEMA = "blun.cms-content-tombstone.v1"
30
+ PUBLICATION_SCHEMA = "blun.cms-localization-publication.v3"
31
+ ACK_SCHEMA = "blun.cms-localization-publication-ack.v1"
32
+ TOMBSTONE_DELIVERY_SCHEMA = "blun.cms-localization-tombstone.v1"
33
+ TOMBSTONE_ACK_SCHEMA = "blun.cms-localization-tombstone-ack.v1"
34
+ CAPABILITIES_SCHEMA = "blun.website-localization-capabilities.v3"
35
+ PUBLICATION_HTTP_CONTRACT_SCHEMA = (
36
+ "blun.cms-localization-publication-http-capabilities.v2"
37
+ )
38
+ PUBLICATION_HTTP_REQUEST_SCHEMA = "blun.cms-localization-publication-http.v1"
39
+ PUBLICATION_HTTP_RESPONSE_SCHEMA = "blun.cms-localization-publication-http-ack.v1"
40
+ TOMBSTONE_HTTP_REQUEST_SCHEMA = "blun.cms-localization-tombstone-http.v1"
41
+ TOMBSTONE_HTTP_RESPONSE_SCHEMA = "blun.cms-localization-tombstone-http-ack.v1"
42
+ PUBLICATION_HEALTH_SCHEMA = "blun.cms-localization-publication-health.v1"
43
+ PUBLICATION_HEALTH_ACK_SCHEMA = "blun.cms-localization-publication-health-ack.v1"
44
+ PUBLICATION_HEALTH_HTTP_REQUEST_SCHEMA = (
45
+ "blun.cms-localization-publication-health-http.v1"
46
+ )
47
+ PUBLICATION_HEALTH_HTTP_RESPONSE_SCHEMA = (
48
+ "blun.cms-localization-publication-health-http-ack.v1"
49
+ )
50
+ PUBLICATION_HTTP_BINDING_HEADERS = (
51
+ ("Idempotency-Key", "delivery_id"),
52
+ ("X-Localization-Delivery-Id", "delivery_id"),
53
+ ("X-Localization-Payload-Sha256", "payload_sha256"),
54
+ )
55
+ PUBLICATION_HEALTH_HTTP_BINDING_HEADERS = (
56
+ ("Idempotency-Key", "probe_id"),
57
+ ("X-Localization-Probe-Id", "probe_id"),
58
+ ("X-Localization-Contract-Sha256", "contract_sha256"),
59
+ )
60
+ MAX_MESSAGE_BYTES = 4_000_000
61
+ MAX_ATTEMPTS = 20
62
+ MAX_LEASE_SECONDS = 86_400.0
63
+ TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
64
+ SIGNATURE_VALUE = re.compile(r"^[A-Za-z0-9_.:/+=-]{1,4096}$")
65
+ ERROR_CODE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
66
+ SHA256 = re.compile(r"^[0-9a-f]{64}$")
67
+ _EVENT_COLUMNS = (
68
+ "event_id", "event_sha256", "event_json", "signature_algorithm", "key_id",
69
+ "signature", "plan_id", "status", "created_at", "updated_at",
70
+ )
71
+ _DELIVERY_COLUMNS = (
72
+ "delivery_id", "event_id", "plan_id", "payload_json", "payload_sha256",
73
+ "signature_algorithm", "key_id", "signature", "status", "attempts",
74
+ "max_attempts", "next_attempt_at", "lease_owner", "lease_token",
75
+ "lease_expires_at", "last_error_code", "last_error_detail_hash",
76
+ "created_at", "updated_at",
77
+ )
78
+ _EVENT_TOPIC_COLUMNS = (
79
+ "event_id", "site_id", "source_id", "generation", "created_at",
80
+ )
81
+ _SUPERSESSION_COLUMNS = (
82
+ "event_id", "superseded_by_event_id", "created_at",
83
+ )
84
+ _CANCELLATION_COLUMNS = (
85
+ "cancellation_id", "event_id", "cancellation_sha256", "cancellation_json",
86
+ "signature_algorithm", "key_id", "signature", "created_at",
87
+ )
88
+ _TOMBSTONE_COLUMNS = (
89
+ "tombstone_id", "event_id", "tombstone_sha256", "tombstone_json",
90
+ "request_signature_algorithm", "request_key_id", "request_signature",
91
+ "delivery_id", "plan_id", "payload_json", "payload_sha256",
92
+ "signature_algorithm", "key_id", "signature", "status", "attempts",
93
+ "max_attempts", "next_attempt_at", "lease_owner", "lease_token",
94
+ "lease_expires_at", "last_error_code", "last_error_detail_hash",
95
+ "created_at", "updated_at",
96
+ )
97
+ _LOCALE_PROFILE_FIELDS = (
98
+ "locale", "eu_code", "language", "native_name", "script",
99
+ "quality_profile_version", "quality_profile_sha256", "direction",
100
+ )
101
+ _EXPECTED_CONTENT_TYPES = frozenset({
102
+ "headline", "cta", "marketing", "ui", "documentation", "seo", "legal",
103
+ "commercial",
104
+ })
105
+ _EXPECTED_COMMERCIAL_DIMENSIONS = (
106
+ "amount_currency", "discount_basis", "qualifiers", "tax_status",
107
+ "billing_interval", "commitment", "renewal", "cancellation",
108
+ "conditions", "offer_assignment",
109
+ )
110
+ _DEFAULT_TARGET_POLICY = "all-eu-official-locales-except-source-language"
111
+
112
+
113
+ def _load_module(name: str, path: Path):
114
+ spec = importlib.util.spec_from_file_location(name, path)
115
+ if spec is None or spec.loader is None:
116
+ raise RuntimeError(f"cannot load required CMS dependency: {path.name}")
117
+ module = importlib.util.module_from_spec(spec)
118
+ sys.modules[spec.name] = module
119
+ spec.loader.exec_module(module)
120
+ return module
121
+
122
+
123
+ _ROOT = Path(__file__).resolve().parents[1]
124
+ _PLANNER = _load_module(
125
+ "blun_website_localization_cms_planner",
126
+ _ROOT / "integrations" / "website_localization.py",
127
+ )
128
+ _COMMERCIAL = _load_module(
129
+ "blun_website_localization_cms_commercial_profile",
130
+ _ROOT / "integrations" / "commercial_localization_profile.py",
131
+ )
132
+ _RELEASE = _load_module(
133
+ "blun_website_localization_cms_release",
134
+ _ROOT / "integrations" / "website_localization_release.py",
135
+ )
136
+ _QUEUE = _RELEASE._QUEUE
137
+
138
+
139
+ class CMSBridgeBlocked(RuntimeError):
140
+ """Stable, content-free CMS bridge failure."""
141
+
142
+ def __init__(self, code: str):
143
+ if not isinstance(code, str) or ERROR_CODE.fullmatch(code) is None:
144
+ raise ValueError("CMS failure code is invalid")
145
+ super().__init__(code)
146
+ self.code = code
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class CMSMessageSignature:
151
+ algorithm: str
152
+ key_id: str
153
+ signature: str
154
+
155
+
156
+ class CMSMessageAuthority(Protocol):
157
+ def sign(self, payload: bytes) -> CMSMessageSignature: ...
158
+ def verify(self, payload: bytes, signature: CMSMessageSignature) -> bool: ...
159
+
160
+
161
+ class CMSPublisher(Protocol):
162
+ def publish(self, request: "CMSPublicationRequest") -> Mapping[str, Any]: ...
163
+
164
+
165
+ class CMSPublishFailed(RuntimeError):
166
+ """Provider-neutral transport failure with an explicit retry decision."""
167
+
168
+ def __init__(self, code: str, *, retryable: bool, detail: str | None = None):
169
+ if not isinstance(code, str) or ERROR_CODE.fullmatch(code) is None:
170
+ raise ValueError("publisher failure code is invalid")
171
+ if not isinstance(retryable, bool):
172
+ raise ValueError("retryable must be boolean")
173
+ if detail is not None and not isinstance(detail, str):
174
+ raise ValueError("publisher failure detail must be text")
175
+ super().__init__(code)
176
+ self.code = code
177
+ self.retryable = retryable
178
+ self.detail = detail
179
+
180
+
181
+ def _declared_publish_failure(error: Exception) -> CMSPublishFailed | None:
182
+ """Normalize an adapter failure without depending on module class identity."""
183
+
184
+ if isinstance(error, CMSPublishFailed):
185
+ return error
186
+ try:
187
+ declared = error.cms_publish_failure is True
188
+ code = error.code
189
+ retryable = error.retryable
190
+ except Exception:
191
+ return None
192
+ if (
193
+ not declared
194
+ or not isinstance(code, str)
195
+ or ERROR_CODE.fullmatch(code) is None
196
+ or not isinstance(retryable, bool)
197
+ ):
198
+ return None
199
+ full_code = "publisher." + code
200
+ if ERROR_CODE.fullmatch(full_code) is None:
201
+ full_code = "publisher.failure"
202
+ return CMSPublishFailed(full_code, retryable=retryable)
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class IngestedChange:
207
+ event_id: str
208
+ plan_id: str
209
+ job_count: int
210
+ inserted_jobs: int
211
+ status: str
212
+
213
+
214
+ @dataclass(frozen=True)
215
+ class CancelledChange:
216
+ cancellation_id: str
217
+ event_id: str
218
+ status: str
219
+ newly_cancelled: bool
220
+
221
+
222
+ @dataclass(frozen=True)
223
+ class TombstoneAccepted:
224
+ tombstone_id: str
225
+ event_id: str
226
+ delivery_id: str
227
+ status: str
228
+ newly_requested: bool
229
+
230
+
231
+ @dataclass(frozen=True)
232
+ class CMSPublicationRequest:
233
+ delivery_id: str
234
+ payload: dict[str, Any]
235
+ payload_sha256: str
236
+ signature: CMSMessageSignature
237
+
238
+
239
+ @dataclass(frozen=True)
240
+ class CMSTombstoneRequest:
241
+ delivery_id: str
242
+ payload: dict[str, Any]
243
+ payload_sha256: str
244
+ signature: CMSMessageSignature
245
+
246
+
247
+ @dataclass(frozen=True)
248
+ class ClaimedDelivery:
249
+ request: CMSPublicationRequest
250
+ attempt: int
251
+ max_attempts: int
252
+ lease_owner: str
253
+ lease_token: str
254
+ lease_expires_at: float
255
+
256
+
257
+ @dataclass(frozen=True)
258
+ class ClaimedTombstone:
259
+ request: CMSTombstoneRequest
260
+ attempt: int
261
+ max_attempts: int
262
+ lease_owner: str
263
+ lease_token: str
264
+ lease_expires_at: float
265
+
266
+
267
+ @dataclass(frozen=True)
268
+ class DeliveryStatus:
269
+ delivery_id: str
270
+ event_id: str
271
+ plan_id: str
272
+ status: str
273
+ attempts: int
274
+ max_attempts: int
275
+ next_attempt_at: float
276
+ lease_expires_at: float | None
277
+ last_error_code: str | None
278
+ last_error_detail_hash: str | None
279
+ payload_sha256: str
280
+
281
+
282
+ @dataclass(frozen=True)
283
+ class DeliveryOutcome:
284
+ status: str
285
+ delivery_id: str | None = None
286
+ attempt: int | None = None
287
+ error_code: str | None = None
288
+
289
+
290
+ @dataclass(frozen=True)
291
+ class LocaleProgress:
292
+ job_id: str
293
+ target_locale: str
294
+ status: str
295
+ attempts: int
296
+ max_attempts: int
297
+ next_attempt_at: float
298
+ lease_expires_at: float | None
299
+ lease_expired: bool
300
+ last_error_code: str | None
301
+ last_error_detail_hash: str | None
302
+ result_sha256: str | None
303
+
304
+
305
+ @dataclass(frozen=True)
306
+ class ChangeProgress:
307
+ event_id: str
308
+ site_id: str
309
+ plan_id: str
310
+ website_version: str
311
+ source_sequence: int
312
+ job_count: int
313
+ counts: dict[str, int]
314
+ locales: tuple[LocaleProgress, ...]
315
+ cancelled: bool
316
+ queue_recovery_pending: bool
317
+
318
+
319
+ @dataclass(frozen=True)
320
+ class ChangeLifecycle:
321
+ event_id: str
322
+ site_id: str
323
+ plan_id: str
324
+ website_version: str
325
+ source_sequence: int
326
+ status: str
327
+ required_locales: tuple[str, ...]
328
+ approved_locales: tuple[str, ...]
329
+ blocked_locales: tuple[tuple[str, str], ...]
330
+ queue_counts: dict[str, int]
331
+ delivery: dict[str, Any] | None
332
+ tombstone: dict[str, Any] | None
333
+
334
+
335
+ def _canonical_json(value: Any) -> str:
336
+ try:
337
+ encoded = json.dumps(
338
+ value,
339
+ ensure_ascii=False,
340
+ allow_nan=False,
341
+ sort_keys=True,
342
+ separators=(",", ":"),
343
+ )
344
+ except (TypeError, ValueError) as error:
345
+ raise CMSBridgeBlocked("cms.json.invalid") from error
346
+ if len(encoded.encode("utf-8")) > MAX_MESSAGE_BYTES:
347
+ raise CMSBridgeBlocked("cms.message.too_large")
348
+ return encoded
349
+
350
+
351
+ def _hash(value: str) -> str:
352
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
353
+
354
+
355
+ def _text(value: Any, code: str, *, limit: int = 256) -> str:
356
+ if not isinstance(value, str) or not value or value != value.strip():
357
+ raise CMSBridgeBlocked(code)
358
+ if len(value) > limit or "\x00" in value or not unicodedata.is_normalized("NFC", value):
359
+ raise CMSBridgeBlocked(code)
360
+ return value
361
+
362
+
363
+ def _token(value: Any, code: str) -> str:
364
+ value = _text(value, code)
365
+ if TOKEN.fullmatch(value) is None:
366
+ raise CMSBridgeBlocked(code)
367
+ return value
368
+
369
+
370
+ def _timestamp(value: Any, code: str) -> float:
371
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
372
+ raise CMSBridgeBlocked(code)
373
+ value = float(value)
374
+ if value < 0 or value != value or value in {float("inf"), float("-inf")}:
375
+ raise CMSBridgeBlocked(code)
376
+ return value
377
+
378
+
379
+ def _valid_release_evidence(
380
+ value: Any,
381
+ *,
382
+ locale: Any,
383
+ target_sha256: Any,
384
+ approval_id: Any,
385
+ ) -> bool:
386
+ try:
387
+ evidence = _RELEASE.validate_publication_evidence(value)
388
+ except _RELEASE.LocalizationReleaseBlocked:
389
+ return False
390
+ return (
391
+ evidence["target_locale"] == locale
392
+ and evidence["target_sha256"] == target_sha256
393
+ and evidence["approval_id"] == approval_id
394
+ )
395
+
396
+
397
+ def _positive_integer(value: Any, code: str) -> int:
398
+ if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value < 2**63:
399
+ raise CMSBridgeBlocked(code)
400
+ return value
401
+
402
+
403
+ def _duration(value: Any, code: str, *, maximum: float = MAX_LEASE_SECONDS) -> float:
404
+ value = _timestamp(value, code)
405
+ if value <= 0 or value > maximum:
406
+ raise CMSBridgeBlocked(code)
407
+ return value
408
+
409
+
410
+ def _signature(value: Any, code: str = "cms.signature.invalid") -> CMSMessageSignature:
411
+ if not isinstance(value, CMSMessageSignature):
412
+ expected = tuple(field.name for field in fields(CMSMessageSignature))
413
+ try:
414
+ actual = tuple(field.name for field in fields(value))
415
+ parameters = type(value).__dataclass_params__
416
+ if (
417
+ not is_dataclass(value)
418
+ or isinstance(value, type)
419
+ or type(value).__name__ != CMSMessageSignature.__name__
420
+ or parameters.frozen is not True
421
+ or actual != expected
422
+ ):
423
+ raise TypeError("incompatible signature")
424
+ value = CMSMessageSignature(**{
425
+ field: getattr(value, field) for field in expected
426
+ })
427
+ except Exception:
428
+ raise CMSBridgeBlocked(code) from None
429
+ for part in (value.algorithm, value.key_id):
430
+ if not isinstance(part, str) or TOKEN.fullmatch(part) is None:
431
+ raise CMSBridgeBlocked(code)
432
+ if not isinstance(value.signature, str) or SIGNATURE_VALUE.fullmatch(value.signature) is None:
433
+ raise CMSBridgeBlocked(code)
434
+ return value
435
+
436
+
437
+ def _verify(authority: Any, payload: bytes, signature: CMSMessageSignature, code: str) -> None:
438
+ verify = getattr(authority, "verify", None)
439
+ try:
440
+ accepted = callable(verify) and verify(payload, signature) is True
441
+ except Exception:
442
+ accepted = False
443
+ if not accepted:
444
+ raise CMSBridgeBlocked(code)
445
+
446
+
447
+ @contextmanager
448
+ def _transaction(connection: sqlite3.Connection) -> Iterator[None]:
449
+ if connection.in_transaction:
450
+ raise CMSBridgeBlocked("cms.transaction.external")
451
+ try:
452
+ connection.execute("BEGIN IMMEDIATE")
453
+ yield
454
+ except Exception:
455
+ connection.rollback()
456
+ raise
457
+ else:
458
+ connection.commit()
459
+
460
+
461
+ class WebsiteLocalizationCMSBridge:
462
+ """Authenticated CMS change ingestion and complete-bundle delivery."""
463
+
464
+ def __init__(self, connection: sqlite3.Connection, queue: Any, release_store: Any):
465
+ if not isinstance(connection, sqlite3.Connection):
466
+ raise CMSBridgeBlocked("cms.connection.invalid")
467
+ if not isinstance(queue, _QUEUE.LocalizationQueue):
468
+ raise CMSBridgeBlocked("cms.queue.invalid")
469
+ if not isinstance(release_store, _RELEASE.LocalizationReleaseStore):
470
+ raise CMSBridgeBlocked("cms.release_store.invalid")
471
+ if release_store.queue is not queue:
472
+ raise CMSBridgeBlocked("cms.queue_mismatch")
473
+ self.connection = connection
474
+ self.connection.row_factory = sqlite3.Row
475
+ self.connection.execute("PRAGMA foreign_keys = ON")
476
+ self.connection.execute("PRAGMA busy_timeout = 5000")
477
+ self.queue = queue
478
+ self.release_store = release_store
479
+ version = int(connection.execute("PRAGMA user_version").fetchone()[0])
480
+ if version not in {0, 1, 2, 3, SCHEMA_VERSION}:
481
+ raise CMSBridgeBlocked("cms.schema.unsupported")
482
+ if version == 0:
483
+ self._create_schema()
484
+ elif version == 1:
485
+ self._migrate_v1()
486
+ elif version == 2:
487
+ self._migrate_v2()
488
+ elif version == 3:
489
+ self._migrate_v3()
490
+ self._verify_schema()
491
+
492
+ def localization_capabilities(self) -> dict[str, Any]:
493
+ """Return the canonical, content-free planner contract without mutation."""
494
+ try:
495
+ profiles = _PLANNER.EU_OFFICIAL_LOCALES
496
+ content_types = _PLANNER.CONTENT_TYPES
497
+ quality_passes = _PLANNER.QUALITY_PASSES
498
+ if (
499
+ not isinstance(profiles, tuple)
500
+ or len(profiles) != 24
501
+ or not isinstance(content_types, frozenset)
502
+ or content_types != _EXPECTED_CONTENT_TYPES
503
+ or quality_passes != ("target_native", "source_fidelity")
504
+ or _PLANNER.EU_LANGUAGE_SOURCE
505
+ != "https://european-union.europa.eu/principles-countries-history/languages_en"
506
+ ):
507
+ raise CMSBridgeBlocked("cms.capabilities.registry_invalid")
508
+
509
+ commercial = _COMMERCIAL.public_profile(_PLANNER.COMMERCIAL_PROFILE)
510
+ review_summary_contract = _COMMERCIAL.public_review_summary_contract(
511
+ _PLANNER.COMMERCIAL_PROFILE,
512
+ )
513
+ if (
514
+ not isinstance(commercial, dict)
515
+ or set(commercial) != {
516
+ "schema", "profile", "applies_to", "dimensions",
517
+ "preservation", "rendering", "verification",
518
+ "protected_terms", "review_summary_schema",
519
+ "review_summary_contract", "sha256",
520
+ }
521
+ or commercial["schema"] != _COMMERCIAL.PUBLIC_PROFILE_SCHEMA
522
+ or commercial["profile"] != _PLANNER.COMMERCIAL_PROFILE
523
+ or commercial["review_summary_schema"]
524
+ != _COMMERCIAL.REVIEW_SUMMARY_SCHEMA
525
+ or commercial["review_summary_contract"] != review_summary_contract
526
+ or commercial["applies_to"] != {
527
+ "content_type": "commercial",
528
+ "locales": "all-supported-target-locales",
529
+ }
530
+ or commercial["protected_terms"] != "project-configuration-only"
531
+ or [item.get("name") for item in commercial["dimensions"]]
532
+ != list(_EXPECTED_COMMERCIAL_DIMENSIONS)
533
+ or tuple(_COMMERCIAL.DIMENSIONS) != _EXPECTED_COMMERCIAL_DIMENSIONS
534
+ ):
535
+ raise CMSBridgeBlocked("cms.capabilities.registry_invalid")
536
+ unsigned_review_summary_contract = dict(review_summary_contract)
537
+ review_summary_digest = unsigned_review_summary_contract.pop(
538
+ "sha256", None,
539
+ )
540
+ if (
541
+ set(review_summary_contract) != {
542
+ "schema", "result_schema", "profile", "required_fields",
543
+ "statuses", "review_required_dimensions", "evidence_sha256",
544
+ "content_policy", "sha256",
545
+ }
546
+ or review_summary_contract["schema"]
547
+ != _COMMERCIAL.REVIEW_SUMMARY_CAPABILITIES_SCHEMA
548
+ or review_summary_contract["result_schema"]
549
+ != _COMMERCIAL.REVIEW_SUMMARY_SCHEMA
550
+ or review_summary_contract["profile"]
551
+ != _PLANNER.COMMERCIAL_PROFILE
552
+ or review_summary_contract["required_fields"] != [
553
+ "schema", "profile", "status", "review_required_dimensions",
554
+ "evidence_sha256",
555
+ ]
556
+ or review_summary_contract["statuses"] != {
557
+ "verified": {"review_required_dimensions": "empty"},
558
+ "review_required": {
559
+ "review_required_dimensions": "one-or-more",
560
+ "requires_independent_review": True,
561
+ },
562
+ }
563
+ or review_summary_contract["review_required_dimensions"] != {
564
+ "allowed": list(_EXPECTED_COMMERCIAL_DIMENSIONS),
565
+ "order": list(_EXPECTED_COMMERCIAL_DIMENSIONS),
566
+ "unique": True,
567
+ }
568
+ or review_summary_contract["evidence_sha256"] != {
569
+ "algorithm": "sha-256",
570
+ "canonicalization": (
571
+ "utf-8-json-sort-keys-no-insignificant-whitespace"
572
+ ),
573
+ "covers": "complete-commercial-review-evidence",
574
+ }
575
+ or review_summary_contract["content_policy"] != {
576
+ "source_text": False,
577
+ "target_text": False,
578
+ "source_spans": False,
579
+ "target_spans": False,
580
+ "reviewer_prose": False,
581
+ "project_prices": False,
582
+ "project_brands": False,
583
+ }
584
+ or not isinstance(review_summary_digest, str)
585
+ or SHA256.fullmatch(review_summary_digest) is None
586
+ or review_summary_digest
587
+ != _hash(_canonical_json(unsigned_review_summary_contract))
588
+ ):
589
+ raise CMSBridgeBlocked("cms.capabilities.registry_invalid")
590
+ unsigned_commercial = dict(commercial)
591
+ commercial_digest = unsigned_commercial.pop("sha256", None)
592
+ if (
593
+ not isinstance(commercial_digest, str)
594
+ or SHA256.fullmatch(commercial_digest) is None
595
+ or commercial_digest != _hash(_canonical_json(unsigned_commercial))
596
+ ):
597
+ raise CMSBridgeBlocked("cms.capabilities.registry_invalid")
598
+
599
+ locales = []
600
+ seen_locales: set[str] = set()
601
+ seen_languages: set[str] = set()
602
+ seen_eu_codes: set[str] = set()
603
+ for profile in profiles:
604
+ if (
605
+ not is_dataclass(profile)
606
+ or isinstance(profile, type)
607
+ or type(profile).__name__ != "LocaleProfile"
608
+ or type(profile).__dataclass_params__.frozen is not True
609
+ or tuple(field.name for field in fields(profile))
610
+ != _LOCALE_PROFILE_FIELDS
611
+ ):
612
+ raise CMSBridgeBlocked("cms.capabilities.registry_invalid")
613
+ item = {
614
+ field: getattr(profile, field) for field in _LOCALE_PROFILE_FIELDS
615
+ }
616
+ locale = _token(item["locale"], "cms.capabilities.registry_invalid")
617
+ eu_code = _token(item["eu_code"], "cms.capabilities.registry_invalid")
618
+ language = _token(item["language"], "cms.capabilities.registry_invalid")
619
+ native_name = _text(
620
+ item["native_name"], "cms.capabilities.registry_invalid",
621
+ )
622
+ script = _token(item["script"], "cms.capabilities.registry_invalid")
623
+ version = _token(
624
+ item["quality_profile_version"],
625
+ "cms.capabilities.registry_invalid",
626
+ )
627
+ digest = item["quality_profile_sha256"]
628
+ direction = item["direction"]
629
+ if (
630
+ _PLANNER.canonicalize_locale(locale) != locale
631
+ or locale.split("-", 1)[0] != language
632
+ or not isinstance(digest, str)
633
+ or SHA256.fullmatch(digest) is None
634
+ or direction not in {"ltr", "rtl"}
635
+ or locale in seen_locales
636
+ or language in seen_languages
637
+ or eu_code in seen_eu_codes
638
+ ):
639
+ raise CMSBridgeBlocked("cms.capabilities.registry_invalid")
640
+ quality = _PLANNER.quality_profile_for(locale)
641
+ if (
642
+ not isinstance(quality, dict)
643
+ or quality.get("locale") != locale
644
+ or quality.get("version") != version
645
+ or quality.get("sha256") != digest
646
+ ):
647
+ raise CMSBridgeBlocked("cms.capabilities.registry_invalid")
648
+ seen_locales.add(locale)
649
+ seen_languages.add(language)
650
+ seen_eu_codes.add(eu_code)
651
+ locales.append({
652
+ "locale": locale,
653
+ "eu_code": eu_code,
654
+ "language": language,
655
+ "native_name": native_name,
656
+ "script": script,
657
+ "direction": direction,
658
+ "quality_profile_version": version,
659
+ "quality_profile_sha256": digest,
660
+ })
661
+
662
+ body = {
663
+ "schema": CAPABILITIES_SCHEMA,
664
+ "change_schema": CHANGE_SCHEMA,
665
+ "cancellation_schema": CANCELLATION_SCHEMA,
666
+ "tombstone_schema": TOMBSTONE_SCHEMA,
667
+ "publication_schema": PUBLICATION_SCHEMA,
668
+ "tombstone_delivery_schema": TOMBSTONE_DELIVERY_SCHEMA,
669
+ "plan_schema": _token(
670
+ _PLANNER.SCHEMA, "cms.capabilities.registry_invalid",
671
+ ),
672
+ "job_schema": _token(
673
+ _PLANNER.JOB_SCHEMA, "cms.capabilities.registry_invalid",
674
+ ),
675
+ "eu_language_source": _PLANNER.EU_LANGUAGE_SOURCE,
676
+ "default_target_policy": _DEFAULT_TARGET_POLICY,
677
+ "content_types": sorted(content_types),
678
+ "quality_passes": list(quality_passes),
679
+ "commercial_profile": commercial,
680
+ "publication_http": self._publication_http_capabilities(),
681
+ "locales": locales,
682
+ }
683
+ canonical = _canonical_json(body)
684
+ return {**body, "sha256": _hash(canonical)}
685
+ except CMSBridgeBlocked:
686
+ raise
687
+ except Exception:
688
+ raise CMSBridgeBlocked("cms.capabilities.registry_invalid") from None
689
+
690
+ @staticmethod
691
+ def _publication_http_capabilities() -> dict[str, Any]:
692
+ """Describe the built-in outbound adapter without deployment secrets."""
693
+ try:
694
+ operations = [
695
+ {
696
+ "name": "publication",
697
+ "payload_schema": _token(
698
+ PUBLICATION_SCHEMA, "cms.capabilities.registry_invalid",
699
+ ),
700
+ "request_schema": _token(
701
+ PUBLICATION_HTTP_REQUEST_SCHEMA,
702
+ "cms.capabilities.registry_invalid",
703
+ ),
704
+ "acknowledgement_schema": _token(
705
+ ACK_SCHEMA, "cms.capabilities.registry_invalid",
706
+ ),
707
+ "response_schema": _token(
708
+ PUBLICATION_HTTP_RESPONSE_SCHEMA,
709
+ "cms.capabilities.registry_invalid",
710
+ ),
711
+ "acknowledgement_status": "accepted",
712
+ },
713
+ {
714
+ "name": "tombstone",
715
+ "payload_schema": _token(
716
+ TOMBSTONE_DELIVERY_SCHEMA,
717
+ "cms.capabilities.registry_invalid",
718
+ ),
719
+ "request_schema": _token(
720
+ TOMBSTONE_HTTP_REQUEST_SCHEMA,
721
+ "cms.capabilities.registry_invalid",
722
+ ),
723
+ "acknowledgement_schema": _token(
724
+ TOMBSTONE_ACK_SCHEMA, "cms.capabilities.registry_invalid",
725
+ ),
726
+ "response_schema": _token(
727
+ TOMBSTONE_HTTP_RESPONSE_SCHEMA,
728
+ "cms.capabilities.registry_invalid",
729
+ ),
730
+ "acknowledgement_status": "deleted",
731
+ },
732
+ {
733
+ "name": "health",
734
+ "payload_schema": _token(
735
+ PUBLICATION_HEALTH_SCHEMA,
736
+ "cms.capabilities.registry_invalid",
737
+ ),
738
+ "request_schema": _token(
739
+ PUBLICATION_HEALTH_HTTP_REQUEST_SCHEMA,
740
+ "cms.capabilities.registry_invalid",
741
+ ),
742
+ "acknowledgement_schema": _token(
743
+ PUBLICATION_HEALTH_ACK_SCHEMA,
744
+ "cms.capabilities.registry_invalid",
745
+ ),
746
+ "response_schema": _token(
747
+ PUBLICATION_HEALTH_HTTP_RESPONSE_SCHEMA,
748
+ "cms.capabilities.registry_invalid",
749
+ ),
750
+ "acknowledgement_status": "healthy",
751
+ },
752
+ ]
753
+ headers = []
754
+ seen_headers: set[str] = set()
755
+ for name, binding in PUBLICATION_HTTP_BINDING_HEADERS:
756
+ if (
757
+ not isinstance(name, str)
758
+ or not name.isascii()
759
+ or not re.fullmatch(r"[A-Za-z0-9-]{1,128}", name)
760
+ or binding not in {"delivery_id", "payload_sha256"}
761
+ or name.lower() in seen_headers
762
+ ):
763
+ raise ValueError
764
+ seen_headers.add(name.lower())
765
+ headers.append({
766
+ "name": name,
767
+ "binding": _token(
768
+ binding, "cms.capabilities.registry_invalid",
769
+ ),
770
+ })
771
+ body = {
772
+ "schema": _token(
773
+ PUBLICATION_HTTP_CONTRACT_SCHEMA,
774
+ "cms.capabilities.registry_invalid",
775
+ ),
776
+ "method": "POST",
777
+ "request_content_type": "application/json; charset=utf-8",
778
+ "response_content_types": [
779
+ "application/json", "application/json; charset=utf-8",
780
+ ],
781
+ "delivery_semantics": "at-least-once",
782
+ "release_evidence_schema": _token(
783
+ _RELEASE.PUBLICATION_EVIDENCE_SCHEMA,
784
+ "cms.capabilities.registry_invalid",
785
+ ),
786
+ "binding_headers": headers,
787
+ "health_binding_headers": [
788
+ {
789
+ "name": _token(
790
+ name, "cms.capabilities.registry_invalid",
791
+ ),
792
+ "binding": _token(
793
+ binding, "cms.capabilities.registry_invalid",
794
+ ),
795
+ }
796
+ for name, binding in PUBLICATION_HEALTH_HTTP_BINDING_HEADERS
797
+ ],
798
+ "operations": operations,
799
+ }
800
+ health_headers = body["health_binding_headers"]
801
+ if (
802
+ len(health_headers) != len(PUBLICATION_HEALTH_HTTP_BINDING_HEADERS)
803
+ or len({item["name"].lower() for item in health_headers})
804
+ != len(health_headers)
805
+ or any(
806
+ item["binding"] not in {"probe_id", "contract_sha256"}
807
+ or not item["name"].isascii()
808
+ or re.fullmatch(r"[A-Za-z0-9-]{1,128}", item["name"]) is None
809
+ for item in health_headers
810
+ )
811
+ ):
812
+ raise ValueError
813
+ return {**body, "sha256": _hash(_canonical_json(body))}
814
+ except CMSBridgeBlocked:
815
+ raise
816
+ except Exception:
817
+ raise CMSBridgeBlocked("cms.capabilities.registry_invalid") from None
818
+
819
+ def _create_revision_schema(self) -> None:
820
+ self.connection.execute("""
821
+ CREATE TABLE cms_event_topics (
822
+ event_id TEXT PRIMARY KEY,
823
+ site_id TEXT NOT NULL,
824
+ source_id TEXT NOT NULL,
825
+ generation INTEGER NOT NULL CHECK (generation > 0),
826
+ created_at REAL NOT NULL,
827
+ FOREIGN KEY (event_id) REFERENCES cms_change_events (event_id),
828
+ UNIQUE (site_id, source_id, generation)
829
+ )
830
+ """)
831
+ self.connection.execute("""
832
+ CREATE INDEX cms_event_topic_order
833
+ ON cms_event_topics (site_id, source_id, generation, event_id)
834
+ """)
835
+ self.connection.execute("""
836
+ CREATE TABLE cms_event_supersessions (
837
+ event_id TEXT PRIMARY KEY,
838
+ superseded_by_event_id TEXT NOT NULL,
839
+ created_at REAL NOT NULL,
840
+ CHECK (event_id <> superseded_by_event_id),
841
+ FOREIGN KEY (event_id) REFERENCES cms_change_events (event_id),
842
+ FOREIGN KEY (superseded_by_event_id) REFERENCES cms_change_events (event_id)
843
+ )
844
+ """)
845
+ self._create_cancellation_schema()
846
+
847
+ def _create_cancellation_schema(self) -> None:
848
+ self.connection.execute("""
849
+ CREATE TABLE IF NOT EXISTS cms_event_cancellations (
850
+ cancellation_id TEXT PRIMARY KEY,
851
+ event_id TEXT NOT NULL UNIQUE,
852
+ cancellation_sha256 TEXT NOT NULL,
853
+ cancellation_json TEXT NOT NULL,
854
+ signature_algorithm TEXT NOT NULL,
855
+ key_id TEXT NOT NULL,
856
+ signature TEXT NOT NULL,
857
+ created_at REAL NOT NULL,
858
+ FOREIGN KEY (event_id) REFERENCES cms_change_events (event_id)
859
+ )
860
+ """)
861
+
862
+ def _create_tombstone_schema(self) -> None:
863
+ self.connection.execute(f"""
864
+ CREATE TABLE IF NOT EXISTS cms_tombstone_deliveries (
865
+ tombstone_id TEXT PRIMARY KEY,
866
+ event_id TEXT NOT NULL UNIQUE,
867
+ tombstone_sha256 TEXT NOT NULL,
868
+ tombstone_json TEXT NOT NULL,
869
+ request_signature_algorithm TEXT NOT NULL,
870
+ request_key_id TEXT NOT NULL,
871
+ request_signature TEXT NOT NULL,
872
+ delivery_id TEXT NOT NULL UNIQUE,
873
+ plan_id TEXT NOT NULL,
874
+ payload_json TEXT NOT NULL,
875
+ payload_sha256 TEXT NOT NULL,
876
+ signature_algorithm TEXT NOT NULL,
877
+ key_id TEXT NOT NULL,
878
+ signature TEXT NOT NULL,
879
+ status TEXT NOT NULL CHECK (
880
+ status IN ('pending', 'leased', 'retry_wait', 'succeeded', 'failed')
881
+ ),
882
+ attempts INTEGER NOT NULL CHECK (attempts >= 0),
883
+ max_attempts INTEGER NOT NULL CHECK (
884
+ max_attempts BETWEEN 1 AND {MAX_ATTEMPTS}
885
+ ),
886
+ next_attempt_at REAL NOT NULL,
887
+ lease_owner TEXT,
888
+ lease_token TEXT,
889
+ lease_expires_at REAL,
890
+ last_error_code TEXT,
891
+ last_error_detail_hash TEXT,
892
+ created_at REAL NOT NULL,
893
+ updated_at REAL NOT NULL,
894
+ FOREIGN KEY (event_id) REFERENCES cms_change_events (event_id)
895
+ )
896
+ """)
897
+ self.connection.execute("""
898
+ CREATE INDEX IF NOT EXISTS cms_tombstone_ready
899
+ ON cms_tombstone_deliveries (status, next_attempt_at, created_at, delivery_id)
900
+ """)
901
+
902
+ def _create_schema(self) -> None:
903
+ with _transaction(self.connection):
904
+ self.connection.execute("""
905
+ CREATE TABLE cms_change_events (
906
+ event_id TEXT PRIMARY KEY,
907
+ event_sha256 TEXT NOT NULL,
908
+ event_json TEXT NOT NULL,
909
+ signature_algorithm TEXT NOT NULL,
910
+ key_id TEXT NOT NULL,
911
+ signature TEXT NOT NULL,
912
+ plan_id TEXT NOT NULL,
913
+ status TEXT NOT NULL CHECK (status IN ('accepted', 'enqueued')),
914
+ created_at REAL NOT NULL,
915
+ updated_at REAL NOT NULL
916
+ )
917
+ """)
918
+ self.connection.execute(f"""
919
+ CREATE TABLE cms_publication_deliveries (
920
+ delivery_id TEXT PRIMARY KEY,
921
+ event_id TEXT NOT NULL UNIQUE,
922
+ plan_id TEXT NOT NULL,
923
+ payload_json TEXT NOT NULL,
924
+ payload_sha256 TEXT NOT NULL,
925
+ signature_algorithm TEXT NOT NULL,
926
+ key_id TEXT NOT NULL,
927
+ signature TEXT NOT NULL,
928
+ status TEXT NOT NULL CHECK (
929
+ status IN ('pending', 'leased', 'retry_wait', 'succeeded', 'failed')
930
+ ),
931
+ attempts INTEGER NOT NULL CHECK (attempts >= 0),
932
+ max_attempts INTEGER NOT NULL CHECK (
933
+ max_attempts BETWEEN 1 AND {MAX_ATTEMPTS}
934
+ ),
935
+ next_attempt_at REAL NOT NULL,
936
+ lease_owner TEXT,
937
+ lease_token TEXT,
938
+ lease_expires_at REAL,
939
+ last_error_code TEXT,
940
+ last_error_detail_hash TEXT,
941
+ created_at REAL NOT NULL,
942
+ updated_at REAL NOT NULL,
943
+ FOREIGN KEY (event_id) REFERENCES cms_change_events (event_id)
944
+ )
945
+ """)
946
+ self.connection.execute("""
947
+ CREATE INDEX cms_publication_ready
948
+ ON cms_publication_deliveries (status, next_attempt_at, created_at, delivery_id)
949
+ """)
950
+ self._create_revision_schema()
951
+ self._create_tombstone_schema()
952
+ self.connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
953
+
954
+ def _migrate_v1(self) -> None:
955
+ try:
956
+ with _transaction(self.connection):
957
+ event_columns = tuple(
958
+ row["name"]
959
+ for row in self.connection.execute("PRAGMA table_info(cms_change_events)")
960
+ )
961
+ delivery_columns = tuple(
962
+ row["name"]
963
+ for row in self.connection.execute(
964
+ "PRAGMA table_info(cms_publication_deliveries)"
965
+ )
966
+ )
967
+ if event_columns != _EVENT_COLUMNS or delivery_columns != _DELIVERY_COLUMNS:
968
+ raise CMSBridgeBlocked("cms.schema.altered")
969
+ self._create_revision_schema()
970
+ generations: dict[tuple[str, str], int] = {}
971
+ last_created_at: dict[tuple[str, str], float] = {}
972
+ rows = self.connection.execute(
973
+ "SELECT * FROM cms_change_events ORDER BY created_at, event_id"
974
+ ).fetchall()
975
+ for row in rows:
976
+ if _hash(row["event_json"]) != row["event_sha256"]:
977
+ raise CMSBridgeBlocked("cms.migration.event_invalid")
978
+ try:
979
+ event = json.loads(row["event_json"])
980
+ except json.JSONDecodeError:
981
+ raise CMSBridgeBlocked("cms.migration.event_invalid") from None
982
+ if _canonical_json(event) != row["event_json"]:
983
+ raise CMSBridgeBlocked("cms.migration.event_invalid")
984
+ event, plan = self._validated_event(event, allow_legacy=True)
985
+ if plan.plan_id != row["plan_id"]:
986
+ raise CMSBridgeBlocked("cms.migration.event_invalid")
987
+ topic = (event["site_id"], event["localization"]["source_id"])
988
+ if event.get("schema") == CHANGE_SCHEMA:
989
+ generation = event["source_sequence"]
990
+ else:
991
+ if last_created_at.get(topic) == float(row["created_at"]):
992
+ raise CMSBridgeBlocked("cms.migration.order_ambiguous")
993
+ generation = generations.get(topic, 0) + 1
994
+ generations[topic] = max(generations.get(topic, 0), generation)
995
+ last_created_at[topic] = float(row["created_at"])
996
+ self.connection.execute(
997
+ "INSERT INTO cms_event_topics VALUES (?, ?, ?, ?, ?)",
998
+ (row["event_id"], *topic, generation, row["created_at"]),
999
+ )
1000
+ self.connection.execute("""
1001
+ INSERT INTO cms_event_supersessions
1002
+ SELECT older.event_id, newest.event_id, newest.created_at
1003
+ FROM cms_event_topics AS older
1004
+ JOIN cms_change_events AS older_event
1005
+ ON older_event.event_id = older.event_id
1006
+ JOIN cms_event_topics AS newest
1007
+ ON newest.site_id = older.site_id
1008
+ AND newest.source_id = older.source_id
1009
+ JOIN cms_change_events AS newest_event
1010
+ ON newest_event.event_id = newest.event_id
1011
+ LEFT JOIN cms_publication_deliveries AS delivery
1012
+ ON delivery.event_id = older.event_id
1013
+ AND delivery.status = 'succeeded'
1014
+ WHERE older_event.status = 'enqueued'
1015
+ AND newest_event.status = 'enqueued'
1016
+ AND newest.generation = (
1017
+ SELECT MAX(candidate.generation)
1018
+ FROM cms_event_topics AS candidate
1019
+ JOIN cms_change_events AS candidate_event
1020
+ ON candidate_event.event_id = candidate.event_id
1021
+ WHERE candidate.site_id = older.site_id
1022
+ AND candidate.source_id = older.source_id
1023
+ AND candidate_event.status = 'enqueued'
1024
+ )
1025
+ AND older.generation < newest.generation
1026
+ AND delivery.event_id IS NULL
1027
+ """)
1028
+ self.connection.execute("""
1029
+ UPDATE cms_publication_deliveries
1030
+ SET status = 'failed', lease_owner = NULL, lease_token = NULL,
1031
+ lease_expires_at = NULL, last_error_code = 'event_superseded',
1032
+ last_error_detail_hash = NULL, updated_at = (
1033
+ SELECT created_at FROM cms_event_supersessions
1034
+ WHERE cms_event_supersessions.event_id =
1035
+ cms_publication_deliveries.event_id
1036
+ )
1037
+ WHERE event_id IN (SELECT event_id FROM cms_event_supersessions)
1038
+ AND status <> 'succeeded'
1039
+ """)
1040
+ self._create_tombstone_schema()
1041
+ self.connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
1042
+ except CMSBridgeBlocked:
1043
+ raise
1044
+ except Exception:
1045
+ raise CMSBridgeBlocked("cms.migration.failed") from None
1046
+
1047
+ def _migrate_v2(self) -> None:
1048
+ try:
1049
+ with _transaction(self.connection):
1050
+ event_columns = tuple(
1051
+ row["name"]
1052
+ for row in self.connection.execute("PRAGMA table_info(cms_change_events)")
1053
+ )
1054
+ delivery_columns = tuple(
1055
+ row["name"] for row in self.connection.execute(
1056
+ "PRAGMA table_info(cms_publication_deliveries)"
1057
+ )
1058
+ )
1059
+ topic_columns = tuple(
1060
+ row["name"]
1061
+ for row in self.connection.execute("PRAGMA table_info(cms_event_topics)")
1062
+ )
1063
+ supersession_columns = tuple(
1064
+ row["name"] for row in self.connection.execute(
1065
+ "PRAGMA table_info(cms_event_supersessions)"
1066
+ )
1067
+ )
1068
+ if (
1069
+ event_columns != _EVENT_COLUMNS
1070
+ or delivery_columns != _DELIVERY_COLUMNS
1071
+ or topic_columns != _EVENT_TOPIC_COLUMNS
1072
+ or supersession_columns != _SUPERSESSION_COLUMNS
1073
+ ):
1074
+ raise CMSBridgeBlocked("cms.schema.altered")
1075
+ self._create_cancellation_schema()
1076
+ self._create_tombstone_schema()
1077
+ self.connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
1078
+ except CMSBridgeBlocked:
1079
+ raise
1080
+ except Exception:
1081
+ raise CMSBridgeBlocked("cms.migration.failed") from None
1082
+
1083
+ def _migrate_v3(self) -> None:
1084
+ try:
1085
+ with _transaction(self.connection):
1086
+ self._create_tombstone_schema()
1087
+ self.connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
1088
+ except CMSBridgeBlocked:
1089
+ raise
1090
+ except Exception:
1091
+ raise CMSBridgeBlocked("cms.migration.failed") from None
1092
+
1093
+ def _verify_schema(self) -> None:
1094
+ event_columns = tuple(
1095
+ row["name"] for row in self.connection.execute("PRAGMA table_info(cms_change_events)")
1096
+ )
1097
+ delivery_columns = tuple(
1098
+ row["name"]
1099
+ for row in self.connection.execute("PRAGMA table_info(cms_publication_deliveries)")
1100
+ )
1101
+ topic_columns = tuple(
1102
+ row["name"] for row in self.connection.execute("PRAGMA table_info(cms_event_topics)")
1103
+ )
1104
+ supersession_columns = tuple(
1105
+ row["name"]
1106
+ for row in self.connection.execute("PRAGMA table_info(cms_event_supersessions)")
1107
+ )
1108
+ cancellation_columns = tuple(
1109
+ row["name"]
1110
+ for row in self.connection.execute("PRAGMA table_info(cms_event_cancellations)")
1111
+ )
1112
+ tombstone_columns = tuple(
1113
+ row["name"]
1114
+ for row in self.connection.execute("PRAGMA table_info(cms_tombstone_deliveries)")
1115
+ )
1116
+ version = int(self.connection.execute("PRAGMA user_version").fetchone()[0])
1117
+ if (
1118
+ version != SCHEMA_VERSION
1119
+ or event_columns != _EVENT_COLUMNS
1120
+ or delivery_columns != _DELIVERY_COLUMNS
1121
+ or topic_columns != _EVENT_TOPIC_COLUMNS
1122
+ or supersession_columns != _SUPERSESSION_COLUMNS
1123
+ or cancellation_columns != _CANCELLATION_COLUMNS
1124
+ or tombstone_columns != _TOMBSTONE_COLUMNS
1125
+ ):
1126
+ raise CMSBridgeBlocked("cms.schema.altered")
1127
+
1128
+ def _validated_event(
1129
+ self,
1130
+ event: Any,
1131
+ *,
1132
+ allow_legacy: bool = False,
1133
+ ) -> tuple[dict[str, Any], Any]:
1134
+ if not isinstance(event, dict):
1135
+ raise CMSBridgeBlocked("cms.event.invalid")
1136
+ if not isinstance(allow_legacy, bool):
1137
+ raise CMSBridgeBlocked("cms.event.legacy_mode_invalid")
1138
+ expected = {
1139
+ "schema", "event_id", "site_id", "website_version",
1140
+ "source_sequence", "localization",
1141
+ }
1142
+ legacy = event.get("schema") == "blun.cms-content-change.v1"
1143
+ if legacy:
1144
+ expected.remove("source_sequence")
1145
+ if (
1146
+ set(event) != expected
1147
+ or (event.get("schema") != CHANGE_SCHEMA and not (allow_legacy and legacy))
1148
+ ):
1149
+ raise CMSBridgeBlocked("cms.event.invalid")
1150
+ _token(event.get("event_id"), "cms.event_id.invalid")
1151
+ _token(event.get("site_id"), "cms.site_id.invalid")
1152
+ _token(event.get("website_version"), "cms.website_version.invalid")
1153
+ if not legacy:
1154
+ _positive_integer(event.get("source_sequence"), "cms.source_sequence.invalid")
1155
+ localization = event.get("localization")
1156
+ try:
1157
+ plan = _PLANNER.plan_from_mapping(localization)
1158
+ except _PLANNER.LocalizationPlanBlocked:
1159
+ raise CMSBridgeBlocked("cms.localization.invalid") from None
1160
+ return event, plan
1161
+
1162
+ def _validated_cancellation(self, cancellation: Any) -> dict[str, Any]:
1163
+ expected = {
1164
+ "schema", "cancellation_id", "event_id", "site_id",
1165
+ "website_version", "source_id", "source_sequence",
1166
+ }
1167
+ if (
1168
+ not isinstance(cancellation, dict)
1169
+ or set(cancellation) != expected
1170
+ or cancellation.get("schema") != CANCELLATION_SCHEMA
1171
+ ):
1172
+ raise CMSBridgeBlocked("cms.cancellation.invalid")
1173
+ for field in (
1174
+ "cancellation_id", "event_id", "site_id", "website_version", "source_id",
1175
+ ):
1176
+ _token(cancellation.get(field), f"cms.cancellation.{field}_invalid")
1177
+ _positive_integer(
1178
+ cancellation.get("source_sequence"),
1179
+ "cms.cancellation.source_sequence_invalid",
1180
+ )
1181
+ return cancellation
1182
+
1183
+ def _validated_tombstone(self, tombstone: Any) -> dict[str, Any]:
1184
+ expected = {
1185
+ "schema", "tombstone_id", "event_id", "site_id",
1186
+ "website_version", "source_id", "source_sequence",
1187
+ }
1188
+ if (
1189
+ not isinstance(tombstone, dict)
1190
+ or set(tombstone) != expected
1191
+ or tombstone.get("schema") != TOMBSTONE_SCHEMA
1192
+ ):
1193
+ raise CMSBridgeBlocked("cms.tombstone.invalid")
1194
+ for field in (
1195
+ "tombstone_id", "event_id", "site_id", "website_version", "source_id",
1196
+ ):
1197
+ _token(tombstone.get(field), f"cms.tombstone.{field}_invalid")
1198
+ _positive_integer(
1199
+ tombstone.get("source_sequence"),
1200
+ "cms.tombstone.source_sequence_invalid",
1201
+ )
1202
+ return tombstone
1203
+
1204
+ def _verify_stored_cancellation(
1205
+ self,
1206
+ row: sqlite3.Row,
1207
+ event: dict[str, Any],
1208
+ topic: sqlite3.Row,
1209
+ event_key_id: str,
1210
+ verifier: CMSMessageAuthority,
1211
+ ) -> dict[str, Any]:
1212
+ if _hash(row["cancellation_json"]) != row["cancellation_sha256"]:
1213
+ raise CMSBridgeBlocked("cms.cancellation.tampered")
1214
+ try:
1215
+ cancellation = json.loads(row["cancellation_json"])
1216
+ except json.JSONDecodeError:
1217
+ raise CMSBridgeBlocked("cms.cancellation.tampered") from None
1218
+ if _canonical_json(cancellation) != row["cancellation_json"]:
1219
+ raise CMSBridgeBlocked("cms.cancellation.tampered")
1220
+ cancellation = self._validated_cancellation(cancellation)
1221
+ signature = _signature(CMSMessageSignature(
1222
+ row["signature_algorithm"], row["key_id"], row["signature"],
1223
+ ))
1224
+ _verify(
1225
+ verifier,
1226
+ row["cancellation_json"].encode("utf-8"),
1227
+ signature,
1228
+ "cms.cancellation.signature_rejected",
1229
+ )
1230
+ if (
1231
+ cancellation["cancellation_id"] != row["cancellation_id"]
1232
+ or cancellation["event_id"] != row["event_id"]
1233
+ or cancellation["event_id"] != event["event_id"]
1234
+ or cancellation["site_id"] != event["site_id"]
1235
+ or cancellation["website_version"] != event["website_version"]
1236
+ or cancellation["source_id"] != event["localization"]["source_id"]
1237
+ or cancellation["source_sequence"] != int(topic["generation"])
1238
+ or row["key_id"] != event_key_id
1239
+ ):
1240
+ raise CMSBridgeBlocked("cms.cancellation.binding_invalid")
1241
+ return cancellation
1242
+
1243
+ def cancel_change(
1244
+ self,
1245
+ cancellation: Any,
1246
+ signature: CMSMessageSignature,
1247
+ verifier: CMSMessageAuthority,
1248
+ *,
1249
+ now: float | int,
1250
+ ) -> CancelledChange:
1251
+ cancellation = self._validated_cancellation(cancellation)
1252
+ signature = _signature(signature)
1253
+ now = _timestamp(now, "cms.time.invalid")
1254
+ cancellation_json = _canonical_json(cancellation)
1255
+ cancellation_hash = _hash(cancellation_json)
1256
+ _verify(
1257
+ verifier,
1258
+ cancellation_json.encode("utf-8"),
1259
+ signature,
1260
+ "cms.cancellation.signature_rejected",
1261
+ )
1262
+ event, _ = self._load_event(
1263
+ cancellation["event_id"],
1264
+ verifier,
1265
+ allow_superseded=True,
1266
+ allow_cancelled=True,
1267
+ allow_accepted=True,
1268
+ )
1269
+ topic = self.connection.execute(
1270
+ "SELECT * FROM cms_event_topics WHERE event_id = ?",
1271
+ (event["event_id"],),
1272
+ ).fetchone()
1273
+ event_row = self.connection.execute(
1274
+ "SELECT key_id FROM cms_change_events WHERE event_id = ?",
1275
+ (event["event_id"],),
1276
+ ).fetchone()
1277
+ if topic is None or event_row is None:
1278
+ raise CMSBridgeBlocked("cms.event.topic_invalid")
1279
+ if signature.key_id != event_row["key_id"]:
1280
+ raise CMSBridgeBlocked("cms.cancellation.scope_rejected")
1281
+ expected = {
1282
+ "event_id": event["event_id"],
1283
+ "site_id": event["site_id"],
1284
+ "website_version": event["website_version"],
1285
+ "source_id": event["localization"]["source_id"],
1286
+ "source_sequence": int(topic["generation"]),
1287
+ }
1288
+ if any(cancellation[field] != value for field, value in expected.items()):
1289
+ raise CMSBridgeBlocked("cms.cancellation.binding_invalid")
1290
+
1291
+ with _transaction(self.connection):
1292
+ delivery = self.connection.execute("""
1293
+ SELECT status FROM cms_publication_deliveries WHERE event_id = ?
1294
+ """, (event["event_id"],)).fetchone()
1295
+ if delivery is not None and delivery["status"] == "succeeded":
1296
+ raise CMSBridgeBlocked("cms.cancellation.already_published")
1297
+ if delivery is not None and delivery["status"] == "leased":
1298
+ raise CMSBridgeBlocked("cms.cancellation.delivery_in_flight")
1299
+ by_id = self.connection.execute("""
1300
+ SELECT event_id, cancellation_sha256
1301
+ FROM cms_event_cancellations WHERE cancellation_id = ?
1302
+ """, (cancellation["cancellation_id"],)).fetchone()
1303
+ by_event = self.connection.execute("""
1304
+ SELECT cancellation_id, cancellation_sha256
1305
+ FROM cms_event_cancellations WHERE event_id = ?
1306
+ """, (event["event_id"],)).fetchone()
1307
+ if by_id is not None and (
1308
+ by_id["event_id"] != event["event_id"]
1309
+ or by_id["cancellation_sha256"] != cancellation_hash
1310
+ ):
1311
+ raise CMSBridgeBlocked("cms.cancellation.idempotency_collision")
1312
+ if by_event is not None and (
1313
+ by_event["cancellation_id"] != cancellation["cancellation_id"]
1314
+ or by_event["cancellation_sha256"] != cancellation_hash
1315
+ ):
1316
+ raise CMSBridgeBlocked("cms.cancellation.idempotency_collision")
1317
+ newly_cancelled = by_id is None and by_event is None
1318
+ if newly_cancelled:
1319
+ self.connection.execute("""
1320
+ INSERT INTO cms_event_cancellations VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1321
+ """, (
1322
+ cancellation["cancellation_id"], event["event_id"],
1323
+ cancellation_hash, cancellation_json, signature.algorithm,
1324
+ signature.key_id, signature.signature, now,
1325
+ ))
1326
+ self.connection.execute("""
1327
+ UPDATE cms_publication_deliveries
1328
+ SET status = 'failed', lease_owner = NULL, lease_token = NULL,
1329
+ lease_expires_at = NULL, last_error_code = 'event_cancelled',
1330
+ last_error_detail_hash = NULL, updated_at = ?
1331
+ WHERE event_id = ? AND status <> 'succeeded'
1332
+ """, (now, event["event_id"]))
1333
+ return CancelledChange(
1334
+ cancellation["cancellation_id"], event["event_id"], "cancelled",
1335
+ newly_cancelled,
1336
+ )
1337
+
1338
+ def request_tombstone(
1339
+ self,
1340
+ tombstone: Any,
1341
+ signature: CMSMessageSignature,
1342
+ event_verifier: CMSMessageAuthority,
1343
+ publication_authority: CMSMessageAuthority,
1344
+ *,
1345
+ now: float | int,
1346
+ max_attempts: int = 5,
1347
+ ) -> TombstoneAccepted:
1348
+ """Accept and sign deletion only for an exactly confirmed publication."""
1349
+ tombstone = self._validated_tombstone(tombstone)
1350
+ signature = _signature(signature)
1351
+ now = _timestamp(now, "cms.time.invalid")
1352
+ if (
1353
+ isinstance(max_attempts, bool)
1354
+ or not isinstance(max_attempts, int)
1355
+ or not 1 <= max_attempts <= MAX_ATTEMPTS
1356
+ ):
1357
+ raise CMSBridgeBlocked("cms.max_attempts.invalid")
1358
+ tombstone_json = _canonical_json(tombstone)
1359
+ tombstone_hash = _hash(tombstone_json)
1360
+ _verify(
1361
+ event_verifier,
1362
+ tombstone_json.encode("utf-8"),
1363
+ signature,
1364
+ "cms.tombstone.signature_rejected",
1365
+ )
1366
+ event, plan = self._load_event(
1367
+ tombstone["event_id"], event_verifier, allow_superseded=True,
1368
+ )
1369
+ topic = self.connection.execute(
1370
+ "SELECT generation FROM cms_event_topics WHERE event_id = ?",
1371
+ (event["event_id"],),
1372
+ ).fetchone()
1373
+ event_row = self.connection.execute(
1374
+ "SELECT key_id FROM cms_change_events WHERE event_id = ?",
1375
+ (event["event_id"],),
1376
+ ).fetchone()
1377
+ if topic is None or event_row is None:
1378
+ raise CMSBridgeBlocked("cms.event.topic_invalid")
1379
+ if signature.key_id != event_row["key_id"]:
1380
+ raise CMSBridgeBlocked("cms.tombstone.scope_rejected")
1381
+ expected = {
1382
+ "event_id": event["event_id"],
1383
+ "site_id": event["site_id"],
1384
+ "website_version": event["website_version"],
1385
+ "source_id": event["localization"]["source_id"],
1386
+ "source_sequence": int(topic["generation"]),
1387
+ }
1388
+ if any(tombstone[field] != value for field, value in expected.items()):
1389
+ raise CMSBridgeBlocked("cms.tombstone.binding_invalid")
1390
+ if self.connection.execute(
1391
+ "SELECT 1 FROM cms_event_cancellations WHERE event_id = ?",
1392
+ (event["event_id"],),
1393
+ ).fetchone() is not None:
1394
+ raise CMSBridgeBlocked("cms.tombstone.not_published")
1395
+ publication_row = self.connection.execute(
1396
+ "SELECT * FROM cms_publication_deliveries WHERE event_id = ?",
1397
+ (event["event_id"],),
1398
+ ).fetchone()
1399
+ if publication_row is None or publication_row["status"] != "succeeded":
1400
+ raise CMSBridgeBlocked("cms.tombstone.not_published")
1401
+ publication = self._request_from_row(
1402
+ publication_row, publication_authority, now,
1403
+ require_current_approvals=False,
1404
+ )
1405
+
1406
+ existing = self.connection.execute(
1407
+ "SELECT * FROM cms_tombstone_deliveries WHERE tombstone_id = ? OR event_id = ?",
1408
+ (tombstone["tombstone_id"], event["event_id"]),
1409
+ ).fetchone()
1410
+ if existing is not None:
1411
+ if (
1412
+ existing["tombstone_id"] != tombstone["tombstone_id"]
1413
+ or existing["event_id"] != event["event_id"]
1414
+ or existing["tombstone_sha256"] != tombstone_hash
1415
+ or existing["request_key_id"] != signature.key_id
1416
+ ):
1417
+ raise CMSBridgeBlocked("cms.tombstone.idempotency_collision")
1418
+ request = self._tombstone_request_from_row(
1419
+ existing, event_verifier, publication_authority,
1420
+ )
1421
+ return TombstoneAccepted(
1422
+ tombstone["tombstone_id"], event["event_id"],
1423
+ request.delivery_id, existing["status"], False,
1424
+ )
1425
+
1426
+ locales = sorted(
1427
+ item["locale"] for item in publication.payload["localizations"]
1428
+ )
1429
+ unsigned = {
1430
+ "schema": TOMBSTONE_DELIVERY_SCHEMA,
1431
+ "tombstone_id": tombstone["tombstone_id"],
1432
+ "event_id": event["event_id"],
1433
+ "site_id": event["site_id"],
1434
+ "website_version": event["website_version"],
1435
+ "plan_id": plan.plan_id,
1436
+ "source_id": event["localization"]["source_id"],
1437
+ "source_sequence": int(topic["generation"]),
1438
+ "publication_delivery_id": publication.delivery_id,
1439
+ "publication_payload_sha256": publication.payload_sha256,
1440
+ "locales": locales,
1441
+ }
1442
+ delivery_id = "blun-cms-tombstone-" + _hash(_canonical_json(unsigned))
1443
+ payload = {**unsigned, "delivery_id": delivery_id}
1444
+ payload_json = _canonical_json(payload)
1445
+ payload_hash = _hash(payload_json)
1446
+ sign = getattr(publication_authority, "sign", None)
1447
+ try:
1448
+ delivery_signature = _signature(
1449
+ sign(payload_json.encode("utf-8")) if callable(sign) else None,
1450
+ )
1451
+ except CMSBridgeBlocked:
1452
+ raise
1453
+ except Exception:
1454
+ raise CMSBridgeBlocked("cms.tombstone.signing_failed") from None
1455
+ _verify(
1456
+ publication_authority,
1457
+ payload_json.encode("utf-8"),
1458
+ delivery_signature,
1459
+ "cms.tombstone.signature_rejected",
1460
+ )
1461
+ with _transaction(self.connection):
1462
+ concurrent = self.connection.execute(
1463
+ "SELECT * FROM cms_tombstone_deliveries WHERE tombstone_id = ? OR event_id = ?",
1464
+ (tombstone["tombstone_id"], event["event_id"]),
1465
+ ).fetchone()
1466
+ if concurrent is not None:
1467
+ if (
1468
+ concurrent["tombstone_id"] != tombstone["tombstone_id"]
1469
+ or concurrent["event_id"] != event["event_id"]
1470
+ or concurrent["tombstone_sha256"] != tombstone_hash
1471
+ or concurrent["request_key_id"] != signature.key_id
1472
+ ):
1473
+ raise CMSBridgeBlocked("cms.tombstone.idempotency_collision")
1474
+ request = self._tombstone_request_from_row(
1475
+ concurrent, event_verifier, publication_authority,
1476
+ )
1477
+ return TombstoneAccepted(
1478
+ tombstone["tombstone_id"], event["event_id"],
1479
+ request.delivery_id, concurrent["status"], False,
1480
+ )
1481
+ live_publication = self.connection.execute(
1482
+ "SELECT status, payload_sha256 FROM cms_publication_deliveries WHERE event_id = ?",
1483
+ (event["event_id"],),
1484
+ ).fetchone()
1485
+ if (
1486
+ live_publication is None
1487
+ or live_publication["status"] != "succeeded"
1488
+ or live_publication["payload_sha256"] != publication.payload_sha256
1489
+ ):
1490
+ raise CMSBridgeBlocked("cms.tombstone.not_published")
1491
+ self.connection.execute("""
1492
+ INSERT INTO cms_tombstone_deliveries (
1493
+ tombstone_id, event_id, tombstone_sha256, tombstone_json,
1494
+ request_signature_algorithm, request_key_id, request_signature,
1495
+ delivery_id, plan_id, payload_json, payload_sha256,
1496
+ signature_algorithm, key_id, signature, status, attempts,
1497
+ max_attempts, next_attempt_at, created_at, updated_at
1498
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
1499
+ 'pending', 0, ?, ?, ?, ?)
1500
+ """, (
1501
+ tombstone["tombstone_id"], event["event_id"], tombstone_hash,
1502
+ tombstone_json, signature.algorithm, signature.key_id,
1503
+ signature.signature, delivery_id, plan.plan_id, payload_json,
1504
+ payload_hash, delivery_signature.algorithm,
1505
+ delivery_signature.key_id, delivery_signature.signature,
1506
+ max_attempts, now, now, now,
1507
+ ))
1508
+ return TombstoneAccepted(
1509
+ tombstone["tombstone_id"], event["event_id"], delivery_id,
1510
+ "pending", True,
1511
+ )
1512
+
1513
+ def ingest_change(
1514
+ self,
1515
+ event: Any,
1516
+ signature: CMSMessageSignature,
1517
+ verifier: CMSMessageAuthority,
1518
+ *,
1519
+ max_attempts: int = 3,
1520
+ now: float | int,
1521
+ ) -> IngestedChange:
1522
+ event, plan = self._validated_event(event, allow_legacy=True)
1523
+ signature = _signature(signature)
1524
+ now = _timestamp(now, "cms.time.invalid")
1525
+ event_json = _canonical_json(event)
1526
+ event_hash = _hash(event_json)
1527
+ _verify(verifier, event_json.encode("utf-8"), signature, "cms.event.signature_rejected")
1528
+ event_id = event["event_id"]
1529
+ site_id = event["site_id"]
1530
+ source_id = event["localization"]["source_id"]
1531
+
1532
+ with _transaction(self.connection):
1533
+ row = self.connection.execute(
1534
+ "SELECT event_sha256, plan_id FROM cms_change_events WHERE event_id = ?",
1535
+ (event_id,),
1536
+ ).fetchone()
1537
+ if row is None:
1538
+ if event.get("schema") != CHANGE_SCHEMA:
1539
+ raise CMSBridgeBlocked("cms.event.legacy_replay_only")
1540
+ generation = event["source_sequence"]
1541
+ collision = self.connection.execute("""
1542
+ SELECT 1 FROM cms_event_topics
1543
+ WHERE site_id = ? AND source_id = ? AND generation = ?
1544
+ """, (site_id, source_id, generation)).fetchone()
1545
+ if collision is not None:
1546
+ raise CMSBridgeBlocked("cms.event.sequence_collision")
1547
+ self.connection.execute("""
1548
+ INSERT INTO cms_change_events VALUES (?, ?, ?, ?, ?, ?, ?, 'accepted', ?, ?)
1549
+ """, (
1550
+ event_id, event_hash, event_json, signature.algorithm, signature.key_id,
1551
+ signature.signature, plan.plan_id, now, now,
1552
+ ))
1553
+ self.connection.execute(
1554
+ "INSERT INTO cms_event_topics VALUES (?, ?, ?, ?, ?)",
1555
+ (event_id, site_id, source_id, generation, now),
1556
+ )
1557
+ elif row["event_sha256"] != event_hash or row["plan_id"] != plan.plan_id:
1558
+ raise CMSBridgeBlocked("cms.event.idempotency_collision")
1559
+ else:
1560
+ topic = self.connection.execute(
1561
+ "SELECT site_id, source_id FROM cms_event_topics WHERE event_id = ?",
1562
+ (event_id,),
1563
+ ).fetchone()
1564
+ if topic is None or (topic["site_id"], topic["source_id"]) != (site_id, source_id):
1565
+ raise CMSBridgeBlocked("cms.event.topic_invalid")
1566
+
1567
+ cancelled = self.connection.execute(
1568
+ "SELECT 1 FROM cms_event_cancellations WHERE event_id = ?",
1569
+ (event_id,),
1570
+ ).fetchone() is not None
1571
+
1572
+ if cancelled:
1573
+ self._load_event(
1574
+ event_id,
1575
+ verifier,
1576
+ allow_superseded=True,
1577
+ allow_cancelled=True,
1578
+ allow_accepted=True,
1579
+ )
1580
+ return IngestedChange(event_id, plan.plan_id, len(plan.jobs), 0, "cancelled")
1581
+
1582
+ try:
1583
+ inserted = self.queue.enqueue_plan(plan, max_attempts=max_attempts, now=now)
1584
+ except _QUEUE.LocalizationQueueBlocked:
1585
+ raise CMSBridgeBlocked("cms.queue.rejected") from None
1586
+ with _transaction(self.connection):
1587
+ cancelled = self.connection.execute(
1588
+ "SELECT 1 FROM cms_event_cancellations WHERE event_id = ?",
1589
+ (event_id,),
1590
+ ).fetchone() is not None
1591
+ if cancelled:
1592
+ self._load_event(
1593
+ event_id,
1594
+ verifier,
1595
+ allow_superseded=True,
1596
+ allow_cancelled=True,
1597
+ allow_accepted=True,
1598
+ )
1599
+ else:
1600
+ updated = self.connection.execute("""
1601
+ UPDATE cms_change_events SET status = 'enqueued', updated_at = ?
1602
+ WHERE event_id = ? AND event_sha256 = ? AND plan_id = ?
1603
+ """, (now, event_id, event_hash, plan.plan_id))
1604
+ if updated.rowcount != 1:
1605
+ raise CMSBridgeBlocked("cms.event.identity_lost")
1606
+ newest = self.connection.execute("""
1607
+ SELECT topic.event_id, topic.generation
1608
+ FROM cms_event_topics AS topic
1609
+ JOIN cms_change_events AS event ON event.event_id = topic.event_id
1610
+ WHERE topic.site_id = ? AND topic.source_id = ?
1611
+ AND event.status = 'enqueued'
1612
+ ORDER BY topic.generation DESC LIMIT 1
1613
+ """, (site_id, source_id)).fetchone()
1614
+ if newest is None:
1615
+ raise CMSBridgeBlocked("cms.event.topic_invalid")
1616
+ older = self.connection.execute("""
1617
+ SELECT older.event_id
1618
+ FROM cms_event_topics AS older
1619
+ JOIN cms_change_events AS events ON events.event_id = older.event_id
1620
+ LEFT JOIN cms_publication_deliveries AS delivery
1621
+ ON delivery.event_id = older.event_id AND delivery.status = 'succeeded'
1622
+ WHERE older.site_id = ? AND older.source_id = ?
1623
+ AND older.generation < ? AND events.status = 'enqueued'
1624
+ AND delivery.event_id IS NULL
1625
+ ORDER BY older.generation, older.event_id
1626
+ """, (site_id, source_id, newest["generation"])).fetchall()
1627
+ for prior in older:
1628
+ self.connection.execute("""
1629
+ INSERT OR IGNORE INTO cms_event_supersessions
1630
+ VALUES (?, ?, ?)
1631
+ """, (prior["event_id"], newest["event_id"], now))
1632
+ self.connection.execute("""
1633
+ UPDATE cms_publication_deliveries
1634
+ SET status = 'failed', lease_owner = NULL, lease_token = NULL,
1635
+ lease_expires_at = NULL, last_error_code = 'event_superseded',
1636
+ last_error_detail_hash = NULL, updated_at = ?
1637
+ WHERE event_id IN (
1638
+ SELECT supersession.event_id FROM cms_event_supersessions AS supersession
1639
+ JOIN cms_event_topics AS topic ON topic.event_id = supersession.event_id
1640
+ WHERE topic.site_id = ? AND topic.source_id = ?
1641
+ ) AND status <> 'succeeded'
1642
+ """, (now, site_id, source_id))
1643
+ superseded = self.connection.execute(
1644
+ "SELECT 1 FROM cms_event_supersessions WHERE event_id = ?",
1645
+ (event_id,),
1646
+ ).fetchone() is not None
1647
+ if cancelled:
1648
+ return IngestedChange(
1649
+ event_id, plan.plan_id, len(plan.jobs), inserted, "cancelled",
1650
+ )
1651
+ return IngestedChange(
1652
+ event_id, plan.plan_id, len(plan.jobs), inserted,
1653
+ "superseded" if superseded else "enqueued",
1654
+ )
1655
+
1656
+ def resume_accepted_change(
1657
+ self,
1658
+ event_id: Any,
1659
+ verifier: CMSMessageAuthority,
1660
+ *,
1661
+ max_attempts: int = 3,
1662
+ now: float | int,
1663
+ ) -> IngestedChange | None:
1664
+ """Resume one signed event persisted before its queue transaction."""
1665
+ event_id = _token(event_id, "cms.event_id.invalid")
1666
+ row = self.connection.execute(
1667
+ "SELECT * FROM cms_change_events WHERE event_id = ?",
1668
+ (event_id,),
1669
+ ).fetchone()
1670
+ if row is None:
1671
+ raise CMSBridgeBlocked("cms.event.not_enqueued")
1672
+ if row["status"] != "accepted":
1673
+ return None
1674
+
1675
+ event, _ = self._load_event(
1676
+ event_id,
1677
+ verifier,
1678
+ allow_cancelled=True,
1679
+ allow_accepted=True,
1680
+ )
1681
+ signature = _signature(CMSMessageSignature(
1682
+ row["signature_algorithm"], row["key_id"], row["signature"],
1683
+ ))
1684
+ return self.ingest_change(
1685
+ event,
1686
+ signature,
1687
+ verifier,
1688
+ max_attempts=max_attempts,
1689
+ now=now,
1690
+ )
1691
+
1692
+ def change_progress(
1693
+ self,
1694
+ event_id: Any,
1695
+ event_verifier: CMSMessageAuthority,
1696
+ *,
1697
+ site_id: Any,
1698
+ requester_key_id: Any,
1699
+ now: float | int,
1700
+ ) -> ChangeProgress:
1701
+ """Return content-free progress only to the event's exact site credential."""
1702
+ event_id = _token(event_id, "cms.event_id.invalid")
1703
+ site_id = _token(site_id, "cms.site_id.invalid")
1704
+ requester_key_id = _token(
1705
+ requester_key_id, "cms.status.requester_key_id.invalid",
1706
+ )
1707
+ now = _timestamp(now, "cms.time.invalid")
1708
+ topic = self.connection.execute(
1709
+ "SELECT site_id, generation FROM cms_event_topics WHERE event_id = ?",
1710
+ (event_id,),
1711
+ ).fetchone()
1712
+ event_row = self.connection.execute(
1713
+ "SELECT key_id, status FROM cms_change_events WHERE event_id = ?",
1714
+ (event_id,),
1715
+ ).fetchone()
1716
+ if (
1717
+ topic is None
1718
+ or event_row is None
1719
+ or topic["site_id"] != site_id
1720
+ or event_row["key_id"] != requester_key_id
1721
+ ):
1722
+ raise CMSBridgeBlocked("cms.status.scope_rejected")
1723
+
1724
+ cancelled = self.connection.execute(
1725
+ "SELECT 1 FROM cms_event_cancellations WHERE event_id = ?",
1726
+ (event_id,),
1727
+ ).fetchone() is not None
1728
+ queue_recovery_pending = (
1729
+ event_row["status"] == "accepted" and not cancelled
1730
+ )
1731
+ event, plan = self._load_event(
1732
+ event_id,
1733
+ event_verifier,
1734
+ allow_cancelled=cancelled,
1735
+ allow_accepted=cancelled or queue_recovery_pending,
1736
+ )
1737
+ if event["site_id"] != site_id:
1738
+ raise CMSBridgeBlocked("cms.status.scope_rejected")
1739
+ if cancelled and event_row["status"] == "accepted":
1740
+ counts = {status: 0 for status in _QUEUE._STATUSES}
1741
+ counts["cancelled"] = len(plan.jobs)
1742
+ locales = tuple(LocaleProgress(
1743
+ job_id=job.job_id,
1744
+ target_locale=job.target.locale,
1745
+ status="cancelled",
1746
+ attempts=0,
1747
+ max_attempts=0,
1748
+ next_attempt_at=0.0,
1749
+ lease_expires_at=None,
1750
+ lease_expired=False,
1751
+ last_error_code="event_cancelled",
1752
+ last_error_detail_hash=None,
1753
+ result_sha256=None,
1754
+ ) for job in sorted(plan.jobs, key=lambda item: item.target.locale))
1755
+ return ChangeProgress(
1756
+ event_id=event["event_id"],
1757
+ site_id=event["site_id"],
1758
+ plan_id=plan.plan_id,
1759
+ website_version=event["website_version"],
1760
+ source_sequence=int(topic["generation"]),
1761
+ job_count=len(plan.jobs),
1762
+ counts=counts,
1763
+ locales=locales,
1764
+ cancelled=True,
1765
+ queue_recovery_pending=False,
1766
+ )
1767
+ if queue_recovery_pending:
1768
+ counts = {status: 0 for status in _QUEUE._STATUSES}
1769
+ locales = tuple(LocaleProgress(
1770
+ job_id=job.job_id,
1771
+ target_locale=job.target.locale,
1772
+ status="awaiting_queue_resume",
1773
+ attempts=0,
1774
+ max_attempts=0,
1775
+ next_attempt_at=0.0,
1776
+ lease_expires_at=None,
1777
+ lease_expired=False,
1778
+ last_error_code="cms.event.awaiting_queue_resume",
1779
+ last_error_detail_hash=None,
1780
+ result_sha256=None,
1781
+ ) for job in sorted(plan.jobs, key=lambda item: item.target.locale))
1782
+ return ChangeProgress(
1783
+ event_id=event["event_id"],
1784
+ site_id=event["site_id"],
1785
+ plan_id=plan.plan_id,
1786
+ website_version=event["website_version"],
1787
+ source_sequence=int(topic["generation"]),
1788
+ job_count=len(plan.jobs),
1789
+ counts=counts,
1790
+ locales=locales,
1791
+ cancelled=False,
1792
+ queue_recovery_pending=True,
1793
+ )
1794
+ locales = []
1795
+ try:
1796
+ for job in plan.jobs:
1797
+ status = self.queue.status(job.job_id)
1798
+ if (
1799
+ status.target_locale != job.target.locale
1800
+ or plan.plan_id not in status.plan_ids
1801
+ ):
1802
+ raise CMSBridgeBlocked("cms.queue.identity_lost")
1803
+ if status.status == "succeeded":
1804
+ self.queue.result(job.job_id)
1805
+ locales.append(LocaleProgress(
1806
+ job_id=status.job_id,
1807
+ target_locale=status.target_locale,
1808
+ status=status.status,
1809
+ attempts=status.attempts,
1810
+ max_attempts=status.max_attempts,
1811
+ next_attempt_at=status.next_attempt_at,
1812
+ lease_expires_at=status.lease_expires_at,
1813
+ lease_expired=(
1814
+ status.status == "leased"
1815
+ and status.lease_expires_at is not None
1816
+ and status.lease_expires_at <= now
1817
+ ),
1818
+ last_error_code=status.last_error_code,
1819
+ last_error_detail_hash=status.last_error_detail_hash,
1820
+ result_sha256=status.result_sha256,
1821
+ ))
1822
+ counts = self.queue.plan_counts(plan.plan_id)
1823
+ except _QUEUE.LocalizationQueueBlocked:
1824
+ raise CMSBridgeBlocked("cms.queue.integrity_failed") from None
1825
+ if sum(counts.values()) != len(plan.jobs) or len(locales) != len(plan.jobs):
1826
+ raise CMSBridgeBlocked("cms.queue.identity_lost")
1827
+ return ChangeProgress(
1828
+ event_id=event["event_id"],
1829
+ site_id=event["site_id"],
1830
+ plan_id=plan.plan_id,
1831
+ website_version=event["website_version"],
1832
+ source_sequence=int(topic["generation"]),
1833
+ job_count=len(plan.jobs),
1834
+ counts=counts,
1835
+ locales=tuple(sorted(locales, key=lambda item: item.target_locale)),
1836
+ cancelled=cancelled,
1837
+ queue_recovery_pending=False,
1838
+ )
1839
+
1840
+ def change_lifecycle(
1841
+ self,
1842
+ event_id: Any,
1843
+ event_verifier: CMSMessageAuthority,
1844
+ approval_authority: Any,
1845
+ publication_authority: CMSMessageAuthority,
1846
+ *,
1847
+ site_id: Any,
1848
+ requester_key_id: Any,
1849
+ now: float | int,
1850
+ ) -> ChangeLifecycle:
1851
+ """Return the verified end-to-end state for one tenant-scoped event."""
1852
+ now = _timestamp(now, "cms.time.invalid")
1853
+ progress = self.change_progress(
1854
+ event_id,
1855
+ event_verifier,
1856
+ site_id=site_id,
1857
+ requester_key_id=requester_key_id,
1858
+ now=now,
1859
+ )
1860
+ event, plan = self._load_event(
1861
+ progress.event_id,
1862
+ event_verifier,
1863
+ allow_cancelled=progress.cancelled,
1864
+ allow_accepted=(
1865
+ progress.cancelled or progress.queue_recovery_pending
1866
+ ),
1867
+ )
1868
+ if progress.queue_recovery_pending:
1869
+ required_locales = tuple(sorted(
1870
+ job.target.locale for job in plan.jobs
1871
+ ))
1872
+ return ChangeLifecycle(
1873
+ event_id=progress.event_id,
1874
+ site_id=progress.site_id,
1875
+ plan_id=progress.plan_id,
1876
+ website_version=progress.website_version,
1877
+ source_sequence=progress.source_sequence,
1878
+ status="queue_recovery",
1879
+ required_locales=required_locales,
1880
+ approved_locales=(),
1881
+ blocked_locales=tuple(
1882
+ (locale, "queue.awaiting_resume")
1883
+ for locale in required_locales
1884
+ ),
1885
+ queue_counts=progress.counts,
1886
+ delivery=None,
1887
+ tombstone=None,
1888
+ )
1889
+ try:
1890
+ readiness = self.release_store.readiness(
1891
+ plan, approval_authority, now=now,
1892
+ )
1893
+ except _RELEASE.LocalizationReleaseBlocked:
1894
+ raise CMSBridgeBlocked("cms.release.integrity_failed") from None
1895
+ expected_release_blocks = {"approval.missing", "approval.expired"}
1896
+ if any(
1897
+ code not in expected_release_blocks
1898
+ for _, code in readiness.blocked
1899
+ ):
1900
+ raise CMSBridgeBlocked("cms.release.integrity_failed")
1901
+
1902
+ row = self.connection.execute(
1903
+ "SELECT * FROM cms_publication_deliveries WHERE event_id = ?",
1904
+ (progress.event_id,),
1905
+ ).fetchone()
1906
+ delivery = None
1907
+ if row is not None:
1908
+ request = self._request_from_row(
1909
+ row, publication_authority, now, require_current_approvals=False,
1910
+ )
1911
+ payload = request.payload
1912
+ expected_payload_keys = {
1913
+ "schema", "delivery_id", "event_id", "site_id",
1914
+ "website_version", "plan_id", "source_id", "source_revision",
1915
+ "source_sequence", "source_sha256", "localizations",
1916
+ }
1917
+ expected_localization_keys = {
1918
+ "locale", "target_text", "target_sha256", "approval_id",
1919
+ "approval_expires_at", "release_evidence",
1920
+ }
1921
+ localizations = payload.get("localizations")
1922
+ if (
1923
+ set(payload) != expected_payload_keys
1924
+ or payload.get("schema") != PUBLICATION_SCHEMA
1925
+ or payload.get("delivery_id") != row["delivery_id"]
1926
+ or payload.get("event_id") != event["event_id"]
1927
+ or payload.get("site_id") != event["site_id"]
1928
+ or payload.get("website_version") != event["website_version"]
1929
+ or payload.get("plan_id") != plan.plan_id
1930
+ or payload.get("source_id") != event["localization"]["source_id"]
1931
+ or payload.get("source_revision")
1932
+ != event["localization"]["source_revision"]
1933
+ or payload.get("source_sequence") != progress.source_sequence
1934
+ or payload.get("source_sha256") != plan.source_hash
1935
+ or not isinstance(localizations, list)
1936
+ or any(
1937
+ not isinstance(item, dict)
1938
+ or set(item) != expected_localization_keys
1939
+ for item in localizations
1940
+ )
1941
+ or tuple(sorted(item["locale"] for item in localizations))
1942
+ != readiness.required_locales
1943
+ or any(
1944
+ _token(item["locale"], "cms.delivery.tampered")
1945
+ != item["locale"]
1946
+ or not isinstance(item["target_text"], str)
1947
+ or not item["target_text"]
1948
+ or not unicodedata.is_normalized("NFC", item["target_text"])
1949
+ or SHA256.fullmatch(str(item["target_sha256"])) is None
1950
+ or _token(item["approval_id"], "cms.delivery.tampered")
1951
+ != item["approval_id"]
1952
+ or _timestamp(
1953
+ item["approval_expires_at"], "cms.delivery.tampered",
1954
+ ) <= 0
1955
+ or not _valid_release_evidence(
1956
+ item["release_evidence"],
1957
+ locale=item["locale"],
1958
+ target_sha256=item["target_sha256"],
1959
+ approval_id=item["approval_id"],
1960
+ )
1961
+ for item in localizations
1962
+ )
1963
+ ):
1964
+ raise CMSBridgeBlocked("cms.delivery.tampered")
1965
+ delivery_status = self.delivery_status(request.delivery_id)
1966
+ try:
1967
+ next_attempt_at = _timestamp(
1968
+ delivery_status.next_attempt_at, "cms.delivery.tampered",
1969
+ )
1970
+ lease_expires_at = (
1971
+ None
1972
+ if delivery_status.lease_expires_at is None
1973
+ else _timestamp(
1974
+ delivery_status.lease_expires_at,
1975
+ "cms.delivery.tampered",
1976
+ )
1977
+ )
1978
+ except CMSBridgeBlocked:
1979
+ raise
1980
+ if (
1981
+ delivery_status.event_id != progress.event_id
1982
+ or delivery_status.plan_id != progress.plan_id
1983
+ or delivery_status.payload_sha256 != request.payload_sha256
1984
+ or delivery_status.status
1985
+ not in {"pending", "leased", "retry_wait", "failed", "succeeded"}
1986
+ or isinstance(delivery_status.attempts, bool)
1987
+ or not isinstance(delivery_status.attempts, int)
1988
+ or isinstance(delivery_status.max_attempts, bool)
1989
+ or not isinstance(delivery_status.max_attempts, int)
1990
+ or not 0 <= delivery_status.attempts <= delivery_status.max_attempts
1991
+ or not 1 <= delivery_status.max_attempts <= MAX_ATTEMPTS
1992
+ or (
1993
+ delivery_status.last_error_code is not None
1994
+ and (
1995
+ not isinstance(delivery_status.last_error_code, str)
1996
+ or ERROR_CODE.fullmatch(delivery_status.last_error_code) is None
1997
+ )
1998
+ )
1999
+ or (
2000
+ delivery_status.last_error_detail_hash is not None
2001
+ and SHA256.fullmatch(
2002
+ str(delivery_status.last_error_detail_hash),
2003
+ ) is None
2004
+ )
2005
+ or (delivery_status.status == "leased") != (lease_expires_at is not None)
2006
+ or (
2007
+ delivery_status.status in {"pending", "leased", "succeeded"}
2008
+ and delivery_status.last_error_code is not None
2009
+ )
2010
+ or (
2011
+ delivery_status.status in {"retry_wait", "failed"}
2012
+ and delivery_status.last_error_code is None
2013
+ )
2014
+ ):
2015
+ raise CMSBridgeBlocked("cms.delivery.tampered")
2016
+ delivery = {
2017
+ "delivery_id": delivery_status.delivery_id,
2018
+ "status": delivery_status.status,
2019
+ "attempts": delivery_status.attempts,
2020
+ "max_attempts": delivery_status.max_attempts,
2021
+ "next_attempt_at": next_attempt_at,
2022
+ "lease_expires_at": lease_expires_at,
2023
+ "lease_expired": (
2024
+ delivery_status.status == "leased"
2025
+ and lease_expires_at is not None
2026
+ and lease_expires_at <= now
2027
+ ),
2028
+ "last_error_code": delivery_status.last_error_code,
2029
+ "last_error_detail_hash": delivery_status.last_error_detail_hash,
2030
+ }
2031
+
2032
+ tombstone_row = self.connection.execute(
2033
+ "SELECT * FROM cms_tombstone_deliveries WHERE event_id = ?",
2034
+ (progress.event_id,),
2035
+ ).fetchone()
2036
+ tombstone = None
2037
+ if tombstone_row is not None:
2038
+ tombstone_request = self._tombstone_request_from_row(
2039
+ tombstone_row, event_verifier, publication_authority,
2040
+ )
2041
+ tombstone_status = self.tombstone_status(tombstone_request.delivery_id)
2042
+ tombstone = {
2043
+ "tombstone_id": tombstone_row["tombstone_id"],
2044
+ "delivery_id": tombstone_status.delivery_id,
2045
+ "status": tombstone_status.status,
2046
+ "attempts": tombstone_status.attempts,
2047
+ "max_attempts": tombstone_status.max_attempts,
2048
+ "next_attempt_at": tombstone_status.next_attempt_at,
2049
+ "lease_expires_at": tombstone_status.lease_expires_at,
2050
+ "lease_expired": (
2051
+ tombstone_status.status == "leased"
2052
+ and tombstone_status.lease_expires_at is not None
2053
+ and tombstone_status.lease_expires_at <= now
2054
+ ),
2055
+ "last_error_code": tombstone_status.last_error_code,
2056
+ "last_error_detail_hash": tombstone_status.last_error_detail_hash,
2057
+ }
2058
+
2059
+ if progress.cancelled:
2060
+ status = "cancelled"
2061
+ elif tombstone is not None and tombstone["status"] == "succeeded":
2062
+ status = "deleted"
2063
+ elif tombstone is not None and tombstone["status"] == "failed":
2064
+ status = "deletion_failed"
2065
+ elif tombstone is not None:
2066
+ status = "deleting"
2067
+ elif delivery is not None and delivery["status"] == "succeeded":
2068
+ status = "published"
2069
+ elif delivery is not None and delivery["status"] == "failed":
2070
+ status = "publication_failed"
2071
+ elif delivery is not None and any(
2072
+ code == "approval.expired" for _, code in readiness.blocked
2073
+ ):
2074
+ status = "publication_blocked"
2075
+ elif delivery is not None:
2076
+ status = "publishing"
2077
+ elif readiness.ready:
2078
+ status = "ready"
2079
+ elif progress.counts["failed"]:
2080
+ status = "localization_failed"
2081
+ elif progress.counts["succeeded"] == progress.job_count:
2082
+ status = "awaiting_approval"
2083
+ else:
2084
+ status = "processing"
2085
+
2086
+ return ChangeLifecycle(
2087
+ event_id=progress.event_id,
2088
+ site_id=progress.site_id,
2089
+ plan_id=progress.plan_id,
2090
+ website_version=progress.website_version,
2091
+ source_sequence=progress.source_sequence,
2092
+ status=status,
2093
+ required_locales=readiness.required_locales,
2094
+ approved_locales=readiness.approved_locales,
2095
+ blocked_locales=readiness.blocked,
2096
+ queue_counts=progress.counts,
2097
+ delivery=delivery,
2098
+ tombstone=tombstone,
2099
+ )
2100
+
2101
+ def _load_event(
2102
+ self,
2103
+ event_id: Any,
2104
+ verifier: CMSMessageAuthority,
2105
+ *,
2106
+ allow_superseded: bool = False,
2107
+ allow_cancelled: bool = False,
2108
+ allow_accepted: bool = False,
2109
+ ) -> tuple[dict[str, Any], Any]:
2110
+ event_id = _token(event_id, "cms.event_id.invalid")
2111
+ if not isinstance(allow_superseded, bool):
2112
+ raise CMSBridgeBlocked("cms.event.supersession_mode_invalid")
2113
+ if not isinstance(allow_cancelled, bool):
2114
+ raise CMSBridgeBlocked("cms.event.cancellation_mode_invalid")
2115
+ if not isinstance(allow_accepted, bool):
2116
+ raise CMSBridgeBlocked("cms.event.status_mode_invalid")
2117
+ row = self.connection.execute(
2118
+ "SELECT * FROM cms_change_events WHERE event_id = ?",
2119
+ (event_id,),
2120
+ ).fetchone()
2121
+ if row is None:
2122
+ raise CMSBridgeBlocked("cms.event.not_enqueued")
2123
+ supersession = self.connection.execute(
2124
+ "SELECT 1 FROM cms_event_supersessions WHERE event_id = ?",
2125
+ (event_id,),
2126
+ ).fetchone()
2127
+ if supersession is not None and not allow_superseded:
2128
+ raise CMSBridgeBlocked("cms.event.superseded")
2129
+ if _hash(row["event_json"]) != row["event_sha256"]:
2130
+ raise CMSBridgeBlocked("cms.event.tampered")
2131
+ try:
2132
+ event = json.loads(row["event_json"])
2133
+ except json.JSONDecodeError:
2134
+ raise CMSBridgeBlocked("cms.event.tampered") from None
2135
+ if _canonical_json(event) != row["event_json"]:
2136
+ raise CMSBridgeBlocked("cms.event.tampered")
2137
+ signature = _signature(CMSMessageSignature(
2138
+ row["signature_algorithm"], row["key_id"], row["signature"],
2139
+ ))
2140
+ _verify(verifier, row["event_json"].encode("utf-8"), signature, "cms.event.signature_rejected")
2141
+ event, plan = self._validated_event(event, allow_legacy=True)
2142
+ if plan.plan_id != row["plan_id"]:
2143
+ raise CMSBridgeBlocked("cms.event.plan_mismatch")
2144
+ topic = self.connection.execute("""
2145
+ SELECT site_id, source_id, generation FROM cms_event_topics
2146
+ WHERE event_id = ?
2147
+ """, (event_id,)).fetchone()
2148
+ if topic is None or (
2149
+ topic["site_id"], topic["source_id"]
2150
+ ) != (event["site_id"], event["localization"]["source_id"]):
2151
+ raise CMSBridgeBlocked("cms.event.topic_invalid")
2152
+ cancellation = self.connection.execute(
2153
+ "SELECT * FROM cms_event_cancellations WHERE event_id = ?",
2154
+ (event_id,),
2155
+ ).fetchone()
2156
+ if cancellation is not None:
2157
+ self._verify_stored_cancellation(
2158
+ cancellation, event, topic, row["key_id"], verifier,
2159
+ )
2160
+ if not allow_cancelled:
2161
+ raise CMSBridgeBlocked("cms.event.cancelled")
2162
+ if (
2163
+ row["status"] != "enqueued"
2164
+ and not (allow_accepted and row["status"] == "accepted")
2165
+ ):
2166
+ raise CMSBridgeBlocked("cms.event.not_enqueued")
2167
+ newer = self.connection.execute("""
2168
+ SELECT 1
2169
+ FROM cms_event_topics AS candidate
2170
+ JOIN cms_change_events AS candidate_event
2171
+ ON candidate_event.event_id = candidate.event_id
2172
+ WHERE candidate.site_id = ? AND candidate.source_id = ?
2173
+ AND candidate.generation > ? AND candidate_event.status = 'enqueued'
2174
+ LIMIT 1
2175
+ """, (topic["site_id"], topic["source_id"], topic["generation"])).fetchone()
2176
+ if newer is not None and not allow_superseded:
2177
+ raise CMSBridgeBlocked("cms.event.superseded")
2178
+ return event, plan
2179
+
2180
+ def _event_is_current(self, event_id: str) -> bool:
2181
+ row = self.connection.execute("""
2182
+ SELECT topic.site_id, topic.source_id, topic.generation
2183
+ FROM cms_event_topics AS topic
2184
+ JOIN cms_change_events AS event ON event.event_id = topic.event_id
2185
+ WHERE topic.event_id = ? AND event.status = 'enqueued'
2186
+ AND topic.event_id NOT IN (SELECT event_id FROM cms_event_supersessions)
2187
+ AND topic.event_id NOT IN (SELECT event_id FROM cms_event_cancellations)
2188
+ """, (event_id,)).fetchone()
2189
+ if row is None:
2190
+ return False
2191
+ return self.connection.execute("""
2192
+ SELECT 1
2193
+ FROM cms_event_topics AS newer
2194
+ JOIN cms_change_events AS event ON event.event_id = newer.event_id
2195
+ WHERE newer.site_id = ? AND newer.source_id = ?
2196
+ AND newer.generation > ? AND event.status = 'enqueued'
2197
+ LIMIT 1
2198
+ """, (row["site_id"], row["source_id"], row["generation"])).fetchone() is None
2199
+
2200
+ def prepare_delivery(
2201
+ self,
2202
+ event_id: Any,
2203
+ event_verifier: CMSMessageAuthority,
2204
+ approval_authority: Any,
2205
+ publication_authority: CMSMessageAuthority,
2206
+ *,
2207
+ now: float | int,
2208
+ max_attempts: int = 5,
2209
+ ) -> CMSPublicationRequest:
2210
+ now = _timestamp(now, "cms.time.invalid")
2211
+ if isinstance(max_attempts, bool) or not isinstance(max_attempts, int):
2212
+ raise CMSBridgeBlocked("cms.max_attempts.invalid")
2213
+ if not 1 <= max_attempts <= MAX_ATTEMPTS:
2214
+ raise CMSBridgeBlocked("cms.max_attempts.invalid")
2215
+ event, plan = self._load_event(event_id, event_verifier)
2216
+ try:
2217
+ bundle = self.release_store.publication_bundle(
2218
+ plan, approval_authority, now=now,
2219
+ )
2220
+ except _RELEASE.LocalizationReleaseBlocked:
2221
+ raise CMSBridgeBlocked("cms.website.not_ready") from None
2222
+ required = tuple(sorted(job.target.locale for job in plan.jobs))
2223
+ actual = tuple(item.target_locale for item in bundle)
2224
+ if actual != required:
2225
+ raise CMSBridgeBlocked("cms.bundle.incomplete")
2226
+ source_sequence = event.get("source_sequence")
2227
+ if source_sequence is None:
2228
+ topic = self.connection.execute(
2229
+ "SELECT generation FROM cms_event_topics WHERE event_id = ?",
2230
+ (event["event_id"],),
2231
+ ).fetchone()
2232
+ if topic is None:
2233
+ raise CMSBridgeBlocked("cms.event.topic_invalid")
2234
+ source_sequence = topic["generation"]
2235
+ unsigned = {
2236
+ "schema": PUBLICATION_SCHEMA,
2237
+ "event_id": event["event_id"],
2238
+ "site_id": event["site_id"],
2239
+ "website_version": event["website_version"],
2240
+ "plan_id": plan.plan_id,
2241
+ "source_id": event["localization"]["source_id"],
2242
+ "source_revision": event["localization"]["source_revision"],
2243
+ "source_sequence": source_sequence,
2244
+ "source_sha256": plan.source_hash,
2245
+ "localizations": [
2246
+ {
2247
+ "locale": item.target_locale,
2248
+ "target_text": item.candidate,
2249
+ "target_sha256": item.target_sha256,
2250
+ "approval_id": item.approval_id,
2251
+ "approval_expires_at": item.expires_at,
2252
+ "release_evidence": item.release_evidence,
2253
+ }
2254
+ for item in bundle
2255
+ ],
2256
+ }
2257
+ delivery_id = "blun-cms-delivery-" + _hash(_canonical_json(unsigned))
2258
+ payload = {**unsigned, "delivery_id": delivery_id}
2259
+ payload_json = _canonical_json(payload)
2260
+ payload_hash = _hash(payload_json)
2261
+ sign = getattr(publication_authority, "sign", None)
2262
+ try:
2263
+ signature = _signature(sign(payload_json.encode("utf-8"))) if callable(sign) else None
2264
+ except CMSBridgeBlocked:
2265
+ raise
2266
+ except Exception:
2267
+ raise CMSBridgeBlocked("cms.publication.signing_failed") from None
2268
+ if signature is None:
2269
+ raise CMSBridgeBlocked("cms.publication.authority_invalid")
2270
+ _verify(
2271
+ publication_authority,
2272
+ payload_json.encode("utf-8"),
2273
+ signature,
2274
+ "cms.publication.signature_rejected",
2275
+ )
2276
+ with _transaction(self.connection):
2277
+ if self.connection.execute(
2278
+ "SELECT 1 FROM cms_event_cancellations WHERE event_id = ?",
2279
+ (event["event_id"],),
2280
+ ).fetchone() is not None:
2281
+ raise CMSBridgeBlocked("cms.event.cancelled")
2282
+ prior = self.connection.execute(
2283
+ "SELECT delivery_id, payload_sha256 FROM cms_publication_deliveries WHERE event_id = ?",
2284
+ (event["event_id"],),
2285
+ ).fetchone()
2286
+ if prior is not None:
2287
+ if prior["delivery_id"] != delivery_id or prior["payload_sha256"] != payload_hash:
2288
+ raise CMSBridgeBlocked("cms.delivery.idempotency_collision")
2289
+ else:
2290
+ self.connection.execute("""
2291
+ INSERT INTO cms_publication_deliveries (
2292
+ delivery_id, event_id, plan_id, payload_json, payload_sha256,
2293
+ signature_algorithm, key_id, signature, status, attempts,
2294
+ max_attempts, next_attempt_at, created_at, updated_at
2295
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, ?)
2296
+ """, (
2297
+ delivery_id, event["event_id"], plan.plan_id, payload_json, payload_hash,
2298
+ signature.algorithm, signature.key_id, signature.signature,
2299
+ max_attempts, now, now, now,
2300
+ ))
2301
+ return CMSPublicationRequest(delivery_id, payload, payload_hash, signature)
2302
+
2303
+ def _request_from_row(
2304
+ self,
2305
+ row: sqlite3.Row,
2306
+ authority: CMSMessageAuthority,
2307
+ now: float,
2308
+ *,
2309
+ require_current_approvals: bool = True,
2310
+ ) -> CMSPublicationRequest:
2311
+ if not isinstance(require_current_approvals, bool):
2312
+ raise CMSBridgeBlocked("cms.delivery.validation_mode_invalid")
2313
+ if _hash(row["payload_json"]) != row["payload_sha256"]:
2314
+ raise CMSBridgeBlocked("cms.delivery.tampered")
2315
+ try:
2316
+ payload = json.loads(row["payload_json"])
2317
+ except json.JSONDecodeError:
2318
+ raise CMSBridgeBlocked("cms.delivery.tampered") from None
2319
+ if _canonical_json(payload) != row["payload_json"] or payload.get("delivery_id") != row["delivery_id"]:
2320
+ raise CMSBridgeBlocked("cms.delivery.tampered")
2321
+ localizations = payload.get("localizations")
2322
+ if not isinstance(localizations, list) or not localizations:
2323
+ raise CMSBridgeBlocked("cms.delivery.tampered")
2324
+ expiries = [item.get("approval_expires_at") for item in localizations if isinstance(item, dict)]
2325
+ if len(expiries) != len(localizations):
2326
+ raise CMSBridgeBlocked("cms.delivery.tampered")
2327
+ validated_expiries = tuple(
2328
+ _timestamp(expiry, "cms.delivery.tampered") for expiry in expiries
2329
+ )
2330
+ try:
2331
+ for item in localizations:
2332
+ if not isinstance(item, dict):
2333
+ raise _RELEASE.LocalizationReleaseBlocked(
2334
+ "publication.evidence.invalid"
2335
+ )
2336
+ if not _valid_release_evidence(
2337
+ item.get("release_evidence"),
2338
+ locale=item.get("locale"),
2339
+ target_sha256=item.get("target_sha256"),
2340
+ approval_id=item.get("approval_id"),
2341
+ ):
2342
+ raise _RELEASE.LocalizationReleaseBlocked(
2343
+ "publication.evidence.invalid"
2344
+ )
2345
+ except _RELEASE.LocalizationReleaseBlocked:
2346
+ raise CMSBridgeBlocked("cms.delivery.tampered") from None
2347
+ if require_current_approvals and any(
2348
+ expiry <= now for expiry in validated_expiries
2349
+ ):
2350
+ raise CMSBridgeBlocked("cms.delivery.approval_expired")
2351
+ signature = _signature(CMSMessageSignature(
2352
+ row["signature_algorithm"], row["key_id"], row["signature"],
2353
+ ))
2354
+ _verify(
2355
+ authority,
2356
+ row["payload_json"].encode("utf-8"),
2357
+ signature,
2358
+ "cms.delivery.signature_invalid",
2359
+ )
2360
+ return CMSPublicationRequest(row["delivery_id"], payload, row["payload_sha256"], signature)
2361
+
2362
+ def _tombstone_request_from_row(
2363
+ self,
2364
+ row: sqlite3.Row,
2365
+ event_verifier: CMSMessageAuthority,
2366
+ publication_authority: CMSMessageAuthority,
2367
+ ) -> CMSTombstoneRequest:
2368
+ if (
2369
+ _hash(row["tombstone_json"]) != row["tombstone_sha256"]
2370
+ or _hash(row["payload_json"]) != row["payload_sha256"]
2371
+ ):
2372
+ raise CMSBridgeBlocked("cms.tombstone.tampered")
2373
+ try:
2374
+ tombstone = json.loads(row["tombstone_json"])
2375
+ payload = json.loads(row["payload_json"])
2376
+ except json.JSONDecodeError:
2377
+ raise CMSBridgeBlocked("cms.tombstone.tampered") from None
2378
+ if (
2379
+ _canonical_json(tombstone) != row["tombstone_json"]
2380
+ or _canonical_json(payload) != row["payload_json"]
2381
+ ):
2382
+ raise CMSBridgeBlocked("cms.tombstone.tampered")
2383
+ tombstone = self._validated_tombstone(tombstone)
2384
+ event, plan = self._load_event(
2385
+ row["event_id"], event_verifier, allow_superseded=True,
2386
+ )
2387
+ topic = self.connection.execute(
2388
+ "SELECT generation FROM cms_event_topics WHERE event_id = ?",
2389
+ (row["event_id"],),
2390
+ ).fetchone()
2391
+ event_row = self.connection.execute(
2392
+ "SELECT key_id FROM cms_change_events WHERE event_id = ?",
2393
+ (row["event_id"],),
2394
+ ).fetchone()
2395
+ if topic is None or event_row is None:
2396
+ raise CMSBridgeBlocked("cms.tombstone.tampered")
2397
+ request_signature = _signature(CMSMessageSignature(
2398
+ row["request_signature_algorithm"], row["request_key_id"],
2399
+ row["request_signature"],
2400
+ ))
2401
+ _verify(
2402
+ event_verifier, row["tombstone_json"].encode("utf-8"),
2403
+ request_signature, "cms.tombstone.signature_rejected",
2404
+ )
2405
+ request_binding = {
2406
+ "tombstone_id": row["tombstone_id"],
2407
+ "event_id": event["event_id"],
2408
+ "site_id": event["site_id"],
2409
+ "website_version": event["website_version"],
2410
+ "source_id": event["localization"]["source_id"],
2411
+ "source_sequence": int(topic["generation"]),
2412
+ }
2413
+ if (
2414
+ any(tombstone[field] != value for field, value in request_binding.items())
2415
+ or row["request_key_id"] != event_row["key_id"]
2416
+ ):
2417
+ raise CMSBridgeBlocked("cms.tombstone.binding_invalid")
2418
+ publication_row = self.connection.execute(
2419
+ "SELECT * FROM cms_publication_deliveries WHERE event_id = ?",
2420
+ (event["event_id"],),
2421
+ ).fetchone()
2422
+ if publication_row is None or publication_row["status"] != "succeeded":
2423
+ raise CMSBridgeBlocked("cms.tombstone.publication_invalid")
2424
+ publication = self._request_from_row(
2425
+ publication_row, publication_authority, 0,
2426
+ require_current_approvals=False,
2427
+ )
2428
+ expected_keys = {
2429
+ "schema", "delivery_id", "tombstone_id", "event_id", "site_id",
2430
+ "website_version", "plan_id", "source_id", "source_sequence",
2431
+ "publication_delivery_id", "publication_payload_sha256", "locales",
2432
+ }
2433
+ locales = sorted(
2434
+ item["locale"] for item in publication.payload["localizations"]
2435
+ )
2436
+ expected_payload = {
2437
+ "schema": TOMBSTONE_DELIVERY_SCHEMA,
2438
+ "delivery_id": row["delivery_id"],
2439
+ "tombstone_id": row["tombstone_id"],
2440
+ "event_id": event["event_id"],
2441
+ "site_id": event["site_id"],
2442
+ "website_version": event["website_version"],
2443
+ "plan_id": plan.plan_id,
2444
+ "source_id": event["localization"]["source_id"],
2445
+ "source_sequence": int(topic["generation"]),
2446
+ "publication_delivery_id": publication.delivery_id,
2447
+ "publication_payload_sha256": publication.payload_sha256,
2448
+ "locales": locales,
2449
+ }
2450
+ unsigned = {key: value for key, value in payload.items() if key != "delivery_id"}
2451
+ if (
2452
+ set(payload) != expected_keys
2453
+ or payload != expected_payload
2454
+ or locales != sorted(set(locales))
2455
+ or payload["delivery_id"]
2456
+ != "blun-cms-tombstone-" + _hash(_canonical_json(unsigned))
2457
+ or row["plan_id"] != plan.plan_id
2458
+ ):
2459
+ raise CMSBridgeBlocked("cms.tombstone.tampered")
2460
+ delivery_signature = _signature(CMSMessageSignature(
2461
+ row["signature_algorithm"], row["key_id"], row["signature"],
2462
+ ))
2463
+ _verify(
2464
+ publication_authority, row["payload_json"].encode("utf-8"),
2465
+ delivery_signature, "cms.tombstone.delivery_signature_invalid",
2466
+ )
2467
+ return CMSTombstoneRequest(
2468
+ row["delivery_id"], payload, row["payload_sha256"], delivery_signature,
2469
+ )
2470
+
2471
+ def claim_tombstone(
2472
+ self,
2473
+ worker_id: Any,
2474
+ event_verifier: CMSMessageAuthority,
2475
+ publication_authority: CMSMessageAuthority,
2476
+ *,
2477
+ now: float | int,
2478
+ lease_seconds: float | int = 300,
2479
+ ) -> ClaimedTombstone | None:
2480
+ worker_id = _token(worker_id, "cms.worker_id.invalid")
2481
+ now = _timestamp(now, "cms.time.invalid")
2482
+ lease_seconds = _duration(lease_seconds, "cms.lease.invalid")
2483
+ with _transaction(self.connection):
2484
+ self.connection.execute("""
2485
+ UPDATE cms_tombstone_deliveries
2486
+ SET status = 'failed', lease_owner = NULL, lease_token = NULL,
2487
+ lease_expires_at = NULL, last_error_code = 'lease_expired', updated_at = ?
2488
+ WHERE status = 'leased' AND lease_expires_at <= ? AND attempts >= max_attempts
2489
+ """, (now, now))
2490
+ self.connection.execute("""
2491
+ UPDATE cms_tombstone_deliveries
2492
+ SET status = 'retry_wait', next_attempt_at = ?, lease_owner = NULL,
2493
+ lease_token = NULL, lease_expires_at = NULL,
2494
+ last_error_code = 'lease_expired', updated_at = ?
2495
+ WHERE status = 'leased' AND lease_expires_at <= ? AND attempts < max_attempts
2496
+ """, (now, now, now))
2497
+ row = self.connection.execute("""
2498
+ SELECT * FROM cms_tombstone_deliveries
2499
+ WHERE status IN ('pending', 'retry_wait') AND next_attempt_at <= ?
2500
+ AND attempts < max_attempts
2501
+ ORDER BY created_at, delivery_id LIMIT 1
2502
+ """, (now,)).fetchone()
2503
+ if row is None:
2504
+ return None
2505
+ request = self._tombstone_request_from_row(
2506
+ row, event_verifier, publication_authority,
2507
+ )
2508
+ lease_token = secrets.token_urlsafe(32)
2509
+ expires = now + lease_seconds
2510
+ updated = self.connection.execute("""
2511
+ UPDATE cms_tombstone_deliveries
2512
+ SET status = 'leased', attempts = attempts + 1, lease_owner = ?,
2513
+ lease_token = ?, lease_expires_at = ?, last_error_code = NULL,
2514
+ last_error_detail_hash = NULL, updated_at = ?
2515
+ WHERE delivery_id = ? AND status IN ('pending', 'retry_wait')
2516
+ """, (worker_id, lease_token, expires, now, row["delivery_id"]))
2517
+ if updated.rowcount != 1:
2518
+ raise CMSBridgeBlocked("cms.tombstone.claim_lost")
2519
+ return ClaimedTombstone(
2520
+ request, int(row["attempts"]) + 1, int(row["max_attempts"]),
2521
+ worker_id, lease_token, expires,
2522
+ )
2523
+
2524
+ def _live_tombstone(
2525
+ self,
2526
+ claim: Any,
2527
+ event_verifier: CMSMessageAuthority,
2528
+ publication_authority: CMSMessageAuthority,
2529
+ now: float,
2530
+ ) -> sqlite3.Row:
2531
+ if not isinstance(claim, ClaimedTombstone):
2532
+ raise CMSBridgeBlocked("cms.tombstone.claim_invalid")
2533
+ row = self.connection.execute(
2534
+ "SELECT * FROM cms_tombstone_deliveries WHERE delivery_id = ?",
2535
+ (claim.request.delivery_id,),
2536
+ ).fetchone()
2537
+ if row is None or row["status"] != "leased":
2538
+ raise CMSBridgeBlocked("cms.tombstone.lease_lost")
2539
+ if row["lease_owner"] != claim.lease_owner or row["lease_token"] != claim.lease_token:
2540
+ raise CMSBridgeBlocked("cms.tombstone.lease_lost")
2541
+ if float(row["lease_expires_at"]) <= now:
2542
+ raise CMSBridgeBlocked("cms.tombstone.lease_expired")
2543
+ request = self._tombstone_request_from_row(
2544
+ row, event_verifier, publication_authority,
2545
+ )
2546
+ if request != claim.request:
2547
+ raise CMSBridgeBlocked("cms.tombstone.claim_mutated")
2548
+ return row
2549
+
2550
+ def _finish_tombstone(
2551
+ self,
2552
+ claim: ClaimedTombstone,
2553
+ event_verifier: CMSMessageAuthority,
2554
+ publication_authority: CMSMessageAuthority,
2555
+ *,
2556
+ now: float,
2557
+ error: CMSPublishFailed | None,
2558
+ ) -> DeliveryStatus:
2559
+ with _transaction(self.connection):
2560
+ row = self._live_tombstone(
2561
+ claim, event_verifier, publication_authority, now,
2562
+ )
2563
+ if error is None:
2564
+ status, next_attempt, code, detail_hash = "succeeded", now, None, None
2565
+ else:
2566
+ terminal = not error.retryable or int(row["attempts"]) >= int(row["max_attempts"])
2567
+ status = "failed" if terminal else "retry_wait"
2568
+ next_attempt = now if terminal else now + min(
2569
+ 3600.0, 5.0 * (2 ** (int(row["attempts"]) - 1)),
2570
+ )
2571
+ code = error.code
2572
+ detail_hash = _hash(error.detail) if error.detail is not None else None
2573
+ updated = self.connection.execute("""
2574
+ UPDATE cms_tombstone_deliveries
2575
+ SET status = ?, next_attempt_at = ?, lease_owner = NULL,
2576
+ lease_token = NULL, lease_expires_at = NULL,
2577
+ last_error_code = ?, last_error_detail_hash = ?, updated_at = ?
2578
+ WHERE delivery_id = ? AND status = 'leased'
2579
+ AND lease_owner = ? AND lease_token = ?
2580
+ """, (
2581
+ status, next_attempt, code, detail_hash, now,
2582
+ claim.request.delivery_id, claim.lease_owner, claim.lease_token,
2583
+ ))
2584
+ if updated.rowcount != 1:
2585
+ raise CMSBridgeBlocked("cms.tombstone.finish_lost")
2586
+ return self.tombstone_status(claim.request.delivery_id)
2587
+
2588
+ def run_tombstone(
2589
+ self,
2590
+ publisher: CMSPublisher,
2591
+ event_verifier: CMSMessageAuthority,
2592
+ publication_authority: CMSMessageAuthority,
2593
+ *,
2594
+ worker_id: Any,
2595
+ clock: Callable[[], float] = time.time,
2596
+ lease_seconds: float | int = 300,
2597
+ operation_guard: Callable[[float], Any] | None = None,
2598
+ ) -> DeliveryOutcome:
2599
+ if operation_guard is not None and not callable(operation_guard):
2600
+ raise CMSBridgeBlocked("cms.tombstone.operation_guard_invalid")
2601
+ claim = self.claim_tombstone(
2602
+ worker_id, event_verifier, publication_authority,
2603
+ now=clock(), lease_seconds=lease_seconds,
2604
+ )
2605
+ if claim is None:
2606
+ return DeliveryOutcome("idle")
2607
+ if operation_guard is not None:
2608
+ try:
2609
+ operation_guard(float(lease_seconds))
2610
+ except Exception:
2611
+ raise CMSBridgeBlocked("cms.tombstone.operation_guard_failed") from None
2612
+ publish = getattr(publisher, "publish", None)
2613
+ try:
2614
+ if not callable(publish):
2615
+ raise CMSPublishFailed("publisher.invalid", retryable=False)
2616
+ acknowledgement = publish(claim.request)
2617
+ expected = {
2618
+ "schema": TOMBSTONE_ACK_SCHEMA,
2619
+ "delivery_id": claim.request.delivery_id,
2620
+ "payload_sha256": claim.request.payload_sha256,
2621
+ "status": "deleted",
2622
+ }
2623
+ if not isinstance(acknowledgement, Mapping) or dict(acknowledgement) != expected:
2624
+ raise CMSPublishFailed("publisher.ack_invalid", retryable=True)
2625
+ except Exception as original_error:
2626
+ error = _declared_publish_failure(original_error)
2627
+ if error is None:
2628
+ error = CMSPublishFailed("publisher.unavailable", retryable=True)
2629
+ status = self._finish_tombstone(
2630
+ claim, event_verifier, publication_authority,
2631
+ now=_timestamp(clock(), "cms.time.invalid"), error=error,
2632
+ )
2633
+ return DeliveryOutcome(status.status, status.delivery_id, status.attempts, error.code)
2634
+ status = self._finish_tombstone(
2635
+ claim, event_verifier, publication_authority,
2636
+ now=_timestamp(clock(), "cms.time.invalid"), error=None,
2637
+ )
2638
+ return DeliveryOutcome(status.status, status.delivery_id, status.attempts)
2639
+
2640
+ def tombstone_status(self, delivery_id: Any) -> DeliveryStatus:
2641
+ delivery_id = _token(delivery_id, "cms.delivery_id.invalid")
2642
+ row = self.connection.execute("""
2643
+ SELECT delivery_id, event_id, plan_id, status, attempts, max_attempts,
2644
+ next_attempt_at, lease_expires_at, last_error_code,
2645
+ last_error_detail_hash, payload_sha256
2646
+ FROM cms_tombstone_deliveries WHERE delivery_id = ?
2647
+ """, (delivery_id,)).fetchone()
2648
+ if row is None:
2649
+ raise CMSBridgeBlocked("cms.tombstone.missing")
2650
+ return DeliveryStatus(**dict(row))
2651
+
2652
+ def claim_delivery(
2653
+ self,
2654
+ worker_id: Any,
2655
+ authority: CMSMessageAuthority,
2656
+ *,
2657
+ now: float | int,
2658
+ lease_seconds: float | int = 300,
2659
+ ) -> ClaimedDelivery | None:
2660
+ worker_id = _token(worker_id, "cms.worker_id.invalid")
2661
+ now = _timestamp(now, "cms.time.invalid")
2662
+ lease_seconds = _duration(lease_seconds, "cms.lease.invalid")
2663
+ with _transaction(self.connection):
2664
+ self.connection.execute("""
2665
+ UPDATE cms_publication_deliveries
2666
+ SET status = 'failed', lease_owner = NULL, lease_token = NULL,
2667
+ lease_expires_at = NULL, last_error_code = 'lease_expired', updated_at = ?
2668
+ WHERE status = 'leased' AND lease_expires_at <= ? AND attempts >= max_attempts
2669
+ """, (now, now))
2670
+ self.connection.execute("""
2671
+ UPDATE cms_publication_deliveries
2672
+ SET status = 'retry_wait', next_attempt_at = ?, lease_owner = NULL,
2673
+ lease_token = NULL, lease_expires_at = NULL,
2674
+ last_error_code = 'lease_expired', updated_at = ?
2675
+ WHERE status = 'leased' AND lease_expires_at <= ? AND attempts < max_attempts
2676
+ """, (now, now, now))
2677
+ row = self.connection.execute("""
2678
+ SELECT * FROM cms_publication_deliveries
2679
+ WHERE status IN ('pending', 'retry_wait') AND next_attempt_at <= ?
2680
+ AND attempts < max_attempts
2681
+ AND event_id NOT IN (SELECT event_id FROM cms_event_supersessions)
2682
+ AND event_id NOT IN (SELECT event_id FROM cms_event_cancellations)
2683
+ AND NOT EXISTS (
2684
+ SELECT 1
2685
+ FROM cms_event_topics AS current
2686
+ JOIN cms_event_topics AS newer
2687
+ ON newer.site_id = current.site_id
2688
+ AND newer.source_id = current.source_id
2689
+ AND newer.generation > current.generation
2690
+ JOIN cms_change_events AS newer_event
2691
+ ON newer_event.event_id = newer.event_id
2692
+ WHERE current.event_id = cms_publication_deliveries.event_id
2693
+ AND newer_event.status = 'enqueued'
2694
+ )
2695
+ ORDER BY created_at, delivery_id LIMIT 1
2696
+ """, (now,)).fetchone()
2697
+ if row is None:
2698
+ return None
2699
+ request = self._request_from_row(row, authority, now)
2700
+ lease_token = secrets.token_urlsafe(32)
2701
+ expires = now + lease_seconds
2702
+ updated = self.connection.execute("""
2703
+ UPDATE cms_publication_deliveries
2704
+ SET status = 'leased', attempts = attempts + 1, lease_owner = ?,
2705
+ lease_token = ?, lease_expires_at = ?, last_error_code = NULL,
2706
+ last_error_detail_hash = NULL, updated_at = ?
2707
+ WHERE delivery_id = ? AND status IN ('pending', 'retry_wait')
2708
+ """, (worker_id, lease_token, expires, now, row["delivery_id"]))
2709
+ if updated.rowcount != 1:
2710
+ raise CMSBridgeBlocked("cms.delivery.claim_lost")
2711
+ return ClaimedDelivery(
2712
+ request, int(row["attempts"]) + 1, int(row["max_attempts"]),
2713
+ worker_id, lease_token, expires,
2714
+ )
2715
+
2716
+ def _live_delivery(self, claim: Any, now: float) -> sqlite3.Row:
2717
+ if not isinstance(claim, ClaimedDelivery):
2718
+ raise CMSBridgeBlocked("cms.delivery.claim_invalid")
2719
+ row = self.connection.execute(
2720
+ "SELECT * FROM cms_publication_deliveries WHERE delivery_id = ?",
2721
+ (claim.request.delivery_id,),
2722
+ ).fetchone()
2723
+ if row is None:
2724
+ raise CMSBridgeBlocked("cms.delivery.lease_lost")
2725
+ if not self._event_is_current(row["event_id"]):
2726
+ cancelled = self.connection.execute(
2727
+ "SELECT 1 FROM cms_event_cancellations WHERE event_id = ?",
2728
+ (row["event_id"],),
2729
+ ).fetchone() is not None
2730
+ if cancelled:
2731
+ raise CMSBridgeBlocked("cms.delivery.event_cancelled")
2732
+ raise CMSBridgeBlocked("cms.delivery.event_superseded")
2733
+ if row["status"] != "leased":
2734
+ raise CMSBridgeBlocked("cms.delivery.lease_lost")
2735
+ if row["lease_owner"] != claim.lease_owner or row["lease_token"] != claim.lease_token:
2736
+ raise CMSBridgeBlocked("cms.delivery.lease_lost")
2737
+ if float(row["lease_expires_at"]) <= now:
2738
+ raise CMSBridgeBlocked("cms.delivery.lease_expired")
2739
+ return row
2740
+
2741
+ def _finish(self, claim: ClaimedDelivery, *, now: float, error: CMSPublishFailed | None) -> DeliveryStatus:
2742
+ with _transaction(self.connection):
2743
+ row = self._live_delivery(claim, now)
2744
+ if error is None:
2745
+ status = "succeeded"
2746
+ next_attempt = now
2747
+ code = detail_hash = None
2748
+ else:
2749
+ terminal = not error.retryable or int(row["attempts"]) >= int(row["max_attempts"])
2750
+ status = "failed" if terminal else "retry_wait"
2751
+ next_attempt = now if terminal else now + min(3600.0, 5.0 * (2 ** (int(row["attempts"]) - 1)))
2752
+ code = error.code
2753
+ detail_hash = _hash(error.detail) if error.detail is not None else None
2754
+ updated = self.connection.execute("""
2755
+ UPDATE cms_publication_deliveries
2756
+ SET status = ?, next_attempt_at = ?, lease_owner = NULL,
2757
+ lease_token = NULL, lease_expires_at = NULL,
2758
+ last_error_code = ?, last_error_detail_hash = ?, updated_at = ?
2759
+ WHERE delivery_id = ? AND status = 'leased'
2760
+ AND lease_owner = ? AND lease_token = ?
2761
+ """, (
2762
+ status, next_attempt, code, detail_hash, now,
2763
+ claim.request.delivery_id, claim.lease_owner, claim.lease_token,
2764
+ ))
2765
+ if updated.rowcount != 1:
2766
+ raise CMSBridgeBlocked("cms.delivery.finish_lost")
2767
+ return self.delivery_status(claim.request.delivery_id)
2768
+
2769
+ def run_delivery(
2770
+ self,
2771
+ publisher: CMSPublisher,
2772
+ publication_authority: CMSMessageAuthority,
2773
+ *,
2774
+ worker_id: Any,
2775
+ clock: Callable[[], float] = time.time,
2776
+ lease_seconds: float | int = 300,
2777
+ operation_guard: Callable[[float], Any] | None = None,
2778
+ ) -> DeliveryOutcome:
2779
+ if operation_guard is not None and not callable(operation_guard):
2780
+ raise CMSBridgeBlocked("cms.delivery.operation_guard_invalid")
2781
+ claim = self.claim_delivery(
2782
+ worker_id, publication_authority, now=clock(), lease_seconds=lease_seconds,
2783
+ )
2784
+ if claim is None:
2785
+ return DeliveryOutcome("idle")
2786
+ try:
2787
+ self._live_delivery(claim, _timestamp(clock(), "cms.time.invalid"))
2788
+ except CMSBridgeBlocked as error:
2789
+ if error.code in {
2790
+ "cms.delivery.event_superseded", "cms.delivery.event_cancelled",
2791
+ }:
2792
+ return DeliveryOutcome(
2793
+ "failed", claim.request.delivery_id, claim.attempt, error.code,
2794
+ )
2795
+ raise
2796
+ if operation_guard is not None:
2797
+ try:
2798
+ operation_guard(float(lease_seconds))
2799
+ except Exception:
2800
+ raise CMSBridgeBlocked(
2801
+ "cms.delivery.operation_guard_failed",
2802
+ ) from None
2803
+ publish = getattr(publisher, "publish", None)
2804
+ try:
2805
+ if not callable(publish):
2806
+ raise CMSPublishFailed("publisher.invalid", retryable=False)
2807
+ acknowledgement = publish(claim.request)
2808
+ expected = {
2809
+ "schema": ACK_SCHEMA,
2810
+ "delivery_id": claim.request.delivery_id,
2811
+ "payload_sha256": claim.request.payload_sha256,
2812
+ "status": "accepted",
2813
+ }
2814
+ if not isinstance(acknowledgement, Mapping) or dict(acknowledgement) != expected:
2815
+ raise CMSPublishFailed("publisher.ack_invalid", retryable=True)
2816
+ except Exception as original_error:
2817
+ error = _declared_publish_failure(original_error)
2818
+ if error is None:
2819
+ error = CMSPublishFailed("publisher.unavailable", retryable=True)
2820
+ status = self._finish(claim, now=_timestamp(clock(), "cms.time.invalid"), error=error)
2821
+ return DeliveryOutcome(status.status, status.delivery_id, status.attempts, error.code)
2822
+ status = self._finish(claim, now=_timestamp(clock(), "cms.time.invalid"), error=None)
2823
+ return DeliveryOutcome(status.status, status.delivery_id, status.attempts)
2824
+
2825
+ def delivery_status(self, delivery_id: Any) -> DeliveryStatus:
2826
+ delivery_id = _token(delivery_id, "cms.delivery_id.invalid")
2827
+ row = self.connection.execute("""
2828
+ SELECT delivery_id, event_id, plan_id, status, attempts, max_attempts,
2829
+ next_attempt_at, lease_expires_at, last_error_code,
2830
+ last_error_detail_hash, payload_sha256
2831
+ FROM cms_publication_deliveries WHERE delivery_id = ?
2832
+ """, (delivery_id,)).fetchone()
2833
+ if row is None:
2834
+ raise CMSBridgeBlocked("cms.delivery.missing")
2835
+ return DeliveryStatus(**dict(row))