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,1469 @@
1
+ #!/usr/bin/env python3
2
+ """Durable HTTPS/WSGI receiver for source-CMS terminal notifications.
3
+
4
+ The receiver stores the exact content-free notification before returning its
5
+ acknowledgement. Replays converge on the stored row; changed data under either
6
+ the event or notification identity fails closed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import math
14
+ import os
15
+ import re
16
+ import secrets
17
+ import sqlite3
18
+ import threading
19
+ import time
20
+ import urllib.parse
21
+ from contextlib import contextmanager
22
+ from dataclasses import dataclass, field
23
+ from types import MappingProxyType
24
+ from typing import Any, Callable, Iterator, Mapping
25
+
26
+
27
+ NOTIFICATION_SCHEMA = "blun.cms-source-terminal-notification.v1"
28
+ ACK_SCHEMA = "blun.cms-source-terminal-notification-ack.v1"
29
+ PROCESSING_ACK_SCHEMA = "blun.cms-terminal-notification-processing-ack.v1"
30
+ AUTH_SCHEMA = "blun.cms-source-terminal-notification-http-auth.v1"
31
+ PRINCIPAL_SCHEMA = "blun.cms-source-terminal-notification-principal.v1"
32
+ ERROR_SCHEMA = "blun.cms-source-terminal-notification-http-error.v1"
33
+ API_SCHEMA = "blun.cms-terminal-receiver-api.v1"
34
+ CAPABILITIES_SCHEMA = "blun.cms-terminal-receiver-capabilities.v1"
35
+ CAPABILITIES_RESPONSE_SCHEMA = (
36
+ "blun.cms-terminal-receiver-capabilities-response.v1"
37
+ )
38
+ HEALTH_RESPONSE_SCHEMA = "blun.cms-terminal-receiver-health.v1"
39
+ HEALTH_RESPONSE_FIELDS = (
40
+ "schema", "status", "runtime_state", "worker_state", "inbox_status",
41
+ "received", "processing_counts", "processing_due", "expired_leases",
42
+ "failed", "error_code",
43
+ )
44
+ WRITE_SCOPE = "terminal-notification:write"
45
+ STATUS_SCOPE = "terminal-notification-status:read"
46
+ READINESS_SCOPE = "terminal-notification-readiness:read"
47
+ CAPABILITIES_SCOPE = "terminal-notification-capabilities:read"
48
+ HEALTH_SCOPE = "terminal-notification-health:read"
49
+ DEFAULT_PATH = "/v1/localization/terminal-notifications"
50
+ STATUS_PATH = DEFAULT_PATH + "/status"
51
+ READINESS_PATH = DEFAULT_PATH + "/readiness"
52
+ CAPABILITIES_PATH = DEFAULT_PATH + "/capabilities"
53
+ HEALTH_PATH = DEFAULT_PATH + "/health"
54
+ STATUS_REQUEST_SCHEMA = "blun.cms-terminal-receiver-status-request.v1"
55
+ STATUS_RESPONSE_SCHEMA = "blun.cms-terminal-receiver-status-response.v1"
56
+ READINESS_RESPONSE_SCHEMA = "blun.cms-terminal-receiver-readiness.v1"
57
+ MAX_BODY_BYTES = 16_384
58
+ MAX_HEADERS = 64
59
+ MAX_HEADER_VALUE_LENGTH = 4_096
60
+ TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
61
+ SHA256 = re.compile(r"^[a-f0-9]{64}$")
62
+ NOTIFICATION_ID = re.compile(r"^terminal-[a-f0-9]{64}$")
63
+ ERROR_CODE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
64
+ SCHEMA_VERSION = 2
65
+ PROCESSING_STATUSES = ("pending", "leased", "retry_wait", "succeeded", "failed")
66
+ TERMINAL_STATUSES = {
67
+ "cancelled", "deleted", "deletion_failed", "localization_failed",
68
+ "publication_blocked", "publication_failed", "published", "superseded",
69
+ }
70
+ NOTIFICATION_FIELDS = {
71
+ "schema", "notification_id", "event_id", "site_id", "plan_id",
72
+ "website_version", "source_sequence", "job_count", "change_sha256",
73
+ "lifecycle_binding_sha256", "terminal_status", "lifecycle_sha256",
74
+ }
75
+ PRINCIPAL_FIELDS = {
76
+ "schema", "principal_id", "credential_id", "credential_version", "scope",
77
+ "site_id",
78
+ }
79
+ _COLUMNS = (
80
+ "notification_id", "event_id", "site_id", "terminal_status",
81
+ "payload_sha256", "payload_json", "received_at",
82
+ )
83
+ _PROCESSING_COLUMNS = (
84
+ "notification_id", "status", "attempts", "max_attempts",
85
+ "next_attempt_at", "lease_owner", "lease_token", "lease_expires_at",
86
+ "last_error_code", "processed_at", "created_at", "updated_at",
87
+ )
88
+
89
+
90
+ class TerminalNotificationReceiverBlocked(RuntimeError):
91
+ """Stable, content-free receiver failure."""
92
+
93
+ def __init__(self, code: str, status: int):
94
+ super().__init__(code)
95
+ self.code = code
96
+ self.status = status
97
+
98
+
99
+ class TerminalNotificationProcessingFailure(RuntimeError):
100
+ """Public host-handler failure requesting a bounded processing retry."""
101
+
102
+ cms_terminal_processing_failure = True
103
+
104
+ def __init__(self, code: str, *, retryable: bool):
105
+ if ERROR_CODE.fullmatch(code) is None or not isinstance(retryable, bool):
106
+ raise ValueError("invalid terminal processing failure")
107
+ super().__init__(code)
108
+ self.code = code
109
+ self.retryable = retryable
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class ReceivedTerminalNotification:
114
+ notification_id: str
115
+ event_id: str
116
+ site_id: str
117
+ terminal_status: str
118
+ payload_sha256: str
119
+ received_at: float
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class TerminalNotificationInboxHealth:
124
+ status: str
125
+ received: int
126
+ counts: dict[str, int]
127
+ due: int
128
+ expired_leases: int
129
+ failed: int
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class TerminalNotificationProcessingClaim:
134
+ notification_id: str
135
+ event_id: str
136
+ site_id: str
137
+ terminal_status: str
138
+ payload_sha256: str
139
+ attempt: int
140
+ max_attempts: int
141
+ worker_id: str
142
+ lease_token: str
143
+ lease_expires_at: float
144
+ payload: Mapping[str, Any] = field(repr=False, compare=False)
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class TerminalNotificationProcessingStatus:
149
+ notification_id: str
150
+ event_id: str
151
+ site_id: str
152
+ terminal_status: str
153
+ payload_sha256: str
154
+ status: str
155
+ attempts: int
156
+ max_attempts: int
157
+ next_attempt_at: float
158
+ lease_expires_at: float | None
159
+ lease_expired: bool
160
+ last_error_code: str | None
161
+ processed_at: float | None
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class TerminalNotificationProcessingOutcome:
166
+ notification_id: str
167
+ event_id: str
168
+ status: str
169
+ attempt: int
170
+ error_code: str | None
171
+
172
+
173
+ def _blocked(code: str, status: int) -> None:
174
+ raise TerminalNotificationReceiverBlocked("notification_receiver." + code, status)
175
+
176
+
177
+ def _canonical(value: Any) -> bytes:
178
+ try:
179
+ result = json.dumps(
180
+ value, ensure_ascii=False, allow_nan=False, sort_keys=True,
181
+ separators=(",", ":"),
182
+ ).encode("utf-8")
183
+ except (TypeError, ValueError, RecursionError):
184
+ _blocked("request_invalid", 400)
185
+ if not result or len(result) > MAX_BODY_BYTES:
186
+ _blocked("request_invalid", 400)
187
+ return result
188
+
189
+
190
+ def _pairs(items: list[tuple[str, Any]]) -> dict[str, Any]:
191
+ result: dict[str, Any] = {}
192
+ for key, value in items:
193
+ if key in result:
194
+ raise ValueError("duplicate key")
195
+ result[key] = value
196
+ return result
197
+
198
+
199
+ def _constant(_value: str) -> None:
200
+ raise ValueError("non-finite number")
201
+
202
+
203
+ def _token(value: Any) -> bool:
204
+ return isinstance(value, str) and TOKEN.fullmatch(value) is not None
205
+
206
+
207
+ def _time(value: Any) -> float:
208
+ if (
209
+ isinstance(value, bool)
210
+ or not isinstance(value, (int, float))
211
+ or not math.isfinite(float(value))
212
+ or float(value) < 0
213
+ ):
214
+ _blocked("clock_invalid", 503)
215
+ return float(value)
216
+
217
+
218
+ def _duration(value: Any, code: str) -> float:
219
+ result = _time(value)
220
+ if not 0 < result <= 86_400:
221
+ _blocked(code, 503)
222
+ return result
223
+
224
+
225
+ def _error(value: Any) -> bool:
226
+ return isinstance(value, str) and ERROR_CODE.fullmatch(value) is not None
227
+
228
+
229
+ def _origin(value: Any) -> str:
230
+ if (
231
+ not isinstance(value, str)
232
+ or value != value.strip()
233
+ or len(value) > 2_048
234
+ or any(ord(character) <= 32 or ord(character) == 127 for character in value)
235
+ ):
236
+ raise ValueError("origin must be one exact HTTPS origin")
237
+ try:
238
+ parsed = urllib.parse.urlsplit(value)
239
+ hostname = parsed.hostname
240
+ port = parsed.port
241
+ except ValueError:
242
+ raise ValueError("origin must be one exact HTTPS origin") from None
243
+ if (
244
+ parsed.scheme != "https"
245
+ or not hostname
246
+ or parsed.username is not None
247
+ or parsed.password is not None
248
+ or parsed.path
249
+ or parsed.query
250
+ or parsed.fragment
251
+ or (port is not None and not 1 <= port <= 65_535)
252
+ or value != f"https://{parsed.netloc}"
253
+ ):
254
+ raise ValueError("origin must be one exact HTTPS origin")
255
+ return value
256
+
257
+
258
+ def _parse_notification(body: bytes) -> tuple[dict[str, Any], str]:
259
+ if not isinstance(body, bytes) or not body or len(body) > MAX_BODY_BYTES:
260
+ _blocked("body_invalid", 400)
261
+ try:
262
+ text = body.decode("utf-8")
263
+ if text.startswith("\ufeff"):
264
+ raise ValueError("BOM rejected")
265
+ value = json.loads(
266
+ text, object_pairs_hook=_pairs, parse_constant=_constant,
267
+ )
268
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError):
269
+ _blocked("json_invalid", 400)
270
+ if not isinstance(value, dict) or set(value) != NOTIFICATION_FIELDS:
271
+ _blocked("request_invalid", 400)
272
+ if _canonical(value) != body:
273
+ _blocked("canonical_body_required", 400)
274
+ lifecycle_sha = value.get("lifecycle_sha256")
275
+ status = value.get("terminal_status")
276
+ if not (
277
+ value.get("schema") == NOTIFICATION_SCHEMA
278
+ and all(
279
+ _token(value.get(name))
280
+ for name in ("event_id", "site_id", "plan_id", "website_version")
281
+ )
282
+ and all(
283
+ isinstance(value.get(name), int)
284
+ and not isinstance(value.get(name), bool)
285
+ and value[name] > 0
286
+ for name in ("source_sequence", "job_count")
287
+ )
288
+ and all(
289
+ isinstance(value.get(name), str)
290
+ and SHA256.fullmatch(value[name]) is not None
291
+ for name in ("change_sha256", "lifecycle_binding_sha256")
292
+ )
293
+ and status in TERMINAL_STATUSES
294
+ and (
295
+ status in {"cancelled", "superseded"} and lifecycle_sha is None
296
+ or status not in {"cancelled", "superseded"}
297
+ and isinstance(lifecycle_sha, str)
298
+ and SHA256.fullmatch(lifecycle_sha) is not None
299
+ )
300
+ ):
301
+ _blocked("request_invalid", 400)
302
+ identity_source = dict(value)
303
+ notification_id = identity_source.pop("notification_id", None)
304
+ expected_id = "terminal-" + hashlib.sha256(_canonical(identity_source)).hexdigest()
305
+ if (
306
+ not isinstance(notification_id, str)
307
+ or NOTIFICATION_ID.fullmatch(notification_id) is None
308
+ or notification_id != expected_id
309
+ ):
310
+ _blocked("request_invalid", 400)
311
+ return value, hashlib.sha256(body).hexdigest()
312
+
313
+
314
+ def _principal(
315
+ value: Any,
316
+ site_id: str | None,
317
+ scope: str = WRITE_SCOPE,
318
+ ) -> dict[str, str]:
319
+ if not isinstance(value, Mapping) or set(value) != PRINCIPAL_FIELDS:
320
+ _blocked("authentication_failed", 401)
321
+ if not (
322
+ value.get("schema") == PRINCIPAL_SCHEMA
323
+ and value.get("scope") == scope
324
+ and (site_id is None or value.get("site_id") == site_id)
325
+ and all(
326
+ _token(value.get(name))
327
+ for name in (
328
+ "principal_id", "credential_id", "credential_version", "site_id",
329
+ )
330
+ )
331
+ ):
332
+ _blocked("authorization_failed", 403)
333
+ return dict(value)
334
+
335
+
336
+ def capabilities_payload(notification_path: str = DEFAULT_PATH) -> dict[str, Any]:
337
+ """Return the exact, content-free receiver contract for integrations."""
338
+
339
+ expected_controls = {
340
+ "capabilities": {
341
+ "method": "GET",
342
+ "path": CAPABILITIES_PATH,
343
+ "scope": CAPABILITIES_SCOPE,
344
+ "request_schema": None,
345
+ "request_fields": [],
346
+ "response_schema": CAPABILITIES_RESPONSE_SCHEMA,
347
+ "response_fields": ["schema", "capabilities"],
348
+ "success_status": 200,
349
+ },
350
+ "health": {
351
+ "method": "GET",
352
+ "path": HEALTH_PATH,
353
+ "scope": HEALTH_SCOPE,
354
+ "request_schema": None,
355
+ "request_fields": [],
356
+ "response_schema": HEALTH_RESPONSE_SCHEMA,
357
+ "response_fields": [
358
+ *HEALTH_RESPONSE_FIELDS, "capabilities_sha256",
359
+ ],
360
+ "success_status": 200,
361
+ },
362
+ "readiness": {
363
+ "method": "GET",
364
+ "path": READINESS_PATH,
365
+ "scope": READINESS_SCOPE,
366
+ "request_schema": None,
367
+ "request_fields": [],
368
+ "response_schema": READINESS_RESPONSE_SCHEMA,
369
+ "response_fields": [
370
+ "schema", "status", "worker_state", "inbox_status",
371
+ "error_code", "capabilities_sha256",
372
+ ],
373
+ "success_status": 200,
374
+ },
375
+ "status": {
376
+ "method": "POST",
377
+ "path": STATUS_PATH,
378
+ "scope": STATUS_SCOPE,
379
+ "request_schema": STATUS_REQUEST_SCHEMA,
380
+ "request_fields": ["schema", "event_id", "site_id"],
381
+ "response_schema": STATUS_RESPONSE_SCHEMA,
382
+ "response_fields": [
383
+ "schema", "notification_id", "event_id", "site_id",
384
+ "terminal_status", "notification_sha256",
385
+ "processing_status", "attempts", "max_attempts",
386
+ "next_attempt_at", "lease_expires_at", "lease_expired",
387
+ "last_error_code", "processed_at",
388
+ "capabilities_sha256",
389
+ ],
390
+ "success_status": 200,
391
+ },
392
+ }
393
+ if (
394
+ not isinstance(notification_path, str)
395
+ or notification_path in {
396
+ STATUS_PATH, READINESS_PATH, CAPABILITIES_PATH, HEALTH_PATH,
397
+ }
398
+ or len({
399
+ WRITE_SCOPE, STATUS_SCOPE, READINESS_SCOPE, CAPABILITIES_SCOPE,
400
+ HEALTH_SCOPE,
401
+ }) != 5
402
+ ):
403
+ _blocked("capabilities_invalid", 503)
404
+ operations = {
405
+ **expected_controls,
406
+ "notification": {
407
+ "method": "POST",
408
+ "path": notification_path,
409
+ "scope": WRITE_SCOPE,
410
+ "request_schema": NOTIFICATION_SCHEMA,
411
+ "request_fields": [
412
+ "schema", "notification_id", "event_id", "site_id",
413
+ "plan_id", "website_version", "source_sequence",
414
+ "job_count", "change_sha256", "lifecycle_binding_sha256",
415
+ "terminal_status", "lifecycle_sha256",
416
+ ],
417
+ "response_schema": ACK_SCHEMA,
418
+ "response_fields": [
419
+ "schema", "notification_id", "event_id", "site_id",
420
+ "status", "notification_sha256",
421
+ ],
422
+ "success_status": 200,
423
+ },
424
+ }
425
+ capabilities = {
426
+ "schema": CAPABILITIES_SCHEMA,
427
+ "api_schema": API_SCHEMA,
428
+ "authentication_request_schema": AUTH_SCHEMA,
429
+ "principal_schema": PRINCIPAL_SCHEMA,
430
+ "error_schema": ERROR_SCHEMA,
431
+ "limits": {
432
+ "max_body_bytes": MAX_BODY_BYTES,
433
+ "max_headers": MAX_HEADERS,
434
+ "max_header_value_bytes": MAX_HEADER_VALUE_LENGTH,
435
+ "processing_max_attempts_min": 1,
436
+ "processing_max_attempts_max": 20,
437
+ },
438
+ "processing_statuses": list(PROCESSING_STATUSES),
439
+ "terminal_statuses": sorted(TERMINAL_STATUSES),
440
+ "operations": operations,
441
+ }
442
+ try:
443
+ if set(operations) != {
444
+ "capabilities", "health", "notification", "readiness", "status",
445
+ }:
446
+ raise ValueError
447
+ if set(NOTIFICATION_FIELDS) != set(
448
+ operations["notification"]["request_fields"]
449
+ ):
450
+ raise ValueError
451
+ encoded = json.dumps(
452
+ capabilities, ensure_ascii=False, allow_nan=False, sort_keys=True,
453
+ separators=(",", ":"),
454
+ ).encode("utf-8")
455
+ if not encoded or len(encoded) > MAX_BODY_BYTES:
456
+ raise ValueError
457
+ except (TypeError, ValueError, RecursionError):
458
+ _blocked("capabilities_invalid", 503)
459
+ return {
460
+ **capabilities,
461
+ "sha256": hashlib.sha256(encoded).hexdigest(),
462
+ }
463
+
464
+
465
+ @contextmanager
466
+ def _transaction(connection: sqlite3.Connection) -> Iterator[None]:
467
+ if connection.in_transaction:
468
+ _blocked("transaction_nested", 503)
469
+ try:
470
+ connection.execute("BEGIN IMMEDIATE")
471
+ yield
472
+ connection.commit()
473
+ except Exception:
474
+ if connection.in_transaction:
475
+ connection.rollback()
476
+ raise
477
+
478
+
479
+ class DurableCMSTerminalNotificationInbox:
480
+ """One process-owned, serialized SQLite terminal-notification ledger."""
481
+
482
+ def __init__(
483
+ self,
484
+ connection: sqlite3.Connection,
485
+ *,
486
+ database_guard: Callable[[], None] | None = None,
487
+ processing_max_attempts: int = 5,
488
+ processing_base_delay_seconds: float | int = 5,
489
+ processing_max_delay_seconds: float | int = 300,
490
+ ):
491
+ if not isinstance(connection, sqlite3.Connection):
492
+ raise TypeError("connection must be sqlite3.Connection")
493
+ if database_guard is not None and not callable(database_guard):
494
+ raise TypeError("database_guard must be callable")
495
+ if (
496
+ isinstance(processing_max_attempts, bool)
497
+ or not isinstance(processing_max_attempts, int)
498
+ or not 1 <= processing_max_attempts <= 20
499
+ ):
500
+ raise ValueError("processing_max_attempts must be between 1 and 20")
501
+ processing_base_delay_seconds = _duration(
502
+ processing_base_delay_seconds, "processing_delay_invalid"
503
+ )
504
+ processing_max_delay_seconds = _duration(
505
+ processing_max_delay_seconds, "processing_delay_invalid"
506
+ )
507
+ if processing_base_delay_seconds > processing_max_delay_seconds:
508
+ raise ValueError("processing delay range is invalid")
509
+ self.connection = connection
510
+ self.connection.row_factory = sqlite3.Row
511
+ self._lock = threading.RLock()
512
+ self._owner_pid = os.getpid()
513
+ self._database_guard = database_guard or (lambda: None)
514
+ self.processing_max_attempts = processing_max_attempts
515
+ self.processing_base_delay_seconds = processing_base_delay_seconds
516
+ self.processing_max_delay_seconds = processing_max_delay_seconds
517
+ self._initialize()
518
+
519
+ def _owner(self) -> None:
520
+ if os.getpid() != self._owner_pid:
521
+ _blocked("foreign_process", 503)
522
+
523
+ def _initialize(self) -> None:
524
+ with self._lock:
525
+ self._guard()
526
+ with _transaction(self.connection):
527
+ tables = {
528
+ row[0] for row in self.connection.execute(
529
+ "SELECT name FROM sqlite_master WHERE type = 'table' "
530
+ "AND name LIKE 'cms_terminal_notification_%'"
531
+ ).fetchall()
532
+ }
533
+ if not tables:
534
+ self._create_schema()
535
+ elif tables == {
536
+ "cms_terminal_notification_inbox_meta",
537
+ "cms_terminal_notification_inbox",
538
+ }:
539
+ self._migrate_v1()
540
+ self._validate_schema()
541
+
542
+ def _guard(self) -> None:
543
+ try:
544
+ self._database_guard()
545
+ except TerminalNotificationReceiverBlocked:
546
+ raise
547
+ except Exception:
548
+ _blocked("database_unsafe", 503)
549
+
550
+ def _create_schema(self) -> None:
551
+ self.connection.execute("""
552
+ CREATE TABLE IF NOT EXISTS cms_terminal_notification_inbox_meta (
553
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
554
+ schema_version INTEGER NOT NULL CHECK (schema_version = 2)
555
+ )
556
+ """)
557
+ self.connection.execute("""
558
+ INSERT OR IGNORE INTO cms_terminal_notification_inbox_meta
559
+ VALUES (1, 2)
560
+ """)
561
+ self.connection.execute("""
562
+ CREATE TABLE IF NOT EXISTS cms_terminal_notification_inbox (
563
+ notification_id TEXT PRIMARY KEY,
564
+ event_id TEXT NOT NULL UNIQUE,
565
+ site_id TEXT NOT NULL,
566
+ terminal_status TEXT NOT NULL,
567
+ payload_sha256 TEXT NOT NULL UNIQUE,
568
+ payload_json TEXT NOT NULL,
569
+ received_at REAL NOT NULL
570
+ )
571
+ """)
572
+ self._create_processing_schema()
573
+
574
+ def _create_processing_schema(self) -> None:
575
+ self.connection.execute("""
576
+ CREATE TABLE cms_terminal_notification_processing (
577
+ notification_id TEXT PRIMARY KEY,
578
+ status TEXT NOT NULL CHECK (
579
+ status IN ('pending', 'leased', 'retry_wait',
580
+ 'succeeded', 'failed')
581
+ ),
582
+ attempts INTEGER NOT NULL CHECK (attempts >= 0),
583
+ max_attempts INTEGER NOT NULL CHECK (
584
+ max_attempts BETWEEN 1 AND 20
585
+ ),
586
+ next_attempt_at REAL NOT NULL,
587
+ lease_owner TEXT,
588
+ lease_token TEXT,
589
+ lease_expires_at REAL,
590
+ last_error_code TEXT,
591
+ processed_at REAL,
592
+ created_at REAL NOT NULL,
593
+ updated_at REAL NOT NULL,
594
+ FOREIGN KEY (notification_id)
595
+ REFERENCES cms_terminal_notification_inbox(notification_id)
596
+ ON DELETE RESTRICT,
597
+ CHECK (
598
+ (status = 'leased' AND lease_owner IS NOT NULL
599
+ AND lease_token IS NOT NULL AND lease_expires_at IS NOT NULL)
600
+ OR
601
+ (status <> 'leased' AND lease_owner IS NULL
602
+ AND lease_token IS NULL AND lease_expires_at IS NULL)
603
+ )
604
+ )
605
+ """)
606
+ self.connection.execute("""
607
+ CREATE INDEX cms_terminal_notification_processing_due
608
+ ON cms_terminal_notification_processing (
609
+ status, next_attempt_at, created_at, notification_id
610
+ )
611
+ """)
612
+
613
+ def _migrate_v1(self) -> None:
614
+ self._validate_v1_schema()
615
+ rows = self.connection.execute(
616
+ "SELECT * FROM cms_terminal_notification_inbox "
617
+ "ORDER BY received_at, event_id"
618
+ ).fetchall()
619
+ for row in rows:
620
+ self._validated_row(row)
621
+ self._create_processing_schema()
622
+ for row in rows:
623
+ self.connection.execute("""
624
+ INSERT INTO cms_terminal_notification_processing (
625
+ notification_id, status, attempts, max_attempts,
626
+ next_attempt_at, created_at, updated_at
627
+ ) VALUES (?, 'pending', 0, ?, ?, ?, ?)
628
+ """, (
629
+ row["notification_id"], self.processing_max_attempts,
630
+ row["received_at"], row["received_at"], row["received_at"],
631
+ ))
632
+ self.connection.execute(
633
+ "DROP TABLE cms_terminal_notification_inbox_meta"
634
+ )
635
+ self.connection.execute("""
636
+ CREATE TABLE cms_terminal_notification_inbox_meta (
637
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
638
+ schema_version INTEGER NOT NULL CHECK (schema_version = 2)
639
+ )
640
+ """)
641
+ self.connection.execute(
642
+ "INSERT INTO cms_terminal_notification_inbox_meta VALUES (1, 2)"
643
+ )
644
+
645
+ def _validate_v1_schema(self) -> None:
646
+ meta_columns = tuple(row["name"] for row in self.connection.execute(
647
+ "PRAGMA table_info(cms_terminal_notification_inbox_meta)"
648
+ ).fetchall())
649
+ columns = tuple(row["name"] for row in self.connection.execute(
650
+ "PRAGMA table_info(cms_terminal_notification_inbox)"
651
+ ).fetchall())
652
+ meta = self.connection.execute(
653
+ "SELECT singleton, schema_version "
654
+ "FROM cms_terminal_notification_inbox_meta"
655
+ ).fetchall()
656
+ if (
657
+ meta_columns != ("singleton", "schema_version")
658
+ or columns != _COLUMNS
659
+ or len(meta) != 1
660
+ or tuple(meta[0]) != (1, 1)
661
+ ):
662
+ _blocked("schema_altered", 503)
663
+
664
+ def _validate_schema(self) -> None:
665
+ meta_columns = tuple(row["name"] for row in self.connection.execute(
666
+ "PRAGMA table_info(cms_terminal_notification_inbox_meta)"
667
+ ).fetchall())
668
+ columns = tuple(row["name"] for row in self.connection.execute(
669
+ "PRAGMA table_info(cms_terminal_notification_inbox)"
670
+ ).fetchall())
671
+ processing_columns = tuple(
672
+ row["name"] for row in self.connection.execute(
673
+ "PRAGMA table_info(cms_terminal_notification_processing)"
674
+ ).fetchall()
675
+ )
676
+ foreign_keys = [
677
+ tuple(row) for row in self.connection.execute(
678
+ "PRAGMA foreign_key_list(cms_terminal_notification_processing)"
679
+ ).fetchall()
680
+ ]
681
+ meta = self.connection.execute(
682
+ "SELECT singleton, schema_version "
683
+ "FROM cms_terminal_notification_inbox_meta"
684
+ ).fetchall()
685
+ if (
686
+ meta_columns != ("singleton", "schema_version")
687
+ or columns != _COLUMNS
688
+ or processing_columns != _PROCESSING_COLUMNS
689
+ or foreign_keys != [(
690
+ 0, 0, "cms_terminal_notification_inbox", "notification_id",
691
+ "notification_id", "NO ACTION", "RESTRICT", "NONE",
692
+ )]
693
+ or len(meta) != 1
694
+ or tuple(meta[0]) != (1, SCHEMA_VERSION)
695
+ ):
696
+ _blocked("schema_altered", 503)
697
+
698
+ @staticmethod
699
+ def _ack(payload: Mapping[str, Any], payload_sha256: str) -> dict[str, Any]:
700
+ return {
701
+ "schema": ACK_SCHEMA,
702
+ "notification_id": payload["notification_id"],
703
+ "event_id": payload["event_id"],
704
+ "site_id": payload["site_id"],
705
+ "status": "accepted",
706
+ "notification_sha256": payload_sha256,
707
+ }
708
+
709
+ @staticmethod
710
+ def _validated_row(row: sqlite3.Row) -> ReceivedTerminalNotification:
711
+ try:
712
+ parsed, payload_sha256 = _parse_notification(
713
+ row["payload_json"].encode("utf-8")
714
+ )
715
+ if not all(
716
+ row[name] == expected
717
+ for name, expected in {
718
+ "notification_id": parsed["notification_id"],
719
+ "event_id": parsed["event_id"],
720
+ "site_id": parsed["site_id"],
721
+ "terminal_status": parsed["terminal_status"],
722
+ "payload_sha256": payload_sha256,
723
+ }.items()
724
+ ):
725
+ raise ValueError("binding mismatch")
726
+ received_at = _time(row["received_at"])
727
+ except TerminalNotificationReceiverBlocked:
728
+ _blocked("stored_binding_invalid", 503)
729
+ except Exception:
730
+ _blocked("stored_binding_invalid", 503)
731
+ return ReceivedTerminalNotification(
732
+ row["notification_id"], row["event_id"], row["site_id"],
733
+ row["terminal_status"], row["payload_sha256"], received_at,
734
+ )
735
+
736
+ @staticmethod
737
+ def _validated_processing_row(
738
+ row: sqlite3.Row,
739
+ notification_row: sqlite3.Row,
740
+ ) -> None:
741
+ try:
742
+ received = DurableCMSTerminalNotificationInbox._validated_row(
743
+ notification_row
744
+ )
745
+ status = row["status"]
746
+ attempts = row["attempts"]
747
+ max_attempts = row["max_attempts"]
748
+ next_attempt_at = _time(row["next_attempt_at"])
749
+ created_at = _time(row["created_at"])
750
+ updated_at = _time(row["updated_at"])
751
+ lease_values = (
752
+ row["lease_owner"], row["lease_token"], row["lease_expires_at"],
753
+ )
754
+ leased = status == "leased"
755
+ error = row["last_error_code"]
756
+ processed_at = row["processed_at"]
757
+ valid = (
758
+ row["notification_id"] == received.notification_id
759
+ and status in PROCESSING_STATUSES
760
+ and isinstance(attempts, int)
761
+ and not isinstance(attempts, bool)
762
+ and isinstance(max_attempts, int)
763
+ and not isinstance(max_attempts, bool)
764
+ and 0 <= attempts <= max_attempts <= 20
765
+ and max_attempts >= 1
766
+ and leased == all(value is not None for value in lease_values)
767
+ and (not leased or (
768
+ _token(row["lease_owner"])
769
+ and _token(row["lease_token"])
770
+ and _time(row["lease_expires_at"]) > updated_at
771
+ ))
772
+ and (leased or all(value is None for value in lease_values))
773
+ and (status in {"retry_wait", "failed"}) == (error is not None)
774
+ and (error is None or _error(error))
775
+ and (status == "succeeded") == (processed_at is not None)
776
+ and (
777
+ processed_at is None
778
+ or _time(processed_at) >= created_at
779
+ )
780
+ and created_at == received.received_at
781
+ and next_attempt_at >= created_at
782
+ and updated_at >= created_at
783
+ and (status == "pending") == (attempts == 0)
784
+ and (status == "pending" or attempts > 0)
785
+ )
786
+ except Exception:
787
+ valid = False
788
+ if not valid:
789
+ _blocked("processing_state_invalid", 503)
790
+
791
+ def _processing_rows(self) -> list[tuple[sqlite3.Row, sqlite3.Row]]:
792
+ notifications = {
793
+ row["notification_id"]: row
794
+ for row in self.connection.execute(
795
+ "SELECT * FROM cms_terminal_notification_inbox"
796
+ ).fetchall()
797
+ }
798
+ processing = self.connection.execute(
799
+ "SELECT * FROM cms_terminal_notification_processing"
800
+ ).fetchall()
801
+ if len(notifications) != len(processing):
802
+ _blocked("processing_state_invalid", 503)
803
+ result = []
804
+ for row in processing:
805
+ notification = notifications.get(row["notification_id"])
806
+ if notification is None:
807
+ _blocked("processing_state_invalid", 503)
808
+ self._validated_processing_row(row, notification)
809
+ result.append((row, notification))
810
+ return result
811
+
812
+ def accept(
813
+ self,
814
+ payload: Mapping[str, Any],
815
+ body: bytes,
816
+ payload_sha256: str,
817
+ *,
818
+ now: float | int,
819
+ ) -> dict[str, Any]:
820
+ self._owner()
821
+ now = _time(now)
822
+ parsed, actual_sha256 = _parse_notification(body)
823
+ if not isinstance(payload, Mapping) or not isinstance(payload_sha256, str):
824
+ _blocked("binding_invalid", 400)
825
+ try:
826
+ copied = dict(payload)
827
+ except Exception:
828
+ _blocked("binding_invalid", 400)
829
+ if (
830
+ copied != parsed
831
+ or payload_sha256 != actual_sha256
832
+ or SHA256.fullmatch(payload_sha256) is None
833
+ ):
834
+ _blocked("binding_invalid", 400)
835
+ rendered = body.decode("utf-8")
836
+ with self._lock:
837
+ self._guard()
838
+ self._validate_schema()
839
+ try:
840
+ with _transaction(self.connection):
841
+ self._processing_rows()
842
+ rows = self.connection.execute("""
843
+ SELECT * FROM cms_terminal_notification_inbox
844
+ WHERE notification_id = ? OR event_id = ?
845
+ OR payload_sha256 = ?
846
+ """, (
847
+ parsed["notification_id"], parsed["event_id"], payload_sha256,
848
+ )).fetchall()
849
+ if rows:
850
+ if len(rows) != 1 or not all(
851
+ rows[0][name] == expected
852
+ for name, expected in {
853
+ "notification_id": parsed["notification_id"],
854
+ "event_id": parsed["event_id"],
855
+ "site_id": parsed["site_id"],
856
+ "terminal_status": parsed["terminal_status"],
857
+ "payload_sha256": payload_sha256,
858
+ "payload_json": rendered,
859
+ }.items()
860
+ ):
861
+ _blocked("idempotency_collision", 409)
862
+ else:
863
+ self.connection.execute("""
864
+ INSERT INTO cms_terminal_notification_inbox (
865
+ notification_id, event_id, site_id, terminal_status,
866
+ payload_sha256, payload_json, received_at
867
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
868
+ """, (
869
+ parsed["notification_id"], parsed["event_id"],
870
+ parsed["site_id"], parsed["terminal_status"],
871
+ payload_sha256, rendered, now,
872
+ ))
873
+ self.connection.execute("""
874
+ INSERT INTO cms_terminal_notification_processing (
875
+ notification_id, status, attempts, max_attempts,
876
+ next_attempt_at, created_at, updated_at
877
+ ) VALUES (?, 'pending', 0, ?, ?, ?, ?)
878
+ """, (
879
+ parsed["notification_id"],
880
+ self.processing_max_attempts, now, now, now,
881
+ ))
882
+ except TerminalNotificationReceiverBlocked:
883
+ raise
884
+ except sqlite3.Error:
885
+ _blocked("storage_unavailable", 503)
886
+ return self._ack(parsed, payload_sha256)
887
+
888
+ def status(self, event_id: str) -> ReceivedTerminalNotification:
889
+ self._owner()
890
+ if not _token(event_id):
891
+ _blocked("request_invalid", 400)
892
+ with self._lock:
893
+ self._guard()
894
+ self._validate_schema()
895
+ try:
896
+ row = self.connection.execute(
897
+ "SELECT * FROM cms_terminal_notification_inbox WHERE event_id = ?",
898
+ (event_id,),
899
+ ).fetchone()
900
+ except sqlite3.Error:
901
+ _blocked("storage_unavailable", 503)
902
+ if row is None:
903
+ _blocked("not_found", 404)
904
+ return self._validated_row(row)
905
+
906
+ @staticmethod
907
+ def _processing_status(
908
+ row: sqlite3.Row,
909
+ notification_row: sqlite3.Row,
910
+ now: float,
911
+ ) -> TerminalNotificationProcessingStatus:
912
+ DurableCMSTerminalNotificationInbox._validated_processing_row(
913
+ row, notification_row
914
+ )
915
+ notification = DurableCMSTerminalNotificationInbox._validated_row(
916
+ notification_row
917
+ )
918
+ lease_expires_at = row["lease_expires_at"]
919
+ return TerminalNotificationProcessingStatus(
920
+ notification.notification_id,
921
+ notification.event_id,
922
+ notification.site_id,
923
+ notification.terminal_status,
924
+ notification.payload_sha256,
925
+ row["status"],
926
+ int(row["attempts"]),
927
+ int(row["max_attempts"]),
928
+ float(row["next_attempt_at"]),
929
+ None if lease_expires_at is None else float(lease_expires_at),
930
+ row["status"] == "leased" and float(lease_expires_at) <= now,
931
+ row["last_error_code"],
932
+ None if row["processed_at"] is None else float(row["processed_at"]),
933
+ )
934
+
935
+ def claim_processing(
936
+ self,
937
+ worker_id: str,
938
+ *,
939
+ now: float | int,
940
+ lease_seconds: float | int = 600,
941
+ ) -> TerminalNotificationProcessingClaim | None:
942
+ self._owner()
943
+ if not _token(worker_id) or len(worker_id) > 128:
944
+ _blocked("processing_worker_invalid", 503)
945
+ now = _time(now)
946
+ lease_seconds = _duration(lease_seconds, "processing_lease_invalid")
947
+ with self._lock:
948
+ self._guard()
949
+ self._validate_schema()
950
+ try:
951
+ with _transaction(self.connection):
952
+ self._processing_rows()
953
+ self.connection.execute("""
954
+ UPDATE cms_terminal_notification_processing
955
+ SET status = CASE WHEN attempts >= max_attempts
956
+ THEN 'failed' ELSE 'retry_wait' END,
957
+ next_attempt_at = ?, lease_owner = NULL,
958
+ lease_token = NULL, lease_expires_at = NULL,
959
+ last_error_code = 'processing_lease_expired',
960
+ updated_at = ?
961
+ WHERE status = 'leased' AND lease_expires_at <= ?
962
+ """, (now, now, now))
963
+ row = self.connection.execute("""
964
+ SELECT * FROM cms_terminal_notification_processing
965
+ WHERE status IN ('pending', 'retry_wait')
966
+ AND next_attempt_at <= ? AND attempts < max_attempts
967
+ ORDER BY created_at, notification_id LIMIT 1
968
+ """, (now,)).fetchone()
969
+ if row is None:
970
+ return None
971
+ notification_row = self.connection.execute(
972
+ "SELECT * FROM cms_terminal_notification_inbox "
973
+ "WHERE notification_id = ?",
974
+ (row["notification_id"],),
975
+ ).fetchone()
976
+ if notification_row is None:
977
+ _blocked("processing_state_invalid", 503)
978
+ self._validated_processing_row(row, notification_row)
979
+ token = secrets.token_urlsafe(32)
980
+ expires_at = now + lease_seconds
981
+ updated = self.connection.execute("""
982
+ UPDATE cms_terminal_notification_processing
983
+ SET status = 'leased', attempts = attempts + 1,
984
+ lease_owner = ?, lease_token = ?,
985
+ lease_expires_at = ?, last_error_code = NULL,
986
+ updated_at = ?
987
+ WHERE notification_id = ?
988
+ AND status IN ('pending', 'retry_wait')
989
+ """, (
990
+ worker_id, token, expires_at, now,
991
+ row["notification_id"],
992
+ ))
993
+ if updated.rowcount != 1:
994
+ _blocked("processing_claim_lost", 503)
995
+ payload, _payload_sha256 = _parse_notification(
996
+ notification_row["payload_json"].encode("utf-8")
997
+ )
998
+ received = self._validated_row(notification_row)
999
+ return TerminalNotificationProcessingClaim(
1000
+ received.notification_id,
1001
+ received.event_id,
1002
+ received.site_id,
1003
+ received.terminal_status,
1004
+ received.payload_sha256,
1005
+ int(row["attempts"]) + 1,
1006
+ int(row["max_attempts"]),
1007
+ worker_id,
1008
+ token,
1009
+ expires_at,
1010
+ MappingProxyType(payload),
1011
+ )
1012
+ except TerminalNotificationReceiverBlocked:
1013
+ raise
1014
+ except sqlite3.Error:
1015
+ _blocked("storage_unavailable", 503)
1016
+
1017
+ def processing_status(
1018
+ self,
1019
+ event_id: str,
1020
+ *,
1021
+ now: float | int,
1022
+ ) -> TerminalNotificationProcessingStatus:
1023
+ self._owner()
1024
+ if not _token(event_id):
1025
+ _blocked("request_invalid", 400)
1026
+ now = _time(now)
1027
+ with self._lock:
1028
+ self._guard()
1029
+ self._validate_schema()
1030
+ try:
1031
+ rows = self._processing_rows()
1032
+ except TerminalNotificationReceiverBlocked:
1033
+ raise
1034
+ except sqlite3.Error:
1035
+ _blocked("storage_unavailable", 503)
1036
+ for row, notification_row in rows:
1037
+ if notification_row["event_id"] == event_id:
1038
+ return self._processing_status(row, notification_row, now)
1039
+ _blocked("not_found", 404)
1040
+
1041
+ def _require_processing_claim(
1042
+ self,
1043
+ claim: TerminalNotificationProcessingClaim,
1044
+ now: float,
1045
+ ) -> tuple[sqlite3.Row, sqlite3.Row]:
1046
+ if not isinstance(claim, TerminalNotificationProcessingClaim):
1047
+ _blocked("processing_claim_invalid", 503)
1048
+ row = self.connection.execute(
1049
+ "SELECT * FROM cms_terminal_notification_processing "
1050
+ "WHERE notification_id = ?",
1051
+ (claim.notification_id,),
1052
+ ).fetchone()
1053
+ notification_row = self.connection.execute(
1054
+ "SELECT * FROM cms_terminal_notification_inbox "
1055
+ "WHERE notification_id = ?",
1056
+ (claim.notification_id,),
1057
+ ).fetchone()
1058
+ if row is None or notification_row is None:
1059
+ _blocked("processing_claim_lost", 503)
1060
+ self._validated_processing_row(row, notification_row)
1061
+ received = self._validated_row(notification_row)
1062
+ try:
1063
+ claim_payload = _canonical(dict(claim.payload)).decode("utf-8")
1064
+ except Exception:
1065
+ _blocked("processing_claim_invalid", 503)
1066
+ if not (
1067
+ row["status"] == "leased"
1068
+ and row["lease_owner"] == claim.worker_id
1069
+ and row["lease_token"] == claim.lease_token
1070
+ and int(row["attempts"]) == claim.attempt
1071
+ and int(row["max_attempts"]) == claim.max_attempts
1072
+ and float(row["lease_expires_at"]) == claim.lease_expires_at
1073
+ and notification_row["payload_json"] == claim_payload
1074
+ and (
1075
+ received.notification_id,
1076
+ received.event_id,
1077
+ received.site_id,
1078
+ received.terminal_status,
1079
+ received.payload_sha256,
1080
+ ) == (
1081
+ claim.notification_id,
1082
+ claim.event_id,
1083
+ claim.site_id,
1084
+ claim.terminal_status,
1085
+ claim.payload_sha256,
1086
+ )
1087
+ ):
1088
+ _blocked("processing_claim_lost", 503)
1089
+ if float(row["lease_expires_at"]) <= now:
1090
+ _blocked("processing_lease_expired", 503)
1091
+ return row, notification_row
1092
+
1093
+ def complete_processing(
1094
+ self,
1095
+ claim: TerminalNotificationProcessingClaim,
1096
+ response: Any,
1097
+ *,
1098
+ now: float | int,
1099
+ ) -> TerminalNotificationProcessingStatus:
1100
+ self._owner()
1101
+ now = _time(now)
1102
+ if not isinstance(response, Mapping):
1103
+ _blocked("processing_response_invalid", 503)
1104
+ try:
1105
+ copied = json.loads(_canonical(dict(response)))
1106
+ except Exception:
1107
+ _blocked("processing_response_invalid", 503)
1108
+ expected = {
1109
+ "schema": PROCESSING_ACK_SCHEMA,
1110
+ "notification_id": claim.notification_id,
1111
+ "event_id": claim.event_id,
1112
+ "site_id": claim.site_id,
1113
+ "status": "processed",
1114
+ "notification_sha256": claim.payload_sha256,
1115
+ }
1116
+ if copied != expected:
1117
+ _blocked("processing_response_invalid", 503)
1118
+ with self._lock:
1119
+ self._guard()
1120
+ self._validate_schema()
1121
+ try:
1122
+ with _transaction(self.connection):
1123
+ self._processing_rows()
1124
+ self._require_processing_claim(claim, now)
1125
+ updated = self.connection.execute("""
1126
+ UPDATE cms_terminal_notification_processing
1127
+ SET status = 'succeeded', next_attempt_at = ?,
1128
+ lease_owner = NULL, lease_token = NULL,
1129
+ lease_expires_at = NULL, last_error_code = NULL,
1130
+ processed_at = ?, updated_at = ?
1131
+ WHERE notification_id = ? AND status = 'leased'
1132
+ AND lease_owner = ? AND lease_token = ?
1133
+ """, (
1134
+ now, now, now, claim.notification_id,
1135
+ claim.worker_id, claim.lease_token,
1136
+ ))
1137
+ if updated.rowcount != 1:
1138
+ _blocked("processing_completion_lost", 503)
1139
+ except TerminalNotificationReceiverBlocked:
1140
+ raise
1141
+ except sqlite3.Error:
1142
+ _blocked("storage_unavailable", 503)
1143
+ return self.processing_status(claim.event_id, now=now)
1144
+
1145
+ def retry_processing(
1146
+ self,
1147
+ claim: TerminalNotificationProcessingClaim,
1148
+ code: str,
1149
+ *,
1150
+ now: float | int,
1151
+ ) -> TerminalNotificationProcessingStatus:
1152
+ return self._finish_processing(claim, code, retryable=True, now=now)
1153
+
1154
+ def fail_processing(
1155
+ self,
1156
+ claim: TerminalNotificationProcessingClaim,
1157
+ code: str,
1158
+ *,
1159
+ now: float | int,
1160
+ ) -> TerminalNotificationProcessingStatus:
1161
+ return self._finish_processing(claim, code, retryable=False, now=now)
1162
+
1163
+ def _finish_processing(
1164
+ self,
1165
+ claim: TerminalNotificationProcessingClaim,
1166
+ code: str,
1167
+ *,
1168
+ retryable: bool,
1169
+ now: float | int,
1170
+ ) -> TerminalNotificationProcessingStatus:
1171
+ self._owner()
1172
+ if not _error(code):
1173
+ _blocked("processing_error_invalid", 503)
1174
+ now = _time(now)
1175
+ with self._lock:
1176
+ self._guard()
1177
+ self._validate_schema()
1178
+ try:
1179
+ with _transaction(self.connection):
1180
+ self._processing_rows()
1181
+ row, _notification_row = self._require_processing_claim(
1182
+ claim, now
1183
+ )
1184
+ terminal = not retryable or int(row["attempts"]) >= int(
1185
+ row["max_attempts"]
1186
+ )
1187
+ delay = min(
1188
+ self.processing_max_delay_seconds,
1189
+ self.processing_base_delay_seconds
1190
+ * (2 ** max(0, claim.attempt - 1)),
1191
+ )
1192
+ updated = self.connection.execute("""
1193
+ UPDATE cms_terminal_notification_processing
1194
+ SET status = ?, next_attempt_at = ?, lease_owner = NULL,
1195
+ lease_token = NULL, lease_expires_at = NULL,
1196
+ last_error_code = ?, processed_at = NULL,
1197
+ updated_at = ?
1198
+ WHERE notification_id = ? AND status = 'leased'
1199
+ AND lease_owner = ? AND lease_token = ?
1200
+ """, (
1201
+ "failed" if terminal else "retry_wait",
1202
+ now if terminal else now + delay,
1203
+ code,
1204
+ now,
1205
+ claim.notification_id,
1206
+ claim.worker_id,
1207
+ claim.lease_token,
1208
+ ))
1209
+ if updated.rowcount != 1:
1210
+ _blocked("processing_failure_lost", 503)
1211
+ except TerminalNotificationReceiverBlocked:
1212
+ raise
1213
+ except sqlite3.Error:
1214
+ _blocked("storage_unavailable", 503)
1215
+ return self.processing_status(claim.event_id, now=now)
1216
+
1217
+ def run_next_processing(
1218
+ self,
1219
+ callback: Callable[[Mapping[str, Any]], Any],
1220
+ worker_id: str,
1221
+ *,
1222
+ now: float | int,
1223
+ lease_seconds: float | int = 600,
1224
+ ) -> TerminalNotificationProcessingOutcome | None:
1225
+ if not callable(callback):
1226
+ _blocked("processing_callback_invalid", 503)
1227
+ now = _time(now)
1228
+ claim = self.claim_processing(
1229
+ worker_id, now=now, lease_seconds=lease_seconds
1230
+ )
1231
+ if claim is None:
1232
+ return None
1233
+ try:
1234
+ response = callback(dict(claim.payload))
1235
+ status = self.complete_processing(claim, response, now=now)
1236
+ except TerminalNotificationReceiverBlocked as error:
1237
+ if error.code != "notification_receiver.processing_response_invalid":
1238
+ raise
1239
+ status = self.fail_processing(claim, error.code, now=now)
1240
+ except Exception as error:
1241
+ retryable = (
1242
+ getattr(error, "cms_terminal_processing_failure", False) is True
1243
+ and getattr(error, "retryable", None) is True
1244
+ )
1245
+ code = getattr(error, "code", None)
1246
+ if not _error(code):
1247
+ code = "processing_callback_failure"
1248
+ retryable = False
1249
+ status = (
1250
+ self.retry_processing(claim, code, now=now)
1251
+ if retryable
1252
+ else self.fail_processing(claim, code, now=now)
1253
+ )
1254
+ return TerminalNotificationProcessingOutcome(
1255
+ status.notification_id,
1256
+ status.event_id,
1257
+ status.status,
1258
+ status.attempts,
1259
+ status.last_error_code,
1260
+ )
1261
+
1262
+ def health(
1263
+ self,
1264
+ *,
1265
+ now: float | int | None = None,
1266
+ ) -> TerminalNotificationInboxHealth:
1267
+ self._owner()
1268
+ now = _time(time.time() if now is None else now)
1269
+ with self._lock:
1270
+ self._guard()
1271
+ self._validate_schema()
1272
+ try:
1273
+ integrity = self.connection.execute(
1274
+ "PRAGMA integrity_check"
1275
+ ).fetchall()
1276
+ foreign_keys = self.connection.execute(
1277
+ "PRAGMA foreign_key_check"
1278
+ ).fetchall()
1279
+ rows = self.connection.execute(
1280
+ "SELECT * FROM cms_terminal_notification_inbox "
1281
+ "ORDER BY received_at, event_id"
1282
+ ).fetchall()
1283
+ processing_rows = self._processing_rows()
1284
+ except sqlite3.Error:
1285
+ _blocked("storage_unavailable", 503)
1286
+ if [tuple(row) for row in integrity] != [("ok",)] or foreign_keys:
1287
+ _blocked("integrity_failed", 503)
1288
+ for row in rows:
1289
+ self._validated_row(row)
1290
+ counts = {status: 0 for status in PROCESSING_STATUSES}
1291
+ for row, _notification_row in processing_rows:
1292
+ counts[row["status"]] += 1
1293
+ due = sum(
1294
+ row["status"] in {"pending", "retry_wait"}
1295
+ and float(row["next_attempt_at"]) <= now
1296
+ for row, _notification_row in processing_rows
1297
+ )
1298
+ expired = sum(
1299
+ row["status"] == "leased"
1300
+ and float(row["lease_expires_at"]) <= now
1301
+ for row, _notification_row in processing_rows
1302
+ )
1303
+ failed = counts["failed"]
1304
+ return TerminalNotificationInboxHealth(
1305
+ "blocked" if expired or failed else "ok",
1306
+ len(rows),
1307
+ counts,
1308
+ due,
1309
+ expired,
1310
+ failed,
1311
+ )
1312
+
1313
+
1314
+ class CMSTerminalNotificationReceiverApplication:
1315
+ """Authenticate, durably store, and then acknowledge one notification."""
1316
+
1317
+ def __init__(
1318
+ self,
1319
+ inbox: DurableCMSTerminalNotificationInbox,
1320
+ authenticate: Callable[[Mapping[str, Any], Mapping[str, str]], Any],
1321
+ *,
1322
+ origin: str,
1323
+ clock: Callable[[], float | int],
1324
+ path: str = DEFAULT_PATH,
1325
+ require_https: bool = True,
1326
+ ):
1327
+ if not isinstance(inbox, DurableCMSTerminalNotificationInbox):
1328
+ raise TypeError("inbox is invalid")
1329
+ if not callable(authenticate) or not callable(clock):
1330
+ raise TypeError("authenticate and clock must be callable")
1331
+ origin = _origin(origin)
1332
+ if (
1333
+ not isinstance(path, str)
1334
+ or not path.isascii()
1335
+ or not path.startswith("/")
1336
+ or path.startswith("//")
1337
+ or "?" in path
1338
+ or "#" in path
1339
+ or len(path) > 256
1340
+ or path in {
1341
+ STATUS_PATH, READINESS_PATH, CAPABILITIES_PATH, HEALTH_PATH,
1342
+ }
1343
+ ):
1344
+ raise ValueError("path is invalid")
1345
+ if not isinstance(require_https, bool):
1346
+ raise TypeError("require_https must be boolean")
1347
+ self.inbox = inbox
1348
+ self.authenticate = authenticate
1349
+ self.origin = origin
1350
+ self.clock = clock
1351
+ self.path = path
1352
+ self.require_https = require_https
1353
+
1354
+ @staticmethod
1355
+ def _headers(environ: Mapping[str, Any]) -> dict[str, str]:
1356
+ result: dict[str, str] = {}
1357
+ for key, value in environ.items():
1358
+ if key == "CONTENT_TYPE":
1359
+ name = "content-type"
1360
+ elif key == "CONTENT_LENGTH":
1361
+ name = "content-length"
1362
+ elif isinstance(key, str) and key.startswith("HTTP_"):
1363
+ name = key[5:].replace("_", "-").lower()
1364
+ else:
1365
+ continue
1366
+ if (
1367
+ name in result
1368
+ or not isinstance(value, str)
1369
+ or not value
1370
+ or len(value) > MAX_HEADER_VALUE_LENGTH
1371
+ or "\r" in value
1372
+ or "\n" in value
1373
+ ):
1374
+ _blocked("headers_invalid", 400)
1375
+ result[name] = value
1376
+ if len(result) > MAX_HEADERS:
1377
+ _blocked("headers_invalid", 400)
1378
+ return result
1379
+
1380
+ @staticmethod
1381
+ def _send(start_response: Callable[..., Any], status: int, value: Any):
1382
+ body = _canonical(value)
1383
+ phrases = {
1384
+ 200: "OK", 400: "Bad Request", 401: "Unauthorized",
1385
+ 403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed",
1386
+ 409: "Conflict", 411: "Length Required", 413: "Content Too Large",
1387
+ 415: "Unsupported Media Type", 503: "Service Unavailable",
1388
+ }
1389
+ start_response(f"{status} {phrases[status]}", (
1390
+ ("Content-Type", "application/json; charset=utf-8"),
1391
+ ("Content-Length", str(len(body))),
1392
+ ("Cache-Control", "no-store"),
1393
+ ("X-Content-Type-Options", "nosniff"),
1394
+ ("Referrer-Policy", "no-referrer"),
1395
+ ))
1396
+ return [body]
1397
+
1398
+ def __call__(self, environ: Mapping[str, Any], start_response: Callable[..., Any]):
1399
+ try:
1400
+ if not isinstance(environ, Mapping):
1401
+ _blocked("environment_invalid", 400)
1402
+ if environ.get("PATH_INFO") != self.path:
1403
+ _blocked("path_not_found", 404)
1404
+ if environ.get("REQUEST_METHOD") != "POST":
1405
+ _blocked("method_not_allowed", 405)
1406
+ if self.require_https and environ.get("wsgi.url_scheme") != "https":
1407
+ _blocked("https_required", 400)
1408
+ if environ.get("QUERY_STRING") not in {None, ""}:
1409
+ _blocked("query_rejected", 400)
1410
+ if environ.get("HTTP_TRANSFER_ENCODING") not in {None, ""}:
1411
+ _blocked("framing_invalid", 400)
1412
+ headers = self._headers(environ)
1413
+ if headers.get("content-type", "").lower().replace(" ", "") != (
1414
+ "application/json;charset=utf-8"
1415
+ ):
1416
+ _blocked("content_type", 415)
1417
+ length = headers.get("content-length")
1418
+ if not isinstance(length, str) or not length.isascii() or not length.isdecimal():
1419
+ _blocked("content_length_required", 411)
1420
+ size = int(length)
1421
+ if size <= 0:
1422
+ _blocked("body_invalid", 400)
1423
+ if size > MAX_BODY_BYTES:
1424
+ _blocked("body_too_large", 413)
1425
+ try:
1426
+ body = environ["wsgi.input"].read(size)
1427
+ except Exception:
1428
+ body = None
1429
+ if not isinstance(body, bytes) or len(body) != size:
1430
+ _blocked("body_invalid", 400)
1431
+ payload, body_sha256 = _parse_notification(body)
1432
+ expected_headers = {
1433
+ "idempotency-key": payload["notification_id"],
1434
+ "x-localization-terminal-notification-id": payload["notification_id"],
1435
+ "x-localization-terminal-notification-sha256": body_sha256,
1436
+ }
1437
+ if any(headers.get(name) != value for name, value in expected_headers.items()):
1438
+ _blocked("header_binding_invalid", 400)
1439
+ request = {
1440
+ "schema": AUTH_SCHEMA,
1441
+ "method": "POST",
1442
+ "origin": self.origin,
1443
+ "path": self.path,
1444
+ "notification_id": payload["notification_id"],
1445
+ "event_id": payload["event_id"],
1446
+ "site_id": payload["site_id"],
1447
+ "body_sha256": body_sha256,
1448
+ }
1449
+ try:
1450
+ principal = self.authenticate(dict(request), dict(headers))
1451
+ except Exception:
1452
+ _blocked("authentication_unavailable", 503)
1453
+ _principal(principal, payload["site_id"])
1454
+ acknowledgement = self.inbox.accept(
1455
+ payload, body, body_sha256, now=self.clock(),
1456
+ )
1457
+ return self._send(start_response, 200, acknowledgement)
1458
+ except TerminalNotificationReceiverBlocked as failure:
1459
+ return self._send(start_response, failure.status, {
1460
+ "schema": ERROR_SCHEMA,
1461
+ "status": "BLOCK",
1462
+ "error_code": failure.code,
1463
+ })
1464
+ except Exception:
1465
+ return self._send(start_response, 503, {
1466
+ "schema": ERROR_SCHEMA,
1467
+ "status": "BLOCK",
1468
+ "error_code": "notification_receiver.internal",
1469
+ })