approval-md 0.0.1 → 0.2.0

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 (722) hide show
  1. package/LICENSE +176 -0
  2. package/NOTICE +5 -0
  3. package/README.md +940 -4
  4. package/SPEC.md +476 -34
  5. package/cli.js +29 -3
  6. package/dist/src/adapters/agentmail.d.ts +426 -0
  7. package/dist/src/adapters/agentmail.js +1200 -0
  8. package/dist/src/adapters/agentmail.js.map +1 -0
  9. package/dist/src/adapters/conformance.d.ts +149 -0
  10. package/dist/src/adapters/conformance.js +461 -0
  11. package/dist/src/adapters/conformance.js.map +1 -0
  12. package/dist/src/adapters/contract.d.ts +628 -0
  13. package/dist/src/adapters/contract.js +1035 -0
  14. package/dist/src/adapters/contract.js.map +1 -0
  15. package/dist/src/adapters/email.d.ts +324 -0
  16. package/dist/src/adapters/email.js +749 -0
  17. package/dist/src/adapters/email.js.map +1 -0
  18. package/dist/src/adapters/env-passphrase.d.ts +93 -0
  19. package/dist/src/adapters/env-passphrase.js +132 -0
  20. package/dist/src/adapters/env-passphrase.js.map +1 -0
  21. package/dist/src/adapters/public.d.ts +11 -0
  22. package/dist/src/adapters/public.js +11 -0
  23. package/dist/src/adapters/public.js.map +1 -0
  24. package/dist/src/adapters/registry.d.ts +59 -0
  25. package/dist/src/adapters/registry.js +77 -0
  26. package/dist/src/adapters/registry.js.map +1 -0
  27. package/dist/src/adapters/smtp.d.ts +213 -0
  28. package/dist/src/adapters/smtp.js +499 -0
  29. package/dist/src/adapters/smtp.js.map +1 -0
  30. package/dist/src/adapters/vault-provider.d.ts +114 -0
  31. package/dist/src/adapters/vault-provider.js +161 -0
  32. package/dist/src/adapters/vault-provider.js.map +1 -0
  33. package/dist/src/adapters/zzz.d.ts +66 -0
  34. package/dist/src/adapters/zzz.js +299 -0
  35. package/dist/src/adapters/zzz.js.map +1 -0
  36. package/dist/src/channels/batch.d.ts +109 -0
  37. package/dist/src/channels/batch.js +121 -0
  38. package/dist/src/channels/batch.js.map +1 -0
  39. package/dist/src/channels/cli.d.ts +193 -0
  40. package/dist/src/channels/cli.js +468 -0
  41. package/dist/src/channels/cli.js.map +1 -0
  42. package/dist/src/channels/conformance.d.ts +92 -0
  43. package/dist/src/channels/conformance.js +445 -0
  44. package/dist/src/channels/conformance.js.map +1 -0
  45. package/dist/src/channels/contract.d.ts +623 -0
  46. package/dist/src/channels/contract.js +494 -0
  47. package/dist/src/channels/contract.js.map +1 -0
  48. package/dist/src/channels/payload-view.d.ts +35 -0
  49. package/dist/src/channels/payload-view.js +43 -0
  50. package/dist/src/channels/payload-view.js.map +1 -0
  51. package/dist/src/channels/render-queue.d.ts +149 -0
  52. package/dist/src/channels/render-queue.js +564 -0
  53. package/dist/src/channels/render-queue.js.map +1 -0
  54. package/dist/src/channels/tagging.d.ts +196 -0
  55. package/dist/src/channels/tagging.js +723 -0
  56. package/dist/src/channels/tagging.js.map +1 -0
  57. package/dist/src/channels/telegram.d.ts +1832 -0
  58. package/dist/src/channels/telegram.js +3190 -0
  59. package/dist/src/channels/telegram.js.map +1 -0
  60. package/dist/src/channels/web.d.ts +341 -0
  61. package/dist/src/channels/web.js +903 -0
  62. package/dist/src/channels/web.js.map +1 -0
  63. package/dist/src/cli/adapter.d.ts +90 -0
  64. package/dist/src/cli/adapter.js +288 -0
  65. package/dist/src/cli/adapter.js.map +1 -0
  66. package/dist/src/cli/amend.d.ts +59 -0
  67. package/dist/src/cli/amend.js +2171 -0
  68. package/dist/src/cli/amend.js.map +1 -0
  69. package/dist/src/cli/args.d.ts +43 -0
  70. package/dist/src/cli/args.js +86 -0
  71. package/dist/src/cli/args.js.map +1 -0
  72. package/dist/src/cli/attest.d.ts +41 -0
  73. package/dist/src/cli/attest.js +307 -0
  74. package/dist/src/cli/attest.js.map +1 -0
  75. package/dist/src/cli/audit-card.d.ts +62 -0
  76. package/dist/src/cli/audit-card.js +201 -0
  77. package/dist/src/cli/audit-card.js.map +1 -0
  78. package/dist/src/cli/audit.d.ts +59 -0
  79. package/dist/src/cli/audit.js +460 -0
  80. package/dist/src/cli/audit.js.map +1 -0
  81. package/dist/src/cli/channel-telegram.d.ts +806 -0
  82. package/dist/src/cli/channel-telegram.js +2063 -0
  83. package/dist/src/cli/channel-telegram.js.map +1 -0
  84. package/dist/src/cli/channel-web.d.ts +131 -0
  85. package/dist/src/cli/channel-web.js +357 -0
  86. package/dist/src/cli/channel-web.js.map +1 -0
  87. package/dist/src/cli/channel.d.ts +71 -0
  88. package/dist/src/cli/channel.js +438 -0
  89. package/dist/src/cli/channel.js.map +1 -0
  90. package/dist/src/cli/checkpoint-tap.d.ts +169 -0
  91. package/dist/src/cli/checkpoint-tap.js +238 -0
  92. package/dist/src/cli/checkpoint-tap.js.map +1 -0
  93. package/dist/src/cli/codex.d.ts +2 -0
  94. package/dist/src/cli/codex.js +172 -0
  95. package/dist/src/cli/codex.js.map +1 -0
  96. package/dist/src/cli/coverage.d.ts +61 -0
  97. package/dist/src/cli/coverage.js +343 -0
  98. package/dist/src/cli/coverage.js.map +1 -0
  99. package/dist/src/cli/daemon.d.ts +120 -0
  100. package/dist/src/cli/daemon.js +631 -0
  101. package/dist/src/cli/daemon.js.map +1 -0
  102. package/dist/src/cli/doctor.d.ts +129 -0
  103. package/dist/src/cli/doctor.js +2762 -0
  104. package/dist/src/cli/doctor.js.map +1 -0
  105. package/dist/src/cli/env.d.ts +65 -0
  106. package/dist/src/cli/env.js +302 -0
  107. package/dist/src/cli/env.js.map +1 -0
  108. package/dist/src/cli/execute.d.ts +202 -0
  109. package/dist/src/cli/execute.js +1682 -0
  110. package/dist/src/cli/execute.js.map +1 -0
  111. package/dist/src/cli/exit-codes.d.ts +73 -0
  112. package/dist/src/cli/exit-codes.js +82 -0
  113. package/dist/src/cli/exit-codes.js.map +1 -0
  114. package/dist/src/cli/feedback.d.ts +60 -0
  115. package/dist/src/cli/feedback.js +205 -0
  116. package/dist/src/cli/feedback.js.map +1 -0
  117. package/dist/src/cli/gate-window.d.ts +40 -0
  118. package/dist/src/cli/gate-window.js +294 -0
  119. package/dist/src/cli/gate-window.js.map +1 -0
  120. package/dist/src/cli/gate.d.ts +68 -0
  121. package/dist/src/cli/gate.js +557 -0
  122. package/dist/src/cli/gate.js.map +1 -0
  123. package/dist/src/cli/git-scope.d.ts +190 -0
  124. package/dist/src/cli/git-scope.js +295 -0
  125. package/dist/src/cli/git-scope.js.map +1 -0
  126. package/dist/src/cli/gloss-attach.d.ts +85 -0
  127. package/dist/src/cli/gloss-attach.js +107 -0
  128. package/dist/src/cli/gloss-attach.js.map +1 -0
  129. package/dist/src/cli/gloss-codex-child.d.ts +9 -0
  130. package/dist/src/cli/gloss-codex-child.js +149 -0
  131. package/dist/src/cli/gloss-codex-child.js.map +1 -0
  132. package/dist/src/cli/gloss-codex.d.ts +24 -0
  133. package/dist/src/cli/gloss-codex.js +255 -0
  134. package/dist/src/cli/gloss-codex.js.map +1 -0
  135. package/dist/src/cli/gloss-options.d.ts +42 -0
  136. package/dist/src/cli/gloss-options.js +79 -0
  137. package/dist/src/cli/gloss-options.js.map +1 -0
  138. package/dist/src/cli/gloss.d.ts +265 -0
  139. package/dist/src/cli/gloss.js +362 -0
  140. package/dist/src/cli/gloss.js.map +1 -0
  141. package/dist/src/cli/help.d.ts +103 -0
  142. package/dist/src/cli/help.js +2339 -0
  143. package/dist/src/cli/help.js.map +1 -0
  144. package/dist/src/cli/hook-codex.d.ts +78 -0
  145. package/dist/src/cli/hook-codex.js +167 -0
  146. package/dist/src/cli/hook-codex.js.map +1 -0
  147. package/dist/src/cli/hook.d.ts +331 -0
  148. package/dist/src/cli/hook.js +2849 -0
  149. package/dist/src/cli/hook.js.map +1 -0
  150. package/dist/src/cli/import.d.ts +35 -0
  151. package/dist/src/cli/import.js +175 -0
  152. package/dist/src/cli/import.js.map +1 -0
  153. package/dist/src/cli/init.d.ts +84 -0
  154. package/dist/src/cli/init.js +336 -0
  155. package/dist/src/cli/init.js.map +1 -0
  156. package/dist/src/cli/instructions.d.ts +23 -0
  157. package/dist/src/cli/instructions.js +262 -0
  158. package/dist/src/cli/instructions.js.map +1 -0
  159. package/dist/src/cli/journal.d.ts +41 -0
  160. package/dist/src/cli/journal.js +238 -0
  161. package/dist/src/cli/journal.js.map +1 -0
  162. package/dist/src/cli/log-advance.d.ts +287 -0
  163. package/dist/src/cli/log-advance.js +840 -0
  164. package/dist/src/cli/log-advance.js.map +1 -0
  165. package/dist/src/cli/log-anchor.d.ts +176 -0
  166. package/dist/src/cli/log-anchor.js +387 -0
  167. package/dist/src/cli/log-anchor.js.map +1 -0
  168. package/dist/src/cli/log-checkpoint.d.ts +22 -0
  169. package/dist/src/cli/log-checkpoint.js +128 -0
  170. package/dist/src/cli/log-checkpoint.js.map +1 -0
  171. package/dist/src/cli/log-sync.d.ts +243 -0
  172. package/dist/src/cli/log-sync.js +849 -0
  173. package/dist/src/cli/log-sync.js.map +1 -0
  174. package/dist/src/cli/log-verbs.d.ts +16 -0
  175. package/dist/src/cli/log-verbs.js +360 -0
  176. package/dist/src/cli/log-verbs.js.map +1 -0
  177. package/dist/src/cli/long-help.d.ts +70 -0
  178. package/dist/src/cli/long-help.js +148 -0
  179. package/dist/src/cli/long-help.js.map +1 -0
  180. package/dist/src/cli/main.d.ts +77 -0
  181. package/dist/src/cli/main.js +1206 -0
  182. package/dist/src/cli/main.js.map +1 -0
  183. package/dist/src/cli/mcp.d.ts +52 -0
  184. package/dist/src/cli/mcp.js +306 -0
  185. package/dist/src/cli/mcp.js.map +1 -0
  186. package/dist/src/cli/paths.d.ts +56 -0
  187. package/dist/src/cli/paths.js +79 -0
  188. package/dist/src/cli/paths.js.map +1 -0
  189. package/dist/src/cli/payload.d.ts +58 -0
  190. package/dist/src/cli/payload.js +253 -0
  191. package/dist/src/cli/payload.js.map +1 -0
  192. package/dist/src/cli/policy.d.ts +43 -0
  193. package/dist/src/cli/policy.js +229 -0
  194. package/dist/src/cli/policy.js.map +1 -0
  195. package/dist/src/cli/preflight.d.ts +363 -0
  196. package/dist/src/cli/preflight.js +1175 -0
  197. package/dist/src/cli/preflight.js.map +1 -0
  198. package/dist/src/cli/progress.d.ts +78 -0
  199. package/dist/src/cli/progress.js +112 -0
  200. package/dist/src/cli/progress.js.map +1 -0
  201. package/dist/src/cli/prompt.d.ts +209 -0
  202. package/dist/src/cli/prompt.js +312 -0
  203. package/dist/src/cli/prompt.js.map +1 -0
  204. package/dist/src/cli/quickstart.d.ts +46 -0
  205. package/dist/src/cli/quickstart.js +297 -0
  206. package/dist/src/cli/quickstart.js.map +1 -0
  207. package/dist/src/cli/records.d.ts +34 -0
  208. package/dist/src/cli/records.js +66 -0
  209. package/dist/src/cli/records.js.map +1 -0
  210. package/dist/src/cli/render.d.ts +22 -0
  211. package/dist/src/cli/render.js +132 -0
  212. package/dist/src/cli/render.js.map +1 -0
  213. package/dist/src/cli/sandbox.d.ts +51 -0
  214. package/dist/src/cli/sandbox.js +150 -0
  215. package/dist/src/cli/sandbox.js.map +1 -0
  216. package/dist/src/cli/scaffold.d.ts +79 -0
  217. package/dist/src/cli/scaffold.js +137 -0
  218. package/dist/src/cli/scaffold.js.map +1 -0
  219. package/dist/src/cli/setup-adapter.d.ts +137 -0
  220. package/dist/src/cli/setup-adapter.js +509 -0
  221. package/dist/src/cli/setup-adapter.js.map +1 -0
  222. package/dist/src/cli/setup-channel.d.ts +117 -0
  223. package/dist/src/cli/setup-channel.js +635 -0
  224. package/dist/src/cli/setup-channel.js.map +1 -0
  225. package/dist/src/cli/setup-checkpoint.d.ts +57 -0
  226. package/dist/src/cli/setup-checkpoint.js +196 -0
  227. package/dist/src/cli/setup-checkpoint.js.map +1 -0
  228. package/dist/src/cli/setup-common.d.ts +275 -0
  229. package/dist/src/cli/setup-common.js +376 -0
  230. package/dist/src/cli/setup-common.js.map +1 -0
  231. package/dist/src/cli/setup-flow.d.ts +287 -0
  232. package/dist/src/cli/setup-flow.js +476 -0
  233. package/dist/src/cli/setup-flow.js.map +1 -0
  234. package/dist/src/cli/setup-service.d.ts +96 -0
  235. package/dist/src/cli/setup-service.js +308 -0
  236. package/dist/src/cli/setup-service.js.map +1 -0
  237. package/dist/src/cli/setup.d.ts +202 -0
  238. package/dist/src/cli/setup.js +473 -0
  239. package/dist/src/cli/setup.js.map +1 -0
  240. package/dist/src/cli/style.d.ts +320 -0
  241. package/dist/src/cli/style.js +469 -0
  242. package/dist/src/cli/style.js.map +1 -0
  243. package/dist/src/cli/token.d.ts +39 -0
  244. package/dist/src/cli/token.js +274 -0
  245. package/dist/src/cli/token.js.map +1 -0
  246. package/dist/src/cli/up.d.ts +155 -0
  247. package/dist/src/cli/up.js +849 -0
  248. package/dist/src/cli/up.js.map +1 -0
  249. package/dist/src/cli/usage.d.ts +37 -0
  250. package/dist/src/cli/usage.js +91 -0
  251. package/dist/src/cli/usage.js.map +1 -0
  252. package/dist/src/cli/values.d.ts +40 -0
  253. package/dist/src/cli/values.js +189 -0
  254. package/dist/src/cli/values.js.map +1 -0
  255. package/dist/src/cli/vault.d.ts +59 -0
  256. package/dist/src/cli/vault.js +362 -0
  257. package/dist/src/cli/vault.js.map +1 -0
  258. package/dist/src/cli/verb-registry.d.ts +76 -0
  259. package/dist/src/cli/verb-registry.js +2341 -0
  260. package/dist/src/cli/verb-registry.js.map +1 -0
  261. package/dist/src/cli/wordmark.d.ts +31 -0
  262. package/dist/src/cli/wordmark.js +52 -0
  263. package/dist/src/cli/wordmark.js.map +1 -0
  264. package/dist/src/codex/doctor.d.ts +13 -0
  265. package/dist/src/codex/doctor.js +41 -0
  266. package/dist/src/codex/doctor.js.map +1 -0
  267. package/dist/src/codex/manifest.d.ts +49 -0
  268. package/dist/src/codex/manifest.js +103 -0
  269. package/dist/src/codex/manifest.js.map +1 -0
  270. package/dist/src/codex/templates.d.ts +41 -0
  271. package/dist/src/codex/templates.js +319 -0
  272. package/dist/src/codex/templates.js.map +1 -0
  273. package/dist/src/codex/trust.d.ts +19 -0
  274. package/dist/src/codex/trust.js +183 -0
  275. package/dist/src/codex/trust.js.map +1 -0
  276. package/dist/src/codex/workspace-plan.d.ts +131 -0
  277. package/dist/src/codex/workspace-plan.js +561 -0
  278. package/dist/src/codex/workspace-plan.js.map +1 -0
  279. package/dist/src/core/actor.d.ts +2 -0
  280. package/dist/src/core/actor.js +5 -0
  281. package/dist/src/core/actor.js.map +1 -0
  282. package/dist/src/core/advance-cycle.d.ts +170 -0
  283. package/dist/src/core/advance-cycle.js +200 -0
  284. package/dist/src/core/advance-cycle.js.map +1 -0
  285. package/dist/src/core/agents-md.d.ts +276 -0
  286. package/dist/src/core/agents-md.js +747 -0
  287. package/dist/src/core/agents-md.js.map +1 -0
  288. package/dist/src/core/apply-patch.d.ts +49 -0
  289. package/dist/src/core/apply-patch.js +266 -0
  290. package/dist/src/core/apply-patch.js.map +1 -0
  291. package/dist/src/core/attest.d.ts +420 -0
  292. package/dist/src/core/attest.js +589 -0
  293. package/dist/src/core/attest.js.map +1 -0
  294. package/dist/src/core/audit.d.ts +492 -0
  295. package/dist/src/core/audit.js +882 -0
  296. package/dist/src/core/audit.js.map +1 -0
  297. package/dist/src/core/budgets.d.ts +238 -0
  298. package/dist/src/core/budgets.js +449 -0
  299. package/dist/src/core/budgets.js.map +1 -0
  300. package/dist/src/core/checkpoint.d.ts +500 -0
  301. package/dist/src/core/checkpoint.js +738 -0
  302. package/dist/src/core/checkpoint.js.map +1 -0
  303. package/dist/src/core/child-env.d.ts +88 -0
  304. package/dist/src/core/child-env.js +86 -0
  305. package/dist/src/core/child-env.js.map +1 -0
  306. package/dist/src/core/clock.d.ts +52 -0
  307. package/dist/src/core/clock.js +43 -0
  308. package/dist/src/core/clock.js.map +1 -0
  309. package/dist/src/core/command-class.d.ts +543 -0
  310. package/dist/src/core/command-class.js +2356 -0
  311. package/dist/src/core/command-class.js.map +1 -0
  312. package/dist/src/core/coverage-sources/adapter.d.ts +40 -0
  313. package/dist/src/core/coverage-sources/adapter.js +71 -0
  314. package/dist/src/core/coverage-sources/adapter.js.map +1 -0
  315. package/dist/src/core/coverage-sources/gh.d.ts +48 -0
  316. package/dist/src/core/coverage-sources/gh.js +136 -0
  317. package/dist/src/core/coverage-sources/gh.js.map +1 -0
  318. package/dist/src/core/coverage-sources/git.d.ts +101 -0
  319. package/dist/src/core/coverage-sources/git.js +269 -0
  320. package/dist/src/core/coverage-sources/git.js.map +1 -0
  321. package/dist/src/core/coverage.d.ts +217 -0
  322. package/dist/src/core/coverage.js +337 -0
  323. package/dist/src/core/coverage.js.map +1 -0
  324. package/dist/src/core/credential-spec.d.ts +72 -0
  325. package/dist/src/core/credential-spec.js +23 -0
  326. package/dist/src/core/credential-spec.js.map +1 -0
  327. package/dist/src/core/dark-session.d.ts +331 -0
  328. package/dist/src/core/dark-session.js +714 -0
  329. package/dist/src/core/dark-session.js.map +1 -0
  330. package/dist/src/core/decision-refusal.d.ts +185 -0
  331. package/dist/src/core/decision-refusal.js +265 -0
  332. package/dist/src/core/decision-refusal.js.map +1 -0
  333. package/dist/src/core/env-file.d.ts +450 -0
  334. package/dist/src/core/env-file.js +837 -0
  335. package/dist/src/core/env-file.js.map +1 -0
  336. package/dist/src/core/execute.d.ts +858 -0
  337. package/dist/src/core/execute.js +1271 -0
  338. package/dist/src/core/execute.js.map +1 -0
  339. package/dist/src/core/frontmatter.d.ts +78 -0
  340. package/dist/src/core/frontmatter.js +100 -0
  341. package/dist/src/core/frontmatter.js.map +1 -0
  342. package/dist/src/core/gate-window.d.ts +312 -0
  343. package/dist/src/core/gate-window.js +506 -0
  344. package/dist/src/core/gate-window.js.map +1 -0
  345. package/dist/src/core/gate.d.ts +1364 -0
  346. package/dist/src/core/gate.js +3002 -0
  347. package/dist/src/core/gate.js.map +1 -0
  348. package/dist/src/core/git-run.d.ts +73 -0
  349. package/dist/src/core/git-run.js +93 -0
  350. package/dist/src/core/git-run.js.map +1 -0
  351. package/dist/src/core/harness-version.d.ts +157 -0
  352. package/dist/src/core/harness-version.js +211 -0
  353. package/dist/src/core/harness-version.js.map +1 -0
  354. package/dist/src/core/harness-wait.d.ts +55 -0
  355. package/dist/src/core/harness-wait.js +58 -0
  356. package/dist/src/core/harness-wait.js.map +1 -0
  357. package/dist/src/core/head-retry.d.ts +107 -0
  358. package/dist/src/core/head-retry.js +121 -0
  359. package/dist/src/core/head-retry.js.map +1 -0
  360. package/dist/src/core/instance.d.ts +253 -0
  361. package/dist/src/core/instance.js +319 -0
  362. package/dist/src/core/instance.js.map +1 -0
  363. package/dist/src/core/intake-limits.d.ts +247 -0
  364. package/dist/src/core/intake-limits.js +350 -0
  365. package/dist/src/core/intake-limits.js.map +1 -0
  366. package/dist/src/core/jcs.d.ts +52 -0
  367. package/dist/src/core/jcs.js +132 -0
  368. package/dist/src/core/jcs.js.map +1 -0
  369. package/dist/src/core/journal.d.ts +144 -0
  370. package/dist/src/core/journal.js +200 -0
  371. package/dist/src/core/journal.js.map +1 -0
  372. package/dist/src/core/live-draw.d.ts +436 -0
  373. package/dist/src/core/live-draw.js +703 -0
  374. package/dist/src/core/live-draw.js.map +1 -0
  375. package/dist/src/core/log-reconcile.d.ts +89 -0
  376. package/dist/src/core/log-reconcile.js +136 -0
  377. package/dist/src/core/log-reconcile.js.map +1 -0
  378. package/dist/src/core/log-subscribe.d.ts +36 -0
  379. package/dist/src/core/log-subscribe.js +162 -0
  380. package/dist/src/core/log-subscribe.js.map +1 -0
  381. package/dist/src/core/log.d.ts +278 -0
  382. package/dist/src/core/log.js +546 -0
  383. package/dist/src/core/log.js.map +1 -0
  384. package/dist/src/core/loop.d.ts +274 -0
  385. package/dist/src/core/loop.js +487 -0
  386. package/dist/src/core/loop.js.map +1 -0
  387. package/dist/src/core/md-fence.d.ts +41 -0
  388. package/dist/src/core/md-fence.js +74 -0
  389. package/dist/src/core/md-fence.js.map +1 -0
  390. package/dist/src/core/money.d.ts +147 -0
  391. package/dist/src/core/money.js +195 -0
  392. package/dist/src/core/money.js.map +1 -0
  393. package/dist/src/core/payload-census.d.ts +74 -0
  394. package/dist/src/core/payload-census.js +146 -0
  395. package/dist/src/core/payload-census.js.map +1 -0
  396. package/dist/src/core/payload-store.d.ts +175 -0
  397. package/dist/src/core/payload-store.js +340 -0
  398. package/dist/src/core/payload-store.js.map +1 -0
  399. package/dist/src/core/payload.d.ts +71 -0
  400. package/dist/src/core/payload.js +80 -0
  401. package/dist/src/core/payload.js.map +1 -0
  402. package/dist/src/core/policy-diff.d.ts +292 -0
  403. package/dist/src/core/policy-diff.js +588 -0
  404. package/dist/src/core/policy-diff.js.map +1 -0
  405. package/dist/src/core/policy-expectations.d.ts +199 -0
  406. package/dist/src/core/policy-expectations.js +394 -0
  407. package/dist/src/core/policy-expectations.js.map +1 -0
  408. package/dist/src/core/policy-explain.d.ts +150 -0
  409. package/dist/src/core/policy-explain.js +258 -0
  410. package/dist/src/core/policy-explain.js.map +1 -0
  411. package/dist/src/core/policy-load.d.ts +527 -0
  412. package/dist/src/core/policy-load.js +536 -0
  413. package/dist/src/core/policy-load.js.map +1 -0
  414. package/dist/src/core/policy-match.d.ts +281 -0
  415. package/dist/src/core/policy-match.js +478 -0
  416. package/dist/src/core/policy-match.js.map +1 -0
  417. package/dist/src/core/policy-proposal.d.ts +265 -0
  418. package/dist/src/core/policy-proposal.js +458 -0
  419. package/dist/src/core/policy-proposal.js.map +1 -0
  420. package/dist/src/core/prompt-layout.d.ts +221 -0
  421. package/dist/src/core/prompt-layout.js +422 -0
  422. package/dist/src/core/prompt-layout.js.map +1 -0
  423. package/dist/src/core/protected-path-guard.d.ts +453 -0
  424. package/dist/src/core/protected-path-guard.js +1566 -0
  425. package/dist/src/core/protected-path-guard.js.map +1 -0
  426. package/dist/src/core/registration.d.ts +25 -0
  427. package/dist/src/core/registration.js +39 -0
  428. package/dist/src/core/registration.js.map +1 -0
  429. package/dist/src/core/reindex.d.ts +99 -0
  430. package/dist/src/core/reindex.js +336 -0
  431. package/dist/src/core/reindex.js.map +1 -0
  432. package/dist/src/core/sampler.d.ts +313 -0
  433. package/dist/src/core/sampler.js +388 -0
  434. package/dist/src/core/sampler.js.map +1 -0
  435. package/dist/src/core/sandbox.d.ts +290 -0
  436. package/dist/src/core/sandbox.js +424 -0
  437. package/dist/src/core/sandbox.js.map +1 -0
  438. package/dist/src/core/seal.d.ts +165 -0
  439. package/dist/src/core/seal.js +290 -0
  440. package/dist/src/core/seal.js.map +1 -0
  441. package/dist/src/core/state.d.ts +505 -0
  442. package/dist/src/core/state.js +1009 -0
  443. package/dist/src/core/state.js.map +1 -0
  444. package/dist/src/core/task-file.d.ts +185 -0
  445. package/dist/src/core/task-file.js +464 -0
  446. package/dist/src/core/task-file.js.map +1 -0
  447. package/dist/src/core/telegram-config.d.ts +93 -0
  448. package/dist/src/core/telegram-config.js +114 -0
  449. package/dist/src/core/telegram-config.js.map +1 -0
  450. package/dist/src/core/token.d.ts +409 -0
  451. package/dist/src/core/token.js +561 -0
  452. package/dist/src/core/token.js.map +1 -0
  453. package/dist/src/core/validate.d.ts +138 -0
  454. package/dist/src/core/validate.js +0 -0
  455. package/dist/src/core/validate.js.map +1 -0
  456. package/dist/src/core/values.d.ts +137 -0
  457. package/dist/src/core/values.js +153 -0
  458. package/dist/src/core/values.js.map +1 -0
  459. package/dist/src/core/vault.d.ts +291 -0
  460. package/dist/src/core/vault.js +612 -0
  461. package/dist/src/core/vault.js.map +1 -0
  462. package/dist/src/core/verified-snapshot.d.ts +204 -0
  463. package/dist/src/core/verified-snapshot.js +506 -0
  464. package/dist/src/core/verified-snapshot.js.map +1 -0
  465. package/dist/src/core/verify.d.ts +336 -0
  466. package/dist/src/core/verify.js +549 -0
  467. package/dist/src/core/verify.js.map +1 -0
  468. package/dist/src/core/version.d.ts +8 -0
  469. package/dist/src/core/version.js +9 -0
  470. package/dist/src/core/version.js.map +1 -0
  471. package/dist/src/core/wysiwys.d.ts +370 -0
  472. package/dist/src/core/wysiwys.js +728 -0
  473. package/dist/src/core/wysiwys.js.map +1 -0
  474. package/dist/src/daemon/advance-child.d.ts +39 -0
  475. package/dist/src/daemon/advance-child.js +78 -0
  476. package/dist/src/daemon/advance-child.js.map +1 -0
  477. package/dist/src/daemon/advance.d.ts +466 -0
  478. package/dist/src/daemon/advance.js +849 -0
  479. package/dist/src/daemon/advance.js.map +1 -0
  480. package/dist/src/daemon/audit.d.ts +87 -0
  481. package/dist/src/daemon/audit.js +90 -0
  482. package/dist/src/daemon/audit.js.map +1 -0
  483. package/dist/src/daemon/daemon.d.ts +1180 -0
  484. package/dist/src/daemon/daemon.js +1988 -0
  485. package/dist/src/daemon/daemon.js.map +1 -0
  486. package/dist/src/daemon/dark-session.d.ts +64 -0
  487. package/dist/src/daemon/dark-session.js +119 -0
  488. package/dist/src/daemon/dark-session.js.map +1 -0
  489. package/dist/src/daemon/draw-child.d.ts +36 -0
  490. package/dist/src/daemon/draw-child.js +132 -0
  491. package/dist/src/daemon/draw-child.js.map +1 -0
  492. package/dist/src/daemon/draw.d.ts +154 -0
  493. package/dist/src/daemon/draw.js +458 -0
  494. package/dist/src/daemon/draw.js.map +1 -0
  495. package/dist/src/daemon/git-evidence.d.ts +173 -0
  496. package/dist/src/daemon/git-evidence.js +345 -0
  497. package/dist/src/daemon/git-evidence.js.map +1 -0
  498. package/dist/src/daemon/projection.d.ts +180 -0
  499. package/dist/src/daemon/projection.js +233 -0
  500. package/dist/src/daemon/projection.js.map +1 -0
  501. package/dist/src/daemon/prune.d.ts +207 -0
  502. package/dist/src/daemon/prune.js +376 -0
  503. package/dist/src/daemon/prune.js.map +1 -0
  504. package/dist/src/mcp/http.d.ts +113 -0
  505. package/dist/src/mcp/http.js +343 -0
  506. package/dist/src/mcp/http.js.map +1 -0
  507. package/dist/src/mcp/server.d.ts +265 -0
  508. package/dist/src/mcp/server.js +602 -0
  509. package/dist/src/mcp/server.js.map +1 -0
  510. package/docs/adapter-api.md +106 -0
  511. package/docs/cli-reference.md +5716 -0
  512. package/docs/codex-enforced-session.md +30 -0
  513. package/package.json +53 -4
  514. package/schema/.gitkeep +0 -0
  515. package/schema/LICENSE +117 -0
  516. package/schema/codex-instance.schema.json +82 -0
  517. package/schema/envelope.schema.json +137 -0
  518. package/schema/event.schema.json +1811 -0
  519. package/schema/fixtures/codex-instance/invalid/unpinned-codex-version.json +40 -0
  520. package/schema/fixtures/codex-instance/valid/canonical.json +40 -0
  521. package/schema/fixtures/envelope/invalid/action-missing-idempotency-key.json +15 -0
  522. package/schema/fixtures/envelope/invalid/action-unknown-class-format.json +14 -0
  523. package/schema/fixtures/envelope/invalid/confidence-out-of-range.json +11 -0
  524. package/schema/fixtures/envelope/invalid/est-cost-bare-number.json +14 -0
  525. package/schema/fixtures/envelope/invalid/est-cost-noncanonical-string.json +14 -0
  526. package/schema/fixtures/envelope/invalid/malformed-assignee.json +10 -0
  527. package/schema/fixtures/envelope/invalid/malformed-created-by.json +7 -0
  528. package/schema/fixtures/envelope/invalid/malformed-max-latency.json +11 -0
  529. package/schema/fixtures/envelope/invalid/malformed-payload-hash.json +14 -0
  530. package/schema/fixtures/envelope/invalid/max-cost-bare-number.json +11 -0
  531. package/schema/fixtures/envelope/invalid/missing-origin.json +3 -0
  532. package/schema/fixtures/envelope/invalid/negative-est-cost.json +14 -0
  533. package/schema/fixtures/envelope/invalid/unknown-state.json +7 -0
  534. package/schema/fixtures/envelope/invalid/unknown-top-level-field.json +8 -0
  535. package/schema/fixtures/envelope/valid/action-payload-hash.json +17 -0
  536. package/schema/fixtures/envelope/valid/actions-without-budget.json +18 -0
  537. package/schema/fixtures/envelope/valid/canonical.json +25 -0
  538. package/schema/fixtures/envelope/valid/minimal.json +7 -0
  539. package/schema/fixtures/envelope/valid/multi-action-executed.json +30 -0
  540. package/schema/fixtures/envelope/valid/record-write-stage.json +22 -0
  541. package/schema/fixtures/event/invalid/approval-granted-agent-actor.json +15 -0
  542. package/schema/fixtures/event/invalid/approval-granted-empty-batch-delivery-id.json +17 -0
  543. package/schema/fixtures/event/invalid/approval-granted-fifth-reaction.json +16 -0
  544. package/schema/fixtures/event/invalid/approval-granted-missing-actor.json +14 -0
  545. package/schema/fixtures/event/invalid/approval-requested-missing-action-key.json +14 -0
  546. package/schema/fixtures/event/invalid/approval-withdrawn-agent-policy-drift.json +15 -0
  547. package/schema/fixtures/event/invalid/approval-withdrawn-missing-reason.json +15 -0
  548. package/schema/fixtures/event/invalid/approval-withdrawn-system-actor.json +15 -0
  549. package/schema/fixtures/event/invalid/audit-decision-refused-human-actor.json +17 -0
  550. package/schema/fixtures/event/invalid/audit-decision-refused-missing-code.json +16 -0
  551. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +15 -0
  552. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +16 -0
  553. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +15 -0
  554. package/schema/fixtures/event/invalid/bad-actor-prefix.json +15 -0
  555. package/schema/fixtures/event/invalid/est-cost-bare-number.json +17 -0
  556. package/schema/fixtures/event/invalid/execution-completed-fabricated-exit-code.json +16 -0
  557. package/schema/fixtures/event/invalid/execution-completed-provider-ref-empty-id.json +18 -0
  558. package/schema/fixtures/event/invalid/execution-completed-provider-ref-extra-field.json +19 -0
  559. package/schema/fixtures/event/invalid/execution-completed-provider-ref-id-not-string.json +18 -0
  560. package/schema/fixtures/event/invalid/execution-completed-provider-ref-missing-adapter.json +17 -0
  561. package/schema/fixtures/event/invalid/execution-failed-open-reported-by.json +16 -0
  562. package/schema/fixtures/event/invalid/execution-indeterminate-open-reason.json +14 -0
  563. package/schema/fixtures/event/invalid/execution-reconciled-agent-actor.json +17 -0
  564. package/schema/fixtures/event/invalid/execution-started-negative-env-stripped.json +16 -0
  565. package/schema/fixtures/event/invalid/gate-bypassed-missing-opened-seq.json +15 -0
  566. package/schema/fixtures/event/invalid/gate-closed-non-integer-opened-seq.json +13 -0
  567. package/schema/fixtures/event/invalid/gate-opened-agent-actor.json +16 -0
  568. package/schema/fixtures/event/invalid/gate-organ-attested-absolute-path.json +14 -0
  569. package/schema/fixtures/event/invalid/gate-organ-attested-agent-actor.json +14 -0
  570. package/schema/fixtures/event/invalid/gate-organ-attested-missing-organ-path.json +13 -0
  571. package/schema/fixtures/event/invalid/harness-unknown-kind.json +18 -0
  572. package/schema/fixtures/event/invalid/harness-version-multiline.json +16 -0
  573. package/schema/fixtures/event/invalid/log-checkpoint-agent-actor.json +17 -0
  574. package/schema/fixtures/event/invalid/log-checkpoint-missing-signature.json +16 -0
  575. package/schema/fixtures/event/invalid/log-checkpoint-short-signed-hash.json +17 -0
  576. package/schema/fixtures/event/invalid/log-checkpoint-unknown-signature-alg.json +17 -0
  577. package/schema/fixtures/event/invalid/malformed-ts.json +15 -0
  578. package/schema/fixtures/event/invalid/missing-alg.json +14 -0
  579. package/schema/fixtures/event/invalid/missing-hash.json +14 -0
  580. package/schema/fixtures/event/invalid/non-integer-seq.json +15 -0
  581. package/schema/fixtures/event/invalid/payload-pruned-human-actor.json +14 -0
  582. package/schema/fixtures/event/invalid/payload-pruned-missing-hash.json +14 -0
  583. package/schema/fixtures/event/invalid/policy-declined-agent-actor.json +15 -0
  584. package/schema/fixtures/event/invalid/policy-proposed-missing-diff.json +19 -0
  585. package/schema/fixtures/event/invalid/policy-proposed-system-actor.json +26 -0
  586. package/schema/fixtures/event/invalid/short-hash.json +15 -0
  587. package/schema/fixtures/event/invalid/unknown-alg.json +15 -0
  588. package/schema/fixtures/event/invalid/unknown-event-type.json +15 -0
  589. package/schema/fixtures/event/invalid/unknown-top-level-field.json +16 -0
  590. package/schema/fixtures/event/valid/approval-expired.json +15 -0
  591. package/schema/fixtures/event/valid/approval-granted-batch.json +19 -0
  592. package/schema/fixtures/event/valid/approval-granted-reaction.json +16 -0
  593. package/schema/fixtures/event/valid/approval-granted.json +15 -0
  594. package/schema/fixtures/event/valid/approval-rejected.json +15 -0
  595. package/schema/fixtures/event/valid/approval-requested.json +19 -0
  596. package/schema/fixtures/event/valid/approval-revoked.json +15 -0
  597. package/schema/fixtures/event/valid/approval-withdrawn-policy-drift.json +17 -0
  598. package/schema/fixtures/event/valid/approval-withdrawn.json +16 -0
  599. package/schema/fixtures/event/valid/audit-decision-refused.json +20 -0
  600. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +17 -0
  601. package/schema/fixtures/event/valid/audit-reviewed.json +15 -0
  602. package/schema/fixtures/event/valid/audit-sampled.json +14 -0
  603. package/schema/fixtures/event/valid/budget-exceeded.json +21 -0
  604. package/schema/fixtures/event/valid/envelope-drift.json +16 -0
  605. package/schema/fixtures/event/valid/execution-completed-harness-report.json +16 -0
  606. package/schema/fixtures/event/valid/execution-completed-provider-ref.json +18 -0
  607. package/schema/fixtures/event/valid/execution-completed.json +15 -0
  608. package/schema/fixtures/event/valid/execution-failed-harness-report.json +16 -0
  609. package/schema/fixtures/event/valid/execution-failed.json +15 -0
  610. package/schema/fixtures/event/valid/execution-indeterminate.json +15 -0
  611. package/schema/fixtures/event/valid/execution-reconciled.json +17 -0
  612. package/schema/fixtures/event/valid/execution-started-env-stripped.json +17 -0
  613. package/schema/fixtures/event/valid/execution-started.json +14 -0
  614. package/schema/fixtures/event/valid/gate-bypassed-harness-version.json +18 -0
  615. package/schema/fixtures/event/valid/gate-bypassed.json +19 -0
  616. package/schema/fixtures/event/valid/gate-closed.json +14 -0
  617. package/schema/fixtures/event/valid/gate-opened.json +16 -0
  618. package/schema/fixtures/event/valid/gate-organ-attested.json +14 -0
  619. package/schema/fixtures/event/valid/genesis-null-prev.json +14 -0
  620. package/schema/fixtures/event/valid/log-checkpoint.json +17 -0
  621. package/schema/fixtures/event/valid/payload-pruned-orphan.json +13 -0
  622. package/schema/fixtures/event/valid/payload-pruned.json +17 -0
  623. package/schema/fixtures/event/valid/policy-declined.json +16 -0
  624. package/schema/fixtures/event/valid/policy-proposed.json +35 -0
  625. package/schema/fixtures/event/valid/policy-updated.json +14 -0
  626. package/schema/fixtures/event/valid/reconciliation-required.json +18 -0
  627. package/schema/fixtures/event/valid/reconciliation-satisfied.json +17 -0
  628. package/schema/fixtures/event/valid/route-accepted.json +15 -0
  629. package/schema/fixtures/event/valid/route-proposed.json +16 -0
  630. package/schema/fixtures/event/valid/spec-example.json +15 -0
  631. package/schema/fixtures/event/valid/task-registered-harness-version.json +23 -0
  632. package/schema/fixtures/event/valid/task-registered.json +14 -0
  633. package/schema/fixtures/hash/known-answer-pre-121.json +74 -0
  634. package/schema/fixtures/hash/known-answer.json +74 -0
  635. package/schema/fixtures/policy/invalid/bad-approval-ttl.json +7 -0
  636. package/schema/fixtures/policy/invalid/bad-web-port.json +4 -0
  637. package/schema/fixtures/policy/invalid/checkpoint-key-not-base64.json +6 -0
  638. package/schema/fixtures/policy/invalid/class-rule-missing-autonomy.json +9 -0
  639. package/schema/fixtures/policy/invalid/empty-class-key.json +6 -0
  640. package/schema/fixtures/policy/invalid/live-rate-on-human-only.json +7 -0
  641. package/schema/fixtures/policy/invalid/malformed-class-key.json +6 -0
  642. package/schema/fixtures/policy/invalid/missing-version.json +8 -0
  643. package/schema/fixtures/policy/invalid/negative-limit.json +9 -0
  644. package/schema/fixtures/policy/invalid/non-numeric-limit.json +9 -0
  645. package/schema/fixtures/policy/invalid/non-positive-max-pending.json +9 -0
  646. package/schema/fixtures/policy/invalid/on-expiry-grant.json +8 -0
  647. package/schema/fixtures/policy/invalid/payload-retention-bare-number.json +4 -0
  648. package/schema/fixtures/policy/invalid/payload-retention-compound.json +4 -0
  649. package/schema/fixtures/policy/invalid/payload-retention-fractional.json +4 -0
  650. package/schema/fixtures/policy/invalid/payload-retention-zero.json +4 -0
  651. package/schema/fixtures/policy/invalid/protected-paths-escape.json +4 -0
  652. package/schema/fixtures/policy/invalid/protected-paths-glob.json +4 -0
  653. package/schema/fixtures/policy/invalid/retro-rate-on-human-only.json +7 -0
  654. package/schema/fixtures/policy/invalid/retro-rate-on-manual.json +7 -0
  655. package/schema/fixtures/policy/invalid/retro-rate-zero.json +7 -0
  656. package/schema/fixtures/policy/invalid/sample-rate-too-high.json +5 -0
  657. package/schema/fixtures/policy/invalid/sampling-secret-env-empty.json +7 -0
  658. package/schema/fixtures/policy/invalid/sampling-secret-env-not-string.json +6 -0
  659. package/schema/fixtures/policy/invalid/skew-tolerance-compound.json +6 -0
  660. package/schema/fixtures/policy/invalid/unknown-autonomy.json +7 -0
  661. package/schema/fixtures/policy/invalid/unknown-class-rule-key.json +6 -0
  662. package/schema/fixtures/policy/invalid/unknown-top-level-key.json +7 -0
  663. package/schema/fixtures/policy/invalid/vault-passphrase-env-empty.json +6 -0
  664. package/schema/fixtures/policy/invalid/vault-passphrase-literal.json +6 -0
  665. package/schema/fixtures/policy/invalid/version-not-string.json +4 -0
  666. package/schema/fixtures/policy/valid/canonical.json +47 -0
  667. package/schema/fixtures/policy/valid/checkpoint-keys.json +18 -0
  668. package/schema/fixtures/policy/valid/class-approvers-limits.json +25 -0
  669. package/schema/fixtures/policy/valid/class-retro-rate.json +17 -0
  670. package/schema/fixtures/policy/valid/global-budgets.json +19 -0
  671. package/schema/fixtures/policy/valid/human-only.json +9 -0
  672. package/schema/fixtures/policy/valid/minimal.json +6 -0
  673. package/schema/fixtures/policy/valid/protected-paths.json +10 -0
  674. package/schema/fixtures/policy/valid/record-namespace.json +13 -0
  675. package/schema/fixtures/policy/valid/request-volume-limits.json +26 -0
  676. package/schema/fixtures/policy/valid/retention-and-sampling-secret.json +16 -0
  677. package/schema/fixtures/policy/valid/skew-tolerance.json +15 -0
  678. package/schema/fixtures/policy/valid/vault-passphrase-env.json +14 -0
  679. package/schema/fixtures/policy/valid/wildcards.json +15 -0
  680. package/schema/fixtures/policy-md/invalid/alias-bomb.md +15 -0
  681. package/schema/fixtures/policy-md/invalid/no-fence.md +7 -0
  682. package/schema/fixtures/policy-md/invalid/protected-route-not-a-subclass.md +16 -0
  683. package/schema/fixtures/policy-md/invalid/schema-invalid-autonomy.md +16 -0
  684. package/schema/fixtures/policy-md/invalid/schema-invalid-read-proof.md +17 -0
  685. package/schema/fixtures/policy-md/invalid/two-fences.md +19 -0
  686. package/schema/fixtures/policy-md/invalid/unclosed-fence.md +11 -0
  687. package/schema/fixtures/policy-md/invalid/wrong-info-string.md +11 -0
  688. package/schema/fixtures/policy-md/invalid/yaml-syntax-error.md +13 -0
  689. package/schema/fixtures/policy-md/precedence/both/APPROVAL.md +7 -0
  690. package/schema/fixtures/policy-md/precedence/both/APPROVALS.md +7 -0
  691. package/schema/fixtures/policy-md/precedence/fallback-only/APPROVALS.md +7 -0
  692. package/schema/fixtures/policy-md/valid/canonical.md +50 -0
  693. package/schema/fixtures/policy-md/valid/daemon-read-proof.md +18 -0
  694. package/schema/fixtures/policy-md/valid/minimal.md +3 -0
  695. package/schema/fixtures/policy-md/valid/prose-lookalikes.md +54 -0
  696. package/schema/fixtures/policy-md/valid/routed-protected-paths.md +49 -0
  697. package/schema/fixtures/policy-md/valid/with-values.md +79 -0
  698. package/schema/fixtures/sample-record/invalid/bad-date-time.json +4 -0
  699. package/schema/fixtures/sample-record/invalid/missing-required-field.json +3 -0
  700. package/schema/fixtures/sample-record/invalid/unknown-top-level-field.json +5 -0
  701. package/schema/fixtures/sample-record/invalid/wrong-type.json +4 -0
  702. package/schema/fixtures/sample-record/valid/minimal.json +4 -0
  703. package/schema/fixtures/sample-record/valid/with-note.json +5 -0
  704. package/schema/fixtures/values/invalid/class-shaped.json +9 -0
  705. package/schema/fixtures/values/invalid/duplicate-entry.json +4 -0
  706. package/schema/fixtures/values/invalid/non-string-item.json +4 -0
  707. package/schema/fixtures/values/invalid/over-cap.json +26 -0
  708. package/schema/fixtures/values/invalid/unknown-key.json +5 -0
  709. package/schema/fixtures/values/invalid/version-string.json +1 -0
  710. package/schema/fixtures/values/valid/empty-lists.json +7 -0
  711. package/schema/fixtures/values/valid/full.json +20 -0
  712. package/schema/fixtures/values/valid/minimal.json +1 -0
  713. package/schema/fixtures/values-md/invalid/schema-invalid.md +62 -0
  714. package/schema/fixtures/values-md/invalid/two-blocks.md +69 -0
  715. package/schema/fixtures/values-md/invalid/unterminated.md +61 -0
  716. package/schema/fixtures/values-md/invalid/yaml-error.md +63 -0
  717. package/schema/fixtures/values-md/valid/absent.md +50 -0
  718. package/schema/fixtures/values-md/valid/with-values.md +79 -0
  719. package/schema/policy.schema.json +501 -0
  720. package/schema/sample-record.schema.json +26 -0
  721. package/schema/values.schema.json +55 -0
  722. package/templates/codex/README.md +9 -0
@@ -0,0 +1,3190 @@
1
+ /**
2
+ * The Telegram push channel (SPEC.md §10.3, APRV-26).
3
+ *
4
+ * A Telegram bot is the reference *push* channel: the runtime sends the pending
5
+ * request into a chat the approver already reads, and the approver answers with
6
+ * one tap. Everything the contract says about a channel still holds here and is
7
+ * worth restating, because a network channel is where the temptations live:
8
+ *
9
+ * - **It decides nothing.** A `callback_query` becomes a {@link ChannelDecision}
10
+ * and is handed to the handler the runtime registered. That handler calls
11
+ * `recordChannelDecision`, which calls the human-only `decide()`. There is no
12
+ * second path, so TTL lapse, budget re-check, attestation, idempotency and
13
+ * compare-and-append all still apply to a button press.
14
+ * - **It never sees a token.** A grant mints a single-use execution token, and
15
+ * `recordChannelDecision` hands it to *its* caller, not to the channel. See
16
+ * "The token never goes back into the chat" below.
17
+ * - **It holds no decision state.** The only thing kept in memory is the map
18
+ * from a callback nonce to the action key it was issued for, which is
19
+ * delivery bookkeeping, not authorization. It is lost on restart, and a
20
+ * restarted listener re-notifies the pending queue. Since APRV-196 a button
21
+ * also carries a digest of its action key, so a tap on a pre-restart copy
22
+ * resolves to the request the new process is holding open and decides it;
23
+ * what a lost map costs is a duplicate message, not a dead button. The trade
24
+ * is unchanged and is the reason that works at all: an approval that survives
25
+ * a restart lives in the log, never in a channel's memory, so the thing a
26
+ * stale button resolves against is a request the LOG still calls pending.
27
+ *
28
+ * ## Zero dependencies
29
+ *
30
+ * The Bot API is plain HTTPS with JSON bodies, so this module uses `fetch`
31
+ * (global since Node 18) and nothing else. No SDK, no polling library, no
32
+ * webhook framework. `fetch` is injectable ({@link TelegramConfig.fetch}) and
33
+ * `apiBase` is injectable, which is how the test suite runs the whole channel —
34
+ * notify, long-poll, callbacks, failure modes — against a local mock Bot API
35
+ * server and never touches the real network.
36
+ *
37
+ * ## Config-declared identity — SPEC.md §11
38
+ *
39
+ * > Human identity in v0.1 is config-declared (an environment variable or
40
+ * > flag); the trust boundary is the local machine, and anyone who can set that
41
+ * > configuration and write to the log is inside it.
42
+ *
43
+ * This channel does **not** authenticate the person who tapped the button. It
44
+ * checks that the callback arrived from the configured chat id, and the
45
+ * decision is then recorded against the human actor the *runtime* was
46
+ * configured with (`APPROVAL_HUMAN` / `--as`), not against anything the
47
+ * callback carried. So the guarantee is "someone with access to the configured
48
+ * chat, on a runtime configured by someone with local control, tapped Approve"
49
+ * — not "alice tapped Approve". Anyone in that chat can approve as the
50
+ * configured actor. Use a private chat with the bot, and treat the chat's
51
+ * membership as part of the trust boundary. Cryptographic identity is future
52
+ * work and is not a v0.1 claim.
53
+ *
54
+ * ## Formatting: HTML, not MarkdownV2 — a deliberate choice
55
+ *
56
+ * Messages use `parse_mode: "HTML"`. MarkdownV2 requires escaping eighteen
57
+ * characters (`_*[]()~\`>#+-=|{}.!`) in every text position, with different
58
+ * rules inside code spans, and a single missed one is not a cosmetic bug: it is
59
+ * agent-authored text (a summary, a payload body) changing the *structure* of
60
+ * the message a human is about to approve. HTML mode needs exactly three
61
+ * escapes — `&`, `<`, `>` — applied uniformly to every interpolated value by
62
+ * {@link escapeHtml}, and `<pre>` carries the payload bytes without any
63
+ * character being special inside it beyond those three. A narrower escape rule
64
+ * is a narrower injection surface, and the untrusted input here is precisely
65
+ * the claimed fields and the payload.
66
+ *
67
+ * ## The token never goes back into the chat — flagged for human review
68
+ *
69
+ * `recordChannelDecision` returns the raw execution token to the runtime on a
70
+ * grant. The runtime (`cli/channel.ts`) prints it on the **listener's stdout**
71
+ * and nowhere else. It is never sent as a Telegram message, never put in an
72
+ * `answerCallbackQuery` text, and never logged by this module. A chat
73
+ * transcript is stored on someone else's servers, is backed up to phones, and
74
+ * is readable by anyone who is later added to the chat; a single-use execution
75
+ * token in it would be a credential in a place with none of the properties a
76
+ * credential store has. The consequence is real and is the reason this is
77
+ * flagged: the human who approves on their phone does not get the token on
78
+ * their phone — the agent or operator at the terminal running `approval channel
79
+ * telegram listen` does. For v0.1's local-first, single-operator model that is
80
+ * the right side of the trade; a deployment where the approver and the runtime
81
+ * are different people needs a token-delivery design, not a chat message.
82
+ *
83
+ * ## Reject collects no free-text reason — flagged for human review
84
+ *
85
+ * Telegram inline keyboards have no text input: a button press returns only its
86
+ * `callback_data`. Collecting the approver's reason would require a
87
+ * `ForceReply` round trip (send a prompt, wait for the *next* message in the
88
+ * chat, correlate it), which means holding a second piece of per-request state
89
+ * and deciding what to do when the reply never comes. This task records the
90
+ * rejection immediately with the note `rejected via telegram (callback <id>)`,
91
+ * so the audit trail says how the refusal was collected and which callback it
92
+ * came from, and says nothing about why. A follow-up may add the ForceReply
93
+ * flow; until then, a reason belongs in `approval reject --note`.
94
+ *
95
+ * ## Batching (B7): the digest (APRV-115)
96
+ *
97
+ * SPEC.md §10.3 lets a channel collect one gesture over a set, and until
98
+ * APRV-115 this channel took that option **degenerately**: one message per
99
+ * member, each with its own keyboard, all sharing one batch delivery id. The
100
+ * semantics were right and the ergonomics were the incident. A research session
101
+ * once produced forty near-identical `network.call` prompts in twenty minutes,
102
+ * one message each, and a channel that behaves like a notification hose is a
103
+ * channel a human learns to swipe away.
104
+ *
105
+ * A group of similar pending requests (the grouping key is
106
+ * {@link digestKeyOf}, applied by the listener) is now delivered as a
107
+ * **digest**: every member's full prompt and full payload first, in its own
108
+ * messages and with no buttons, then ONE trailing message carrying the
109
+ * headline, one summary line per member, and the keyboard — a per-member
110
+ * Approve/Reject row for each, plus an "all" row.
111
+ *
112
+ * Four properties hold it together:
113
+ *
114
+ * - **The payloads come first.** The buttons are on the LAST message, and
115
+ * every member's `<pre>` payload region has already been sent above it. An
116
+ * approver cannot reach an "Approve all" without the bytes it covers having
117
+ * been put in front of them (SPEC.md §10.4).
118
+ * - **It fails toward more messages.** A group whose digest text would not fit
119
+ * inside {@link TELEGRAM_MAX_MESSAGE_CHARS}, or that has fewer than two
120
+ * members, falls back to the old one-message-per-member delivery, and so
121
+ * does a group `assembleBatch` refuses. The listener caps a digest at
122
+ * {@link TELEGRAM_DIGEST_MAX_MEMBERS} and splits a larger burst into
123
+ * several. Never a grant covering an unseen payload.
124
+ * - **"All" is N decisions, not one.** An all-button hands the runtime's
125
+ * handler one {@link ChannelDecision} per still-armed member, in order, and
126
+ * the handler records each through the gate's compare-and-append on its own.
127
+ * The log never learns the word "batch": it gets N `approval.granted` or
128
+ * `approval.rejected` events, each bound to its own action and payload hash,
129
+ * each carrying the shared batch delivery id (SPEC.md §10.3).
130
+ * - **Annotation is per member.** A decided, expired or withdrawn member marks
131
+ * its own line on the digest and loses its own buttons; the others stay
132
+ * armed. A partially decided digest therefore shows mixed state, which is
133
+ * what {@link TelegramChannel.annotate} redraws it to.
134
+ *
135
+ * The digest bookkeeping is delivery state of exactly the kind the nonce map
136
+ * already was: what was sent where, never what was decided. Every outcome word
137
+ * on it comes from the verified log or from the record the gate appended, and
138
+ * losing the map to a restart degrades to a stale message whose buttons the
139
+ * gate refuses, never to a wrong one.
140
+ *
141
+ * ## Every terminal state edits its message (APRV-113)
142
+ *
143
+ * A decided prompt used to look exactly like a pending one: the tap toasted,
144
+ * and the message kept its text and its live buttons. So did a request answered
145
+ * at the CLI or the web queue while the chat prompt was up, and so did one the
146
+ * daemon expired. The chat transcript — the thing the approver actually scrolls
147
+ * — said "APPROVAL REQUIRED" about a question that had been settled hours ago.
148
+ *
149
+ * Every terminal state this process observes for a message it delivered now
150
+ * edits that message: {@link TelegramChannel.annotate} replaces the text with
151
+ * the outcome and clears the keyboard in ONE `editMessageText`, and forgets the
152
+ * delivery so a tap on a button the edit did not remove refuses rather than
153
+ * decides. {@link TelegramChannel.retract} is the withdrawal case of it.
154
+ *
155
+ * Two properties this keeps, deliberately:
156
+ *
157
+ * - **It is not state.** The map this consults is delivery bookkeeping, and
158
+ * annotating removes from it rather than adding. Losing it (a restart)
159
+ * degrades to a message that is never annotated — stale text in front of a
160
+ * human whose gate still refuses every tap on it — and never to a message
161
+ * annotated with the wrong outcome, because every outcome word comes from the
162
+ * verified log at the moment it is written.
163
+ * - **The token is never in an edit.** An annotation carries the outcome word,
164
+ * the action key, who decided, when, and the record's seq. It never carries
165
+ * the execution token, for the reason spelled out above.
166
+ *
167
+ * ## The bookkeeping is swept (APRV-135)
168
+ *
169
+ * Both maps used to be released only by process exit. Annotating a delivery
170
+ * removes its nonces, but nothing removes a delivery that is never annotated
171
+ * (a request that simply lapsed) or a digest whose members were each settled
172
+ * individually, so a listener left running for weeks held memory proportional
173
+ * to every prompt it had ever sent — and APRV-110's ambient runtime makes
174
+ * week-long listeners the normal case rather than the exception.
175
+ *
176
+ * {@link TelegramChannel.sweep} drops an entry when every member of it is
177
+ * terminal AND the entry is older than the policy's approval TTL. Both halves
178
+ * matter and the pair is what makes the drop safe: past the TTL the gate
179
+ * refuses every decision on the request, so a button referencing a dropped
180
+ * entry could not have been honoured anyway, and it is answered by the
181
+ * stale-callback path that a restarted listener's buttons already take. It is
182
+ * process memory and nothing else: no event is appended, no message is edited,
183
+ * and the log is not opened.
184
+ */
185
+ import { createHash } from "node:crypto";
186
+ import { GLOSS_UNVERIFIED_SUFFIX, refusedDecisionLine } from "./contract.js";
187
+ // APRV-299. The reaction vocabulary and the verdict type, imported for the
188
+ // reason `core/audit.ts` states where it exports them: they are shown here and
189
+ // decided nowhere. Nothing in this file branches on a reaction, and SPEC.md
190
+ // §11.1 invariant 10's guard scans the modules that decide, which this is not.
191
+ import { REACTIONS } from "../core/audit.js";
192
+ import { TELEGRAM_PROMPT_LAYOUT, } from "../core/prompt-layout.js";
193
+ import { commandPayloadView, payloadRegionText } from "./payload-view.js";
194
+ // ---------------------------------------------------------------------------
195
+ // Configuration
196
+ // ---------------------------------------------------------------------------
197
+ // The variable NAMES and their resolvers live in `core/telegram-config.ts`
198
+ // (APRV-72, moved in APRV-73 so `approval env` can read them without a
199
+ // core -> channels import). Re-exported here so channel callers keep one
200
+ // import path. Still true: nothing under `src/channels/` reads `process.env`.
201
+ import { TELEGRAM_CHAT_ENV, TELEGRAM_TOKEN_ENV } from "../core/telegram-config.js";
202
+ export { TELEGRAM_CHAT_ENV, TELEGRAM_TOKEN_ENV, telegramChatEnvFor, telegramTokenEnvFor, } from "../core/telegram-config.js";
203
+ /** The real Bot API. Overridden only by tests, against a local mock. */
204
+ export const TELEGRAM_DEFAULT_API_BASE = "https://api.telegram.org";
205
+ /** Telegram's hard limit on a message's text. */
206
+ export const TELEGRAM_MAX_MESSAGE_CHARS = 4096;
207
+ /** Telegram's hard limit on `callback_data`, in bytes. */
208
+ export const TELEGRAM_MAX_CALLBACK_BYTES = 64;
209
+ /** The note recorded on a rejection collected from a button. */
210
+ export const TELEGRAM_REJECT_NOTE = "rejected via telegram";
211
+ /**
212
+ * The toast a tap gets when no branch produced one of its own (APRV-196).
213
+ *
214
+ * It is deliberately about the tap and not about the request: this text is only
215
+ * ever reached when the handler threw or forgot, which are exactly the states
216
+ * in which this process does not know what became of the request. Saying so is
217
+ * the honest answer, and it is still infinitely better than a button that spins.
218
+ */
219
+ export const TELEGRAM_ACK_FALLBACK = "Received — this listener could not finish reading your tap. Nothing was recorded by it; check the message above for the outcome.";
220
+ /**
221
+ * The toast a tap gets the instant it is recognized, BEFORE the gate runs
222
+ * (APRV-206).
223
+ *
224
+ * Telegram gives a callback query exactly one answer, and until it arrives the
225
+ * button spins on the approver's phone. Sending it after the decision made the
226
+ * spinner as long as the decision — which grew with the log — and the human,
227
+ * with no way to tell a slow tap from a swallowed one, tapped again.
228
+ *
229
+ * So this is what the single answer says, and its wording is load-bearing: it
230
+ * claims only that the tap ARRIVED. It must never say granted, rejected,
231
+ * approved, recorded, or anything else a reader could take as "the log now says
232
+ * so", because at the moment it is sent nothing has been appended and the gate
233
+ * may still refuse. What became of the request is said by the message edit that
234
+ * follows, which is written from the record the gate actually appended (or from
235
+ * its refusal). The toast vanishes; the message stays.
236
+ */
237
+ export const TELEGRAM_ACK_HEARD = "Heard — deciding. The message will say what the log recorded.";
238
+ /**
239
+ * The headline on a message whose tap the gate refused (APRV-206).
240
+ *
241
+ * Before the early ack, a refusal was a toast and the message was left alone.
242
+ * Now that the single answer is spent on "heard", the refusal has to reach the
243
+ * approver here or nowhere. The buttons go with it ({@link annotate} disarms),
244
+ * which is the right outcome in both directions: a request the gate calls
245
+ * terminal has no live decision left to collect, and a request that is still
246
+ * pending is re-delivered by the next dispatch cycle as a fresh prompt.
247
+ */
248
+ export const TELEGRAM_NOT_RECORDED = "✗ NOT RECORDED";
249
+ /**
250
+ * The detail line under {@link TELEGRAM_NOT_RECORDED} when the runtime's
251
+ * decision handler threw (APRV-206).
252
+ *
253
+ * The wording is careful about what it does not know: a handler that threw may
254
+ * have thrown before or after its append, so this says where to look rather
255
+ * than what happened. The log is the thing that knows.
256
+ */
257
+ export const TELEGRAM_HANDLER_FAILED = "This listener failed while recording your tap. Check `approval queue` — the log is what says whether anything was recorded.";
258
+ /** Prefixed to the toast when the tap arrived on a pre-restart copy (APRV-196). */
259
+ export const TELEGRAM_STALE_COPY_PREFIX = "Earlier copy of this request — ";
260
+ /**
261
+ * The toast for a tap on a copy of an action this process is not holding open,
262
+ * when no verified-log probe is configured to say more (APRV-196).
263
+ */
264
+ export const TELEGRAM_STALE_UNKNOWN = "This request is not open here — it was already decided, it lapsed, or another listener holds it. Nothing was recorded.";
265
+ /**
266
+ * The headline of an ordinary single-request prompt.
267
+ *
268
+ * Exported because the mock Bot API and several tests key on it, and because a
269
+ * digest member's header deliberately does NOT use it: a member prompt carries
270
+ * no buttons, so calling it "APPROVAL REQUIRED" would point a reader at a
271
+ * message that cannot take their answer.
272
+ */
273
+ export const TELEGRAM_PROMPT_HEADING = "APPROVAL REQUIRED";
274
+ /**
275
+ * What the label over the payload chunks names (APRV-162).
276
+ *
277
+ * The chunks carry the canonical rendering, which is a deterministic function
278
+ * of the bytes and not the bytes themselves; calling it "the exact bytes" told
279
+ * the reader that a diff view and a JSON file were the same object. The
280
+ * rendering names its own `display_hash`, and the store path inside it is the
281
+ * route back to the bytes.
282
+ */
283
+ export const PAYLOAD_CHUNK_LABEL_TAIL = "the canonical rendering this approval's display_hash names; raw bytes at the store path inside";
284
+ export const PAYLOAD_CHUNK_LABEL = `PAYLOAD — ${PAYLOAD_CHUNK_LABEL_TAIL}`;
285
+ /**
286
+ * What the claimed block is headed, and what a second claimed message is headed
287
+ * when a rationale overflows one (APRV-165).
288
+ *
289
+ * Both say CLAIMED and both say NOT verified, because a continuation is a
290
+ * message a reader may see first, and a claimed line that arrives under no
291
+ * heading at all reads as the runtime's own.
292
+ */
293
+ export const TELEGRAM_CLAIMED_HEADING_PREFIX = "WHAT THIS DOES — CLAIMED by";
294
+ export const TELEGRAM_CLAIMED_HEADING_SUFFIX = "NOT verified by the runtime";
295
+ export const TELEGRAM_CLAIMED_CONTINUED_HEADING = `WHAT THIS DOES (continued) — CLAIMED, ${TELEGRAM_CLAIMED_HEADING_SUFFIX}`;
296
+ /**
297
+ * The most members one digest may carry (APRV-115).
298
+ *
299
+ * Not a rendering limit — {@link renderDigest} checks the real one against
300
+ * {@link TELEGRAM_MAX_MESSAGE_CHARS} — but a *reading* one: a keyboard of
301
+ * twenty rows is a wall, and the failure this feature exists to fix is a human
302
+ * who stops reading. A burst larger than this becomes several digests, which is
303
+ * the direction this whole design fails in.
304
+ */
305
+ export const TELEGRAM_DIGEST_MAX_MEMBERS = 8;
306
+ /**
307
+ * The headline each terminal state puts on the message it settles (APRV-113).
308
+ *
309
+ * Keyed by `core/state.ts`'s `RequestState` names for the terminal states, so
310
+ * the caller that derived the state from the verified log picks a word by
311
+ * indexing rather than by re-deciding what happened.
312
+ *
313
+ * Glyphs, not emoji: `✓`/`✗` are the vocabulary `cli/style.ts` uses for the
314
+ * same ok/fail distinction, and every line of *message text* this channel
315
+ * writes ("APPROVAL REQUIRED", "PAYLOAD", "WITHDRAWN") is emoji-free. The
316
+ * emoji live on the button labels, which are a different surface and stay as
317
+ * they are. `withdrawn` keeps the exact wording APRV-106 shipped.
318
+ */
319
+ export const TELEGRAM_TERMINAL_HEADLINES = {
320
+ granted: "✓ APPROVED",
321
+ rejected: "✗ REJECTED",
322
+ revoked: "✗ REVOKED — the grant was taken back",
323
+ expired: "✗ EXPIRED — the approval window closed",
324
+ withdrawn: "WITHDRAWN — no decision is needed",
325
+ };
326
+ /** Whether a derived request state is one an annotation can settle a message on. */
327
+ export function isTelegramTerminalState(state) {
328
+ return Object.prototype.hasOwnProperty.call(TELEGRAM_TERMINAL_HEADLINES, state);
329
+ }
330
+ /**
331
+ * `HH:MM UTC`, or the raw instant when it does not parse.
332
+ *
333
+ * UTC and not a local zone: the listener, the approver's phone and the log can
334
+ * all be in different places, and the log's own timestamps are UTC. A clock a
335
+ * reader can line up against `approval log` beats one that matches their wrist.
336
+ */
337
+ export function utcClock(ts) {
338
+ const ms = Date.parse(ts);
339
+ if (Number.isNaN(ms))
340
+ return ts;
341
+ const at = new Date(ms);
342
+ return `${String(at.getUTCHours()).padStart(2, "0")}:${String(at.getUTCMinutes()).padStart(2, "0")} UTC`;
343
+ }
344
+ /** The "who decided, when, and which record says so" line of an annotation. */
345
+ export function decidedLine(actor, ts, seq) {
346
+ return `by ${actor} at ${utcClock(ts)} (seq ${seq})`;
347
+ }
348
+ /** Room left under {@link TELEGRAM_MAX_MESSAGE_CHARS} for our own markup. */
349
+ const SEGMENT_BUDGET = 3600;
350
+ /** The `getUpdates` long-poll timeout, in seconds, when none is configured. */
351
+ const DEFAULT_POLL_TIMEOUT_SECONDS = 25;
352
+ /** First backoff step after a failed poll. Doubles, capped. */
353
+ const DEFAULT_BACKOFF_MS = 1_000;
354
+ const DEFAULT_MAX_BACKOFF_MS = 30_000;
355
+ /**
356
+ * How long a settled delivery is remembered when the policy declares no
357
+ * `defaults.approval_ttl` (APRV-135).
358
+ *
359
+ * A policy with no TTL bounds nothing, so "past the approval TTL" can never
360
+ * become true and a sweep keyed on it alone would never fire — which is the
361
+ * unbounded map this task exists to remove. The retention floor takes over
362
+ * there, and it applies only to entries whose every member this process has
363
+ * seen settled: with no TTL an undecided request stays answerable forever, and
364
+ * forgetting its button would take a live decision away from an approver.
365
+ *
366
+ * A day, because the point of remembering a settled delivery at all is that an
367
+ * approver may still tap a button on a message already scrolled past, and the
368
+ * answer they should get is the stale-callback reply either way.
369
+ */
370
+ export const TELEGRAM_DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
371
+ /** Least time between two sweeps. A sweep is O(map); once a minute is plenty. */
372
+ export const TELEGRAM_SWEEP_INTERVAL_MS = 60_000;
373
+ // ---------------------------------------------------------------------------
374
+ // Anomalies
375
+ // ---------------------------------------------------------------------------
376
+ /**
377
+ * Why a callback was ignored.
378
+ *
379
+ * Every one of these is counted and complained about on stderr, and **none of
380
+ * them reaches the decision path or the log**. An ignored callback is not an
381
+ * event: writing "someone we do not answer to pressed a button" into an
382
+ * append-only approval log would let any stranger who guessed the bot's handle
383
+ * grow the record a human is asked to trust.
384
+ */
385
+ export const TELEGRAM_ANOMALY_KINDS = [
386
+ /** The callback came from a chat that is not the configured one. */
387
+ "foreign-chat",
388
+ /** `callback_data` did not parse as one of ours. */
389
+ "malformed-callback",
390
+ /** A well-formed nonce this listener never issued (or issued before a restart). */
391
+ "unknown-callback",
392
+ /** The action key carried in `callback_data` disagrees with the issued nonce. */
393
+ "key-mismatch",
394
+ /**
395
+ * A tap on a copy of a request this process is no longer holding open
396
+ * (APRV-196): the nonce is not one of ours, and the action it names is not
397
+ * pending here either — it was decided, it lapsed, or another process owns
398
+ * it. Distinct from `unknown-callback` because the operator's question is
399
+ * different: nothing is wrong with the button, the question behind it is
400
+ * over. Always answered with a toast that names the state.
401
+ */
402
+ "stale-copy",
403
+ /**
404
+ * A message in the approver chat that began with `/` and named no command
405
+ * this channel answers (APRV-216). Counted rather than replied to: the chat
406
+ * belongs to a human, other bots and other slash commands live in it, and a
407
+ * channel that answered every unrecognised one would be noise in the one
408
+ * place an approver's attention is supposed to be scarce.
409
+ */
410
+ "unknown-command",
411
+ ];
412
+ export const TELEGRAM_COMMANDS = ["queue", "skip", "next"];
413
+ /**
414
+ * The command a message's text names, or `null` (APRV-216).
415
+ *
416
+ * Pure, and exported so the listener's tests can exercise the grammar without
417
+ * a transport. Telegram delivers a command in a group chat as `/skip@thebot`,
418
+ * so the `@suffix` is stripped; the bot's own username is not checked, because
419
+ * this channel only ever reads ONE chat and a message in it that says `/skip`
420
+ * to some other bot is a message the approver still meant as a skip more often
421
+ * than not. Anything after the command word is ignored: none of these three
422
+ * takes an argument, and silently discarding one is better than refusing a
423
+ * command a human typed with a stray word on the end.
424
+ */
425
+ export function parseBotCommand(text) {
426
+ if (typeof text !== "string")
427
+ return null;
428
+ const first = text.trim().split(/\s+/u)[0];
429
+ if (first === undefined || !first.startsWith("/"))
430
+ return null;
431
+ const word = first.slice(1).split("@")[0]?.toLowerCase();
432
+ return TELEGRAM_COMMANDS.find((command) => command === word) ?? null;
433
+ }
434
+ // ---------------------------------------------------------------------------
435
+ // Small helpers
436
+ // ---------------------------------------------------------------------------
437
+ /** The three characters Telegram's HTML mode treats as markup. */
438
+ export function escapeHtml(text) {
439
+ return text.replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
440
+ }
441
+ function sleep(ms) {
442
+ return new Promise((resolve) => {
443
+ setTimeout(resolve, ms);
444
+ });
445
+ }
446
+ /** A field's `source` / `author` label, for the "(log)" / "(agent:x)" suffix. */
447
+ function originOf(field) {
448
+ return field.kind === "computed" ? field.source : field.author;
449
+ }
450
+ /**
451
+ * The suffix the model-authored line carries, on the line itself (APRV-144).
452
+ *
453
+ * One constant, shared with the terminal channel since APRV-197: this name is
454
+ * kept because the tests and the help text pin it, and it now resolves to
455
+ * {@link GLOSS_UNVERIFIED_SUFFIX} so the two surfaces cannot drift apart.
456
+ */
457
+ export const TELEGRAM_GLOSS_SUFFIX = GLOSS_UNVERIFIED_SUFFIX;
458
+ /**
459
+ * The prefix a health row carries when it is the reason to look (APRV-163).
460
+ *
461
+ * Only the abnormal state of `autonomy`, `budgets` and the attestation renders
462
+ * at all, so the mark is never routine: a row bearing it is a row the reader
463
+ * has not seen on the last twenty prompts.
464
+ */
465
+ export const TELEGRAM_ANOMALY_MARK = "⚠ ";
466
+ function line(field, tagged, label, text) {
467
+ return { field, kind: tagged.kind, label, text, origin: originOf(tagged) };
468
+ }
469
+ function budgetSummary(request) {
470
+ const verdicts = request.budgets.value;
471
+ if (verdicts.length === 0)
472
+ return "no limits apply";
473
+ return verdicts
474
+ .map((verdict) => {
475
+ const state = verdict.pass ? "ok" : "EXCEEDED";
476
+ return `${state} ${verdict.scope}.${verdict.limit} (${verdict.window}) consumed ${verdict.consumed} + this ${verdict.requested}, ${verdict.remaining} left`;
477
+ })
478
+ .join("; ");
479
+ }
480
+ /**
481
+ * The attestation line.
482
+ *
483
+ * Anything but `attested` is shouted, because an unattested or drifted policy
484
+ * means the rule that produced `autonomy: manual` above is not the rule a human
485
+ * signed off on — which is exactly the thing an approver must not have to infer.
486
+ */
487
+ function attestationSummary(request) {
488
+ const status = request.attestation.value;
489
+ switch (status.status) {
490
+ case "attested":
491
+ return `attested (seq ${status.seq})`;
492
+ case "not-attested":
493
+ return "NOT ATTESTED — no human has signed off on this policy file";
494
+ case "hash-mismatch":
495
+ return `HASH MISMATCH — the policy file changed since attestation seq ${status.seq}`;
496
+ default:
497
+ return `UNREADABLE — ${status.message}`;
498
+ }
499
+ }
500
+ function formatTelegramTtl(ms) {
501
+ if (ms === null)
502
+ return "no expiry declared";
503
+ const seconds = Math.max(0, Math.round(ms / 1000));
504
+ const minutes = Math.floor(seconds / 60);
505
+ const hours = Math.floor(minutes / 60);
506
+ if (hours > 0)
507
+ return `${String(hours)}h ${String(minutes % 60)}m left`;
508
+ if (minutes > 0)
509
+ return `${String(minutes)}m ${String(seconds % 60)}s left`;
510
+ return `${String(seconds)}s left`;
511
+ }
512
+ /** The rows a review card renders, in the order it renders them (APRV-299). */
513
+ export const REVIEW_CARD_ROWS = [
514
+ "class",
515
+ "command_breakdown",
516
+ "task",
517
+ "summary",
518
+ "gloss",
519
+ ];
520
+ /**
521
+ * The five rows a prompt and a review card render identically (APRV-299).
522
+ *
523
+ * Extracted from {@link telegramRow} rather than copied, so that a card and a
524
+ * prompt cannot come to describe the same class, the same command or the same
525
+ * claimed summary in two different ways. The gloss keeps every property
526
+ * APRV-144 gave it here too: it renders under the CLAIMED heading because its
527
+ * field says `claimed`, it carries {@link TELEGRAM_GLOSS_SUFFIX} on the line
528
+ * itself, and nothing branches on what it says.
529
+ */
530
+ function reviewRow(fields, row) {
531
+ const normal = (line_) => ({ line: line_, abnormal: false });
532
+ switch (row) {
533
+ case "task":
534
+ return normal(line("task", fields.task, "task", fields.task.value ?? "(none)"));
535
+ case "class":
536
+ return normal(line("class", fields.class, "class", fields.class.value));
537
+ case "command_breakdown":
538
+ return fields.command_breakdown === undefined
539
+ ? null
540
+ : normal(line("command_breakdown", fields.command_breakdown, "commands", fields.command_breakdown.value));
541
+ case "gloss":
542
+ return fields.gloss === undefined
543
+ ? null
544
+ : normal(line("gloss", fields.gloss, "gloss", `${fields.gloss.value} ${TELEGRAM_GLOSS_SUFFIX}`));
545
+ case "summary":
546
+ return normal(line("summary", fields.summary, "summary", fields.summary.value ?? "(none given)"));
547
+ default:
548
+ return null;
549
+ }
550
+ }
551
+ /**
552
+ * One row's line, or `null` when this request does not carry it and when the
553
+ * channel renders it structurally rather than as a bullet.
554
+ *
555
+ * Every row in {@link PROMPT_ROWS} has a case, including the eight APRV-143 and
556
+ * APRV-163 dropped from the default layout. Those fields never left the
557
+ * `ChannelRequest` — the tasks slimmed the phone rendering and nothing else —
558
+ * so an operator turning one back on with `always` is asking for a line this
559
+ * channel can already build, not for a new fact about the log.
560
+ *
561
+ * The five rows a review card shares live in {@link reviewRow} since APRV-299
562
+ * and are delegated to here.
563
+ */
564
+ function telegramRow(request, row) {
565
+ const normal = (line_) => ({ line: line_, abnormal: false });
566
+ switch (row) {
567
+ // Structural, not a bullet: the action key is the message's second line, in
568
+ // its own `<code>` span. It is a required row, so no policy can hide it,
569
+ // and it is not a bullet, so forcing it on adds nothing.
570
+ case "action_key":
571
+ return null;
572
+ case "task":
573
+ case "class":
574
+ case "command_breakdown":
575
+ case "gloss":
576
+ case "summary":
577
+ return reviewRow(request, row);
578
+ case "protected_path":
579
+ return request.protected_path === undefined
580
+ ? null
581
+ : normal(line("protected_path", request.protected_path, "protected path", request.protected_path.value));
582
+ case "policy_diff":
583
+ return request.policy_diff === undefined
584
+ ? null
585
+ : normal(line("policy_diff", request.policy_diff, "policy diff", request.policy_diff.value));
586
+ case "policy_load":
587
+ return request.policy_load === undefined
588
+ ? null
589
+ : normal(line("policy_load", request.policy_load, "policy loads", request.policy_load.value));
590
+ case "autonomy":
591
+ return {
592
+ line: line("autonomy", request.autonomy, "autonomy", request.autonomy.value),
593
+ abnormal: request.autonomy.value !== "manual",
594
+ };
595
+ case "budgets":
596
+ return {
597
+ line: line("budgets", request.budgets, "budgets", budgetSummary(request)),
598
+ abnormal: !request.budgets.value.every((verdict) => verdict.pass),
599
+ };
600
+ case "attestation":
601
+ return {
602
+ line: line("attestation", request.attestation, "policy", attestationSummary(request)),
603
+ abnormal: request.attestation.value.status !== "attested",
604
+ };
605
+ case "provenance":
606
+ return normal(line("provenance", request.provenance, "resolved by", request.provenance.value));
607
+ case "state":
608
+ return normal(line("state", request.state, "state", request.state.value));
609
+ case "requested_ts":
610
+ return normal(line("requested_ts", request.requested_ts, "requested", request.requested_ts.value));
611
+ case "waiting":
612
+ return normal(line("waiting", request.waiting, "waiting", request.waiting.value));
613
+ case "ttl_remaining_ms":
614
+ return normal(line("ttl_remaining_ms", request.ttl_remaining_ms, "ttl", formatTelegramTtl(request.ttl_remaining_ms.value)));
615
+ case "payload_hash":
616
+ return normal(line("payload_hash", request.payload_hash, "payload sha256", request.payload_hash.value));
617
+ case "chain": {
618
+ const chain = request.chain.value;
619
+ return normal(line("chain", request.chain, "chain", `seq ${String(chain.seq)} hash ${chain.hash} (log head seq ${String(chain.head_seq)})`));
620
+ }
621
+ case "token_delivery":
622
+ return request.token_delivery === undefined
623
+ ? null
624
+ : normal(line("token_delivery", request.token_delivery, "token delivery", request.token_delivery.value));
625
+ case "est_cost_usd":
626
+ return normal(line("est_cost_usd", request.est_cost_usd, "est. cost", `$${request.est_cost_usd.value.toFixed(2)}`));
627
+ // APRV-144's `gloss` and the `summary` row are two of the five {@link
628
+ // reviewRow} answers above. Under the CLAIMED heading, because a model's
629
+ // sentence is not something the runtime derived, and labelled on the line
630
+ // as well: the `(author)` parenthetical every claimed line already carries
631
+ // is small, uniform and easy to stop seeing, and the gloss is the one line
632
+ // in the message that NO party — not the runtime, not even the requesting
633
+ // agent — stands behind. Nothing here or anywhere else branches on what it
634
+ // says, and a layout cannot move it out from under that heading: the region
635
+ // a line lands in comes from the field's `kind`, applied after the ordering.
636
+ case "rationale":
637
+ return request.rationale === undefined
638
+ ? null
639
+ : normal(line("rationale", request.rationale, "rationale", request.rationale.value));
640
+ case "confidence":
641
+ return request.confidence === undefined
642
+ ? null
643
+ : normal(line("confidence", request.confidence, "confidence", `${String(request.confidence.value)} (never a gate)`));
644
+ default:
645
+ return null;
646
+ }
647
+ }
648
+ /**
649
+ * Build the two regions and the line list. Pure: no I/O, no clock.
650
+ *
651
+ * `heading` is the message's first line. It is a parameter for exactly one
652
+ * reason (APRV-115): a digest member's prompt carries no buttons, and telling
653
+ * a reader "APPROVAL REQUIRED" above a message they cannot answer on is the
654
+ * kind of small lie that costs a channel its legibility. Everything below the
655
+ * first line is identical either way, computed/claimed split included.
656
+ *
657
+ * `layout` is the policy's answer to which rows this channel shows (APRV-218).
658
+ * It defaults to {@link TELEGRAM_PROMPT_LAYOUT}, which is the slimmed prompt
659
+ * APRV-143 and APRV-163 left behind, so a policy that declares no
660
+ * `channels.telegram.prompt` renders byte for byte what it rendered before the
661
+ * key existed. Rendering stays a pure function of (request, layout): the layout
662
+ * chooses among facts the request already carries and teaches this channel
663
+ * nothing about the log.
664
+ *
665
+ * The computed/claimed split survives ANY ordering, and that is a property
666
+ * rather than a convention. `layout.order` decides the sequence rows are
667
+ * considered in; the partition below is by `Line.kind`, which comes from the
668
+ * `TaggedField` the row was built from. A `rows` list that puts `summary`
669
+ * first therefore puts it first among the CLAIMED lines, and never above the
670
+ * computed heading.
671
+ */
672
+ export function renderTelegram(request, heading = TELEGRAM_PROMPT_HEADING, layout = TELEGRAM_PROMPT_LAYOUT) {
673
+ const payload = request.fullPayload.value;
674
+ const computedLines = [];
675
+ const claimedLines = [];
676
+ for (const row of layout.order) {
677
+ const visibility = layout.visibility[row];
678
+ if (visibility === "off")
679
+ continue;
680
+ const candidate = telegramRow(request, row);
681
+ if (candidate === null)
682
+ continue;
683
+ if (visibility === "abnormal" && !candidate.abnormal)
684
+ continue;
685
+ const entry = candidate.abnormal
686
+ ? { ...candidate.line, label: `${TELEGRAM_ANOMALY_MARK}${candidate.line.label}` }
687
+ : candidate.line;
688
+ if (entry.kind === "computed")
689
+ computedLines.push(entry);
690
+ else
691
+ claimedLines.push(entry);
692
+ }
693
+ const author = request.summary.kind === "claimed" ? request.summary.author : "the requesting party";
694
+ const render = (entry) => `• <b>${escapeHtml(entry.label)}:</b> ${escapeHtml(entry.text)} <i>(${escapeHtml(entry.origin)})</i>`;
695
+ const header = [
696
+ `<b>${escapeHtml(heading)}</b>`,
697
+ `<code>${escapeHtml(request.action_key.value)}</code>`,
698
+ "",
699
+ "<b>COMPUTED — derived by the runtime from the log, the policy and the payload bytes</b>",
700
+ ...computedLines.map(render),
701
+ ].join("\n");
702
+ const claimedText = [
703
+ `<b>${TELEGRAM_CLAIMED_HEADING_PREFIX} ${escapeHtml(author)}, ${TELEGRAM_CLAIMED_HEADING_SUFFIX}</b>`,
704
+ ...claimedLines.map(render),
705
+ ].join("\n");
706
+ return {
707
+ // Computed first, then claimed, whatever order the messages go out in:
708
+ // `lines` is the conformance suite's view of what was rendered, and the
709
+ // two-kind split it checks is a property of the fields, not of the layout.
710
+ lines: [...computedLines, ...claimedLines],
711
+ header,
712
+ claimedText,
713
+ // A whole payload needs no prefix: the canonical block states its own
714
+ // renderer, class, kind and `payload sha256` in its first lines, and a
715
+ // second sha256 above it is one more line between the reader and the
716
+ // action. A TRUNCATED rendering has no canonical block to say any of that,
717
+ // so it gets a prefix worded as what it is: a refusal. Neither is reachable
718
+ // from a layout: the block is not a row (APRV-218).
719
+ payloadText: payload === null
720
+ ? null
721
+ : `${payload.truncated ? `--- payload TRUNCATED at render (sha256 ${payload.hash}) — no canonical rendering exists; do not grant on this ---\n` : ""}${payloadRegionText(payload, request.class.value)}`,
722
+ };
723
+ }
724
+ /**
725
+ * Split `text` so every chunk survives HTML escaping inside the message limit.
726
+ *
727
+ * Splitting is by *escaped* length, because `&` becomes five characters and a
728
+ * payload full of them would otherwise produce a message Telegram rejects. The
729
+ * payload is never truncated to fit: the bytes a human is asked to approve are
730
+ * the bytes the token will execute, so an oversized payload becomes several
731
+ * messages, never a shortened one.
732
+ */
733
+ export function chunkForTelegram(text, budget = SEGMENT_BUDGET) {
734
+ const chunks = [];
735
+ let current = "";
736
+ let cost = 0;
737
+ for (const character of text) {
738
+ const size = escapeHtml(character).length;
739
+ if (cost + size > budget && current.length > 0) {
740
+ chunks.push(current);
741
+ current = "";
742
+ cost = 0;
743
+ }
744
+ current += character;
745
+ cost += size;
746
+ }
747
+ if (current.length > 0 || chunks.length === 0)
748
+ chunks.push(current);
749
+ return chunks;
750
+ }
751
+ /**
752
+ * Split an already-marked-up segment so every chunk is valid HTML on its own.
753
+ *
754
+ * {@link chunkForTelegram} may cut anywhere because its caller escapes each
755
+ * chunk and wraps it in `<pre>`; the claimed segment carries markup, so a cut
756
+ * inside `<b>` or inside `&amp;` would reach Telegram as a parse error, and a
757
+ * cut between an opening tag and its close would reach it as unbalanced HTML.
758
+ * Tags and entities are therefore atomic here, and the break is taken at the
759
+ * last line boundary in the chunk when there is one, which keeps each bullet
760
+ * whole and balanced. A bullet longer than the budget on its own (a rationale
761
+ * is unbounded agent text) splits inside its text, between tags, never within
762
+ * one — and it splits rather than being shortened, for the same reason a
763
+ * payload does.
764
+ */
765
+ export function chunkClaimedForTelegram(text, budget = SEGMENT_BUDGET) {
766
+ /** One line, as pieces no longer than the budget, cut between atoms only. */
767
+ const pieces = (input) => {
768
+ if (input.length <= budget)
769
+ return [input];
770
+ const out = [];
771
+ let piece = "";
772
+ for (const atom of input.match(/<[^>]*>|&[^;\s]*;|[\s\S]/gu) ?? []) {
773
+ if (piece.length + atom.length > budget && piece.length > 0) {
774
+ out.push(piece);
775
+ piece = "";
776
+ }
777
+ piece += atom;
778
+ }
779
+ if (piece.length > 0)
780
+ out.push(piece);
781
+ return out;
782
+ };
783
+ const chunks = [];
784
+ let current = "";
785
+ for (const linePieces of text.split("\n").map(pieces)) {
786
+ for (const piece of linePieces) {
787
+ const candidate = current.length === 0 ? piece : `${current}\n${piece}`;
788
+ if (candidate.length > budget) {
789
+ if (current.length > 0)
790
+ chunks.push(current);
791
+ current = piece;
792
+ }
793
+ else {
794
+ current = candidate;
795
+ }
796
+ }
797
+ }
798
+ if (current.length > 0 || chunks.length === 0)
799
+ chunks.push(current);
800
+ return chunks;
801
+ }
802
+ // ---------------------------------------------------------------------------
803
+ // Digests (APRV-115)
804
+ // ---------------------------------------------------------------------------
805
+ /**
806
+ * The shape token of a payload, for grouping.
807
+ *
808
+ * A shell command groups by its `argv[0]`, because that is what makes forty
809
+ * `network.call` prompts "the same question forty times" to the human reading
810
+ * them: forty `curl`s are one decision with forty URLs in it, and a `curl` next
811
+ * to an `rm` is not. Everything else groups by its top-level key set, which is
812
+ * the structural sense in which two payloads are the same shape.
813
+ *
814
+ * Structural, never self-declared: nothing here reads a `kind` or `type` field,
815
+ * for the reason `payload-view.ts` spells out — a field authored by the party
816
+ * under oversight must not choose how the party's requests are presented.
817
+ */
818
+ export function payloadShapeKey(value) {
819
+ const command = commandPayloadView(value);
820
+ if (command !== null) {
821
+ const argv0 = command.command.trim().split(/\s+/u)[0] ?? "";
822
+ return `argv0:${argv0}`;
823
+ }
824
+ if (value === null)
825
+ return "null";
826
+ if (Array.isArray(value))
827
+ return "array";
828
+ if (typeof value !== "object")
829
+ return `scalar:${typeof value}`;
830
+ return `keys:${Object.keys(value).sort().join(",")}`;
831
+ }
832
+ /**
833
+ * The grouping key: requests that share it are the same question asked twice.
834
+ *
835
+ * Signed off 2026-08-25 as (class, origin session/task, argv[0] or payload
836
+ * shape). The requesting actor rides along too, which can only ever SPLIT a
837
+ * group — two agents working the same task get two digests — and splitting is
838
+ * the safe direction: it costs a message and never merges two things a human
839
+ * would have wanted to weigh separately.
840
+ *
841
+ * `"\0"` as the separator because every component is agent-influenced text
842
+ * and a separator that can appear inside one would let a crafted task name
843
+ * collide two classes into one group. Written as the escape, never the raw
844
+ * byte: a literal NUL in the source turns this file into "binary" for grep,
845
+ * diff tooling, and editors, and the escape compiles to the same string.
846
+ */
847
+ export function digestKeyOf(request) {
848
+ return [
849
+ request.class.value,
850
+ request.task.value ?? "",
851
+ request.autonomy.value,
852
+ originOf(request.summary),
853
+ payloadShapeKey(request.fullPayload.value?.value),
854
+ ].join("\0");
855
+ }
856
+ /**
857
+ * Split `requests` into digest groups, preserving queue order.
858
+ *
859
+ * One poll window is the whole window: this is called on the requests one
860
+ * dispatch cycle found undelivered, and nothing here waits for more. A group of
861
+ * one is returned as a group of one, and the caller sends it as an ordinary
862
+ * prompt.
863
+ */
864
+ /**
865
+ * The key that makes ONE tool call one question (APRV-287), or `null` for a
866
+ * request that names no task or no payload.
867
+ *
868
+ * A shell command that touches several classes raises one request per class
869
+ * (`cli/hook.ts` mints `<task>:<class>` keys), and every one of them carries the
870
+ * same task and the same payload hash: they are one command, asked about once,
871
+ * with the log keeping a record per class because that is what audit granularity
872
+ * requires. Grouping them by class the way {@link digestKeyOf} does put five
873
+ * separate cards on a phone for one `git commit && git push` on 2026-09-06, and
874
+ * the approver had to tap through three rounds of them.
875
+ *
876
+ * The pair is enough on its own. A task id is one tool call, and a payload hash
877
+ * is the bytes it is about, so two requests sharing both are two classes of one
878
+ * command and can never be two commands. Both are computed: the task id is
879
+ * minted by the runtime and the hash is recomputed from the payload bytes
880
+ * (`channels/contract.ts`), so nothing an agent authors chooses this grouping.
881
+ */
882
+ function toolCallKeyOf(request) {
883
+ const task = request.task.value;
884
+ const hash = request.payload_hash.value;
885
+ if (task === null || task.length === 0)
886
+ return null;
887
+ if (typeof hash !== "string" || hash.length === 0)
888
+ return null;
889
+ return [task, hash].join("\0");
890
+ }
891
+ export function groupForDigest(requests, max = TELEGRAM_DIGEST_MAX_MEMBERS) {
892
+ const groups = [];
893
+ const byKey = new Map();
894
+ // APRV-287, before the class grouping and never instead of it. The classes of
895
+ // one tool call are one question however many they are; everything else is
896
+ // grouped as it always was, so a burst of forty separate `network.call`s from
897
+ // forty tool calls still digests by class.
898
+ // Only where the classes DIFFER: members of one tool call that share a class
899
+ // are grouped by the class key already, and the two groupings then agree.
900
+ // Narrowing it this way keeps every existing grouping exactly as it was and
901
+ // changes only the case this task is about, the one command a human was asked
902
+ // about once per class.
903
+ const oneCall = new Set();
904
+ const seen = new Map();
905
+ for (const request of requests) {
906
+ const key = toolCallKeyOf(request);
907
+ if (key === null)
908
+ continue;
909
+ const classes = seen.get(key) ?? new Set();
910
+ classes.add(request.class.value);
911
+ seen.set(key, classes);
912
+ if (classes.size > 1)
913
+ oneCall.add(key);
914
+ }
915
+ for (const request of requests) {
916
+ const call = toolCallKeyOf(request);
917
+ const key = call !== null && oneCall.has(call) ? `tool-call\0${call}` : digestKeyOf(request);
918
+ let group = byKey.get(key);
919
+ // A group that has reached the cap is closed and a fresh one opened under
920
+ // the same key: a burst of twenty becomes three digests, never one wall.
921
+ if (group === undefined || group.length >= max) {
922
+ group = [];
923
+ byKey.set(key, group);
924
+ groups.push(group);
925
+ }
926
+ group.push(request);
927
+ }
928
+ return groups;
929
+ }
930
+ /**
931
+ * The computed facts a digest's members share, as the digest states them.
932
+ *
933
+ * Every one is read off the first member, which is sound precisely because the
934
+ * grouping key made them equal across the set: a digest whose members disagreed
935
+ * about their class or their task is a digest the listener would not have
936
+ * built. The last line is the one an approver needs most — it says how many
937
+ * payloads are above and that each request has its own.
938
+ */
939
+ export function digestFacts(members) {
940
+ const first = members[0];
941
+ if (first === undefined)
942
+ return [];
943
+ // APRV-287. A digest may now carry the several classes of ONE tool call, so
944
+ // the class line states the set rather than the first member's, and the
945
+ // grouping line says which of the two groupings put this set together. A
946
+ // digest whose members share one class reads exactly as it did.
947
+ const classes = [...new Set(members.map((member) => member.class.value))];
948
+ const shared = sharedPayload(members);
949
+ const autonomies = [...new Set(members.map((member) => member.autonomy.value))];
950
+ return [
951
+ { label: "class", text: classes.join(", "), origin: originOf(first.class) },
952
+ { label: "autonomy", text: autonomies.join(", "), origin: originOf(first.autonomy) },
953
+ { label: "task", text: first.task.value ?? "(none)", origin: originOf(first.task) },
954
+ {
955
+ label: "grouped by",
956
+ text: shared === null
957
+ ? `one class, one task, one payload shape (${payloadShapeKey(first.fullPayload.value?.value)})`
958
+ : `one tool call: one task, one payload, ${String(classes.length)} class(es) of the same command`,
959
+ origin: "grouping",
960
+ },
961
+ {
962
+ label: "payloads",
963
+ text: shared === null
964
+ ? `${members.length} full payloads, one per request, in the ${members.length} prompts above this message`
965
+ : `one payload, shared by all ${members.length} requests, in the prompt above this message`,
966
+ origin: originOf(first.fullPayload),
967
+ },
968
+ ];
969
+ }
970
+ /**
971
+ * The one payload every member is about, or `null` when they differ
972
+ * (APRV-287).
973
+ *
974
+ * The computed hash decides it, never the rendering: two members share a
975
+ * payload when the bytes the grants bind to are the same bytes. A member whose
976
+ * full payload the channel was not given in full is not a shared payload
977
+ * either, because "they are all this one, which you have read" is a claim about
978
+ * something the approver was shown.
979
+ */
980
+ function sharedPayload(members) {
981
+ const first = members[0];
982
+ if (first === undefined || members.length < 2)
983
+ return null;
984
+ const hash = first.payload_hash.value;
985
+ if (typeof hash !== "string" || hash.length === 0)
986
+ return null;
987
+ if (!members.every((member) => member.payload_hash.value === hash))
988
+ return null;
989
+ const rendering = first.fullPayload.value;
990
+ if (rendering === null || rendering.truncated)
991
+ return null;
992
+ return first;
993
+ }
994
+ /**
995
+ * The collapsed re-delivery's own message: what is waiting, and one way to
996
+ * clear it (APRV-287).
997
+ *
998
+ * Everything above the buttons is computed by the runtime from the verified log
999
+ * — the count, the ages, the classes — and the per-member lines carry the
1000
+ * agent's own summaries under a heading that says so, exactly as an ordinary
1001
+ * digest does. What it does NOT carry is any payload, and the message says so
1002
+ * in the same breath as it explains why the only button rejects: a decision is
1003
+ * bound to the bytes the approver was shown, and nothing here shows them.
1004
+ */
1005
+ function renderStaleSummary(digest, stale, open, total) {
1006
+ const lines = [
1007
+ `<b>${escapeHtml(open === 0
1008
+ ? `ALL ${total} STALE REQUESTS DECIDED`
1009
+ : `${open} STALE REQUEST${open === 1 ? "" : "S"} — NOBODY IS WAITING ON ${open === 1 ? "IT" : "THEM"}`)}</b>`,
1010
+ "",
1011
+ "<b>COMPUTED — derived by the runtime from the log and the policy</b>",
1012
+ ...stale.lines.map((line) => `• ${escapeHtml(line)}`),
1013
+ ...digest.facts.map((fact) => `• <b>${escapeHtml(fact.label)}:</b> ${escapeHtml(fact.text)} <i>(${escapeHtml(fact.origin)})</i>`),
1014
+ "",
1015
+ `<b>CLAIMED — authored by ${escapeHtml(digest.author)}, NOT verified by the runtime</b>`,
1016
+ ];
1017
+ for (const [index, member] of digest.members.entries()) {
1018
+ lines.push(`${index + 1}. <code>${escapeHtml(member.actionKey)}</code> — ${escapeHtml(member.summary)}`);
1019
+ if (member.settled !== null) {
1020
+ lines.push(` <b>${escapeHtml(member.settled.headline)}</b>`);
1021
+ for (const detail of member.settled.detail)
1022
+ lines.push(` ${escapeHtml(detail)}`);
1023
+ }
1024
+ }
1025
+ lines.push("", escapeHtml("These asked while a hook waited, and the wait is long over: no tool call is holding the answer. This message carries NO payload, so it offers no approve button — a decision is bound to the bytes you were shown, and nothing here shows them. Rejecting is one log event per request and authorizes nothing. To approve one instead, decide it on its own card or run `approval grant <action key>`; the requests stay listed by /queue either way."));
1026
+ const rows = open === 0
1027
+ ? []
1028
+ : [
1029
+ [
1030
+ {
1031
+ text: `🛑 Reject all (${open})`,
1032
+ callback_data: digestCallbackData("R", digest.allNonce),
1033
+ },
1034
+ ],
1035
+ ];
1036
+ return {
1037
+ text: lines.join("\n"),
1038
+ keyboard: rows.length === 0 ? null : { inline_keyboard: rows },
1039
+ };
1040
+ }
1041
+ /** The digest's headline, given how much of it is still open. */
1042
+ function digestHeadline(open, total) {
1043
+ if (open === 0)
1044
+ return `ALL ${total} REQUESTS DECIDED`;
1045
+ if (open === total)
1046
+ return `${total} REQUESTS AWAITING APPROVAL`;
1047
+ return `${open} OF ${total} REQUESTS STILL AWAITING APPROVAL`;
1048
+ }
1049
+ /**
1050
+ * The digest message: text plus the keyboard for whatever is still open.
1051
+ *
1052
+ * Pure. The computed/claimed split of an ordinary prompt is kept — the shared
1053
+ * facts are computed and sit under a heading that says so, the per-member lines
1054
+ * are the agent's own words and sit under one that says they are not verified —
1055
+ * because a digest is a prompt, and SPEC.md §9 does not stop applying because
1056
+ * there are five of them.
1057
+ *
1058
+ * A settled member keeps its line, gains its outcome underneath, and loses its
1059
+ * buttons. The "all" row appears only while two or more members are open: with
1060
+ * one left, "all" is the same tap as its own Approve and a second way to do one
1061
+ * thing is a way to do the wrong one.
1062
+ */
1063
+ export function renderDigest(digest) {
1064
+ const open = digest.members.filter((member) => member.settled === null);
1065
+ const total = digest.members.length;
1066
+ const stale = digest.stale ?? null;
1067
+ if (stale !== null)
1068
+ return renderStaleSummary(digest, stale, open.length, total);
1069
+ const lines = [
1070
+ `<b>${escapeHtml(digestHeadline(open.length, total))}</b>`,
1071
+ "",
1072
+ "<b>COMPUTED — derived by the runtime from the log and the policy</b>",
1073
+ ...digest.facts.map((fact) => `• <b>${escapeHtml(fact.label)}:</b> ${escapeHtml(fact.text)} <i>(${escapeHtml(fact.origin)})</i>`),
1074
+ "",
1075
+ `<b>CLAIMED — authored by ${escapeHtml(digest.author)}, NOT verified by the runtime</b>`,
1076
+ ];
1077
+ for (const [index, member] of digest.members.entries()) {
1078
+ lines.push(`${index + 1}. <code>${escapeHtml(member.actionKey)}</code> — ${escapeHtml(member.summary)} · ${escapeHtml(member.cost)}`);
1079
+ if (member.settled !== null) {
1080
+ lines.push(` <b>${escapeHtml(member.settled.headline)}</b>`);
1081
+ for (const detail of member.settled.detail)
1082
+ lines.push(` ${escapeHtml(detail)}`);
1083
+ }
1084
+ }
1085
+ lines.push("", escapeHtml(`Each button decides ONE request, numbered as above. "all" is ${open.length} separate decisions, one log event each; the full payload of every request is in the messages above this one.`));
1086
+ const rows = [];
1087
+ for (const [index, member] of digest.members.entries()) {
1088
+ if (member.settled !== null)
1089
+ continue;
1090
+ rows.push([
1091
+ {
1092
+ text: `✅ Approve ${index + 1}`,
1093
+ callback_data: callbackData("g", member.nonce, member.actionKey),
1094
+ },
1095
+ {
1096
+ text: `🛑 Reject ${index + 1}`,
1097
+ callback_data: callbackData("r", member.nonce, member.actionKey),
1098
+ },
1099
+ ]);
1100
+ }
1101
+ if (open.length > 1) {
1102
+ rows.push([
1103
+ {
1104
+ text: `✅ Approve all (${open.length})`,
1105
+ callback_data: digestCallbackData("G", digest.allNonce),
1106
+ },
1107
+ {
1108
+ text: `🛑 Reject all (${open.length})`,
1109
+ callback_data: digestCallbackData("R", digest.allNonce),
1110
+ },
1111
+ ]);
1112
+ }
1113
+ return {
1114
+ text: lines.join("\n"),
1115
+ keyboard: rows.length === 0 ? null : { inline_keyboard: rows },
1116
+ };
1117
+ }
1118
+ // ---------------------------------------------------------------------------
1119
+ // Callback data
1120
+ // ---------------------------------------------------------------------------
1121
+ /**
1122
+ * The stable short reference to an action key that a button carries (APRV-196).
1123
+ *
1124
+ * The first {@link ACTION_REF_HEX} hex characters of the key's sha256. Two
1125
+ * properties earn it its place, and they are the two the old scheme lacked:
1126
+ *
1127
+ * 1. **It always fits.** `<verb>:<nonce>:<ref>` is well inside Telegram's
1128
+ * 64-byte cap for any nonce this class issues, so the cross-check that used
1129
+ * to be dropped for a long action key is now always present.
1130
+ * 2. **It survives a restart.** The nonce is per-process and per-copy; the ref
1131
+ * is a function of the action key alone, so two copies of the same request
1132
+ * delivered by two different listener processes carry the same ref. That is
1133
+ * what lets a tap on a pre-restart copy resolve to the request the current
1134
+ * process is holding, instead of dying as an unknown nonce.
1135
+ *
1136
+ * It is a REFERENCE and never an authorization. The bytes come back from the
1137
+ * network, so a ref is only ever matched against deliveries THIS process made
1138
+ * (and only from the configured chat); it can select among what the listener
1139
+ * has itself put in front of the approver, and it can name nothing else.
1140
+ */
1141
+ export const ACTION_REF_HEX = 16;
1142
+ export function actionRefOf(actionKey) {
1143
+ return createHash("sha256").update(actionKey, "utf8").digest("hex").slice(0, ACTION_REF_HEX);
1144
+ }
1145
+ /**
1146
+ * `callback_data` for one button: `<g|r>:<nonce>:<action ref>`.
1147
+ *
1148
+ * The **nonce is authoritative** where it resolves: it is issued by this process
1149
+ * at `notify` and maps to the request that was actually delivered, so an
1150
+ * ordinary tap never consults the ref for anything but a cross-check (a
1151
+ * mismatch is an anomaly and the callback is dropped). The ref is the fallback
1152
+ * for the copy whose nonce this process never issued, and {@link actionRefOf}
1153
+ * states the bound on what that fallback may reach.
1154
+ */
1155
+ export function callbackData(verb, nonce, actionKey) {
1156
+ const withRef = `${verb}:${nonce}:${actionRefOf(actionKey)}`;
1157
+ // Unreachable with the nonces this class issues, and kept because the failure
1158
+ // it guards is the worst one available here: `callback_data` over the cap is
1159
+ // refused by `sendMessage`, so an over-long nonce would stop DELIVERY rather
1160
+ // than degrade a lookup. Dropping the reference costs a stale copy's tap its
1161
+ // rescue and leaves every other property intact.
1162
+ return Buffer.byteLength(withRef, "utf8") <= TELEGRAM_MAX_CALLBACK_BYTES
1163
+ ? withRef
1164
+ : `${verb}:${nonce}`;
1165
+ }
1166
+ /**
1167
+ * `callback_data` for a digest's "all" button: `<G|R>:<nonce>` (APRV-115).
1168
+ *
1169
+ * Upper case, and no action key: an "all" button names a *delivery*, and the
1170
+ * set it decides is whichever members of that delivery are still open at the
1171
+ * moment of the tap — which the delivering process knows and the network does
1172
+ * not. Naming keys in the bytes would let something that can reach the bot
1173
+ * choose the set, and there is no length at which that becomes acceptable.
1174
+ */
1175
+ export function digestCallbackData(verb, nonce) {
1176
+ return `${verb}:${nonce}`;
1177
+ }
1178
+ /**
1179
+ * `callback_data` for the checkpoint prompt's two buttons (APRV-257).
1180
+ *
1181
+ * `k:<nonce>` signs, `x:<nonce>` declines. Verbs of their own rather than a
1182
+ * reuse of `g`/`r`, and the separation is load-bearing: {@link CALLBACK_VERBS}
1183
+ * maps every decision verb onto a grant or a reject, so a checkpoint button
1184
+ * spelled `g` would be a button {@link parseCallbackData} hands to the decision
1185
+ * path — where an unknown nonce becomes an action-reference lookup, and a
1186
+ * signature gesture starts hunting for a request to approve. Two vocabularies,
1187
+ * two parsers, and neither can be read as the other.
1188
+ *
1189
+ * No action key and no reference in the bytes: a checkpoint names no request,
1190
+ * and the head it covers is held by the process that issued the nonce, exactly
1191
+ * as a digest's member set is. Nothing that can reach the bot chooses what gets
1192
+ * signed.
1193
+ */
1194
+ export function checkpointCallbackData(verb, nonce) {
1195
+ return `${verb}:${nonce}`;
1196
+ }
1197
+ /** `k:<nonce>` / `x:<nonce>`, or `null` for anything else. Never throws. */
1198
+ export function parseCheckpointCallback(data) {
1199
+ if (typeof data !== "string")
1200
+ return null;
1201
+ const verb = data.slice(0, 1);
1202
+ if ((verb !== "k" && verb !== "x") || data.slice(1, 2) !== ":")
1203
+ return null;
1204
+ const nonce = data.slice(2);
1205
+ if (nonce.length === 0 || nonce.includes(":"))
1206
+ return null;
1207
+ return { sign: verb === "k", nonce };
1208
+ }
1209
+ const CALLBACK_VERBS = {
1210
+ g: { decision: "grant", scope: "one" },
1211
+ r: { decision: "reject", scope: "one" },
1212
+ G: { decision: "grant", scope: "all" },
1213
+ R: { decision: "reject", scope: "all" },
1214
+ };
1215
+ export function parseCallbackData(data) {
1216
+ if (typeof data !== "string")
1217
+ return null;
1218
+ const first = data.indexOf(":");
1219
+ if (first === -1)
1220
+ return null;
1221
+ const verb = CALLBACK_VERBS[data.slice(0, first)];
1222
+ if (verb === undefined)
1223
+ return null;
1224
+ const rest = data.slice(first + 1);
1225
+ const second = rest.indexOf(":");
1226
+ const nonce = second === -1 ? rest : rest.slice(0, second);
1227
+ if (nonce.length === 0)
1228
+ return null;
1229
+ return {
1230
+ decision: verb.decision,
1231
+ scope: verb.scope,
1232
+ nonce,
1233
+ actionRef: second === -1 ? null : rest.slice(second + 1),
1234
+ };
1235
+ }
1236
+ // ---------------------------------------------------------------------------
1237
+ // Review cards (APRV-299)
1238
+ // ---------------------------------------------------------------------------
1239
+ /**
1240
+ * The headline of a retrospective review card.
1241
+ *
1242
+ * Deliberately not {@link TELEGRAM_PROMPT_HEADING} and deliberately not a
1243
+ * question. A sample is an action that ALREADY RAN: nothing is pending, no
1244
+ * token is minted by any button on this message, and a card that said
1245
+ * "APPROVAL REQUIRED" would be telling the approver they are holding something
1246
+ * up. The supervised bargain (SPEC.md §5.2) is "execute now, a fraction is
1247
+ * reviewed after", and this is the "after".
1248
+ */
1249
+ export const TELEGRAM_REVIEW_HEADING = "REVIEW — THIS ALREADY RAN";
1250
+ /** The headline a recorded review puts on the card it settles. */
1251
+ export const TELEGRAM_REVIEW_RECORDED = "✓ REVIEWED";
1252
+ /** The headline a recorded DENIAL puts on the card it settles. */
1253
+ export const TELEGRAM_REVIEW_DENIED = "✗ REVIEWED — DENIED";
1254
+ /**
1255
+ * The headline a card wears while a first Deny tap is armed and nothing has
1256
+ * been recorded.
1257
+ */
1258
+ export const TELEGRAM_REVIEW_ARMED = "DENY ARMED — nothing is recorded yet";
1259
+ /**
1260
+ * What a review tap's single answer says (APRV-302).
1261
+ *
1262
+ * {@link TELEGRAM_ACK_HEARD}'s "deciding" is a request card's word: something is
1263
+ * pending, and the tap just settled it. A review decides nothing — the action
1264
+ * ran, and what the tap does is record what a person thought of it — so a
1265
+ * reviewer told they were "deciding" is being told the wrong thing about the
1266
+ * card in front of them. The load-bearing half is carried over unchanged: this
1267
+ * claims only that the tap ARRIVED, never that anything was appended, because at
1268
+ * the moment it is sent nothing has been and `core/audit.ts` may still refuse.
1269
+ * What became of it is on the card edit that follows.
1270
+ */
1271
+ export const TELEGRAM_REVIEW_ACK = "Heard — recording your review. The card will say what the log recorded.";
1272
+ /** The toast a first Deny tap gets: it says plainly that nothing was written. */
1273
+ export const TELEGRAM_REVIEW_ARM_TOAST = "Deny armed — nothing recorded. Tap Deny again to record it, or a reaction to record it with a grade.";
1274
+ /** The toast a reaction that needs the human's own words gets. */
1275
+ export const TELEGRAM_REVIEW_NOTE_TOAST = "Heard — reply to the prompt with why. Nothing is recorded until it arrives.";
1276
+ /**
1277
+ * What the ForceReply prompt asks for.
1278
+ *
1279
+ * A separate message rather than a second keyboard, because Telegram's inline
1280
+ * keyboards have no text input at all — the same limitation the reject path
1281
+ * documents. The prompt is bound to its card by the message id the reply names,
1282
+ * which this process holds and the network does not.
1283
+ */
1284
+ export function reviewNotePromptLines(reaction, verdict, actionKey) {
1285
+ return [
1286
+ `WHY ${reaction.toUpperCase()}?`,
1287
+ `Reply to this message with the reason. It is recorded verbatim beside a ${verdict} review of ${actionKey}.`,
1288
+ "Nothing has been appended yet, and a blank reply appends nothing: the grade an agent is most likely to act on is the one it can least interpret alone.",
1289
+ ];
1290
+ }
1291
+ /**
1292
+ * The six things a review card's buttons can say (APRV-299).
1293
+ *
1294
+ * `ok` and `deny` are the verdict, which is enforcement; the four reactions are
1295
+ * the grade, which is not (SPEC.md §11.1 invariant 10). Both travel in the same
1296
+ * closed vocabulary because they arrive through the same six buttons, and a
1297
+ * seventh word would be a button nobody drew.
1298
+ */
1299
+ export const REVIEW_CHOICES = ["ok", "deny", ...REACTIONS];
1300
+ /**
1301
+ * `callback_data` for one review button: `v:<nonce>:<choice>`.
1302
+ *
1303
+ * Its own verb, for exactly the reason the checkpoint prompt's is its own
1304
+ * (APRV-257): {@link CALLBACK_VERBS} maps every DECISION verb onto a grant or a
1305
+ * reject, so a review button spelled `g` would be handed to the decision path,
1306
+ * where an unresolved nonce falls back to an action-reference lookup and a
1307
+ * gesture about something that already happened would start hunting for a
1308
+ * request to approve. Three vocabularies, three parsers, and none can be read
1309
+ * as another.
1310
+ *
1311
+ * No action reference in the bytes, and no sample seq: the card names a
1312
+ * DELIVERY, and which sample that delivery is about is held by the process that
1313
+ * issued the nonce. Nothing that can reach the bot chooses what gets reviewed.
1314
+ * There is also no stale-copy ladder underneath it: a review is never urgent,
1315
+ * a lost card leaves the sample open, and the next cycle offers it again.
1316
+ */
1317
+ export function reviewCallbackData(choice, nonce) {
1318
+ return `v:${nonce}:${choice}`;
1319
+ }
1320
+ /** `v:<nonce>:<choice>`, or `null` for anything else. Never throws. */
1321
+ export function parseReviewCallback(data) {
1322
+ if (typeof data !== "string")
1323
+ return null;
1324
+ if (data.slice(0, 2) !== "v:")
1325
+ return null;
1326
+ const rest = data.slice(2);
1327
+ const split = rest.indexOf(":");
1328
+ if (split <= 0)
1329
+ return null;
1330
+ const nonce = rest.slice(0, split);
1331
+ const choice = rest.slice(split + 1);
1332
+ const found = REVIEW_CHOICES.find((candidate) => candidate === choice);
1333
+ return found === undefined ? null : { nonce, choice: found };
1334
+ }
1335
+ /**
1336
+ * The label each button carries. Emoji live here and never in message text.
1337
+ *
1338
+ * Bare emoji, no words (APRV-302). The first live cards put a word beside every
1339
+ * glyph, which bought nothing: six labelled buttons on a phone wrap, and the
1340
+ * words repeated what the card had already said in full sentences above them.
1341
+ * The layout is what carries the meaning now: row one is the verdict (record it
1342
+ * as fine, or arm the denial), row two is the grade, worst to best, in the same
1343
+ * order `REACTIONS` gives everywhere else.
1344
+ */
1345
+ const REVIEW_BUTTON_LABELS = {
1346
+ ok: "✅",
1347
+ deny: "🛑",
1348
+ disliked: "👎",
1349
+ indifferent: "😐",
1350
+ liked: "👍",
1351
+ loved: "❤️",
1352
+ };
1353
+ /**
1354
+ * How much of one refusal message a card carries.
1355
+ *
1356
+ * The audit refusals are paragraphs — they explain what the reviewer meant and
1357
+ * how to say it instead — and a card carrying one whole can overrun Telegram's
1358
+ * message limit, at which point the edit fails and the human is told nothing at
1359
+ * all. So the prose is cut and the cut is marked. The CODE is never cut: it is
1360
+ * the machine-readable half, it is short, and it is on its own line above.
1361
+ */
1362
+ const REVIEW_NOTICE_MAX = 900;
1363
+ function trimNotice(text) {
1364
+ return text.length <= REVIEW_NOTICE_MAX
1365
+ ? text
1366
+ : `${text.slice(0, REVIEW_NOTICE_MAX)}… (cut to fit one message; the whole refusal is on the listener's stderr)`;
1367
+ }
1368
+ /**
1369
+ * The card's message: the rows, whatever notice the last tap produced, and the
1370
+ * keyboard.
1371
+ *
1372
+ * No paragraph explaining the buttons (APRV-302). The heading
1373
+ * ({@link TELEGRAM_REVIEW_HEADING}) is what says a review is not a request, and
1374
+ * the deny latch says itself: the first tap is answered by
1375
+ * {@link TELEGRAM_REVIEW_ARM_TOAST} and the card's own heading becomes
1376
+ * {@link TELEGRAM_REVIEW_ARMED} until it is spent. Four sentences of rules under
1377
+ * every card said the same thing to a reader who had already read them once, and
1378
+ * pushed the rows a review is actually about off the first screen.
1379
+ *
1380
+ * Pure. Two things it deliberately does NOT carry, and both are the same rule
1381
+ * read twice: no payload region, and no approve button. SPEC.md §10.3 requires
1382
+ * the canonical rendering in front of an approver before a DECISION is
1383
+ * collected, and this collects none — the action ran, the review says only what
1384
+ * a person thought of it, and a card that offered an approve would be
1385
+ * presenting a settled fact as a live authorization. A sample is never
1386
+ * delivered as an approval request and never accepts a token.
1387
+ */
1388
+ export function renderReviewCard(state) {
1389
+ const card = state.card;
1390
+ const key = card.fields.action_key.value;
1391
+ if (state.settled !== null) {
1392
+ return {
1393
+ text: [
1394
+ `<b>${escapeHtml(state.settled.headline)}</b>`,
1395
+ `<code>${escapeHtml(key)}</code>`,
1396
+ "",
1397
+ ...state.settled.detail.map((entry) => escapeHtml(trimNotice(entry))),
1398
+ ].join("\n"),
1399
+ keyboard: null,
1400
+ };
1401
+ }
1402
+ const computedLines = [];
1403
+ const claimedLines = [];
1404
+ for (const row of REVIEW_CARD_ROWS) {
1405
+ const candidate = reviewRow(card.fields, row);
1406
+ if (candidate === null)
1407
+ continue;
1408
+ if (candidate.line.kind === "computed")
1409
+ computedLines.push(candidate.line);
1410
+ else
1411
+ claimedLines.push(candidate.line);
1412
+ }
1413
+ computedLines.push(line("ran_at", card.ranAt, "ran at", card.ranAt.value));
1414
+ computedLines.push(line("verdict", card.verdict, "verdict", card.verdict.value));
1415
+ const render = (entry) => `• <b>${escapeHtml(entry.label)}:</b> ${escapeHtml(entry.text)} <i>(${escapeHtml(entry.origin)})</i>`;
1416
+ const author = originOf(card.fields.summary);
1417
+ const lines = [
1418
+ `<b>${escapeHtml(state.denyArmed ? `${TELEGRAM_REVIEW_HEADING} — ${TELEGRAM_REVIEW_ARMED}` : TELEGRAM_REVIEW_HEADING)}</b>`,
1419
+ `<code>${escapeHtml(key)}</code>`,
1420
+ "",
1421
+ "<b>COMPUTED — derived by the runtime from the log, the policy and the payload bytes</b>",
1422
+ ...computedLines.map(render),
1423
+ "",
1424
+ `<b>CLAIMED — authored by ${escapeHtml(author)}, NOT verified by the runtime</b>`,
1425
+ ...claimedLines.map(render),
1426
+ ];
1427
+ if (state.notice !== null) {
1428
+ lines.push("", `<b>${escapeHtml(state.notice.headline)}</b>`, ...state.notice.lines.map((entry) => escapeHtml(trimNotice(entry))));
1429
+ }
1430
+ const button = (choice) => ({
1431
+ text: REVIEW_BUTTON_LABELS[choice],
1432
+ callback_data: reviewCallbackData(choice, state.nonce),
1433
+ });
1434
+ return {
1435
+ text: lines.join("\n"),
1436
+ keyboard: {
1437
+ inline_keyboard: [
1438
+ [button("ok"), button("deny")],
1439
+ REACTIONS.map((reaction) => button(reaction)),
1440
+ ],
1441
+ },
1442
+ };
1443
+ }
1444
+ // ---------------------------------------------------------------------------
1445
+ // Errors and redaction
1446
+ // ---------------------------------------------------------------------------
1447
+ /** A Bot API call that did not produce a usable result. */
1448
+ export class TelegramApiError extends Error {
1449
+ method;
1450
+ status;
1451
+ description;
1452
+ constructor(message, method,
1453
+ /**
1454
+ * The HTTP status, when the failure was an HTTP one. `null` for a transport
1455
+ * failure, an unparseable body, or an `ok: false` envelope that arrived
1456
+ * with a 200 (APRV-277).
1457
+ */
1458
+ status = null,
1459
+ /**
1460
+ * The Bot API's own `description` for this failure, redacted, when the
1461
+ * error body carried one. `null` when the body was absent, unreadable, not
1462
+ * JSON, or carried no description.
1463
+ */
1464
+ description = null) {
1465
+ super(message);
1466
+ this.method = method;
1467
+ this.status = status;
1468
+ this.description = description;
1469
+ this.name = "TelegramApiError";
1470
+ }
1471
+ }
1472
+ /**
1473
+ * Telegram's wording for "that edit would have changed nothing" (APRV-277).
1474
+ *
1475
+ * Matched on the description rather than the status alone, because 400 is also
1476
+ * every malformed edit, every wrong chat and every deleted message.
1477
+ */
1478
+ const TELEGRAM_NOT_MODIFIED = /message is not modified/iu;
1479
+ /**
1480
+ * Whether a failed call is the Bot API saying an edit changed nothing
1481
+ * (APRV-277).
1482
+ *
1483
+ * `editMessageText` answers 400 "Bad Request: message is not modified" when the
1484
+ * text and the keyboard it was handed are already what the message holds. Every
1485
+ * caller here re-annotates from the verified log rather than from memory, so a
1486
+ * message annotated once and derived again produces exactly that: the phone
1487
+ * already shows the outcome, and the operator has nothing to be told. It is the
1488
+ * one 400 that means the intended state stands, which is why it is the only one
1489
+ * that goes unreported.
1490
+ */
1491
+ export function isMessageNotModified(cause) {
1492
+ return (cause instanceof TelegramApiError &&
1493
+ cause.status === 400 &&
1494
+ cause.description !== null &&
1495
+ TELEGRAM_NOT_MODIFIED.test(cause.description));
1496
+ }
1497
+ export class TelegramChannel {
1498
+ name = "telegram";
1499
+ token;
1500
+ chatId;
1501
+ apiBase;
1502
+ fetchImpl;
1503
+ pollTimeoutSeconds;
1504
+ requestTimeoutMs;
1505
+ backoffMs;
1506
+ maxBackoffMs;
1507
+ complain;
1508
+ makeNonce;
1509
+ /** The policy's approval TTL, or `null` when it declares none (APRV-135). */
1510
+ approvalTtlMs;
1511
+ now;
1512
+ /** The listener's verified-log probe for a stale tap (APRV-196), or null. */
1513
+ describeAction;
1514
+ /** The policy's row layout for this channel (APRV-218). Read-only, and pure input to the renderer. */
1515
+ layout;
1516
+ /** When {@link sweep} last ran, so the poll loop can call it every cycle. */
1517
+ lastSweepMs = Number.NEGATIVE_INFINITY;
1518
+ /**
1519
+ * The callback query being handled, and whether an ack has been attempted for
1520
+ * it (APRV-196). Set and cleared by {@link handleUpdate}, which processes
1521
+ * updates one at a time and awaits each.
1522
+ */
1523
+ ack = null;
1524
+ handler = null;
1525
+ /**
1526
+ * What to do with a bot command (APRV-216). Absent unless the runtime asked
1527
+ * for commands, and its absence is what keeps `message` out of
1528
+ * `allowed_updates` — see {@link onCommand}.
1529
+ */
1530
+ commandHandler = null;
1531
+ /**
1532
+ * What to do with a checkpoint tap (APRV-257). Absent unless the runtime
1533
+ * registered one, and its absence makes {@link offerCheckpoint} refuse: a
1534
+ * button nobody is listening for is a button that spins on a phone.
1535
+ */
1536
+ checkpointHandler = null;
1537
+ /**
1538
+ * Checkpoint nonce -> the head that prompt asked about, and the message it is
1539
+ * on. **In memory only**, like every other map in this class and for the same
1540
+ * reason (SPEC.md §10.3: channels hold no state that is a source of truth).
1541
+ *
1542
+ * The head lives HERE and not in the callback bytes, so what is signed is
1543
+ * what this process put on the screen. Losing the map to a restart costs a
1544
+ * tap its meaning — the button answers `unknown-callback` and the listener
1545
+ * offers again on its next lapse — and can never cost a signature over
1546
+ * something nobody was shown.
1547
+ */
1548
+ checkpointNonces = new Map();
1549
+ /**
1550
+ * What to do with a review tap (APRV-299). Absent unless the runtime
1551
+ * registered one, and its absence makes {@link offerReview} refuse, for the
1552
+ * reason {@link offerCheckpoint} refuses: a button nobody is listening for is
1553
+ * a button that spins on a phone.
1554
+ */
1555
+ reviewHandler = null;
1556
+ /**
1557
+ * Review card message id -> what is on it. Delivery bookkeeping, never truth
1558
+ * (SPEC.md §10.3). Losing it to a restart costs the card its buttons; the
1559
+ * sample stays open in the log, `approval audit list` still names it, and the
1560
+ * next cycle offers a fresh card.
1561
+ */
1562
+ reviewCards = new Map();
1563
+ /** Review nonce -> the card message it was issued for. */
1564
+ reviewNonces = new Map();
1565
+ /** Note-prompt message id -> the card whose reply it is waiting for. */
1566
+ reviewNotePrompts = new Map();
1567
+ deliveries = new Map();
1568
+ /** Digest message id -> what is on it. Delivery bookkeeping, never truth. */
1569
+ digests = new Map();
1570
+ /** "All" nonce -> the digest message it was issued for. */
1571
+ allNonces = new Map();
1572
+ rendered = [];
1573
+ offset = 0;
1574
+ counter = 0;
1575
+ stopped = false;
1576
+ inFlight = null;
1577
+ counters = {
1578
+ notified: 0,
1579
+ updates: 0,
1580
+ decisions: 0,
1581
+ pollErrors: 0,
1582
+ anomalies: {
1583
+ "foreign-chat": 0,
1584
+ "malformed-callback": 0,
1585
+ "unknown-callback": 0,
1586
+ "key-mismatch": 0,
1587
+ "stale-copy": 0,
1588
+ "unknown-command": 0,
1589
+ },
1590
+ staleCopyDecisions: 0,
1591
+ commands: 0,
1592
+ reviews: 0,
1593
+ };
1594
+ constructor(config) {
1595
+ this.token = config.token;
1596
+ this.chatId = String(config.chatId);
1597
+ this.apiBase = (config.apiBase ?? TELEGRAM_DEFAULT_API_BASE).replace(/\/+$/u, "");
1598
+ this.fetchImpl = config.fetch ?? globalThis.fetch;
1599
+ this.pollTimeoutSeconds = config.pollTimeoutSeconds ?? DEFAULT_POLL_TIMEOUT_SECONDS;
1600
+ this.requestTimeoutMs = config.requestTimeoutMs ?? null;
1601
+ this.backoffMs = config.backoffMs ?? DEFAULT_BACKOFF_MS;
1602
+ this.maxBackoffMs = config.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
1603
+ this.complain =
1604
+ config.log ??
1605
+ ((message) => {
1606
+ process.stderr.write(`${message}\n`);
1607
+ });
1608
+ this.approvalTtlMs = config.approvalTtlMs ?? null;
1609
+ this.now = config.now ?? (() => Date.now());
1610
+ this.describeAction = config.describeAction ?? null;
1611
+ this.layout = config.layout ?? TELEGRAM_PROMPT_LAYOUT;
1612
+ this.makeNonce =
1613
+ config.nonce ??
1614
+ (() => {
1615
+ this.counter += 1;
1616
+ return `${this.counter.toString(36)}${Math.random().toString(36).slice(2, 8)}`;
1617
+ });
1618
+ }
1619
+ // -------------------------------------------------------------------------
1620
+ // Channel
1621
+ // -------------------------------------------------------------------------
1622
+ onDecision(handler) {
1623
+ this.handler = handler;
1624
+ }
1625
+ /**
1626
+ * Register what to do with a checkpoint tap (APRV-257).
1627
+ *
1628
+ * The handler is the runtime's, on the runtime's side of the boundary, and it
1629
+ * is where the vault passphrase and the signing live. This channel holds a
1630
+ * nonce, a message id and a `(seq, hash)`, and hands the head back when the
1631
+ * button is pressed — the same shape as {@link onDecision}, for the same
1632
+ * reason: a channel that signed anything would be a channel with authority.
1633
+ */
1634
+ onCheckpoint(handler) {
1635
+ this.checkpointHandler = handler;
1636
+ }
1637
+ /**
1638
+ * Put one `CHECKPOINT DUE` prompt in the chat, with a Sign and a Not now
1639
+ * button (APRV-257).
1640
+ *
1641
+ * A unit like any other: the paced walkthrough sends it as one thing to read,
1642
+ * and it is never grouped into a digest, because a digest is a set of
1643
+ * SIMILAR REQUESTS decided together and a checkpoint is neither a request nor
1644
+ * similar to one.
1645
+ *
1646
+ * Refuses when no handler is registered, rather than sending a dead button.
1647
+ */
1648
+ async offerCheckpoint(prompt) {
1649
+ if (this.checkpointHandler === null) {
1650
+ throw new Error("no checkpoint handler is registered on the telegram channel; the runtime registers one before offerCheckpoint(), and a channel that signed its own checkpoint would be deciding rather than transporting (SPEC.md §10.3)");
1651
+ }
1652
+ const nonce = this.makeNonce();
1653
+ const result = await this.call("sendMessage", {
1654
+ chat_id: this.chatId,
1655
+ text: prompt.lines
1656
+ .map((entry, index) => (index === 0 ? `<b>${escapeHtml(entry)}</b>` : escapeHtml(entry)))
1657
+ .join("\n"),
1658
+ parse_mode: "HTML",
1659
+ disable_web_page_preview: true,
1660
+ reply_markup: {
1661
+ inline_keyboard: [
1662
+ [
1663
+ { text: "Sign", callback_data: checkpointCallbackData("k", nonce) },
1664
+ { text: "Not now", callback_data: checkpointCallbackData("x", nonce) },
1665
+ ],
1666
+ ],
1667
+ },
1668
+ });
1669
+ const deliveryId = String(result.message_id);
1670
+ this.checkpointNonces.set(nonce, { deliveryId, head: prompt.head });
1671
+ return deliveryId;
1672
+ }
1673
+ /**
1674
+ * Register what to do with a review tap (APRV-299).
1675
+ *
1676
+ * The handler is the runtime's, on the runtime's side of the boundary, and it
1677
+ * is where the human-only `reviewSample` lives — same shape as
1678
+ * {@link onDecision} and {@link onCheckpoint}, for the same reason: a channel
1679
+ * that appended an `audit.reviewed` of its own would be a supervision backlog
1680
+ * emptying itself through its own transport.
1681
+ *
1682
+ * Registering it, like registering a command handler, is what makes this
1683
+ * channel read `message` updates at all: the note a `loved` or `disliked`
1684
+ * asks for arrives as a reply, and an inline keyboard has no text input.
1685
+ */
1686
+ onReview(handler) {
1687
+ this.reviewHandler = handler;
1688
+ }
1689
+ /**
1690
+ * Put one retrospective review card in the chat (APRV-299).
1691
+ *
1692
+ * A unit like a checkpoint prompt: one thing to read, never grouped into a
1693
+ * digest, and never delivered through {@link notify} — a digest is a set of
1694
+ * similar pending REQUESTS decided together, and a sample is neither pending
1695
+ * nor a request. It sends ONE message: no payload region, and a keyboard
1696
+ * whose six buttons collect a verdict and a grade and mint nothing.
1697
+ *
1698
+ * Refuses when no handler is registered, rather than sending a dead button.
1699
+ */
1700
+ async offerReview(card) {
1701
+ if (this.reviewHandler === null) {
1702
+ throw new Error("no review handler is registered on the telegram channel; the runtime registers one before offerReview(), and a channel that recorded its own audit.reviewed would be the party under oversight closing its own audit item (SPEC.md §5.2, §10.3)");
1703
+ }
1704
+ const nonce = this.makeNonce();
1705
+ const state = {
1706
+ // Assigned once the message exists; nothing consults it before then.
1707
+ deliveryId: "",
1708
+ card,
1709
+ nonce,
1710
+ denyArmed: false,
1711
+ settled: null,
1712
+ notice: null,
1713
+ awaitingNote: null,
1714
+ deliveredAtMs: this.now(),
1715
+ };
1716
+ const drawn = renderReviewCard(state);
1717
+ const result = await this.call("sendMessage", {
1718
+ chat_id: this.chatId,
1719
+ text: drawn.text,
1720
+ parse_mode: "HTML",
1721
+ disable_web_page_preview: true,
1722
+ ...(drawn.keyboard === null ? {} : { reply_markup: drawn.keyboard }),
1723
+ });
1724
+ const deliveryId = String(result.message_id);
1725
+ state.deliveryId = deliveryId;
1726
+ // Armed only now: until the message with the buttons on it exists there is
1727
+ // nothing a callback could legitimately answer.
1728
+ this.reviewCards.set(deliveryId, state);
1729
+ this.reviewNonces.set(nonce, deliveryId);
1730
+ return deliveryId;
1731
+ }
1732
+ /**
1733
+ * Register what to do with `/queue`, `/skip` and `/next` (APRV-216).
1734
+ *
1735
+ * **Registering is what makes this channel read messages at all.** Until a
1736
+ * handler is here, `getUpdates` asks for `callback_query` only, exactly as it
1737
+ * did before this task, so a listener in `burst` delivery consumes no message
1738
+ * updates — which matters because `approval setup channel telegram`
1739
+ * discovers the approver chat by reading one (APRV-74), and a listener that
1740
+ * swallowed it would break the bootstrap of the very channel it runs on.
1741
+ *
1742
+ * The handler owns whatever answer the human gets. This class sends nothing
1743
+ * of its own for a command: it holds no queue to summarise (SPEC.md §10.3),
1744
+ * so the sentence a command produces is written where the pending set is
1745
+ * re-derived, in `cli/channel-telegram.ts`.
1746
+ */
1747
+ onCommand(handler) {
1748
+ this.commandHandler = handler;
1749
+ }
1750
+ health() {
1751
+ const missing = [];
1752
+ if (this.token.length === 0)
1753
+ missing.push(TELEGRAM_TOKEN_ENV);
1754
+ if (this.chatId.length === 0)
1755
+ missing.push(TELEGRAM_CHAT_ENV);
1756
+ if (missing.length > 0) {
1757
+ return { ok: false, detail: `unconfigured: ${missing.join(", ")} is empty` };
1758
+ }
1759
+ const anomalies = Object.values(this.counters.anomalies).reduce((sum, n) => sum + n, 0);
1760
+ const detail = `chat ${this.chatId} via ${this.apiBase}; ${this.counters.notified} notified, ` +
1761
+ `${this.counters.decisions} decision(s), ${this.counters.pollErrors} recovered poll error(s), ` +
1762
+ `${anomalies} ignored callback(s)`;
1763
+ return { ok: true, detail };
1764
+ }
1765
+ /** The rendering split of the most recent `notify`, for the conformance suite. */
1766
+ lastRendered() {
1767
+ return this.rendered;
1768
+ }
1769
+ /** Delivery, decision and anomaly counters. Live; read from anywhere. */
1770
+ stats() {
1771
+ return { ...this.counters, anomalies: { ...this.counters.anomalies } };
1772
+ }
1773
+ /** Ignored callbacks so far. Exposed for `health()` and for operators. */
1774
+ anomalyCount(kind) {
1775
+ if (kind !== undefined)
1776
+ return this.counters.anomalies[kind];
1777
+ return Object.values(this.counters.anomalies).reduce((sum, n) => sum + n, 0);
1778
+ }
1779
+ /**
1780
+ * Put a request, or a set of them, in front of the approver.
1781
+ *
1782
+ * One request is one prompt: its header, its payload chunks, and the
1783
+ * Approve/Reject keyboard on the last message, whose `message_id` is the
1784
+ * delivery id. A {@link ChannelBatch} goes through {@link notifyBatch} and
1785
+ * comes back as a digest when it can be one; either way it gets one shared
1786
+ * batch delivery id, which is what this returns and what every resulting
1787
+ * event will carry.
1788
+ */
1789
+ async notify(target) {
1790
+ if ("requests" in target)
1791
+ return (await this.notifyBatch(target)).batchDeliveryId;
1792
+ const delivered = await this.deliverOne(target, undefined);
1793
+ this.rendered = [delivered.rendered];
1794
+ return delivered.deliveryId;
1795
+ }
1796
+ /**
1797
+ * Deliver a set as one digest, or as one message per member when it cannot
1798
+ * be one (APRV-115).
1799
+ *
1800
+ * The fallback is taken for a set of fewer than two, and for one whose digest
1801
+ * text would not fit inside {@link TELEGRAM_MAX_MESSAGE_CHARS}. Both are the
1802
+ * same rule: the approver sees every member before any button that decides
1803
+ * more than one appears, and when that cannot be arranged the channel sends
1804
+ * MORE messages rather than fewer.
1805
+ *
1806
+ * Not atomic, and it cannot be: a `sendMessage` that fails part way leaves
1807
+ * the messages already sent in the chat, and this throws. Nothing is armed —
1808
+ * the member nonces are registered only once the digest message carrying
1809
+ * their buttons exists — so the caller's retry re-sends the set and the
1810
+ * approver gets a duplicate prompt, never a live button on a half-sent one.
1811
+ */
1812
+ async notifyBatch(batch) {
1813
+ const members = batch.requests;
1814
+ const batchDeliveryId = batch.deliveryId ?? `tg-batch-${this.makeNonce()}`;
1815
+ const digest = members.length < 2 ? null : await this.deliverDigest(members, batchDeliveryId);
1816
+ if (digest !== null) {
1817
+ this.rendered = digest.rendered;
1818
+ return digest;
1819
+ }
1820
+ const rendered = [];
1821
+ const delivered = [];
1822
+ for (const member of members) {
1823
+ const one = await this.deliverOne(member, batchDeliveryId);
1824
+ rendered.push(one.rendered);
1825
+ delivered.push({ action_key: member.action_key.value, delivery_id: one.deliveryId });
1826
+ }
1827
+ this.rendered = rendered;
1828
+ return { batchDeliveryId, digestId: null, members: delivered, rendered };
1829
+ }
1830
+ /**
1831
+ * Deliver a set of stale pending requests as ONE message with a reject-all
1832
+ * button (APRV-287).
1833
+ *
1834
+ * Returns `null` when the message would not fit, and the caller then leaves
1835
+ * the members undelivered so the next cycle shows them the ordinary way:
1836
+ * SPEC.md §10.3's rule for this bookkeeping is that losing it degrades to
1837
+ * showing a request again, never to a pending request nobody is shown.
1838
+ */
1839
+ async notifyStale(members, stale) {
1840
+ if (members.length === 0)
1841
+ return null;
1842
+ return this.deliverDigest(members, `tg-batch-${this.makeNonce()}`, stale);
1843
+ }
1844
+ /**
1845
+ * The digest itself: every member's prompt and payload, then the one message
1846
+ * that carries the buttons.
1847
+ *
1848
+ * Returns `null` when the digest message would not fit, so the caller falls
1849
+ * back — and it decides that BEFORE sending anything, because a fallback
1850
+ * discovered after four member prompts had gone out would double them.
1851
+ *
1852
+ * `stale` (APRV-287) makes it the collapsed re-delivery instead: no member
1853
+ * prompts, no payload, one reject-all button. See {@link StaleSummary}.
1854
+ */
1855
+ async deliverDigest(members, batchDeliveryId, stale = null) {
1856
+ const allNonce = this.makeNonce();
1857
+ const deliveredAtMs = this.now();
1858
+ const state = {
1859
+ deliveredAtMs,
1860
+ // Assigned once the message exists; nothing consults it before then.
1861
+ deliveryId: "",
1862
+ batchDeliveryId,
1863
+ allNonce,
1864
+ stale,
1865
+ facts: stale === null ? digestFacts(members) : [],
1866
+ author: originOf(members[0].summary),
1867
+ members: members.map((member) => ({
1868
+ actionKey: member.action_key.value,
1869
+ nonce: this.makeNonce(),
1870
+ summary: member.summary.value ?? "(none given)",
1871
+ cost: `$${member.est_cost_usd.value.toFixed(2)}`,
1872
+ settled: null,
1873
+ })),
1874
+ };
1875
+ const drawn = renderDigest(state);
1876
+ if (drawn.text.length > TELEGRAM_MAX_MESSAGE_CHARS)
1877
+ return null;
1878
+ // APRV-287. When every member binds to the SAME payload — the several
1879
+ // classes of one tool call — the payload is sent once instead of once per
1880
+ // member. The approver still reads every byte they are deciding about
1881
+ // before any button appears (SPEC.md §10.3), because there is one set of
1882
+ // bytes and it is above the digest; what goes away is five copies of one
1883
+ // command, which was five messages of the flood this task is about.
1884
+ // A collapsed re-delivery sends no payload at all, which is the whole of
1885
+ // what makes it one message; its own text says so and offers no approve, so
1886
+ // it renders no request and claims none.
1887
+ const shared = sharedPayload(members);
1888
+ const rendered = [];
1889
+ if (stale !== null) {
1890
+ // Nothing to render: no prompt goes out for a member here.
1891
+ }
1892
+ else if (shared === null) {
1893
+ for (const [index, member] of members.entries()) {
1894
+ const one = await this.sendPrompt(member, `REQUEST ${index + 1} OF ${members.length} — decide it on the digest below`, null);
1895
+ rendered.push({ ...one.rendered, batchDeliveryId });
1896
+ }
1897
+ }
1898
+ else {
1899
+ const one = await this.sendPrompt(shared, `THE COMMAND ALL ${members.length} REQUESTS ARE ABOUT — decide it on the digest below`, null);
1900
+ for (const member of members) {
1901
+ rendered.push({
1902
+ ...one.rendered,
1903
+ action_key: member.action_key.value,
1904
+ batchDeliveryId,
1905
+ });
1906
+ }
1907
+ }
1908
+ const result = await this.call("sendMessage", {
1909
+ chat_id: this.chatId,
1910
+ text: drawn.text,
1911
+ parse_mode: "HTML",
1912
+ disable_web_page_preview: true,
1913
+ ...(drawn.keyboard === null ? {} : { reply_markup: drawn.keyboard }),
1914
+ });
1915
+ const deliveryId = String(result.message_id);
1916
+ state.deliveryId = deliveryId;
1917
+ // Armed only now, and all at once: until the message with the buttons on it
1918
+ // exists there is nothing a callback could legitimately answer.
1919
+ for (const member of state.members) {
1920
+ this.deliveries.set(member.nonce, {
1921
+ actionKey: member.actionKey,
1922
+ actionRef: actionRefOf(member.actionKey),
1923
+ deliveryId,
1924
+ batchDeliveryId,
1925
+ deliveredAtMs,
1926
+ });
1927
+ }
1928
+ this.digests.set(deliveryId, state);
1929
+ this.allNonces.set(allNonce, deliveryId);
1930
+ this.counters.notified += members.length;
1931
+ return {
1932
+ batchDeliveryId,
1933
+ digestId: deliveryId,
1934
+ members: state.members.map((member) => ({
1935
+ action_key: member.actionKey,
1936
+ delivery_id: deliveryId,
1937
+ })),
1938
+ rendered,
1939
+ };
1940
+ }
1941
+ /**
1942
+ * Send one request's messages: the computed header, the payload chunks, then
1943
+ * the claimed block, with `keyboard` (when there is one) on the last.
1944
+ *
1945
+ * The claimed block goes last because it is the human-meaningful description
1946
+ * of the act, and the message a reader answers on should be the one that says
1947
+ * what they are answering about; bookkeeping above it is context, not the
1948
+ * question. SPEC §10.3 allows claimed material to sit around the canonical
1949
+ * block while it stays visibly separated and labelled, which the heading on
1950
+ * every claimed message keeps. It is always sent, so a missing summary is a
1951
+ * visible "(none given)" rather than an absent message, and so the keyboard
1952
+ * has one message it can always ride on.
1953
+ *
1954
+ * Shared by the ordinary prompt and by a digest member, which differ in
1955
+ * exactly two things: the heading, and whether anything is armed.
1956
+ */
1957
+ async sendPrompt(request, heading, keyboard) {
1958
+ const rendering = renderTelegram(request, heading, this.layout);
1959
+ const segments = [rendering.header];
1960
+ if (rendering.payloadText !== null) {
1961
+ const chunks = chunkForTelegram(rendering.payloadText);
1962
+ for (const [index, chunk] of chunks.entries()) {
1963
+ const label = chunks.length === 1
1964
+ ? `<b>${PAYLOAD_CHUNK_LABEL}</b>`
1965
+ : `<b>PAYLOAD ${index + 1}/${chunks.length} — ${PAYLOAD_CHUNK_LABEL_TAIL}</b>`;
1966
+ segments.push(`${label}\n<pre>${escapeHtml(chunk)}</pre>`);
1967
+ }
1968
+ }
1969
+ for (const [index, chunk] of chunkClaimedForTelegram(rendering.claimedText).entries()) {
1970
+ segments.push(index === 0 ? chunk : `<b>${TELEGRAM_CLAIMED_CONTINUED_HEADING}</b>\n${chunk}`);
1971
+ }
1972
+ let deliveryId = "";
1973
+ for (const [index, segment] of segments.entries()) {
1974
+ const last = index === segments.length - 1;
1975
+ const result = await this.call("sendMessage", {
1976
+ chat_id: this.chatId,
1977
+ text: segment,
1978
+ parse_mode: "HTML",
1979
+ disable_web_page_preview: true,
1980
+ ...(last && keyboard !== null ? { reply_markup: keyboard } : {}),
1981
+ });
1982
+ if (last)
1983
+ deliveryId = String(result.message_id);
1984
+ }
1985
+ const fields = rendering.lines.map((entry) => ({
1986
+ field: entry.field,
1987
+ kind: entry.kind,
1988
+ text: entry.text,
1989
+ }));
1990
+ return {
1991
+ deliveryId,
1992
+ rendered: {
1993
+ action_key: request.action_key.value,
1994
+ fields,
1995
+ fullPayloadText: rendering.payloadText,
1996
+ },
1997
+ };
1998
+ }
1999
+ async deliverOne(request, batchDeliveryId) {
2000
+ const actionKey = request.action_key.value;
2001
+ const nonce = this.makeNonce();
2002
+ const keyboard = {
2003
+ inline_keyboard: [
2004
+ [
2005
+ { text: "✅ Approve", callback_data: callbackData("g", nonce, actionKey) },
2006
+ { text: "🛑 Reject", callback_data: callbackData("r", nonce, actionKey) },
2007
+ ],
2008
+ ],
2009
+ };
2010
+ const sent = await this.sendPrompt(request, TELEGRAM_PROMPT_HEADING, keyboard);
2011
+ this.counters.notified += 1;
2012
+ this.deliveries.set(nonce, {
2013
+ actionKey,
2014
+ actionRef: actionRefOf(actionKey),
2015
+ deliveryId: sent.deliveryId,
2016
+ deliveredAtMs: this.now(),
2017
+ ...(batchDeliveryId === undefined ? {} : { batchDeliveryId }),
2018
+ });
2019
+ return {
2020
+ deliveryId: sent.deliveryId,
2021
+ rendered: {
2022
+ ...sent.rendered,
2023
+ ...(batchDeliveryId === undefined ? {} : { batchDeliveryId }),
2024
+ },
2025
+ };
2026
+ }
2027
+ /**
2028
+ * Forget every nonce issued for `deliveryId`, and report the action key it
2029
+ * was issued for.
2030
+ *
2031
+ * Called by {@link annotate} before the edit goes out, so a tap on a button
2032
+ * the edit does not manage to remove resolves to nothing and is answered as
2033
+ * a `stale-copy` rather than carried to the gate as a decision attempt.
2034
+ * Forgetting is never the channel growing state, and forgetting a SETTLED
2035
+ * request is what stops APRV-196's action-reference fallback from finding it:
2036
+ * the ladder rescues a tap on an old copy of a request still open here, and
2037
+ * a decided one is not that.
2038
+ */
2039
+ disarm(deliveryId) {
2040
+ let actionKey = "";
2041
+ // Deleting the current entry mid-iteration is defined behaviour for a Map,
2042
+ // and every nonce for this message id goes — a re-notify of the same
2043
+ // message would otherwise leave an older nonce still resolving.
2044
+ for (const [nonce, delivery] of this.deliveries) {
2045
+ if (delivery.deliveryId !== deliveryId)
2046
+ continue;
2047
+ actionKey = delivery.actionKey;
2048
+ this.deliveries.delete(nonce);
2049
+ }
2050
+ const digest = this.digests.get(deliveryId);
2051
+ if (digest !== undefined) {
2052
+ this.allNonces.delete(digest.allNonce);
2053
+ this.digests.delete(deliveryId);
2054
+ }
2055
+ return actionKey;
2056
+ }
2057
+ /**
2058
+ * Drop the delivery bookkeeping no callback can still be honoured against
2059
+ * (APRV-135).
2060
+ *
2061
+ * The condition is both halves of the sentence, evaluated per entry:
2062
+ *
2063
+ * 1. **Every member is terminal.** For a digest that means every member
2064
+ * carries a `settled` outcome; for a unit delivery it is automatic in the
2065
+ * other direction, since annotating a decided, expired or withdrawn
2066
+ * request already forgets its nonces ({@link disarm}), so a delivery still
2067
+ * in the map is one this process has not seen settled. A request past its
2068
+ * approval TTL is terminal too — the gate refuses every decision on it —
2069
+ * which is what lets an unannotated delivery be swept at all.
2070
+ * 2. **Older than the retention window**, which is the policy's approval TTL
2071
+ * when it declares one and {@link TELEGRAM_DEFAULT_RETENTION_MS} when it
2072
+ * does not. Measured from the moment THIS process delivered the message,
2073
+ * which is at or after the `approval.requested` the TTL actually runs
2074
+ * from, so the window this sweep waits out is never shorter than the one
2075
+ * the gate enforces.
2076
+ *
2077
+ * Both together are what makes forgetting safe: a live button can never
2078
+ * reference a dropped entry, because the state in which no callback can still
2079
+ * be honoured is exactly the state in which the entry is dropped. A tap that
2080
+ * arrives anyway is answered by the stale-callback path a restarted
2081
+ * listener's buttons already take: `stale-copy` since APRV-196, counted,
2082
+ * toasted with what the log says became of the request, never carried to the
2083
+ * gate.
2084
+ *
2085
+ * Process memory only. No event, no message edit, no log read. `nowMs`
2086
+ * defaults to the configured clock and is a parameter so a test can run a
2087
+ * simulated week without one.
2088
+ */
2089
+ sweep(nowMs = this.now()) {
2090
+ this.lastSweepMs = nowMs;
2091
+ const retention = this.approvalTtlMs ?? TELEGRAM_DEFAULT_RETENTION_MS;
2092
+ const expired = (deliveredAtMs) => nowMs - deliveredAtMs >= retention;
2093
+ // Past the approval TTL the gate refuses every decision, so the request is
2094
+ // terminal whether or not this process saw it settle. With no TTL declared
2095
+ // nothing expires, and only an observed settlement makes an entry droppable.
2096
+ const lapsed = (deliveredAtMs) => this.approvalTtlMs !== null && nowMs - deliveredAtMs >= this.approvalTtlMs;
2097
+ let digests = 0;
2098
+ for (const [deliveryId, digest] of this.digests) {
2099
+ const terminal = digest.members.every((member) => member.settled !== null);
2100
+ if (!(terminal || lapsed(digest.deliveredAtMs)) || !expired(digest.deliveredAtMs))
2101
+ continue;
2102
+ this.digests.delete(deliveryId);
2103
+ this.allNonces.delete(digest.allNonce);
2104
+ digests += 1;
2105
+ }
2106
+ let deliveries = 0;
2107
+ for (const [nonce, delivery] of this.deliveries) {
2108
+ // A nonce whose digest is still remembered is still armed on a message
2109
+ // with buttons, whatever its own age says; the digest is the entry that
2110
+ // decides, and it was just judged above.
2111
+ if (this.digests.has(delivery.deliveryId))
2112
+ continue;
2113
+ if (!lapsed(delivery.deliveredAtMs) || !expired(delivery.deliveredAtMs))
2114
+ continue;
2115
+ this.deliveries.delete(nonce);
2116
+ deliveries += 1;
2117
+ }
2118
+ // APRV-299. A review card is droppable on the same pair of conditions read
2119
+ // for a thing that has no TTL: the runtime recorded an answer for it (so no
2120
+ // button on it can still be honoured — the nonce is already gone) AND it is
2121
+ // older than the retention window. An UNSETTLED card is never swept, and
2122
+ // that is the safe direction: its sample stays open in the log whatever
2123
+ // this map holds, so keeping the buttons alive costs a map entry and
2124
+ // dropping them early would cost a human their thumb.
2125
+ for (const [deliveryId, card] of this.reviewCards) {
2126
+ if (card.settled === null || !expired(card.deliveredAtMs))
2127
+ continue;
2128
+ this.reviewCards.delete(deliveryId);
2129
+ this.reviewNonces.delete(card.nonce);
2130
+ if (card.awaitingNote !== null)
2131
+ this.reviewNotePrompts.delete(card.awaitingNote.promptId);
2132
+ }
2133
+ return { deliveries, digests };
2134
+ }
2135
+ /** How many entries the bookkeeping holds. For tests and for operators. */
2136
+ bookkeepingSize() {
2137
+ return {
2138
+ deliveries: this.deliveries.size,
2139
+ digests: this.digests.size,
2140
+ allNonces: this.allNonces.size,
2141
+ reviewCards: this.reviewCards.size,
2142
+ };
2143
+ }
2144
+ /**
2145
+ * Mark one digest member settled and redraw the digest (APRV-115).
2146
+ *
2147
+ * The member's own nonce is forgotten first, so a tap on a button the redraw
2148
+ * does not manage to remove resolves to nothing rather than reaching the
2149
+ * gate. The other members keep theirs: a partially decided digest is a real
2150
+ * state and the rest of it is still answerable.
2151
+ */
2152
+ async settleMember(digest, actionKey, outcome, detail) {
2153
+ const member = digest.members.find((entry) => entry.actionKey === actionKey);
2154
+ if (member === undefined || member.settled !== null)
2155
+ return;
2156
+ member.settled = { headline: outcome, detail };
2157
+ this.deliveries.delete(member.nonce);
2158
+ if (digest.members.every((entry) => entry.settled !== null)) {
2159
+ this.allNonces.delete(digest.allNonce);
2160
+ }
2161
+ await this.redraw(digest);
2162
+ }
2163
+ /** One `editMessageText` that replaces a digest's text and its keyboard. */
2164
+ async redraw(digest) {
2165
+ const drawn = renderDigest(digest);
2166
+ await this.call("editMessageText", {
2167
+ chat_id: this.chatId,
2168
+ message_id: Number(digest.deliveryId),
2169
+ text: drawn.text,
2170
+ parse_mode: "HTML",
2171
+ disable_web_page_preview: true,
2172
+ ...(drawn.keyboard === null ? {} : { reply_markup: drawn.keyboard }),
2173
+ });
2174
+ }
2175
+ /**
2176
+ * Edit a delivered message to say what became of its question, and remove the
2177
+ * buttons (APRV-106 for withdrawal, generalized in APRV-113 to every terminal
2178
+ * state).
2179
+ *
2180
+ * ONE `editMessageText` call, not two. Telegram's `editMessageText` replaces
2181
+ * the reply markup along with the text, and omitting `reply_markup` clears
2182
+ * it — so the annotation and the disarming land together, and there is no
2183
+ * window in which the message reads "approved" and still offers a tap.
2184
+ *
2185
+ * The text is REPLACED rather than appended to, because this class does not
2186
+ * remember what it sent (it remembers a nonce and a message id) and refetching
2187
+ * a message to append to it would be the channel reconstructing state it is
2188
+ * not supposed to hold. What the approver keeps is the outcome, the action key
2189
+ * and the detail lines, which is what a chat transcript needs to stay readable.
2190
+ *
2191
+ * `outcome` is a headline word (see {@link TELEGRAM_TERMINAL_HEADLINES}) and
2192
+ * `detail` the lines under it; both are HTML-escaped here, and neither may
2193
+ * carry an execution token — no caller in this repository has one to give,
2194
+ * since {@link DecisionOutcome} deliberately does not carry it.
2195
+ *
2196
+ * Best effort: {@link TelegramApiError} propagates to the caller, which logs
2197
+ * it and carries on. A message that could not be edited is a cosmetic
2198
+ * problem — the log has already settled the request, so a tap on the stale
2199
+ * buttons is refused by the gate and answered with the refusal toast.
2200
+ */
2201
+ async annotate(deliveryId, outcome, detail,
2202
+ /**
2203
+ * Which request this settles, when `deliveryId` names a digest (APRV-115).
2204
+ * A digest holds several, so an annotation without one can only mean the
2205
+ * whole delivery is over — which is handled by falling through to the
2206
+ * message-replacing path below, buttons and all.
2207
+ */
2208
+ actionKey) {
2209
+ const digest = this.digests.get(deliveryId);
2210
+ if (digest !== undefined && actionKey !== undefined) {
2211
+ await this.settleMember(digest, actionKey, outcome, detail);
2212
+ return;
2213
+ }
2214
+ const settledKey = this.disarm(deliveryId);
2215
+ const text = [
2216
+ `<b>${escapeHtml(outcome)}</b>`,
2217
+ `<code>${escapeHtml(actionKey ?? settledKey)}</code>`,
2218
+ "",
2219
+ ...detail.map((entry) => escapeHtml(entry)),
2220
+ ].join("\n");
2221
+ await this.call("editMessageText", {
2222
+ chat_id: this.chatId,
2223
+ message_id: Number(deliveryId),
2224
+ text,
2225
+ parse_mode: "HTML",
2226
+ disable_web_page_preview: true,
2227
+ });
2228
+ }
2229
+ /**
2230
+ * The withdrawal case of {@link annotate} (APRV-106), and the one the
2231
+ * {@link Channel} interface names. Its wording is unchanged.
2232
+ */
2233
+ async retract(deliveryId, reason, actionKey) {
2234
+ await this.annotate(deliveryId, TELEGRAM_TERMINAL_HEADLINES.withdrawn, [reason], actionKey);
2235
+ }
2236
+ /**
2237
+ * Send one plain message that carries no question (APRV-196).
2238
+ *
2239
+ * Used for the re-delivery banner the listener puts in front of a startup
2240
+ * batch. It arms nothing, remembers nothing, and names no action key: a
2241
+ * banner is a sentence about the messages that follow, and a reader who
2242
+ * mistook it for a request would be a reader the banner had made worse off.
2243
+ * `lines` are escaped here, exactly as everything else interpolated into an
2244
+ * HTML-mode message is.
2245
+ */
2246
+ async announce(lines) {
2247
+ const result = await this.call("sendMessage", {
2248
+ chat_id: this.chatId,
2249
+ text: lines
2250
+ .map((entry, index) => index === 0 ? `<b>${escapeHtml(entry)}</b>` : escapeHtml(entry))
2251
+ .join("\n"),
2252
+ parse_mode: "HTML",
2253
+ disable_web_page_preview: true,
2254
+ });
2255
+ return String(result.message_id);
2256
+ }
2257
+ // -------------------------------------------------------------------------
2258
+ // Long polling
2259
+ // -------------------------------------------------------------------------
2260
+ /**
2261
+ * Long-poll `getUpdates` until {@link stop} is called (or one batch, with
2262
+ * `once`).
2263
+ *
2264
+ * **The loop survives the network.** A poll that times out, is refused, drops
2265
+ * its socket, returns a 5xx, or answers with something that is not JSON is
2266
+ * counted, complained about on stderr, and retried after a doubling backoff.
2267
+ * There is no failure mode in which the listener quietly stops listening: the
2268
+ * whole value of a push channel is that a human's inbox keeps receiving, and
2269
+ * a listener that died at 3am on a transient 502 would fail exactly when the
2270
+ * queue was filling up.
2271
+ *
2272
+ * Each iteration begins with {@link TelegramListenOptions.beforePoll} when
2273
+ * one is supplied, which is where the runtime's dispatch cycle runs: the
2274
+ * loop is therefore "deliver anything newly pending, then wait for a
2275
+ * decision", not "deliver once at startup, then wait forever".
2276
+ */
2277
+ async listen(options = {}) {
2278
+ this.stopped = false;
2279
+ let backoff = this.backoffMs;
2280
+ while (!this.stopped) {
2281
+ try {
2282
+ if (options.beforePoll !== undefined) {
2283
+ await options.beforePoll();
2284
+ if (this.stopped)
2285
+ return;
2286
+ }
2287
+ await this.pollOnce();
2288
+ backoff = this.backoffMs;
2289
+ if (options.once === true)
2290
+ return;
2291
+ }
2292
+ catch (cause) {
2293
+ if (this.stopped)
2294
+ return;
2295
+ this.counters.pollErrors += 1;
2296
+ this.complain(`approval: telegram getUpdates failed (${this.describe(cause)}); retrying in ${backoff}ms — the listener is still up`);
2297
+ await sleep(backoff);
2298
+ backoff = Math.min(backoff * 2, this.maxBackoffMs);
2299
+ }
2300
+ }
2301
+ }
2302
+ /** Stop the loop and abort any in-flight request. */
2303
+ stop() {
2304
+ this.stopped = true;
2305
+ this.inFlight?.abort();
2306
+ }
2307
+ /**
2308
+ * One `getUpdates` batch, processed. Throws on a transport failure — which is
2309
+ * what {@link listen} catches and retries.
2310
+ */
2311
+ async pollOnce() {
2312
+ // APRV-135. Before the long poll, not after: this is where the loop is
2313
+ // about to block for up to `pollTimeoutSeconds`, and a sweep that ran after
2314
+ // the block would be a sweep that never runs on a quiet chat. Rate-limited
2315
+ // so a driver calling `pollOnce` in a tight loop does not spend its time
2316
+ // walking two maps.
2317
+ const nowMs = this.now();
2318
+ if (nowMs - this.lastSweepMs >= TELEGRAM_SWEEP_INTERVAL_MS)
2319
+ this.sweep(nowMs);
2320
+ const updates = await this.call("getUpdates", {
2321
+ offset: this.offset,
2322
+ timeout: this.pollTimeoutSeconds,
2323
+ // APRV-216. `message` is asked for only while a command handler is
2324
+ // registered: see {@link onCommand} for why a listener that reads
2325
+ // messages nobody asked for would break `approval setup channel
2326
+ // telegram`'s chat discovery.
2327
+ // APRV-299 adds the second reason to read messages: a `loved` or
2328
+ // `disliked` on a review card collects the human's words as a REPLY,
2329
+ // because an inline keyboard has no text input.
2330
+ allowed_updates: this.commandHandler === null && this.reviewHandler === null
2331
+ ? ["callback_query"]
2332
+ : ["callback_query", "message"],
2333
+ }, this.requestTimeoutMs ?? (this.pollTimeoutSeconds + 10) * 1000);
2334
+ const result = {
2335
+ updates: 0,
2336
+ outcomes: [],
2337
+ ignored: [],
2338
+ commands: [],
2339
+ reviews: [],
2340
+ };
2341
+ for (const raw of updates) {
2342
+ const update = (raw ?? {});
2343
+ const id = update["update_id"];
2344
+ if (typeof id === "number")
2345
+ this.offset = Math.max(this.offset, id + 1);
2346
+ this.counters.updates += 1;
2347
+ result.updates += 1;
2348
+ await this.handleUpdate(update, result);
2349
+ }
2350
+ return result;
2351
+ }
2352
+ /**
2353
+ * Exactly one `answerCallbackQuery` per callback query, on every path
2354
+ * (APRV-196).
2355
+ *
2356
+ * The incident this closes: a tap that reached no branch with a toast on it
2357
+ * spun on the approver's phone until Telegram gave up, and the human — with
2358
+ * no way to tell a swallowed tap from a slow one — tapped again. So the ack
2359
+ * is a property of the WRAPPER rather than of each branch: every route below
2360
+ * still writes its own, better sentence, and anything that fails to (a throw
2361
+ * halfway through, a branch a later change forgets) is caught here and
2362
+ * answered with {@link TELEGRAM_ACK_FALLBACK}.
2363
+ *
2364
+ * A thrown handler is answered and swallowed rather than propagated, and that
2365
+ * is deliberate: `pollOnce` throwing puts `listen` into its backoff, so one
2366
+ * malformed update would cost the whole batch and the poll after it. Nothing
2367
+ * is lost by continuing — the gate has already appended whatever it appended,
2368
+ * and the log is what says so.
2369
+ *
2370
+ * APRV-206 moved WHEN that one answer is sent on the decision path: it now
2371
+ * goes out before the gate runs, so the spinner on the phone is one Bot API
2372
+ * call long instead of one decision long. The guarantee is unchanged and is
2373
+ * now enforced in one place — {@link safeAnswer} answers a query at most once,
2374
+ * so the fallback below cannot follow an early ack with a second call.
2375
+ */
2376
+ async handleUpdate(update, result) {
2377
+ const callback = update["callback_query"];
2378
+ if (typeof callback !== "object" || callback === null) {
2379
+ // APRV-216. Swallowed for the same reason a thrown decision handler is:
2380
+ // letting it out would reach `pollOnce`, and a listener that dropped into
2381
+ // backoff because one command failed would be a listener that stopped
2382
+ // listening over something that wrote nothing.
2383
+ try {
2384
+ await this.handleMessage(update, result);
2385
+ }
2386
+ catch (cause) {
2387
+ this.complain(`approval: telegram failed while handling a command: ${this.describe(cause)} — nothing was appended and the listener is still up`);
2388
+ }
2389
+ return;
2390
+ }
2391
+ const query = callback;
2392
+ const callbackId = typeof query["id"] === "string" ? query["id"] : "";
2393
+ this.ack = { id: callbackId, answered: false };
2394
+ try {
2395
+ await this.routeCallback(query, callbackId, result);
2396
+ }
2397
+ catch (cause) {
2398
+ this.complain(`approval: telegram failed while handling a callback: ${this.describe(cause)} — the tap is answered; whatever the gate appended stands`);
2399
+ await this.safeAnswer(callbackId, TELEGRAM_ACK_FALLBACK);
2400
+ }
2401
+ finally {
2402
+ const pending = this.ack;
2403
+ this.ack = null;
2404
+ if (pending !== null && !pending.answered) {
2405
+ await this.safeAnswer(callbackId, TELEGRAM_ACK_FALLBACK);
2406
+ }
2407
+ }
2408
+ }
2409
+ /**
2410
+ * A `message` update: the bot-command path (APRV-216).
2411
+ *
2412
+ * Three rules, in this order, and each of them is a refusal to act on
2413
+ * something the network said:
2414
+ *
2415
+ * 1. **No handler, no reading.** A channel with no command handler wants no
2416
+ * message updates and did not ask for any; one that arrives anyway (a
2417
+ * webhook backlog, a poll issued before the handler was registered) is
2418
+ * dropped without a counter, because there is nothing wrong with it.
2419
+ * 2. **The configured chat only.** A message from anywhere else is counted
2420
+ * `foreign-chat` and answered with nothing at all. Not even a refusal
2421
+ * reply: a stranger who can reach the bot learns from silence that the
2422
+ * bot is there, and learns from a reply what it is for.
2423
+ * 3. **A closed vocabulary.** `/queue`, `/skip`, `/next`. Anything else
2424
+ * beginning with `/` is counted `unknown-command`; anything not beginning
2425
+ * with `/` is ordinary chat and is ignored silently.
2426
+ *
2427
+ * A command decides nothing and appends nothing — it cannot, because it
2428
+ * never reaches {@link handler}. The handler it does reach reorders what the
2429
+ * runtime shows next, which is process memory on the runtime's side of the
2430
+ * boundary (SPEC.md §10.3).
2431
+ */
2432
+ async handleMessage(update, result) {
2433
+ const handler = this.commandHandler;
2434
+ if (handler === null && this.reviewHandler === null)
2435
+ return;
2436
+ const raw = update["message"];
2437
+ if (typeof raw !== "object" || raw === null)
2438
+ return;
2439
+ const message = raw;
2440
+ const text = message["text"];
2441
+ if (typeof text !== "string")
2442
+ return;
2443
+ // APRV-299, before the command vocabulary: a reply to an outstanding note
2444
+ // prompt is the human's own words, and it is bound to its card by the
2445
+ // message id THIS process issued, never by anything the reply asserts about
2446
+ // itself. A reply naming a prompt this process is not holding falls through
2447
+ // and is ordinary chat.
2448
+ if (await this.handleNoteReply(message, text, result))
2449
+ return;
2450
+ if (handler === null || !text.trim().startsWith("/"))
2451
+ return;
2452
+ const chat = (message["chat"] ?? {});
2453
+ const chatId = chat["id"] === undefined ? "" : String(chat["id"]);
2454
+ if (chatId !== this.chatId) {
2455
+ this.counters.anomalies["foreign-chat"] += 1;
2456
+ result.ignored.push({
2457
+ kind: "foreign-chat",
2458
+ detail: `command from chat ${JSON.stringify(chatId)}, which is not the configured approver chat`,
2459
+ });
2460
+ this.complain(`approval: telegram ignored a command (foreign-chat): from chat ${JSON.stringify(chatId)}, which is not the configured approver chat`);
2461
+ return;
2462
+ }
2463
+ const command = parseBotCommand(text);
2464
+ if (command === null) {
2465
+ this.counters.anomalies["unknown-command"] += 1;
2466
+ result.ignored.push({ kind: "unknown-command", detail: this.redact(text.trim()) });
2467
+ return;
2468
+ }
2469
+ this.counters.commands += 1;
2470
+ result.commands.push(command);
2471
+ // A throw here is caught by `handleUpdate`, which complains and carries on:
2472
+ // a command that failed must not cost the batch or the poll after it, and
2473
+ // there is nothing to undo, because a command writes nothing.
2474
+ await handler(command);
2475
+ }
2476
+ async routeCallback(query, callbackId, result) {
2477
+ const message = (query["message"] ?? {});
2478
+ const chat = (message["chat"] ?? {});
2479
+ const chatId = chat["id"] === undefined ? "" : String(chat["id"]);
2480
+ // (a) Not our chat. Counted, answered, never decided, never logged.
2481
+ if (chatId !== this.chatId) {
2482
+ await this.ignore(result, callbackId, "foreign-chat", `callback from chat ${JSON.stringify(chatId)}, which is not the configured approver chat`, "This bot only accepts decisions from its configured approval chat.");
2483
+ return;
2484
+ }
2485
+ // APRV-257, before the decision vocabulary and in a parser of its own. A
2486
+ // checkpoint button decides no request, so it must never reach the ladder
2487
+ // below — where an unresolved nonce falls back to an action reference, and
2488
+ // a signature gesture would start looking for something to approve.
2489
+ const checkpoint = parseCheckpointCallback(query["data"]);
2490
+ if (checkpoint !== null) {
2491
+ await this.handleCheckpointTap(checkpoint, callbackId, result);
2492
+ return;
2493
+ }
2494
+ // APRV-299, before the decision vocabulary and in a parser of its own, for
2495
+ // the same reason the checkpoint one is: a review button decides no request
2496
+ // and must never reach the ladder below, where an unresolved nonce falls
2497
+ // back to an action reference and a gesture about something that already
2498
+ // happened would start looking for something to approve.
2499
+ const review = parseReviewCallback(query["data"]);
2500
+ if (review !== null) {
2501
+ await this.handleReviewTap(review, callbackId, result);
2502
+ return;
2503
+ }
2504
+ const parsed = parseCallbackData(query["data"]);
2505
+ if (parsed === null) {
2506
+ await this.ignore(result, callbackId, "malformed-callback", `callback_data ${JSON.stringify(query["data"])} is not a decision this channel issued`, "Unrecognized button.");
2507
+ return;
2508
+ }
2509
+ // APRV-115. An "all" button names a digest, not a request: the set it
2510
+ // decides is whatever is still open on that delivery right now, which this
2511
+ // process knows and the callback bytes deliberately do not say.
2512
+ if (parsed.scope === "all") {
2513
+ await this.handleDigestAll(parsed.decision, parsed.nonce, callbackId, result);
2514
+ return;
2515
+ }
2516
+ // The resolution ladder (APRV-196). A tap is answered by the nonce when
2517
+ // this process issued it, by the action reference when it did not, and by
2518
+ // the log when neither is holding the action open.
2519
+ let delivery = this.deliveries.get(parsed.nonce);
2520
+ let viaStaleCopy = false;
2521
+ if (delivery === undefined && parsed.actionRef !== null) {
2522
+ // The pre-restart copy. Its nonce died with the process that issued it,
2523
+ // but the request it names is one THIS process has since re-delivered, so
2524
+ // the tap decides that request — on the live copy's message, which is
2525
+ // where the annotation belongs. The bytes select among what this listener
2526
+ // has itself put in this chat and can name nothing else; the gate then
2527
+ // does everything it does for any other tap.
2528
+ delivery = this.liveDeliveryFor(parsed.actionRef);
2529
+ viaStaleCopy = delivery !== undefined;
2530
+ }
2531
+ if (delivery === undefined) {
2532
+ if (parsed.actionRef !== null) {
2533
+ // Nothing open here for that action. Say what the log says, which is
2534
+ // the only thing that knows: decided, lapsed, withdrawn, or unknown.
2535
+ const described = this.describeAction?.(parsed.actionRef) ?? null;
2536
+ await this.ignore(result, callbackId, "stale-copy", `no open delivery for action ref ${JSON.stringify(parsed.actionRef)} (an earlier copy of a request this listener is not holding open)`, described ?? TELEGRAM_STALE_UNKNOWN);
2537
+ return;
2538
+ }
2539
+ await this.ignore(result, callbackId, "unknown-callback", `no delivery for nonce ${JSON.stringify(parsed.nonce)} (a restarted listener forgets its buttons; the pending queue is re-sent on start)`,
2540
+ // Two ways to get here, and the reply has to serve both: a button this
2541
+ // process never issued (a restart forgot it), and a button on a message
2542
+ // this process has already annotated (APRV-113 forgets the nonce with
2543
+ // the edit). Either way the message text is the thing to read.
2544
+ "This button is no longer live — read the message for the outcome, or the newest message for the request.");
2545
+ return;
2546
+ }
2547
+ if (!viaStaleCopy && parsed.actionRef !== null && parsed.actionRef !== delivery.actionRef) {
2548
+ await this.ignore(result, callbackId, "key-mismatch", `callback references ${JSON.stringify(parsed.actionRef)} but the nonce was issued for ${JSON.stringify(delivery.actionKey)}`, "That button does not match a delivered request.");
2549
+ return;
2550
+ }
2551
+ const decision = {
2552
+ action_key: delivery.actionKey,
2553
+ decision: parsed.decision,
2554
+ deliveryId: delivery.deliveryId,
2555
+ ...(delivery.batchDeliveryId === undefined
2556
+ ? {}
2557
+ : { batchDeliveryId: delivery.batchDeliveryId }),
2558
+ ...(parsed.decision === "reject"
2559
+ ? { note: `${TELEGRAM_REJECT_NOTE} (callback ${callbackId})` }
2560
+ : {}),
2561
+ };
2562
+ if (this.handler === null) {
2563
+ await this.ignore(result, callbackId, "unknown-callback", "a callback arrived before the runtime registered a decision handler", "The runtime is not ready to record decisions.");
2564
+ return;
2565
+ }
2566
+ // APRV-206. The ack goes out BEFORE the gate runs, and it is the only
2567
+ // answer this query will get. Everything below — reading the verified log,
2568
+ // re-checking the budgets, appending under the lock — used to happen while
2569
+ // the button spun, so the spinner's length was the log's length. It claims
2570
+ // no decision (see {@link TELEGRAM_ACK_HEARD}): at this instant nothing has
2571
+ // been appended, and the annotation below is what says what the log holds.
2572
+ await this.safeAnswer(callbackId, `${viaStaleCopy ? TELEGRAM_STALE_COPY_PREFIX : ""}${TELEGRAM_ACK_HEARD}`);
2573
+ // APRV-206. A handler that throws is the one case the early ack cannot be
2574
+ // taken back: the human has been told their tap arrived, and the toast that
2575
+ // used to say "this listener could not finish reading your tap" is spent. So
2576
+ // the message says it instead, and the throw still reaches `handleUpdate`,
2577
+ // which complains and keeps the poll loop alive exactly as before.
2578
+ let outcome;
2579
+ try {
2580
+ outcome = this.handler(decision);
2581
+ }
2582
+ catch (cause) {
2583
+ await this.annotateQuietly(delivery.deliveryId, delivery.actionKey, TELEGRAM_NOT_RECORDED, [
2584
+ TELEGRAM_HANDLER_FAILED,
2585
+ ]);
2586
+ throw cause;
2587
+ }
2588
+ this.counters.decisions += 1;
2589
+ if (viaStaleCopy) {
2590
+ this.counters.staleCopyDecisions += 1;
2591
+ this.complain(`approval: telegram resolved a tap on an earlier copy of ${delivery.actionKey} to the live delivery (message ${delivery.deliveryId})`);
2592
+ }
2593
+ result.outcomes.push({ action_key: delivery.actionKey, outcome });
2594
+ // APRV-113, and since APRV-206 the ONLY place the outcome is stated. The
2595
+ // tap is visible in the transcript rather than in a toast that vanishes,
2596
+ // and the words come from the record the gate actually appended.
2597
+ //
2598
+ // Best effort in the same sense `retract` is — a failed edit is complained
2599
+ // about and dropped, because the decision is already in the log and nothing
2600
+ // about it depends on a chat message being redrawn.
2601
+ if (!outcome.ok) {
2602
+ // APRV-206. A refusal used to be a toast; the single answer is now spent
2603
+ // on the ack, so it is said here or nowhere. `annotate` disarms the
2604
+ // message, which is right in both directions: a terminal request has no
2605
+ // decision left to collect, and a still-pending one is re-delivered as a
2606
+ // fresh prompt by the next dispatch cycle.
2607
+ await this.annotateQuietly(delivery.deliveryId, delivery.actionKey, TELEGRAM_NOT_RECORDED, [
2608
+ this.answerFor(outcome),
2609
+ ]);
2610
+ return;
2611
+ }
2612
+ const record = outcome.record;
2613
+ const headline = outcome.decision === "grant"
2614
+ ? TELEGRAM_TERMINAL_HEADLINES.granted
2615
+ : TELEGRAM_TERMINAL_HEADLINES.rejected;
2616
+ try {
2617
+ await this.annotate(delivery.deliveryId, headline, [decidedLine(record.actor, record.ts, record.seq)], delivery.actionKey);
2618
+ }
2619
+ catch (cause) {
2620
+ // APRV-277: the one failure that is not one. See isMessageNotModified.
2621
+ if (isMessageNotModified(cause))
2622
+ return;
2623
+ this.complain(`approval: telegram could not annotate the decided ${delivery.actionKey} (message ${delivery.deliveryId}): ${this.describe(cause)} — the decision is recorded; only the message is stale`);
2624
+ }
2625
+ }
2626
+ /**
2627
+ * One tap over every still-open member of a digest (APRV-115).
2628
+ *
2629
+ * **N decisions, never one.** Each member is turned into its own
2630
+ * {@link ChannelDecision} — its own action key, its own payload binding — and
2631
+ * handed to the runtime's handler on its own, which records it through the
2632
+ * gate's compare-and-append on its own. There is no code path here that could
2633
+ * produce a single event covering two actions, because there is no call here
2634
+ * that writes anything at all.
2635
+ *
2636
+ * A member that refuses (already decided elsewhere, expired, withdrawn) does
2637
+ * not stop the rest, for the reason `channels/batch.ts` sets out: abandoning
2638
+ * four answers because the fifth had lapsed would discard a human's decision,
2639
+ * and un-appending the ones already written is not a thing the log permits.
2640
+ * The toast says how many landed and how many did not.
2641
+ *
2642
+ * The digest is redrawn ONCE at the end rather than per member: N edits of
2643
+ * the same message would show the approver their own decisions arriving one
2644
+ * at a time, and would spend N Bot API calls to end in the same place.
2645
+ */
2646
+ async handleDigestAll(decision, nonce, callbackId, result) {
2647
+ const deliveryId = this.allNonces.get(nonce);
2648
+ const digest = deliveryId === undefined ? undefined : this.digests.get(deliveryId);
2649
+ if (digest === undefined) {
2650
+ await this.ignore(result, callbackId, "unknown-callback", `no digest for nonce ${JSON.stringify(nonce)} (a restarted listener forgets its buttons; the pending queue is re-sent on start)`, "This button is no longer live — read the message for the outcome, or the newest message for the requests.");
2651
+ return;
2652
+ }
2653
+ if (this.handler === null) {
2654
+ await this.ignore(result, callbackId, "unknown-callback", "a callback arrived before the runtime registered a decision handler", "The runtime is not ready to record decisions.");
2655
+ return;
2656
+ }
2657
+ // APRV-206, and this path needs it most: an "all" tap runs one gate
2658
+ // decision per open member, so its old post-hoc toast made the spinner N
2659
+ // decisions long. The redraw below is what says how each member ended.
2660
+ await this.safeAnswer(callbackId, TELEGRAM_ACK_HEARD);
2661
+ const open = digest.members.filter((member) => member.settled === null);
2662
+ let landed = 0;
2663
+ const refusals = [];
2664
+ for (const member of open) {
2665
+ const one = {
2666
+ action_key: member.actionKey,
2667
+ decision,
2668
+ deliveryId: digest.deliveryId,
2669
+ batchDeliveryId: digest.batchDeliveryId,
2670
+ ...(decision === "reject"
2671
+ ? { note: `${TELEGRAM_REJECT_NOTE} (callback ${callbackId}, all)` }
2672
+ : {}),
2673
+ };
2674
+ const outcome = this.handler(one);
2675
+ this.counters.decisions += 1;
2676
+ result.outcomes.push({ action_key: member.actionKey, outcome });
2677
+ if (!outcome.ok) {
2678
+ refusals.push(outcome.code);
2679
+ continue;
2680
+ }
2681
+ landed += 1;
2682
+ // Bookkeeping only: the words come from the record the gate appended.
2683
+ member.settled = {
2684
+ headline: outcome.decision === "grant"
2685
+ ? TELEGRAM_TERMINAL_HEADLINES.granted
2686
+ : TELEGRAM_TERMINAL_HEADLINES.rejected,
2687
+ detail: [decidedLine(outcome.record.actor, outcome.record.ts, outcome.record.seq)],
2688
+ };
2689
+ this.deliveries.delete(member.nonce);
2690
+ }
2691
+ if (digest.members.every((member) => member.settled !== null)) {
2692
+ this.allNonces.delete(digest.allNonce);
2693
+ }
2694
+ const word = decision === "grant" ? "Approved" : "Rejected";
2695
+ const summary = refusals.length === 0
2696
+ ? `${word} ${landed} — one log event each.`
2697
+ : `${word} ${landed}; ${refusals.length} refused (${[...new Set(refusals)].join(", ")}). Nothing was recorded for those.`;
2698
+ // APRV-206: the summary is no longer a toast (the tap's one answer was
2699
+ // spent acknowledging it). The approver reads the outcome off the redrawn
2700
+ // digest, member by member, and the operator gets the tally here.
2701
+ this.complain(`approval: telegram digest ${digest.deliveryId}: ${summary}`);
2702
+ try {
2703
+ await this.redraw(digest);
2704
+ }
2705
+ catch (cause) {
2706
+ // APRV-277: the one failure that is not one. See isMessageNotModified.
2707
+ if (isMessageNotModified(cause))
2708
+ return;
2709
+ this.complain(`approval: telegram could not redraw the digest (message ${digest.deliveryId}): ${this.describe(cause)} — the decisions are recorded; only the message is stale`);
2710
+ }
2711
+ }
2712
+ /**
2713
+ * {@link annotate}, with a failed edit complained about rather than thrown
2714
+ * (APRV-206).
2715
+ *
2716
+ * Every caller on the decision path wants the same thing from a failed edit:
2717
+ * say so on the operator's terminal and carry on, because whatever the gate
2718
+ * did or did not append has already happened and no chat message changes it.
2719
+ *
2720
+ * The exception is {@link isMessageNotModified}, which says the message
2721
+ * already reads the way this call wanted it to read (APRV-277). Nothing is
2722
+ * printed for it: there is no staleness to warn about.
2723
+ */
2724
+ async annotateQuietly(deliveryId, actionKey, headline, detail) {
2725
+ try {
2726
+ await this.annotate(deliveryId, headline, detail, actionKey);
2727
+ }
2728
+ catch (cause) {
2729
+ // APRV-277: the one failure that is not one. See isMessageNotModified.
2730
+ if (isMessageNotModified(cause))
2731
+ return;
2732
+ this.complain(`approval: telegram could not annotate ${actionKey} (message ${deliveryId}): ${this.describe(cause)} — the log is what it is; only the message is stale`);
2733
+ }
2734
+ }
2735
+ /**
2736
+ * What a refused tap is told, in the message edit (APRV-206; it was the toast
2737
+ * until the single answer moved to the early ack).
2738
+ *
2739
+ * The duplicate case is the one worth naming: a second tap on a request the
2740
+ * gate has already decided produces `already-decided`, no second event, and
2741
+ * this text. Telegram redelivers callbacks on its own, so this path is
2742
+ * ordinary traffic, not an error.
2743
+ *
2744
+ * The sentences themselves moved to `channels/contract.ts` in APRV-235, so
2745
+ * that this message edit and the line the terminal channel prints are the
2746
+ * same words and cannot drift apart: a human who taps on their phone and
2747
+ * then reads the operator's terminal should not have to decide which of two
2748
+ * wordings to believe. The edit puts {@link TELEGRAM_NOT_RECORDED} above it
2749
+ * and clears the buttons, in `annotate`'s single call.
2750
+ */
2751
+ answerFor(outcome) {
2752
+ return refusedDecisionLine(outcome.code);
2753
+ }
2754
+ /**
2755
+ * A tap on `Sign` or `Not now` (APRV-257).
2756
+ *
2757
+ * The nonce is authoritative and there is no fallback ladder underneath it:
2758
+ * a checkpoint names no request, so there is no action reference to rescue a
2759
+ * stale copy with, and a tap this process cannot resolve is answered as
2760
+ * `unknown-callback` rather than guessed at. The cost is one dead button
2761
+ * after a restart, and the listener offers again on its next lapse.
2762
+ *
2763
+ * The nonce is consumed BEFORE the handler runs, so a double tap cannot
2764
+ * produce two records: the second tap finds nothing and says so. Even if it
2765
+ * did, `appendCheckpointAt` is a compare-and-append and the log would carry
2766
+ * two honest checkpoints over the same head, which is harmless — but a human
2767
+ * who taps twice should be told what happened rather than shown two
2768
+ * successes.
2769
+ *
2770
+ * The ack goes out FIRST (APRV-206's rule), because signing reads a vault
2771
+ * and appends to a log, and a spinner that lasted a decision long is what
2772
+ * that task removed.
2773
+ */
2774
+ async handleCheckpointTap(tap, callbackId, result) {
2775
+ const held = this.checkpointNonces.get(tap.nonce);
2776
+ if (held === undefined) {
2777
+ await this.ignore(result, callbackId, "unknown-callback", `checkpoint nonce ${JSON.stringify(tap.nonce)} was not issued by this process`, "This checkpoint prompt is from an earlier run. A fresh one is offered when the next is due.");
2778
+ return;
2779
+ }
2780
+ this.checkpointNonces.delete(tap.nonce);
2781
+ const handler = this.checkpointHandler;
2782
+ if (handler === null) {
2783
+ await this.ignore(result, callbackId, "unknown-callback", "a checkpoint tap arrived with no handler registered", TELEGRAM_ACK_FALLBACK);
2784
+ return;
2785
+ }
2786
+ await this.safeAnswer(callbackId, tap.sign ? "Heard — signing. The message will say what the log recorded." : "Not now.");
2787
+ const response = await handler({ sign: tap.sign, head: held.head });
2788
+ // Edited here rather than through `annotate`, which renders an action key
2789
+ // under its headline. A checkpoint has none, and an empty `<code></code>`
2790
+ // where a request's key belongs would be this channel implying a request.
2791
+ // Sending no `reply_markup` is what takes the buttons away.
2792
+ await this.call("editMessageText", {
2793
+ chat_id: this.chatId,
2794
+ message_id: Number(held.deliveryId),
2795
+ text: [
2796
+ `<b>${escapeHtml(response.headline)}</b>`,
2797
+ "",
2798
+ ...response.detail.map((entry) => escapeHtml(entry)),
2799
+ ].join("\n"),
2800
+ parse_mode: "HTML",
2801
+ disable_web_page_preview: true,
2802
+ });
2803
+ }
2804
+ /**
2805
+ * A tap on one of a review card's six buttons (APRV-299).
2806
+ *
2807
+ * The nonce is authoritative and there is no fallback ladder underneath it,
2808
+ * for the reason {@link reviewCallbackData} gives: a review is never urgent,
2809
+ * a card this process is not holding leaves its sample open, and the next
2810
+ * cycle offers a fresh one. An unresolvable tap is answered
2811
+ * `unknown-callback` rather than guessed at.
2812
+ *
2813
+ * Which combinations are legal is decided by `core/audit.ts` and by nothing
2814
+ * here. A denied review that says the human loved the work is refused by
2815
+ * `reviewSample` before it reads the log, with the code SPEC.md §11.2 names,
2816
+ * and this method's job is to put that pair in front of it rather than to
2817
+ * re-implement the rule. The one thing this method owns is the ARMING, which
2818
+ * is process memory that appends nothing.
2819
+ */
2820
+ async handleReviewTap(tap, callbackId, result) {
2821
+ const deliveryId = this.reviewNonces.get(tap.nonce);
2822
+ const state = deliveryId === undefined ? undefined : this.reviewCards.get(deliveryId);
2823
+ if (state === undefined || state.settled !== null) {
2824
+ await this.ignore(result, callbackId, "unknown-callback", `no open review card for nonce ${JSON.stringify(tap.nonce)} (a restarted listener forgets its buttons; the sample stays open and is offered again)`, "This review card is no longer live — the sample is still open, and a fresh card is sent on a later cycle. `approval audit review` names it from a terminal.");
2825
+ return;
2826
+ }
2827
+ if (this.reviewHandler === null) {
2828
+ await this.ignore(result, callbackId, "unknown-callback", "a review tap arrived before the runtime registered a review handler", "The runtime is not ready to record reviews.");
2829
+ return;
2830
+ }
2831
+ // The first Deny tap arms and writes nothing. Stated on the card, so the
2832
+ // approver reads the state rather than inferring it from a toast.
2833
+ if (tap.choice === "deny" && !state.denyArmed) {
2834
+ state.denyArmed = true;
2835
+ state.notice = null;
2836
+ await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ARM_TOAST);
2837
+ await this.redrawReview(state);
2838
+ return;
2839
+ }
2840
+ const verdict = state.denyArmed ? "denied" : "ok";
2841
+ if (tap.choice === "ok" || tap.choice === "deny") {
2842
+ // `ok` with deny armed is a correction, and it disarms: a human who
2843
+ // reached for Deny and then chose OK meant OK, and nothing was written in
2844
+ // between for the change of mind to contradict.
2845
+ const chosen = tap.choice === "deny" ? "denied" : "ok";
2846
+ state.denyArmed = chosen === "denied";
2847
+ await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
2848
+ await this.recordReview(state, { sampleSeq: state.card.sampleSeq, verdict: chosen }, result);
2849
+ return;
2850
+ }
2851
+ const reaction = tap.choice;
2852
+ // The two grades that require the human's own words ask for them FIRST, and
2853
+ // nothing is appended until the reply arrives. The exception is the pair
2854
+ // `core/audit.ts` refuses outright: a denied review that says liked or
2855
+ // loved is answered with `reaction-conflicts-verdict` before any log is
2856
+ // read, so asking for a note to go with it would collect words for a record
2857
+ // that was never going to exist.
2858
+ const conflicts = verdict === "denied" && (reaction === "liked" || reaction === "loved");
2859
+ if (!conflicts && (reaction === "loved" || reaction === "disliked")) {
2860
+ await this.safeAnswer(callbackId, TELEGRAM_REVIEW_NOTE_TOAST);
2861
+ await this.askForNote(state, verdict, reaction);
2862
+ return;
2863
+ }
2864
+ await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
2865
+ await this.recordReview(state, { sampleSeq: state.card.sampleSeq, verdict, reaction }, result);
2866
+ }
2867
+ /**
2868
+ * Send the ForceReply prompt a `loved` or `disliked` needs, and remember what
2869
+ * its reply will record (APRV-299).
2870
+ *
2871
+ * The pending grade lives HERE and not in the reply's own text, exactly as a
2872
+ * checkpoint's head lives in this process rather than in the callback bytes:
2873
+ * what gets recorded is what this process put on the screen. Losing the map
2874
+ * to a restart costs the reply its meaning — nothing is appended, the sample
2875
+ * stays open, and a fresh card is offered — and can never cost a record
2876
+ * nobody asked for.
2877
+ *
2878
+ * A second prompt replaces the first: only one grade can be outstanding on
2879
+ * one card, and the older prompt stops resolving so a late reply to it lands
2880
+ * nowhere rather than recording a grade the human moved on from.
2881
+ */
2882
+ async askForNote(state, verdict, reaction) {
2883
+ if (state.awaitingNote !== null)
2884
+ this.reviewNotePrompts.delete(state.awaitingNote.promptId);
2885
+ const lines = reviewNotePromptLines(reaction, verdict, state.card.fields.action_key.value);
2886
+ const sent = await this.call("sendMessage", {
2887
+ chat_id: this.chatId,
2888
+ text: lines
2889
+ .map((entry, index) => (index === 0 ? `<b>${escapeHtml(entry)}</b>` : escapeHtml(entry)))
2890
+ .join("\n"),
2891
+ parse_mode: "HTML",
2892
+ disable_web_page_preview: true,
2893
+ reply_markup: { force_reply: true },
2894
+ });
2895
+ const promptId = String(sent.message_id);
2896
+ state.awaitingNote = { promptId, verdict, reaction };
2897
+ this.reviewNotePrompts.set(promptId, state.deliveryId);
2898
+ }
2899
+ /**
2900
+ * A message replying to an outstanding note prompt (APRV-299).
2901
+ *
2902
+ * Returns `true` when this update was a note reply and has been dealt with,
2903
+ * so the command path below never sees it. Three refusals to act on something
2904
+ * the network said, in order: a reply naming no prompt this process issued is
2905
+ * not ours, a reply from another chat is counted `foreign-chat` and answered
2906
+ * with nothing at all, and the words themselves are passed to the runtime
2907
+ * verbatim — a blank one included, because whether blank is a note is
2908
+ * `core/audit.ts`'s rule and not this channel's.
2909
+ */
2910
+ async handleNoteReply(message, text, result) {
2911
+ const replyTo = message["reply_to_message"];
2912
+ if (typeof replyTo !== "object" || replyTo === null)
2913
+ return false;
2914
+ const promptId = String(replyTo["message_id"] ?? "");
2915
+ const deliveryId = this.reviewNotePrompts.get(promptId);
2916
+ if (deliveryId === undefined)
2917
+ return false;
2918
+ const chat = (message["chat"] ?? {});
2919
+ const chatId = chat["id"] === undefined ? "" : String(chat["id"]);
2920
+ if (chatId !== this.chatId) {
2921
+ this.counters.anomalies["foreign-chat"] += 1;
2922
+ result.ignored.push({
2923
+ kind: "foreign-chat",
2924
+ detail: `note reply from chat ${JSON.stringify(chatId)}, which is not the configured approver chat`,
2925
+ });
2926
+ this.complain(`approval: telegram ignored a review note (foreign-chat): from chat ${JSON.stringify(chatId)}, which is not the configured approver chat`);
2927
+ return true;
2928
+ }
2929
+ const state = this.reviewCards.get(deliveryId);
2930
+ const pending = state?.awaitingNote ?? null;
2931
+ this.reviewNotePrompts.delete(promptId);
2932
+ if (state === undefined || pending === null || pending.promptId !== promptId)
2933
+ return true;
2934
+ state.awaitingNote = null;
2935
+ await this.recordReview(state, {
2936
+ sampleSeq: state.card.sampleSeq,
2937
+ verdict: pending.verdict,
2938
+ reaction: pending.reaction,
2939
+ note: text,
2940
+ }, result);
2941
+ return true;
2942
+ }
2943
+ /**
2944
+ * Hand one review tap to the runtime and redraw the card from its answer
2945
+ * (APRV-299).
2946
+ *
2947
+ * A recorded review settles the card and forgets its nonce, so a tap on a
2948
+ * button the edit does not manage to remove resolves to nothing rather than
2949
+ * recording a second human observation of one item. A REFUSAL does neither:
2950
+ * nothing was appended, the sample is still open, and the codes that get here
2951
+ * are ones the reviewer can act on — `reaction-conflicts-verdict` asks them
2952
+ * to say which half they meant, and `note-required` asks for words — so the
2953
+ * buttons stay, with the refusal rendered above them and the arming intact.
2954
+ */
2955
+ async recordReview(state, tap, result) {
2956
+ const handler = this.reviewHandler;
2957
+ if (handler === null)
2958
+ return;
2959
+ let response;
2960
+ try {
2961
+ response = await handler(tap);
2962
+ }
2963
+ catch (cause) {
2964
+ state.notice = { headline: TELEGRAM_NOT_RECORDED, lines: [TELEGRAM_HANDLER_FAILED] };
2965
+ await this.redrawReview(state);
2966
+ throw cause;
2967
+ }
2968
+ this.counters.reviews += 1;
2969
+ result.reviews.push({ tap, ok: response.ok });
2970
+ if (response.ok) {
2971
+ state.settled = { headline: response.headline, detail: response.detail };
2972
+ state.notice = null;
2973
+ state.denyArmed = false;
2974
+ this.reviewNonces.delete(state.nonce);
2975
+ if (state.awaitingNote !== null) {
2976
+ this.reviewNotePrompts.delete(state.awaitingNote.promptId);
2977
+ state.awaitingNote = null;
2978
+ }
2979
+ }
2980
+ else {
2981
+ state.notice = { headline: response.headline, lines: response.detail };
2982
+ }
2983
+ await this.redrawReview(state);
2984
+ }
2985
+ /** One `editMessageText` that replaces a review card's text and its keyboard. */
2986
+ async redrawReview(state) {
2987
+ const drawn = renderReviewCard(state);
2988
+ try {
2989
+ await this.call("editMessageText", {
2990
+ chat_id: this.chatId,
2991
+ message_id: Number(state.deliveryId),
2992
+ text: drawn.text,
2993
+ parse_mode: "HTML",
2994
+ disable_web_page_preview: true,
2995
+ ...(drawn.keyboard === null ? {} : { reply_markup: drawn.keyboard }),
2996
+ });
2997
+ }
2998
+ catch (cause) {
2999
+ // APRV-277: the one failure that is not one. See isMessageNotModified.
3000
+ if (isMessageNotModified(cause))
3001
+ return;
3002
+ // Cosmetic in the same sense every other failed edit here is: whatever
3003
+ // the runtime appended has already happened, and no chat message changes
3004
+ // it. The sample's state is the log's answer, never this card's text.
3005
+ this.complain(`approval: telegram could not redraw the review card for sample seq ${String(state.card.sampleSeq)} (message ${state.deliveryId}): ${this.describe(cause)} — the log is what it is; only the message is stale`);
3006
+ }
3007
+ }
3008
+ async ignore(result, callbackId, kind, detail, reply) {
3009
+ this.counters.anomalies[kind] += 1;
3010
+ result.ignored.push({ kind, detail });
3011
+ this.complain(`approval: telegram ignored a callback (${kind}): ${detail}`);
3012
+ // A refusal toast is a courtesy, not part of the decision path: it is best
3013
+ // effort and its failure is not the listener's problem. Through
3014
+ // {@link safeAnswer} since APRV-196, so that this counts as THE ack for the
3015
+ // query and `handleUpdate`'s guarantee does not add a second, vaguer one on
3016
+ // top of the sentence this path already chose.
3017
+ await this.safeAnswer(callbackId, reply);
3018
+ }
3019
+ async answer(callbackId, text) {
3020
+ if (callbackId.length === 0)
3021
+ return;
3022
+ await this.call("answerCallbackQuery", { callback_query_id: callbackId, text });
3023
+ }
3024
+ /**
3025
+ * Answer, and never throw (APRV-196).
3026
+ *
3027
+ * A toast is a courtesy on every path, including the successful one: the
3028
+ * decision is already in the log by the time the ack is attempted, and an
3029
+ * `answerCallbackQuery` that fails (Telegram drops a query after its own
3030
+ * window, and a phone on a train produces plenty of late taps) must not
3031
+ * abandon the annotation or push the poll loop into backoff.
3032
+ *
3033
+ * The attempt is recorded either way, so {@link handleUpdate}'s guarantee
3034
+ * does not turn one failed ack into a second doomed call.
3035
+ *
3036
+ * **Idempotent per callback query since APRV-206.** A query that has already
3037
+ * been answered in this handling is not answered again: the early ack the
3038
+ * decision path sends is THE answer, and every later sentence — a branch's
3039
+ * own toast, the wrapper's fallback — becomes a no-op rather than a second
3040
+ * `answerCallbackQuery`. APRV-196's "exactly one per callback" therefore
3041
+ * holds structurally, in this one method, instead of by every branch
3042
+ * remembering to return.
3043
+ */
3044
+ async safeAnswer(callbackId, text) {
3045
+ if (this.ack !== null && this.ack.id === callbackId) {
3046
+ if (this.ack.answered)
3047
+ return;
3048
+ this.ack.answered = true;
3049
+ }
3050
+ if (callbackId.length === 0)
3051
+ return;
3052
+ try {
3053
+ await this.answer(callbackId, text);
3054
+ }
3055
+ catch (cause) {
3056
+ this.complain(`approval: telegram could not answer a callback (${this.describe(cause)}) — the tap has no toast; the log is unaffected`);
3057
+ }
3058
+ }
3059
+ /**
3060
+ * The delivery this process is holding open for an action reference, if any
3061
+ * (APRV-196).
3062
+ *
3063
+ * A linear walk of the delivery map rather than a second index: the map is
3064
+ * bounded by the pending queue and swept (APRV-135), this runs only on the
3065
+ * uncommon path where a nonce did not resolve, and a second map would be a
3066
+ * second thing to keep in step with `disarm`, `settleMember` and `sweep` —
3067
+ * three places where forgetting is the safety property.
3068
+ *
3069
+ * Digest members are eligible: a member's nonce is deleted the moment it is
3070
+ * settled, so a member still in the map is one still armed on a live message.
3071
+ */
3072
+ liveDeliveryFor(actionRef) {
3073
+ for (const delivery of this.deliveries.values()) {
3074
+ if (delivery.actionRef === actionRef)
3075
+ return delivery;
3076
+ }
3077
+ return undefined;
3078
+ }
3079
+ // -------------------------------------------------------------------------
3080
+ // Transport
3081
+ // -------------------------------------------------------------------------
3082
+ /** Replace the token with a placeholder anywhere it appears in `text`. */
3083
+ redact(text) {
3084
+ return this.token.length === 0 ? text : text.split(this.token).join("<token redacted>");
3085
+ }
3086
+ describe(cause) {
3087
+ return this.redact(cause instanceof Error ? cause.message : String(cause));
3088
+ }
3089
+ /**
3090
+ * The Bot API's own `description` for a failed response, redacted (APRV-277).
3091
+ *
3092
+ * `null` whenever there is nothing trustworthy to quote: the body could not
3093
+ * be read, it was not JSON, or it carried no description. Every failure mode
3094
+ * here is silent by design, because this runs on a path that is already
3095
+ * reporting a failure and a second one thrown from the diagnostic would
3096
+ * replace the real reason with a worse one.
3097
+ */
3098
+ async describeFailure(response) {
3099
+ let body;
3100
+ try {
3101
+ body = await response.text();
3102
+ }
3103
+ catch {
3104
+ return null;
3105
+ }
3106
+ let parsed;
3107
+ try {
3108
+ parsed = JSON.parse(body);
3109
+ }
3110
+ catch {
3111
+ return null;
3112
+ }
3113
+ if (parsed === null || typeof parsed !== "object")
3114
+ return null;
3115
+ const description = parsed["description"];
3116
+ if (typeof description !== "string" || description.length === 0)
3117
+ return null;
3118
+ return this.redact(description);
3119
+ }
3120
+ /**
3121
+ * One Bot API call.
3122
+ *
3123
+ * The token is in the URL, which is how the Bot API works — there is no
3124
+ * header form. It is therefore never put in a message body, an error string,
3125
+ * or a log line: {@link redact} scrubs everything that leaves this class, and
3126
+ * the test suite scans every request body and every log byte for it.
3127
+ */
3128
+ async call(method, body, timeoutMs = this.requestTimeoutMs ?? 30_000) {
3129
+ const controller = new AbortController();
3130
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
3131
+ if (method === "getUpdates")
3132
+ this.inFlight = controller;
3133
+ let raw;
3134
+ try {
3135
+ const response = await this.fetchImpl(`${this.apiBase}/bot${this.token}/${method}`, {
3136
+ method: "POST",
3137
+ headers: { "content-type": "application/json" },
3138
+ body: JSON.stringify(body),
3139
+ signal: controller.signal,
3140
+ });
3141
+ if (!response.ok) {
3142
+ // APRV-277. The Bot API puts its reason in the error body's
3143
+ // `description`, and dropping it made every failure read as a bare
3144
+ // status: an edit that changed nothing and an edit into a chat the bot
3145
+ // was thrown out of were the same "HTTP 400" on the operator's
3146
+ // terminal. Read best effort — a status is still worth reporting when
3147
+ // the body is missing, truncated, or not JSON at all.
3148
+ const description = await this.describeFailure(response);
3149
+ throw new TelegramApiError(description === null
3150
+ ? `${method}: HTTP ${response.status}`
3151
+ : `${method}: HTTP ${response.status} (${description})`, method, response.status, description);
3152
+ }
3153
+ raw = await response.text();
3154
+ }
3155
+ catch (cause) {
3156
+ if (cause instanceof TelegramApiError)
3157
+ throw cause;
3158
+ throw new TelegramApiError(`${method}: ${this.describe(cause)}`, method);
3159
+ }
3160
+ finally {
3161
+ clearTimeout(timer);
3162
+ if (method === "getUpdates")
3163
+ this.inFlight = null;
3164
+ }
3165
+ let parsed;
3166
+ try {
3167
+ parsed = JSON.parse(raw);
3168
+ }
3169
+ catch {
3170
+ throw new TelegramApiError(`${method}: response was not JSON`, method);
3171
+ }
3172
+ const envelope = (parsed ?? {});
3173
+ if (envelope["ok"] !== true) {
3174
+ const said = envelope["description"];
3175
+ const description = typeof said === "string" && said.length > 0 ? this.redact(said) : null;
3176
+ // Unchanged wording: anything the Bot API put here is still shown, and an
3177
+ // absent one is still "no description". `description` is the narrower
3178
+ // field — a non-empty string only — because that is what a caller is
3179
+ // entitled to match on (APRV-277).
3180
+ const shown = said === undefined || said === null ? "no description" : this.redact(String(said));
3181
+ throw new TelegramApiError(`${method}: the Bot API refused (${shown})`, method,
3182
+ // No HTTP status: this envelope arrived on a 2xx. The description is
3183
+ // carried anyway so a caller reads the same field whichever shape the
3184
+ // failure took (APRV-277).
3185
+ null, description);
3186
+ }
3187
+ return envelope["result"];
3188
+ }
3189
+ }
3190
+ //# sourceMappingURL=telegram.js.map