approval-md 0.1.0 → 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 (266) hide show
  1. package/README.md +584 -553
  2. package/SPEC.md +42 -13
  3. package/dist/src/adapters/agentmail.d.ts +426 -0
  4. package/dist/src/adapters/agentmail.js +2 -2
  5. package/dist/src/adapters/conformance.d.ts +149 -0
  6. package/dist/src/adapters/contract.d.ts +628 -0
  7. package/dist/src/adapters/contract.js +110 -16
  8. package/dist/src/adapters/contract.js.map +1 -1
  9. package/dist/src/adapters/email.d.ts +324 -0
  10. package/dist/src/adapters/env-passphrase.d.ts +93 -0
  11. package/dist/src/adapters/public.d.ts +11 -0
  12. package/dist/src/adapters/public.js +11 -0
  13. package/dist/src/adapters/public.js.map +1 -0
  14. package/dist/src/adapters/registry.d.ts +59 -0
  15. package/dist/src/adapters/registry.js +2 -1
  16. package/dist/src/adapters/registry.js.map +1 -1
  17. package/dist/src/adapters/smtp.d.ts +213 -0
  18. package/dist/src/adapters/vault-provider.d.ts +114 -0
  19. package/dist/src/adapters/vault-provider.js +3 -3
  20. package/dist/src/adapters/zzz.d.ts +66 -0
  21. package/dist/src/adapters/zzz.js +299 -0
  22. package/dist/src/adapters/zzz.js.map +1 -0
  23. package/dist/src/channels/batch.d.ts +109 -0
  24. package/dist/src/channels/cli.d.ts +193 -0
  25. package/dist/src/channels/conformance.d.ts +92 -0
  26. package/dist/src/channels/contract.d.ts +623 -0
  27. package/dist/src/channels/payload-view.d.ts +35 -0
  28. package/dist/src/channels/render-queue.d.ts +149 -0
  29. package/dist/src/channels/tagging.d.ts +196 -0
  30. package/dist/src/channels/telegram.d.ts +1832 -0
  31. package/dist/src/channels/web.d.ts +341 -0
  32. package/dist/src/cli/adapter.d.ts +90 -0
  33. package/dist/src/cli/adapter.js +25 -15
  34. package/dist/src/cli/adapter.js.map +1 -1
  35. package/dist/src/cli/amend.d.ts +59 -0
  36. package/dist/src/cli/args.d.ts +43 -0
  37. package/dist/src/cli/attest.d.ts +41 -0
  38. package/dist/src/cli/audit-card.d.ts +62 -0
  39. package/dist/src/cli/audit.d.ts +59 -0
  40. package/dist/src/cli/channel-telegram.d.ts +806 -0
  41. package/dist/src/cli/channel-web.d.ts +131 -0
  42. package/dist/src/cli/channel.d.ts +71 -0
  43. package/dist/src/cli/checkpoint-tap.d.ts +169 -0
  44. package/dist/src/cli/codex.d.ts +2 -0
  45. package/dist/src/cli/codex.js +172 -0
  46. package/dist/src/cli/codex.js.map +1 -0
  47. package/dist/src/cli/coverage.d.ts +61 -0
  48. package/dist/src/cli/daemon.d.ts +120 -0
  49. package/dist/src/cli/doctor.d.ts +129 -0
  50. package/dist/src/cli/doctor.js +119 -5
  51. package/dist/src/cli/doctor.js.map +1 -1
  52. package/dist/src/cli/env.d.ts +65 -0
  53. package/dist/src/cli/execute.d.ts +202 -0
  54. package/dist/src/cli/exit-codes.d.ts +73 -0
  55. package/dist/src/cli/feedback.d.ts +60 -0
  56. package/dist/src/cli/gate-window.d.ts +40 -0
  57. package/dist/src/cli/gate.d.ts +68 -0
  58. package/dist/src/cli/git-scope.d.ts +190 -0
  59. package/dist/src/cli/gloss-attach.d.ts +85 -0
  60. package/dist/src/cli/gloss-codex-child.d.ts +9 -0
  61. package/dist/src/cli/gloss-codex.d.ts +24 -0
  62. package/dist/src/cli/gloss-options.d.ts +42 -0
  63. package/dist/src/cli/gloss.d.ts +265 -0
  64. package/dist/src/cli/help.d.ts +103 -0
  65. package/dist/src/cli/help.js +173 -51
  66. package/dist/src/cli/help.js.map +1 -1
  67. package/dist/src/cli/hook-codex.d.ts +78 -0
  68. package/dist/src/cli/hook-codex.js +167 -0
  69. package/dist/src/cli/hook-codex.js.map +1 -0
  70. package/dist/src/cli/hook.d.ts +331 -0
  71. package/dist/src/cli/hook.js +186 -80
  72. package/dist/src/cli/hook.js.map +1 -1
  73. package/dist/src/cli/import.d.ts +35 -0
  74. package/dist/src/cli/init.d.ts +84 -0
  75. package/dist/src/cli/init.js +2 -2
  76. package/dist/src/cli/init.js.map +1 -1
  77. package/dist/src/cli/instructions.d.ts +23 -0
  78. package/dist/src/cli/journal.d.ts +41 -0
  79. package/dist/src/cli/log-advance.d.ts +287 -0
  80. package/dist/src/cli/log-advance.js +102 -11
  81. package/dist/src/cli/log-advance.js.map +1 -1
  82. package/dist/src/cli/log-anchor.d.ts +176 -0
  83. package/dist/src/cli/log-checkpoint.d.ts +22 -0
  84. package/dist/src/cli/log-sync.d.ts +243 -0
  85. package/dist/src/cli/log-verbs.d.ts +16 -0
  86. package/dist/src/cli/log-verbs.js +7 -1
  87. package/dist/src/cli/log-verbs.js.map +1 -1
  88. package/dist/src/cli/long-help.d.ts +70 -0
  89. package/dist/src/cli/main.d.ts +77 -0
  90. package/dist/src/cli/main.js +155 -5
  91. package/dist/src/cli/main.js.map +1 -1
  92. package/dist/src/cli/mcp.d.ts +52 -0
  93. package/dist/src/cli/paths.d.ts +56 -0
  94. package/dist/src/cli/payload.d.ts +58 -0
  95. package/dist/src/cli/policy.d.ts +43 -0
  96. package/dist/src/cli/preflight.d.ts +363 -0
  97. package/dist/src/cli/preflight.js +294 -7
  98. package/dist/src/cli/preflight.js.map +1 -1
  99. package/dist/src/cli/progress.d.ts +78 -0
  100. package/dist/src/cli/prompt.d.ts +209 -0
  101. package/dist/src/cli/quickstart.d.ts +46 -0
  102. package/dist/src/cli/quickstart.js +297 -0
  103. package/dist/src/cli/quickstart.js.map +1 -0
  104. package/dist/src/cli/records.d.ts +34 -0
  105. package/dist/src/cli/render.d.ts +22 -0
  106. package/dist/src/cli/sandbox.d.ts +51 -0
  107. package/dist/src/cli/scaffold.d.ts +79 -0
  108. package/dist/src/cli/setup-adapter.d.ts +137 -0
  109. package/dist/src/cli/setup-adapter.js +38 -4
  110. package/dist/src/cli/setup-adapter.js.map +1 -1
  111. package/dist/src/cli/setup-channel.d.ts +117 -0
  112. package/dist/src/cli/setup-checkpoint.d.ts +57 -0
  113. package/dist/src/cli/setup-common.d.ts +275 -0
  114. package/dist/src/cli/setup-flow.d.ts +287 -0
  115. package/dist/src/cli/setup-service.d.ts +96 -0
  116. package/dist/src/cli/setup.d.ts +202 -0
  117. package/dist/src/cli/style.d.ts +320 -0
  118. package/dist/src/cli/token.d.ts +39 -0
  119. package/dist/src/cli/up.d.ts +155 -0
  120. package/dist/src/cli/up.js +4 -2
  121. package/dist/src/cli/up.js.map +1 -1
  122. package/dist/src/cli/usage.d.ts +37 -0
  123. package/dist/src/cli/values.d.ts +40 -0
  124. package/dist/src/cli/vault.d.ts +59 -0
  125. package/dist/src/cli/vault.js +2 -2
  126. package/dist/src/cli/vault.js.map +1 -1
  127. package/dist/src/cli/verb-registry.d.ts +76 -0
  128. package/dist/src/cli/verb-registry.js +176 -8
  129. package/dist/src/cli/verb-registry.js.map +1 -1
  130. package/dist/src/cli/wordmark.d.ts +31 -0
  131. package/dist/src/cli/wordmark.js +2 -2
  132. package/dist/src/codex/doctor.d.ts +13 -0
  133. package/dist/src/codex/doctor.js +41 -0
  134. package/dist/src/codex/doctor.js.map +1 -0
  135. package/dist/src/codex/manifest.d.ts +49 -0
  136. package/dist/src/codex/manifest.js +103 -0
  137. package/dist/src/codex/manifest.js.map +1 -0
  138. package/dist/src/codex/templates.d.ts +41 -0
  139. package/dist/src/codex/templates.js +319 -0
  140. package/dist/src/codex/templates.js.map +1 -0
  141. package/dist/src/codex/trust.d.ts +19 -0
  142. package/dist/src/codex/trust.js +183 -0
  143. package/dist/src/codex/trust.js.map +1 -0
  144. package/dist/src/codex/workspace-plan.d.ts +131 -0
  145. package/dist/src/codex/workspace-plan.js +561 -0
  146. package/dist/src/codex/workspace-plan.js.map +1 -0
  147. package/dist/src/core/actor.d.ts +2 -0
  148. package/dist/src/core/actor.js +5 -0
  149. package/dist/src/core/actor.js.map +1 -0
  150. package/dist/src/core/advance-cycle.d.ts +170 -0
  151. package/dist/src/core/agents-md.d.ts +276 -0
  152. package/dist/src/core/apply-patch.d.ts +49 -0
  153. package/dist/src/core/apply-patch.js +266 -0
  154. package/dist/src/core/apply-patch.js.map +1 -0
  155. package/dist/src/core/attest.d.ts +420 -0
  156. package/dist/src/core/attest.js +13 -1
  157. package/dist/src/core/attest.js.map +1 -1
  158. package/dist/src/core/audit.d.ts +492 -0
  159. package/dist/src/core/budgets.d.ts +238 -0
  160. package/dist/src/core/checkpoint.d.ts +500 -0
  161. package/dist/src/core/child-env.d.ts +88 -0
  162. package/dist/src/core/clock.d.ts +52 -0
  163. package/dist/src/core/command-class.d.ts +543 -0
  164. package/dist/src/core/command-class.js +43 -8
  165. package/dist/src/core/command-class.js.map +1 -1
  166. package/dist/src/core/coverage-sources/adapter.d.ts +40 -0
  167. package/dist/src/core/coverage-sources/gh.d.ts +48 -0
  168. package/dist/src/core/coverage-sources/git.d.ts +101 -0
  169. package/dist/src/core/coverage.d.ts +217 -0
  170. package/dist/src/core/credential-spec.d.ts +72 -0
  171. package/dist/src/core/dark-session.d.ts +331 -0
  172. package/dist/src/core/decision-refusal.d.ts +185 -0
  173. package/dist/src/core/env-file.d.ts +450 -0
  174. package/dist/src/core/execute.d.ts +858 -0
  175. package/dist/src/core/execute.js +44 -6
  176. package/dist/src/core/execute.js.map +1 -1
  177. package/dist/src/core/frontmatter.d.ts +78 -0
  178. package/dist/src/core/gate-window.d.ts +312 -0
  179. package/dist/src/core/gate.d.ts +1364 -0
  180. package/dist/src/core/gate.js +68 -13
  181. package/dist/src/core/gate.js.map +1 -1
  182. package/dist/src/core/git-run.d.ts +73 -0
  183. package/dist/src/core/harness-version.d.ts +157 -0
  184. package/dist/src/core/harness-version.js +2 -1
  185. package/dist/src/core/harness-version.js.map +1 -1
  186. package/dist/src/core/harness-wait.d.ts +55 -0
  187. package/dist/src/core/head-retry.d.ts +107 -0
  188. package/dist/src/core/instance.d.ts +253 -0
  189. package/dist/src/core/intake-limits.d.ts +247 -0
  190. package/dist/src/core/jcs.d.ts +52 -0
  191. package/dist/src/core/journal.d.ts +144 -0
  192. package/dist/src/core/live-draw.d.ts +436 -0
  193. package/dist/src/core/log-reconcile.d.ts +89 -0
  194. package/dist/src/core/log-subscribe.d.ts +36 -0
  195. package/dist/src/core/log-subscribe.js +162 -0
  196. package/dist/src/core/log-subscribe.js.map +1 -0
  197. package/dist/src/core/log.d.ts +278 -0
  198. package/dist/src/core/loop.d.ts +274 -0
  199. package/dist/src/core/loop.js +11 -0
  200. package/dist/src/core/loop.js.map +1 -1
  201. package/dist/src/core/md-fence.d.ts +41 -0
  202. package/dist/src/core/money.d.ts +147 -0
  203. package/dist/src/core/payload-census.d.ts +74 -0
  204. package/dist/src/core/payload-store.d.ts +175 -0
  205. package/dist/src/core/payload.d.ts +71 -0
  206. package/dist/src/core/policy-diff.d.ts +292 -0
  207. package/dist/src/core/policy-diff.js +27 -4
  208. package/dist/src/core/policy-diff.js.map +1 -1
  209. package/dist/src/core/policy-expectations.d.ts +199 -0
  210. package/dist/src/core/policy-explain.d.ts +150 -0
  211. package/dist/src/core/policy-explain.js +31 -3
  212. package/dist/src/core/policy-explain.js.map +1 -1
  213. package/dist/src/core/policy-load.d.ts +527 -0
  214. package/dist/src/core/policy-load.js +15 -3
  215. package/dist/src/core/policy-load.js.map +1 -1
  216. package/dist/src/core/policy-match.d.ts +281 -0
  217. package/dist/src/core/policy-match.js +20 -9
  218. package/dist/src/core/policy-match.js.map +1 -1
  219. package/dist/src/core/policy-proposal.d.ts +265 -0
  220. package/dist/src/core/prompt-layout.d.ts +221 -0
  221. package/dist/src/core/protected-path-guard.d.ts +453 -0
  222. package/dist/src/core/protected-path-guard.js +514 -35
  223. package/dist/src/core/protected-path-guard.js.map +1 -1
  224. package/dist/src/core/registration.d.ts +25 -0
  225. package/dist/src/core/reindex.d.ts +99 -0
  226. package/dist/src/core/sampler.d.ts +313 -0
  227. package/dist/src/core/sandbox.d.ts +290 -0
  228. package/dist/src/core/seal.d.ts +165 -0
  229. package/dist/src/core/state.d.ts +505 -0
  230. package/dist/src/core/task-file.d.ts +185 -0
  231. package/dist/src/core/telegram-config.d.ts +93 -0
  232. package/dist/src/core/token.d.ts +409 -0
  233. package/dist/src/core/token.js +21 -38
  234. package/dist/src/core/token.js.map +1 -1
  235. package/dist/src/core/validate.d.ts +138 -0
  236. package/dist/src/core/values.d.ts +137 -0
  237. package/dist/src/core/vault.d.ts +291 -0
  238. package/dist/src/core/verified-snapshot.d.ts +204 -0
  239. package/dist/src/core/verify.d.ts +336 -0
  240. package/dist/src/core/version.d.ts +8 -0
  241. package/dist/src/core/wysiwys.d.ts +370 -0
  242. package/dist/src/daemon/advance-child.d.ts +39 -0
  243. package/dist/src/daemon/advance.d.ts +466 -0
  244. package/dist/src/daemon/audit.d.ts +87 -0
  245. package/dist/src/daemon/daemon.d.ts +1180 -0
  246. package/dist/src/daemon/dark-session.d.ts +64 -0
  247. package/dist/src/daemon/draw-child.d.ts +36 -0
  248. package/dist/src/daemon/draw.d.ts +154 -0
  249. package/dist/src/daemon/git-evidence.d.ts +173 -0
  250. package/dist/src/daemon/git-evidence.js +1 -1
  251. package/dist/src/daemon/projection.d.ts +180 -0
  252. package/dist/src/daemon/prune.d.ts +207 -0
  253. package/dist/src/mcp/http.d.ts +113 -0
  254. package/dist/src/mcp/server.d.ts +265 -0
  255. package/dist/src/mcp/server.js +9 -1
  256. package/dist/src/mcp/server.js.map +1 -1
  257. package/docs/adapter-api.md +106 -0
  258. package/docs/cli-reference.md +389 -36
  259. package/docs/codex-enforced-session.md +30 -0
  260. package/package.json +12 -2
  261. package/schema/codex-instance.schema.json +82 -0
  262. package/schema/event.schema.json +2 -1
  263. package/schema/fixtures/codex-instance/invalid/unpinned-codex-version.json +40 -0
  264. package/schema/fixtures/codex-instance/valid/canonical.json +40 -0
  265. package/schema/policy.schema.json +21 -1
  266. package/templates/codex/README.md +9 -0
@@ -0,0 +1,1832 @@
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 type { ChannelBatch, ChannelDecision, ChannelHealth, ChannelRequest, DecisionOutcome, DeliveryId, RenderedRequest, TaggedField, TestableChannel } from "./contract.js";
186
+ import { type Reaction, type ReviewVerdict } from "../core/audit.js";
187
+ import { type PromptLayout } from "../core/prompt-layout.js";
188
+ export { TELEGRAM_CHAT_ENV, TELEGRAM_TOKEN_ENV, telegramChatEnvFor, telegramTokenEnvFor, } from "../core/telegram-config.js";
189
+ /** The real Bot API. Overridden only by tests, against a local mock. */
190
+ export declare const TELEGRAM_DEFAULT_API_BASE = "https://api.telegram.org";
191
+ /** Telegram's hard limit on a message's text. */
192
+ export declare const TELEGRAM_MAX_MESSAGE_CHARS = 4096;
193
+ /** Telegram's hard limit on `callback_data`, in bytes. */
194
+ export declare const TELEGRAM_MAX_CALLBACK_BYTES = 64;
195
+ /** The note recorded on a rejection collected from a button. */
196
+ export declare const TELEGRAM_REJECT_NOTE = "rejected via telegram";
197
+ /**
198
+ * The toast a tap gets when no branch produced one of its own (APRV-196).
199
+ *
200
+ * It is deliberately about the tap and not about the request: this text is only
201
+ * ever reached when the handler threw or forgot, which are exactly the states
202
+ * in which this process does not know what became of the request. Saying so is
203
+ * the honest answer, and it is still infinitely better than a button that spins.
204
+ */
205
+ export declare const TELEGRAM_ACK_FALLBACK = "Received \u2014 this listener could not finish reading your tap. Nothing was recorded by it; check the message above for the outcome.";
206
+ /**
207
+ * The toast a tap gets the instant it is recognized, BEFORE the gate runs
208
+ * (APRV-206).
209
+ *
210
+ * Telegram gives a callback query exactly one answer, and until it arrives the
211
+ * button spins on the approver's phone. Sending it after the decision made the
212
+ * spinner as long as the decision — which grew with the log — and the human,
213
+ * with no way to tell a slow tap from a swallowed one, tapped again.
214
+ *
215
+ * So this is what the single answer says, and its wording is load-bearing: it
216
+ * claims only that the tap ARRIVED. It must never say granted, rejected,
217
+ * approved, recorded, or anything else a reader could take as "the log now says
218
+ * so", because at the moment it is sent nothing has been appended and the gate
219
+ * may still refuse. What became of the request is said by the message edit that
220
+ * follows, which is written from the record the gate actually appended (or from
221
+ * its refusal). The toast vanishes; the message stays.
222
+ */
223
+ export declare const TELEGRAM_ACK_HEARD = "Heard \u2014 deciding. The message will say what the log recorded.";
224
+ /**
225
+ * The headline on a message whose tap the gate refused (APRV-206).
226
+ *
227
+ * Before the early ack, a refusal was a toast and the message was left alone.
228
+ * Now that the single answer is spent on "heard", the refusal has to reach the
229
+ * approver here or nowhere. The buttons go with it ({@link annotate} disarms),
230
+ * which is the right outcome in both directions: a request the gate calls
231
+ * terminal has no live decision left to collect, and a request that is still
232
+ * pending is re-delivered by the next dispatch cycle as a fresh prompt.
233
+ */
234
+ export declare const TELEGRAM_NOT_RECORDED = "\u2717 NOT RECORDED";
235
+ /**
236
+ * The detail line under {@link TELEGRAM_NOT_RECORDED} when the runtime's
237
+ * decision handler threw (APRV-206).
238
+ *
239
+ * The wording is careful about what it does not know: a handler that threw may
240
+ * have thrown before or after its append, so this says where to look rather
241
+ * than what happened. The log is the thing that knows.
242
+ */
243
+ export declare const TELEGRAM_HANDLER_FAILED = "This listener failed while recording your tap. Check `approval queue` \u2014 the log is what says whether anything was recorded.";
244
+ /** Prefixed to the toast when the tap arrived on a pre-restart copy (APRV-196). */
245
+ export declare const TELEGRAM_STALE_COPY_PREFIX = "Earlier copy of this request \u2014 ";
246
+ /**
247
+ * The toast for a tap on a copy of an action this process is not holding open,
248
+ * when no verified-log probe is configured to say more (APRV-196).
249
+ */
250
+ export declare const TELEGRAM_STALE_UNKNOWN = "This request is not open here \u2014 it was already decided, it lapsed, or another listener holds it. Nothing was recorded.";
251
+ /**
252
+ * The headline of an ordinary single-request prompt.
253
+ *
254
+ * Exported because the mock Bot API and several tests key on it, and because a
255
+ * digest member's header deliberately does NOT use it: a member prompt carries
256
+ * no buttons, so calling it "APPROVAL REQUIRED" would point a reader at a
257
+ * message that cannot take their answer.
258
+ */
259
+ export declare const TELEGRAM_PROMPT_HEADING = "APPROVAL REQUIRED";
260
+ /**
261
+ * What the label over the payload chunks names (APRV-162).
262
+ *
263
+ * The chunks carry the canonical rendering, which is a deterministic function
264
+ * of the bytes and not the bytes themselves; calling it "the exact bytes" told
265
+ * the reader that a diff view and a JSON file were the same object. The
266
+ * rendering names its own `display_hash`, and the store path inside it is the
267
+ * route back to the bytes.
268
+ */
269
+ export declare const PAYLOAD_CHUNK_LABEL_TAIL = "the canonical rendering this approval's display_hash names; raw bytes at the store path inside";
270
+ export declare const PAYLOAD_CHUNK_LABEL = "PAYLOAD \u2014 the canonical rendering this approval's display_hash names; raw bytes at the store path inside";
271
+ /**
272
+ * What the claimed block is headed, and what a second claimed message is headed
273
+ * when a rationale overflows one (APRV-165).
274
+ *
275
+ * Both say CLAIMED and both say NOT verified, because a continuation is a
276
+ * message a reader may see first, and a claimed line that arrives under no
277
+ * heading at all reads as the runtime's own.
278
+ */
279
+ export declare const TELEGRAM_CLAIMED_HEADING_PREFIX = "WHAT THIS DOES \u2014 CLAIMED by";
280
+ export declare const TELEGRAM_CLAIMED_HEADING_SUFFIX = "NOT verified by the runtime";
281
+ export declare const TELEGRAM_CLAIMED_CONTINUED_HEADING = "WHAT THIS DOES (continued) \u2014 CLAIMED, NOT verified by the runtime";
282
+ /**
283
+ * The most members one digest may carry (APRV-115).
284
+ *
285
+ * Not a rendering limit — {@link renderDigest} checks the real one against
286
+ * {@link TELEGRAM_MAX_MESSAGE_CHARS} — but a *reading* one: a keyboard of
287
+ * twenty rows is a wall, and the failure this feature exists to fix is a human
288
+ * who stops reading. A burst larger than this becomes several digests, which is
289
+ * the direction this whole design fails in.
290
+ */
291
+ export declare const TELEGRAM_DIGEST_MAX_MEMBERS = 8;
292
+ /**
293
+ * The headline each terminal state puts on the message it settles (APRV-113).
294
+ *
295
+ * Keyed by `core/state.ts`'s `RequestState` names for the terminal states, so
296
+ * the caller that derived the state from the verified log picks a word by
297
+ * indexing rather than by re-deciding what happened.
298
+ *
299
+ * Glyphs, not emoji: `✓`/`✗` are the vocabulary `cli/style.ts` uses for the
300
+ * same ok/fail distinction, and every line of *message text* this channel
301
+ * writes ("APPROVAL REQUIRED", "PAYLOAD", "WITHDRAWN") is emoji-free. The
302
+ * emoji live on the button labels, which are a different surface and stay as
303
+ * they are. `withdrawn` keeps the exact wording APRV-106 shipped.
304
+ */
305
+ export declare const TELEGRAM_TERMINAL_HEADLINES: {
306
+ readonly granted: "✓ APPROVED";
307
+ readonly rejected: "✗ REJECTED";
308
+ readonly revoked: "✗ REVOKED — the grant was taken back";
309
+ readonly expired: "✗ EXPIRED — the approval window closed";
310
+ readonly withdrawn: "WITHDRAWN — no decision is needed";
311
+ };
312
+ /** A state {@link TELEGRAM_TERMINAL_HEADLINES} has a word for. */
313
+ export type TelegramTerminalState = keyof typeof TELEGRAM_TERMINAL_HEADLINES;
314
+ /** Whether a derived request state is one an annotation can settle a message on. */
315
+ export declare function isTelegramTerminalState(state: string): state is TelegramTerminalState;
316
+ /**
317
+ * `HH:MM UTC`, or the raw instant when it does not parse.
318
+ *
319
+ * UTC and not a local zone: the listener, the approver's phone and the log can
320
+ * all be in different places, and the log's own timestamps are UTC. A clock a
321
+ * reader can line up against `approval log` beats one that matches their wrist.
322
+ */
323
+ export declare function utcClock(ts: string): string;
324
+ /** The "who decided, when, and which record says so" line of an annotation. */
325
+ export declare function decidedLine(actor: string, ts: string, seq: number): string;
326
+ /**
327
+ * How long a settled delivery is remembered when the policy declares no
328
+ * `defaults.approval_ttl` (APRV-135).
329
+ *
330
+ * A policy with no TTL bounds nothing, so "past the approval TTL" can never
331
+ * become true and a sweep keyed on it alone would never fire — which is the
332
+ * unbounded map this task exists to remove. The retention floor takes over
333
+ * there, and it applies only to entries whose every member this process has
334
+ * seen settled: with no TTL an undecided request stays answerable forever, and
335
+ * forgetting its button would take a live decision away from an approver.
336
+ *
337
+ * A day, because the point of remembering a settled delivery at all is that an
338
+ * approver may still tap a button on a message already scrolled past, and the
339
+ * answer they should get is the stale-callback reply either way.
340
+ */
341
+ export declare const TELEGRAM_DEFAULT_RETENTION_MS: number;
342
+ /** Least time between two sweeps. A sweep is O(map); once a minute is plenty. */
343
+ export declare const TELEGRAM_SWEEP_INTERVAL_MS = 60000;
344
+ /**
345
+ * The slice of `fetch` this module uses, structurally.
346
+ *
347
+ * Declared here rather than imported so the channel depends on a shape, not on
348
+ * a lib: a test can hand over a stub, and the default is the global `fetch`
349
+ * that Node ≥ 20 ships.
350
+ */
351
+ export type TelegramFetch = (url: string, init: {
352
+ method: string;
353
+ headers: Record<string, string>;
354
+ body: string;
355
+ signal: AbortSignal;
356
+ }) => Promise<{
357
+ ok: boolean;
358
+ status: number;
359
+ text(): Promise<string>;
360
+ }>;
361
+ export interface TelegramConfig {
362
+ /**
363
+ * The bot token. Resolved by the *verb* from the variable
364
+ * {@link telegramTokenEnvFor} names ({@link TELEGRAM_TOKEN_ENV} by default);
365
+ * this constructor takes the value, so nothing in the channel reads the
366
+ * environment and a test cannot accidentally pick up a real token.
367
+ */
368
+ token: string;
369
+ /** The approver chat id, as a string. Callbacks from any other chat are ignored. */
370
+ chatId: string;
371
+ /** Bot API base. Defaults to {@link TELEGRAM_DEFAULT_API_BASE}. */
372
+ apiBase?: string;
373
+ /** Injectable `fetch`, for tests. Defaults to the global. */
374
+ fetch?: TelegramFetch;
375
+ /** `getUpdates` long-poll timeout, in seconds. */
376
+ pollTimeoutSeconds?: number;
377
+ /**
378
+ * Transport timeout for one call, in milliseconds. Defaults to the long-poll
379
+ * timeout plus ten seconds, which is the only sane default: a `getUpdates`
380
+ * that is *supposed* to hang for 25s must not be aborted at 30s of total
381
+ * silence for the wrong reason. Overridable because a server that accepts a
382
+ * request and then says nothing at all is a real failure mode, and both an
383
+ * operator on a flaky link and this repo's test suite want to bound it.
384
+ */
385
+ requestTimeoutMs?: number;
386
+ /** First backoff step after a failed poll, in milliseconds. */
387
+ backoffMs?: number;
388
+ /** Backoff ceiling, in milliseconds. */
389
+ maxBackoffMs?: number;
390
+ /**
391
+ * Where operational complaints go. Defaults to stderr. Every message passes
392
+ * through {@link redact} first, so a token cannot reach it even by accident.
393
+ */
394
+ log?: (message: string) => void;
395
+ /** Injectable nonce source, for deterministic tests. */
396
+ nonce?: () => string;
397
+ /**
398
+ * The policy's `defaults.approval_ttl` in milliseconds, or `null` when it
399
+ * declares none (APRV-135).
400
+ *
401
+ * Passed in by the verb, which has already loaded the policy; the channel
402
+ * neither reads a policy file nor holds an opinion about what the TTL should
403
+ * be. It is used for one thing: deciding when a delivery this process
404
+ * remembers can no longer be the subject of a decision, and can therefore be
405
+ * forgotten. See {@link TelegramChannel.sweep}.
406
+ */
407
+ approvalTtlMs?: number | null;
408
+ /**
409
+ * Injectable monotonic-ish clock, in milliseconds, for the sweep.
410
+ *
411
+ * Defaults to `Date.now`. It exists so a test can run a week of deliveries in
412
+ * a millisecond; nothing else in this class reads a clock, and nothing that
413
+ * reaches a human or the log reads this one.
414
+ */
415
+ now?: () => number;
416
+ /**
417
+ * What to tell a human who tapped a button for an action this process is not
418
+ * holding open (APRV-196). One sentence, or `null` for "nothing is known".
419
+ *
420
+ * Supplied by the listener, which reads the VERIFIED log and can therefore
421
+ * say whether the request was granted, rejected, revoked, expired or
422
+ * withdrawn. The channel asks the question and repeats the answer; it does
423
+ * not derive one, does not cache one, and could not, because the only thing
424
+ * that knows is the log.
425
+ *
426
+ * The argument is an {@link actionRefOf} digest rather than an action key,
427
+ * for the same reason the button carries one: the string came off the
428
+ * network, and the probe's job is to look for a record whose key hashes to
429
+ * it, never to trust a name it was handed. Optional, and absent by default —
430
+ * a channel with no probe falls back to a toast that names no outcome.
431
+ */
432
+ describeAction?: (actionRef: string) => string | null;
433
+ /**
434
+ * Which rows the prompt shows (APRV-218), from `channels.telegram.prompt`.
435
+ *
436
+ * Passed in by the verb, which has already loaded the policy, for the reason
437
+ * {@link TelegramConfig.approvalTtlMs} is: this channel neither reads a
438
+ * policy file nor holds an opinion about what an operator should see.
439
+ * Defaults to {@link TELEGRAM_PROMPT_LAYOUT}, the layout APRV-143 and
440
+ * APRV-163 left behind, so a channel constructed without one renders exactly
441
+ * what it rendered before the key existed.
442
+ */
443
+ layout?: PromptLayout;
444
+ }
445
+ /**
446
+ * Why a callback was ignored.
447
+ *
448
+ * Every one of these is counted and complained about on stderr, and **none of
449
+ * them reaches the decision path or the log**. An ignored callback is not an
450
+ * event: writing "someone we do not answer to pressed a button" into an
451
+ * append-only approval log would let any stranger who guessed the bot's handle
452
+ * grow the record a human is asked to trust.
453
+ */
454
+ export declare const TELEGRAM_ANOMALY_KINDS: readonly [
455
+ /** The callback came from a chat that is not the configured one. */
456
+ "foreign-chat",
457
+ /** `callback_data` did not parse as one of ours. */
458
+ "malformed-callback",
459
+ /** A well-formed nonce this listener never issued (or issued before a restart). */
460
+ "unknown-callback",
461
+ /** The action key carried in `callback_data` disagrees with the issued nonce. */
462
+ "key-mismatch",
463
+ /**
464
+ * A tap on a copy of a request this process is no longer holding open
465
+ * (APRV-196): the nonce is not one of ours, and the action it names is not
466
+ * pending here either — it was decided, it lapsed, or another process owns
467
+ * it. Distinct from `unknown-callback` because the operator's question is
468
+ * different: nothing is wrong with the button, the question behind it is
469
+ * over. Always answered with a toast that names the state.
470
+ */
471
+ "stale-copy",
472
+ /**
473
+ * A message in the approver chat that began with `/` and named no command
474
+ * this channel answers (APRV-216). Counted rather than replied to: the chat
475
+ * belongs to a human, other bots and other slash commands live in it, and a
476
+ * channel that answered every unrecognised one would be noise in the one
477
+ * place an approver's attention is supposed to be scarce.
478
+ */
479
+ "unknown-command"];
480
+ export type TelegramAnomalyKind = (typeof TELEGRAM_ANOMALY_KINDS)[number];
481
+ export interface TelegramStats {
482
+ /** Messages successfully delivered by `notify`. */
483
+ notified: number;
484
+ /** Updates received from `getUpdates`, of any kind. */
485
+ updates: number;
486
+ /** Callbacks handed to the runtime's decision handler. */
487
+ decisions: number;
488
+ /** Failed `getUpdates` attempts the loop recovered from. */
489
+ pollErrors: number;
490
+ /** Ignored callbacks, by reason. Never a decision, never a log event. */
491
+ anomalies: Record<TelegramAnomalyKind, number>;
492
+ /**
493
+ * Taps that arrived on a copy whose nonce this process never issued and were
494
+ * carried to the gate anyway, because the action they reference is one this
495
+ * process is holding open (APRV-196). Not an anomaly: it is the duplicate-copy
496
+ * trap being defused, and it is counted so an operator can see how often a
497
+ * restart is costing the approver a wrong tap.
498
+ */
499
+ staleCopyDecisions: number;
500
+ /**
501
+ * Bot commands handed to the runtime's command handler (APRV-216). Never a
502
+ * decision and never a log event: a command reorders what this process shows
503
+ * and nothing else.
504
+ */
505
+ commands: number;
506
+ /**
507
+ * Review taps handed to the runtime's review handler (APRV-299). Counted
508
+ * whether the handler recorded or refused, because what this number measures
509
+ * is how much of the retrospective backlog reached a human's thumb; what
510
+ * became of each one is in the log and nowhere else.
511
+ */
512
+ reviews: number;
513
+ }
514
+ /**
515
+ * The bot commands the paced listener answers (APRV-216).
516
+ *
517
+ * A closed set, and deliberately a small one. Each is a verb about ATTENTION —
518
+ * what to put in front of the approver next — and none of them is a verb about
519
+ * the log: there is no `/grant`, and there will not be one, because a decision
520
+ * must name the request it decides and a typed command names nothing the
521
+ * runtime could bind a payload hash to. Buttons decide; commands navigate.
522
+ */
523
+ /**
524
+ * The prompt a checkpoint tap is drawn on (APRV-257).
525
+ *
526
+ * `head` is the `(seq, hash)` the human is being asked to sign and `lines` is
527
+ * what they read. They are separate fields because the head must survive into
528
+ * the signature unchanged while the text is free to be reworded, and because
529
+ * the runtime — not this channel — decides both.
530
+ */
531
+ export interface CheckpointPrompt {
532
+ head: {
533
+ seq: number;
534
+ hash: string;
535
+ };
536
+ lines: string[];
537
+ }
538
+ /** What the runtime did with a tap, as this channel reports it back. */
539
+ export interface CheckpointTapResponse {
540
+ /** Whether a `log.checkpoint` landed. */
541
+ ok: boolean;
542
+ /** The headline for the edited message. */
543
+ headline: string;
544
+ /** The lines under it. */
545
+ detail: string[];
546
+ /** The toast on the button, which Telegram caps at a short sentence. */
547
+ toast: string;
548
+ }
549
+ /**
550
+ * What the runtime does with a checkpoint tap. `sign` is false for "Not now",
551
+ * which appends nothing and is not a refusal of anything.
552
+ */
553
+ export type CheckpointTapHandler = (tap: {
554
+ sign: boolean;
555
+ head: {
556
+ seq: number;
557
+ hash: string;
558
+ };
559
+ }) => CheckpointTapResponse | Promise<CheckpointTapResponse>;
560
+ export declare const TELEGRAM_COMMANDS: readonly ["queue", "skip", "next"];
561
+ export type TelegramCommand = (typeof TELEGRAM_COMMANDS)[number];
562
+ /**
563
+ * The command a message's text names, or `null` (APRV-216).
564
+ *
565
+ * Pure, and exported so the listener's tests can exercise the grammar without
566
+ * a transport. Telegram delivers a command in a group chat as `/skip@thebot`,
567
+ * so the `@suffix` is stripped; the bot's own username is not checked, because
568
+ * this channel only ever reads ONE chat and a message in it that says `/skip`
569
+ * to some other bot is a message the approver still meant as a skip more often
570
+ * than not. Anything after the command word is ignored: none of these three
571
+ * takes an argument, and silently discarding one is better than refusing a
572
+ * command a human typed with a stray word on the end.
573
+ */
574
+ export declare function parseBotCommand(text: unknown): TelegramCommand | null;
575
+ /** The three characters Telegram's HTML mode treats as markup. */
576
+ export declare function escapeHtml(text: string): string;
577
+ /**
578
+ * The suffix the model-authored line carries, on the line itself (APRV-144).
579
+ *
580
+ * One constant, shared with the terminal channel since APRV-197: this name is
581
+ * kept because the tests and the help text pin it, and it now resolves to
582
+ * {@link GLOSS_UNVERIFIED_SUFFIX} so the two surfaces cannot drift apart.
583
+ */
584
+ export declare const TELEGRAM_GLOSS_SUFFIX = "(model, unverified)";
585
+ /**
586
+ * The prefix a health row carries when it is the reason to look (APRV-163).
587
+ *
588
+ * Only the abnormal state of `autonomy`, `budgets` and the attestation renders
589
+ * at all, so the mark is never routine: a row bearing it is a row the reader
590
+ * has not seen on the last twenty prompts.
591
+ */
592
+ export declare const TELEGRAM_ANOMALY_MARK = "\u26A0 ";
593
+ /** One line of the message, and the request member it came from. */
594
+ interface Line {
595
+ field: string;
596
+ kind: "computed" | "claimed";
597
+ label: string;
598
+ text: string;
599
+ origin: string;
600
+ }
601
+ /**
602
+ * The message body, split into the two regions SPEC.md §9 requires a channel to
603
+ * keep visibly apart.
604
+ *
605
+ * The split is the whole point: computed lines sit under a heading that names
606
+ * the runtime as their author, claimed lines under one that names the agent and
607
+ * says "not verified". The `lastRendered()` report is built from *this* value,
608
+ * not from a parallel description of it, so the conformance suite is checking
609
+ * the thing that was actually sent.
610
+ */
611
+ export interface TelegramRendering {
612
+ /** Every line, in the order it appears, tagged as the request tagged it. */
613
+ lines: Line[];
614
+ /** The header segment: heading, action key, computed block. */
615
+ header: string;
616
+ /** The payload region, verbatim, or `null` when the request carries none. */
617
+ payloadText: string | null;
618
+ /**
619
+ * The claimed segment, sent LAST so it sits beside the buttons (APRV-165).
620
+ *
621
+ * The claimed lines are what the act means to a human — what this sends, to
622
+ * whom, why — and the approver decides on that, so it is the thing the thumb
623
+ * should be next to rather than the metadata above it. SPEC §10.3 permits
624
+ * claimed material around the canonical block on the condition this keeps:
625
+ * visibly separated, and headed by a label that names the claiming party and
626
+ * says the runtime did not check them.
627
+ *
628
+ * Never empty. A request with no gloss, no summary and no rationale still
629
+ * gets this message, because an absent description of the act is itself
630
+ * something the approver has to see, and because the keyboard needs one
631
+ * message that is always there to ride on.
632
+ */
633
+ claimedText: string;
634
+ }
635
+ /**
636
+ * The subset of a request's fields a review card also carries (APRV-299).
637
+ *
638
+ * A retrospective review card is not a request and must never be rendered as
639
+ * one, but the five rows below say exactly what they say on a prompt: which
640
+ * class this was, what the command did, which task it belonged to, what the
641
+ * agent claimed it would do, and the model's sentence about it where a listener
642
+ * attached one. Naming the subset as a type is what lets {@link reviewRow} and
643
+ * {@link telegramRow} share one implementation of those five without either
644
+ * side casting: a card supplies exactly these fields, and the compiler refuses
645
+ * a card that reaches for `budgets`, `fullPayload`, or anything else that only
646
+ * a pending question has.
647
+ */
648
+ export type ReviewCardFields = Pick<ChannelRequest, "action_key" | "class" | "task" | "summary"> & Partial<Pick<ChannelRequest, "command_breakdown" | "gloss">>;
649
+ /** The rows a review card renders, in the order it renders them (APRV-299). */
650
+ export declare const REVIEW_CARD_ROWS: readonly ["class", "command_breakdown", "task", "summary", "gloss"];
651
+ export type ReviewCardRow = (typeof REVIEW_CARD_ROWS)[number];
652
+ /**
653
+ * Build the two regions and the line list. Pure: no I/O, no clock.
654
+ *
655
+ * `heading` is the message's first line. It is a parameter for exactly one
656
+ * reason (APRV-115): a digest member's prompt carries no buttons, and telling
657
+ * a reader "APPROVAL REQUIRED" above a message they cannot answer on is the
658
+ * kind of small lie that costs a channel its legibility. Everything below the
659
+ * first line is identical either way, computed/claimed split included.
660
+ *
661
+ * `layout` is the policy's answer to which rows this channel shows (APRV-218).
662
+ * It defaults to {@link TELEGRAM_PROMPT_LAYOUT}, which is the slimmed prompt
663
+ * APRV-143 and APRV-163 left behind, so a policy that declares no
664
+ * `channels.telegram.prompt` renders byte for byte what it rendered before the
665
+ * key existed. Rendering stays a pure function of (request, layout): the layout
666
+ * chooses among facts the request already carries and teaches this channel
667
+ * nothing about the log.
668
+ *
669
+ * The computed/claimed split survives ANY ordering, and that is a property
670
+ * rather than a convention. `layout.order` decides the sequence rows are
671
+ * considered in; the partition below is by `Line.kind`, which comes from the
672
+ * `TaggedField` the row was built from. A `rows` list that puts `summary`
673
+ * first therefore puts it first among the CLAIMED lines, and never above the
674
+ * computed heading.
675
+ */
676
+ export declare function renderTelegram(request: ChannelRequest, heading?: string, layout?: PromptLayout): TelegramRendering;
677
+ /**
678
+ * Split `text` so every chunk survives HTML escaping inside the message limit.
679
+ *
680
+ * Splitting is by *escaped* length, because `&` becomes five characters and a
681
+ * payload full of them would otherwise produce a message Telegram rejects. The
682
+ * payload is never truncated to fit: the bytes a human is asked to approve are
683
+ * the bytes the token will execute, so an oversized payload becomes several
684
+ * messages, never a shortened one.
685
+ */
686
+ export declare function chunkForTelegram(text: string, budget?: number): string[];
687
+ /**
688
+ * Split an already-marked-up segment so every chunk is valid HTML on its own.
689
+ *
690
+ * {@link chunkForTelegram} may cut anywhere because its caller escapes each
691
+ * chunk and wraps it in `<pre>`; the claimed segment carries markup, so a cut
692
+ * inside `<b>` or inside `&amp;` would reach Telegram as a parse error, and a
693
+ * cut between an opening tag and its close would reach it as unbalanced HTML.
694
+ * Tags and entities are therefore atomic here, and the break is taken at the
695
+ * last line boundary in the chunk when there is one, which keeps each bullet
696
+ * whole and balanced. A bullet longer than the budget on its own (a rationale
697
+ * is unbounded agent text) splits inside its text, between tags, never within
698
+ * one — and it splits rather than being shortened, for the same reason a
699
+ * payload does.
700
+ */
701
+ export declare function chunkClaimedForTelegram(text: string, budget?: number): string[];
702
+ /**
703
+ * The shape token of a payload, for grouping.
704
+ *
705
+ * A shell command groups by its `argv[0]`, because that is what makes forty
706
+ * `network.call` prompts "the same question forty times" to the human reading
707
+ * them: forty `curl`s are one decision with forty URLs in it, and a `curl` next
708
+ * to an `rm` is not. Everything else groups by its top-level key set, which is
709
+ * the structural sense in which two payloads are the same shape.
710
+ *
711
+ * Structural, never self-declared: nothing here reads a `kind` or `type` field,
712
+ * for the reason `payload-view.ts` spells out — a field authored by the party
713
+ * under oversight must not choose how the party's requests are presented.
714
+ */
715
+ export declare function payloadShapeKey(value: unknown): string;
716
+ /**
717
+ * The grouping key: requests that share it are the same question asked twice.
718
+ *
719
+ * Signed off 2026-08-25 as (class, origin session/task, argv[0] or payload
720
+ * shape). The requesting actor rides along too, which can only ever SPLIT a
721
+ * group — two agents working the same task get two digests — and splitting is
722
+ * the safe direction: it costs a message and never merges two things a human
723
+ * would have wanted to weigh separately.
724
+ *
725
+ * `"\0"` as the separator because every component is agent-influenced text
726
+ * and a separator that can appear inside one would let a crafted task name
727
+ * collide two classes into one group. Written as the escape, never the raw
728
+ * byte: a literal NUL in the source turns this file into "binary" for grep,
729
+ * diff tooling, and editors, and the escape compiles to the same string.
730
+ */
731
+ export declare function digestKeyOf(request: ChannelRequest): string;
732
+ export declare function groupForDigest(requests: ChannelRequest[], max?: number): ChannelRequest[][];
733
+ /** One button on a digest keyboard. */
734
+ interface InlineButton {
735
+ text: string;
736
+ callback_data: string;
737
+ }
738
+ /** A digest member, as the delivering process remembers it. Never a decision. */
739
+ export interface DigestMemberState {
740
+ actionKey: string;
741
+ /** The nonce this member's own two buttons were issued under. */
742
+ nonce: string;
743
+ /** The agent's one-line description of the effect. Claimed. */
744
+ summary: string;
745
+ /** The agent's cost estimate, formatted. Claimed. */
746
+ cost: string;
747
+ /**
748
+ * The terminal outcome, once one has been observed for this member. Written
749
+ * only from a gate record or the verified log, never inferred here.
750
+ */
751
+ settled: {
752
+ headline: string;
753
+ detail: string[];
754
+ } | null;
755
+ }
756
+ /**
757
+ * The lines a COLLAPSED delivery leads with, and the fact it is one (APRV-287).
758
+ *
759
+ * A listener starting or reconnecting re-derives the pending set from the
760
+ * verified log and re-delivers it, which is right for a queue somebody is
761
+ * waiting on and was a flood for a queue nobody is: on 2026-09-06 a restarted
762
+ * daemon put a dozen requests whose hooks had long since given up in front of
763
+ * an approver, one message each. Those go out as ONE message instead, and this
764
+ * is what distinguishes it from an ordinary digest.
765
+ *
766
+ * It carries a REJECT-ALL button and deliberately no approve. The payloads are
767
+ * not in this message, and SPEC.md §10.3 requires the canonical rendering of a
768
+ * manual action's payload in front of the approver before a decision is
769
+ * collected: an approve-all here would collect a decision for bytes nobody was
770
+ * shown. A rejection authorizes nothing, so it needs no such showing, and every
771
+ * one of these requests can still be approved on its own card or from a
772
+ * terminal.
773
+ */
774
+ export interface StaleSummary {
775
+ /** Computed lines: how many, how old the oldest is, which classes. */
776
+ lines: string[];
777
+ }
778
+ /** One digest message, as the delivering process remembers it. */
779
+ export interface DigestState {
780
+ /** The digest message's own id: what every member's annotation edits. */
781
+ deliveryId: DeliveryId;
782
+ /** Shared by every member's decision event (SPEC.md §10.3). */
783
+ batchDeliveryId: DeliveryId;
784
+ /** The nonce the "all" buttons were issued under. */
785
+ allNonce: string;
786
+ /** The computed facts every member shares, already rendered as text. */
787
+ facts: {
788
+ label: string;
789
+ text: string;
790
+ origin: string;
791
+ }[];
792
+ /**
793
+ * The collapsed re-delivery this message is, or `null` for an ordinary digest
794
+ * (APRV-287). See {@link StaleSummary}.
795
+ */
796
+ stale?: StaleSummary | null;
797
+ /** Who authored the claimed lines below. */
798
+ author: string;
799
+ members: DigestMemberState[];
800
+ /**
801
+ * When this process delivered the digest, on {@link TelegramConfig.now}'s
802
+ * clock (APRV-135). Read by the sweep and by nothing else: it is never
803
+ * displayed, never compared against a log timestamp, and never a deadline —
804
+ * the request's own `ts` remains the only instant a TTL is measured from.
805
+ */
806
+ deliveredAtMs: number;
807
+ }
808
+ /**
809
+ * The computed facts a digest's members share, as the digest states them.
810
+ *
811
+ * Every one is read off the first member, which is sound precisely because the
812
+ * grouping key made them equal across the set: a digest whose members disagreed
813
+ * about their class or their task is a digest the listener would not have
814
+ * built. The last line is the one an approver needs most — it says how many
815
+ * payloads are above and that each request has its own.
816
+ */
817
+ export declare function digestFacts(members: ChannelRequest[]): {
818
+ label: string;
819
+ text: string;
820
+ origin: string;
821
+ }[];
822
+ /**
823
+ * The digest message: text plus the keyboard for whatever is still open.
824
+ *
825
+ * Pure. The computed/claimed split of an ordinary prompt is kept — the shared
826
+ * facts are computed and sit under a heading that says so, the per-member lines
827
+ * are the agent's own words and sit under one that says they are not verified —
828
+ * because a digest is a prompt, and SPEC.md §9 does not stop applying because
829
+ * there are five of them.
830
+ *
831
+ * A settled member keeps its line, gains its outcome underneath, and loses its
832
+ * buttons. The "all" row appears only while two or more members are open: with
833
+ * one left, "all" is the same tap as its own Approve and a second way to do one
834
+ * thing is a way to do the wrong one.
835
+ */
836
+ export declare function renderDigest(digest: DigestState): {
837
+ text: string;
838
+ keyboard: {
839
+ inline_keyboard: InlineButton[][];
840
+ } | null;
841
+ };
842
+ /**
843
+ * The stable short reference to an action key that a button carries (APRV-196).
844
+ *
845
+ * The first {@link ACTION_REF_HEX} hex characters of the key's sha256. Two
846
+ * properties earn it its place, and they are the two the old scheme lacked:
847
+ *
848
+ * 1. **It always fits.** `<verb>:<nonce>:<ref>` is well inside Telegram's
849
+ * 64-byte cap for any nonce this class issues, so the cross-check that used
850
+ * to be dropped for a long action key is now always present.
851
+ * 2. **It survives a restart.** The nonce is per-process and per-copy; the ref
852
+ * is a function of the action key alone, so two copies of the same request
853
+ * delivered by two different listener processes carry the same ref. That is
854
+ * what lets a tap on a pre-restart copy resolve to the request the current
855
+ * process is holding, instead of dying as an unknown nonce.
856
+ *
857
+ * It is a REFERENCE and never an authorization. The bytes come back from the
858
+ * network, so a ref is only ever matched against deliveries THIS process made
859
+ * (and only from the configured chat); it can select among what the listener
860
+ * has itself put in front of the approver, and it can name nothing else.
861
+ */
862
+ export declare const ACTION_REF_HEX = 16;
863
+ export declare function actionRefOf(actionKey: string): string;
864
+ /**
865
+ * `callback_data` for one button: `<g|r>:<nonce>:<action ref>`.
866
+ *
867
+ * The **nonce is authoritative** where it resolves: it is issued by this process
868
+ * at `notify` and maps to the request that was actually delivered, so an
869
+ * ordinary tap never consults the ref for anything but a cross-check (a
870
+ * mismatch is an anomaly and the callback is dropped). The ref is the fallback
871
+ * for the copy whose nonce this process never issued, and {@link actionRefOf}
872
+ * states the bound on what that fallback may reach.
873
+ */
874
+ export declare function callbackData(verb: "g" | "r", nonce: string, actionKey: string): string;
875
+ /**
876
+ * `callback_data` for a digest's "all" button: `<G|R>:<nonce>` (APRV-115).
877
+ *
878
+ * Upper case, and no action key: an "all" button names a *delivery*, and the
879
+ * set it decides is whichever members of that delivery are still open at the
880
+ * moment of the tap — which the delivering process knows and the network does
881
+ * not. Naming keys in the bytes would let something that can reach the bot
882
+ * choose the set, and there is no length at which that becomes acceptable.
883
+ */
884
+ export declare function digestCallbackData(verb: "G" | "R", nonce: string): string;
885
+ /**
886
+ * `callback_data` for the checkpoint prompt's two buttons (APRV-257).
887
+ *
888
+ * `k:<nonce>` signs, `x:<nonce>` declines. Verbs of their own rather than a
889
+ * reuse of `g`/`r`, and the separation is load-bearing: {@link CALLBACK_VERBS}
890
+ * maps every decision verb onto a grant or a reject, so a checkpoint button
891
+ * spelled `g` would be a button {@link parseCallbackData} hands to the decision
892
+ * path — where an unknown nonce becomes an action-reference lookup, and a
893
+ * signature gesture starts hunting for a request to approve. Two vocabularies,
894
+ * two parsers, and neither can be read as the other.
895
+ *
896
+ * No action key and no reference in the bytes: a checkpoint names no request,
897
+ * and the head it covers is held by the process that issued the nonce, exactly
898
+ * as a digest's member set is. Nothing that can reach the bot chooses what gets
899
+ * signed.
900
+ */
901
+ export declare function checkpointCallbackData(verb: "k" | "x", nonce: string): string;
902
+ /** `k:<nonce>` / `x:<nonce>`, or `null` for anything else. Never throws. */
903
+ export declare function parseCheckpointCallback(data: unknown): {
904
+ sign: boolean;
905
+ nonce: string;
906
+ } | null;
907
+ interface ParsedCallback {
908
+ decision: "grant" | "reject";
909
+ /** `all` for a digest's "all" button; `one` for every per-request button. */
910
+ scope: "one" | "all";
911
+ nonce: string;
912
+ /** {@link actionRefOf} of the action this button was drawn for, when present. */
913
+ actionRef: string | null;
914
+ }
915
+ export declare function parseCallbackData(data: unknown): ParsedCallback | null;
916
+ /**
917
+ * The headline of a retrospective review card.
918
+ *
919
+ * Deliberately not {@link TELEGRAM_PROMPT_HEADING} and deliberately not a
920
+ * question. A sample is an action that ALREADY RAN: nothing is pending, no
921
+ * token is minted by any button on this message, and a card that said
922
+ * "APPROVAL REQUIRED" would be telling the approver they are holding something
923
+ * up. The supervised bargain (SPEC.md §5.2) is "execute now, a fraction is
924
+ * reviewed after", and this is the "after".
925
+ */
926
+ export declare const TELEGRAM_REVIEW_HEADING = "REVIEW \u2014 THIS ALREADY RAN";
927
+ /** The headline a recorded review puts on the card it settles. */
928
+ export declare const TELEGRAM_REVIEW_RECORDED = "\u2713 REVIEWED";
929
+ /** The headline a recorded DENIAL puts on the card it settles. */
930
+ export declare const TELEGRAM_REVIEW_DENIED = "\u2717 REVIEWED \u2014 DENIED";
931
+ /**
932
+ * The headline a card wears while a first Deny tap is armed and nothing has
933
+ * been recorded.
934
+ */
935
+ export declare const TELEGRAM_REVIEW_ARMED = "DENY ARMED \u2014 nothing is recorded yet";
936
+ /**
937
+ * What a review tap's single answer says (APRV-302).
938
+ *
939
+ * {@link TELEGRAM_ACK_HEARD}'s "deciding" is a request card's word: something is
940
+ * pending, and the tap just settled it. A review decides nothing — the action
941
+ * ran, and what the tap does is record what a person thought of it — so a
942
+ * reviewer told they were "deciding" is being told the wrong thing about the
943
+ * card in front of them. The load-bearing half is carried over unchanged: this
944
+ * claims only that the tap ARRIVED, never that anything was appended, because at
945
+ * the moment it is sent nothing has been and `core/audit.ts` may still refuse.
946
+ * What became of it is on the card edit that follows.
947
+ */
948
+ export declare const TELEGRAM_REVIEW_ACK = "Heard \u2014 recording your review. The card will say what the log recorded.";
949
+ /** The toast a first Deny tap gets: it says plainly that nothing was written. */
950
+ export declare const TELEGRAM_REVIEW_ARM_TOAST = "Deny armed \u2014 nothing recorded. Tap Deny again to record it, or a reaction to record it with a grade.";
951
+ /** The toast a reaction that needs the human's own words gets. */
952
+ export declare const TELEGRAM_REVIEW_NOTE_TOAST = "Heard \u2014 reply to the prompt with why. Nothing is recorded until it arrives.";
953
+ /**
954
+ * What the ForceReply prompt asks for.
955
+ *
956
+ * A separate message rather than a second keyboard, because Telegram's inline
957
+ * keyboards have no text input at all — the same limitation the reject path
958
+ * documents. The prompt is bound to its card by the message id the reply names,
959
+ * which this process holds and the network does not.
960
+ */
961
+ export declare function reviewNotePromptLines(reaction: Reaction, verdict: ReviewVerdict, actionKey: string): string[];
962
+ /**
963
+ * What a tap on a review card asks the runtime to record (APRV-299).
964
+ *
965
+ * The channel decides none of it. It reports which sample the card was drawn
966
+ * for, which verdict the taps add up to, the grade if one was given, and the
967
+ * human's words if a note prompt collected any — and the runtime's handler
968
+ * calls the human-only `reviewSample`, exactly as {@link ChannelDecision} goes
969
+ * to the human-only `decide()`. The actor is NOT here, for the reason it is not
970
+ * on a decision either: it is the identity the LISTENER was configured with,
971
+ * never a field that arrived from the network.
972
+ */
973
+ export interface ReviewTap {
974
+ /** `seq` of the `audit.sampled` record this card was drawn for. */
975
+ sampleSeq: number;
976
+ verdict: ReviewVerdict;
977
+ /** The grade, when the human gave one. Absent means absent. */
978
+ reaction?: Reaction;
979
+ /** The human's words, when a note prompt collected any. */
980
+ note?: string;
981
+ }
982
+ /** What the runtime did with a review tap, as it reports it back. */
983
+ export interface ReviewTapResponse {
984
+ /** Whether an `audit.reviewed` landed. */
985
+ ok: boolean;
986
+ /** The headline for the edited card. */
987
+ headline: string;
988
+ /** The lines under it: the record, or the refusal code and its message. */
989
+ detail: string[];
990
+ /** The toast, which Telegram caps at a short sentence. */
991
+ toast: string;
992
+ }
993
+ export type ReviewTapHandler = (tap: ReviewTap) => ReviewTapResponse | Promise<ReviewTapResponse>;
994
+ /**
995
+ * One retrospective review card, as the runtime hands it over (APRV-299).
996
+ *
997
+ * Everything here is derived by the runtime from the verified log, the policy
998
+ * and the payload store; the channel adds the buttons and nothing else. The
999
+ * computed/claimed split of SPEC.md §9 is carried by the fields themselves, so
1000
+ * a card cannot render a claimed summary with a computed line's authority any
1001
+ * more than a prompt can.
1002
+ */
1003
+ export interface ReviewCard {
1004
+ /** `seq` of the `audit.sampled` record. What a review names. */
1005
+ sampleSeq: number;
1006
+ /** The rows a request prompt renders identically. */
1007
+ fields: ReviewCardFields;
1008
+ /** Computed: when the sampled execution started, and which record says so. */
1009
+ ranAt: TaggedField<string>;
1010
+ /**
1011
+ * The same instant, machine-readable.
1012
+ *
1013
+ * Not displayed and not a {@link TaggedField} for that reason: it exists so
1014
+ * that the runtime's "oldest awaiting review" arithmetic reads an instant off
1015
+ * the log rather than parsing the sentence {@link ReviewCard.ranAt} renders.
1016
+ * A display string is written for a person and is free to be reworded; a
1017
+ * number a summary is computed from is not.
1018
+ */
1019
+ ranAtTs: string;
1020
+ /** Computed: what the runtime did at the time, and why this is being reviewed. */
1021
+ verdict: TaggedField<string>;
1022
+ }
1023
+ /** A review card, as the delivering process remembers it. Never a decision. */
1024
+ export interface ReviewCardState {
1025
+ deliveryId: DeliveryId;
1026
+ card: ReviewCard;
1027
+ /** The nonce every button on this card was issued under. */
1028
+ nonce: string;
1029
+ /**
1030
+ * Whether a first Deny tap has armed the card. **Process memory**, exactly
1031
+ * like the digest bookkeeping: it appends nothing, it is stated on the card
1032
+ * so the human can see it, and losing it to a restart costs a tap and can
1033
+ * never cost a denial nobody meant. A card whose arming is lost is a card
1034
+ * whose next Deny tap arms again.
1035
+ */
1036
+ denyArmed: boolean;
1037
+ /**
1038
+ * The outcome, once the runtime has recorded one. Written only from the
1039
+ * handler's answer, never inferred here.
1040
+ */
1041
+ settled: {
1042
+ headline: string;
1043
+ detail: string[];
1044
+ } | null;
1045
+ /**
1046
+ * What the last tap produced without settling anything: the arming, or a
1047
+ * refusal the runtime returned. Rendered under the rows so the card keeps
1048
+ * saying what it is about.
1049
+ */
1050
+ notice: {
1051
+ headline: string;
1052
+ lines: string[];
1053
+ } | null;
1054
+ /** The outstanding note prompt, and what its reply will record. */
1055
+ awaitingNote: {
1056
+ promptId: DeliveryId;
1057
+ verdict: ReviewVerdict;
1058
+ reaction: Reaction;
1059
+ } | null;
1060
+ /** When this process delivered the card, on {@link TelegramConfig.now}'s clock. */
1061
+ deliveredAtMs: number;
1062
+ }
1063
+ /**
1064
+ * The six things a review card's buttons can say (APRV-299).
1065
+ *
1066
+ * `ok` and `deny` are the verdict, which is enforcement; the four reactions are
1067
+ * the grade, which is not (SPEC.md §11.1 invariant 10). Both travel in the same
1068
+ * closed vocabulary because they arrive through the same six buttons, and a
1069
+ * seventh word would be a button nobody drew.
1070
+ */
1071
+ export declare const REVIEW_CHOICES: readonly ["ok", "deny", "disliked", "indifferent", "liked", "loved"];
1072
+ export type ReviewChoice = (typeof REVIEW_CHOICES)[number];
1073
+ /**
1074
+ * `callback_data` for one review button: `v:<nonce>:<choice>`.
1075
+ *
1076
+ * Its own verb, for exactly the reason the checkpoint prompt's is its own
1077
+ * (APRV-257): {@link CALLBACK_VERBS} maps every DECISION verb onto a grant or a
1078
+ * reject, so a review button spelled `g` would be handed to the decision path,
1079
+ * where an unresolved nonce falls back to an action-reference lookup and a
1080
+ * gesture about something that already happened would start hunting for a
1081
+ * request to approve. Three vocabularies, three parsers, and none can be read
1082
+ * as another.
1083
+ *
1084
+ * No action reference in the bytes, and no sample seq: the card names a
1085
+ * DELIVERY, and which sample that delivery is about is held by the process that
1086
+ * issued the nonce. Nothing that can reach the bot chooses what gets reviewed.
1087
+ * There is also no stale-copy ladder underneath it: a review is never urgent,
1088
+ * a lost card leaves the sample open, and the next cycle offers it again.
1089
+ */
1090
+ export declare function reviewCallbackData(choice: ReviewChoice, nonce: string): string;
1091
+ /** `v:<nonce>:<choice>`, or `null` for anything else. Never throws. */
1092
+ export declare function parseReviewCallback(data: unknown): {
1093
+ nonce: string;
1094
+ choice: ReviewChoice;
1095
+ } | null;
1096
+ /**
1097
+ * The card's message: the rows, whatever notice the last tap produced, and the
1098
+ * keyboard.
1099
+ *
1100
+ * No paragraph explaining the buttons (APRV-302). The heading
1101
+ * ({@link TELEGRAM_REVIEW_HEADING}) is what says a review is not a request, and
1102
+ * the deny latch says itself: the first tap is answered by
1103
+ * {@link TELEGRAM_REVIEW_ARM_TOAST} and the card's own heading becomes
1104
+ * {@link TELEGRAM_REVIEW_ARMED} until it is spent. Four sentences of rules under
1105
+ * every card said the same thing to a reader who had already read them once, and
1106
+ * pushed the rows a review is actually about off the first screen.
1107
+ *
1108
+ * Pure. Two things it deliberately does NOT carry, and both are the same rule
1109
+ * read twice: no payload region, and no approve button. SPEC.md §10.3 requires
1110
+ * the canonical rendering in front of an approver before a DECISION is
1111
+ * collected, and this collects none — the action ran, the review says only what
1112
+ * a person thought of it, and a card that offered an approve would be
1113
+ * presenting a settled fact as a live authorization. A sample is never
1114
+ * delivered as an approval request and never accepts a token.
1115
+ */
1116
+ export declare function renderReviewCard(state: ReviewCardState): {
1117
+ text: string;
1118
+ keyboard: {
1119
+ inline_keyboard: InlineButton[][];
1120
+ } | null;
1121
+ };
1122
+ /** A Bot API call that did not produce a usable result. */
1123
+ export declare class TelegramApiError extends Error {
1124
+ readonly method: string;
1125
+ /**
1126
+ * The HTTP status, when the failure was an HTTP one. `null` for a transport
1127
+ * failure, an unparseable body, or an `ok: false` envelope that arrived
1128
+ * with a 200 (APRV-277).
1129
+ */
1130
+ readonly status: number | null;
1131
+ /**
1132
+ * The Bot API's own `description` for this failure, redacted, when the
1133
+ * error body carried one. `null` when the body was absent, unreadable, not
1134
+ * JSON, or carried no description.
1135
+ */
1136
+ readonly description: string | null;
1137
+ constructor(message: string, method: string,
1138
+ /**
1139
+ * The HTTP status, when the failure was an HTTP one. `null` for a transport
1140
+ * failure, an unparseable body, or an `ok: false` envelope that arrived
1141
+ * with a 200 (APRV-277).
1142
+ */
1143
+ status?: number | null,
1144
+ /**
1145
+ * The Bot API's own `description` for this failure, redacted, when the
1146
+ * error body carried one. `null` when the body was absent, unreadable, not
1147
+ * JSON, or carried no description.
1148
+ */
1149
+ description?: string | null);
1150
+ }
1151
+ /**
1152
+ * Whether a failed call is the Bot API saying an edit changed nothing
1153
+ * (APRV-277).
1154
+ *
1155
+ * `editMessageText` answers 400 "Bad Request: message is not modified" when the
1156
+ * text and the keyboard it was handed are already what the message holds. Every
1157
+ * caller here re-annotates from the verified log rather than from memory, so a
1158
+ * message annotated once and derived again produces exactly that: the phone
1159
+ * already shows the outcome, and the operator has nothing to be told. It is the
1160
+ * one 400 that means the intended state stands, which is why it is the only one
1161
+ * that goes unreported.
1162
+ */
1163
+ export declare function isMessageNotModified(cause: unknown): boolean;
1164
+ /** What one `pollOnce()` did, for tests and for programmatic drivers. */
1165
+ export interface TelegramPollResult {
1166
+ /** Updates received in this batch. */
1167
+ updates: number;
1168
+ /** Decisions the runtime recorded from this batch, in order. */
1169
+ outcomes: {
1170
+ action_key: string;
1171
+ outcome: DecisionOutcome;
1172
+ }[];
1173
+ /** Callbacks ignored in this batch, with the reason. */
1174
+ ignored: {
1175
+ kind: TelegramAnomalyKind;
1176
+ detail: string;
1177
+ }[];
1178
+ /** Bot commands handed to the runtime in this batch, in order (APRV-216). */
1179
+ commands: TelegramCommand[];
1180
+ /**
1181
+ * Review taps handed to the runtime in this batch, in order (APRV-299), each
1182
+ * with whether an `audit.reviewed` landed. `ok: false` is a refusal the
1183
+ * runtime returned — the card says which code — and nothing was appended.
1184
+ */
1185
+ reviews: {
1186
+ tap: ReviewTap;
1187
+ ok: boolean;
1188
+ }[];
1189
+ }
1190
+ export interface TelegramListenOptions {
1191
+ /** Process exactly one successful `getUpdates` batch, then return. */
1192
+ once?: boolean;
1193
+ /**
1194
+ * Run before every `getUpdates`, including the first and including the poll
1195
+ * that follows a recovered poll error (APRV-55).
1196
+ *
1197
+ * This is how the runtime gets a dispatch cycle without the channel growing
1198
+ * an opinion about what is pending: the callback belongs to
1199
+ * `cli/channel-telegram.ts`, which re-derives the pending queue from the
1200
+ * verified log and sends what it has not sent yet. The channel neither reads
1201
+ * the log nor remembers a queue, so nothing here makes it stateful.
1202
+ *
1203
+ * It MUST NOT throw. A rejection is treated exactly like a poll failure
1204
+ * (counted, complained about, retried after backoff) rather than being
1205
+ * allowed to end the loop, because a listener that stops listening is the
1206
+ * failure mode this loop exists to rule out.
1207
+ */
1208
+ beforePoll?: () => Promise<void>;
1209
+ }
1210
+ /**
1211
+ * What {@link TelegramChannel.notifyBatch} did with a set (APRV-115).
1212
+ *
1213
+ * `digestId` is `null` when the set was delivered the old way, one message per
1214
+ * member — the fallback every "cannot render this whole" path takes. `members`
1215
+ * carries the message id each member's annotation must edit, which for a digest
1216
+ * is the one digest message and for the fallback is the member's own.
1217
+ */
1218
+ export interface TelegramBatchDelivery {
1219
+ batchDeliveryId: DeliveryId;
1220
+ digestId: DeliveryId | null;
1221
+ members: {
1222
+ action_key: string;
1223
+ delivery_id: DeliveryId;
1224
+ }[];
1225
+ rendered: RenderedRequest[];
1226
+ }
1227
+ export declare class TelegramChannel implements TestableChannel {
1228
+ readonly name = "telegram";
1229
+ private readonly token;
1230
+ private readonly chatId;
1231
+ private readonly apiBase;
1232
+ private readonly fetchImpl;
1233
+ private readonly pollTimeoutSeconds;
1234
+ private readonly requestTimeoutMs;
1235
+ private readonly backoffMs;
1236
+ private readonly maxBackoffMs;
1237
+ private readonly complain;
1238
+ private readonly makeNonce;
1239
+ /** The policy's approval TTL, or `null` when it declares none (APRV-135). */
1240
+ private readonly approvalTtlMs;
1241
+ private readonly now;
1242
+ /** The listener's verified-log probe for a stale tap (APRV-196), or null. */
1243
+ private readonly describeAction;
1244
+ /** The policy's row layout for this channel (APRV-218). Read-only, and pure input to the renderer. */
1245
+ private readonly layout;
1246
+ /** When {@link sweep} last ran, so the poll loop can call it every cycle. */
1247
+ private lastSweepMs;
1248
+ /**
1249
+ * The callback query being handled, and whether an ack has been attempted for
1250
+ * it (APRV-196). Set and cleared by {@link handleUpdate}, which processes
1251
+ * updates one at a time and awaits each.
1252
+ */
1253
+ private ack;
1254
+ private handler;
1255
+ /**
1256
+ * What to do with a bot command (APRV-216). Absent unless the runtime asked
1257
+ * for commands, and its absence is what keeps `message` out of
1258
+ * `allowed_updates` — see {@link onCommand}.
1259
+ */
1260
+ private commandHandler;
1261
+ /**
1262
+ * What to do with a checkpoint tap (APRV-257). Absent unless the runtime
1263
+ * registered one, and its absence makes {@link offerCheckpoint} refuse: a
1264
+ * button nobody is listening for is a button that spins on a phone.
1265
+ */
1266
+ private checkpointHandler;
1267
+ /**
1268
+ * Checkpoint nonce -> the head that prompt asked about, and the message it is
1269
+ * on. **In memory only**, like every other map in this class and for the same
1270
+ * reason (SPEC.md §10.3: channels hold no state that is a source of truth).
1271
+ *
1272
+ * The head lives HERE and not in the callback bytes, so what is signed is
1273
+ * what this process put on the screen. Losing the map to a restart costs a
1274
+ * tap its meaning — the button answers `unknown-callback` and the listener
1275
+ * offers again on its next lapse — and can never cost a signature over
1276
+ * something nobody was shown.
1277
+ */
1278
+ private readonly checkpointNonces;
1279
+ /**
1280
+ * What to do with a review tap (APRV-299). Absent unless the runtime
1281
+ * registered one, and its absence makes {@link offerReview} refuse, for the
1282
+ * reason {@link offerCheckpoint} refuses: a button nobody is listening for is
1283
+ * a button that spins on a phone.
1284
+ */
1285
+ private reviewHandler;
1286
+ /**
1287
+ * Review card message id -> what is on it. Delivery bookkeeping, never truth
1288
+ * (SPEC.md §10.3). Losing it to a restart costs the card its buttons; the
1289
+ * sample stays open in the log, `approval audit list` still names it, and the
1290
+ * next cycle offers a fresh card.
1291
+ */
1292
+ private readonly reviewCards;
1293
+ /** Review nonce -> the card message it was issued for. */
1294
+ private readonly reviewNonces;
1295
+ /** Note-prompt message id -> the card whose reply it is waiting for. */
1296
+ private readonly reviewNotePrompts;
1297
+ private readonly deliveries;
1298
+ /** Digest message id -> what is on it. Delivery bookkeeping, never truth. */
1299
+ private readonly digests;
1300
+ /** "All" nonce -> the digest message it was issued for. */
1301
+ private readonly allNonces;
1302
+ private rendered;
1303
+ private offset;
1304
+ private counter;
1305
+ private stopped;
1306
+ private inFlight;
1307
+ private readonly counters;
1308
+ constructor(config: TelegramConfig);
1309
+ onDecision(handler: (decision: ChannelDecision) => DecisionOutcome): void;
1310
+ /**
1311
+ * Register what to do with a checkpoint tap (APRV-257).
1312
+ *
1313
+ * The handler is the runtime's, on the runtime's side of the boundary, and it
1314
+ * is where the vault passphrase and the signing live. This channel holds a
1315
+ * nonce, a message id and a `(seq, hash)`, and hands the head back when the
1316
+ * button is pressed — the same shape as {@link onDecision}, for the same
1317
+ * reason: a channel that signed anything would be a channel with authority.
1318
+ */
1319
+ onCheckpoint(handler: CheckpointTapHandler): void;
1320
+ /**
1321
+ * Put one `CHECKPOINT DUE` prompt in the chat, with a Sign and a Not now
1322
+ * button (APRV-257).
1323
+ *
1324
+ * A unit like any other: the paced walkthrough sends it as one thing to read,
1325
+ * and it is never grouped into a digest, because a digest is a set of
1326
+ * SIMILAR REQUESTS decided together and a checkpoint is neither a request nor
1327
+ * similar to one.
1328
+ *
1329
+ * Refuses when no handler is registered, rather than sending a dead button.
1330
+ */
1331
+ offerCheckpoint(prompt: CheckpointPrompt): Promise<DeliveryId>;
1332
+ /**
1333
+ * Register what to do with a review tap (APRV-299).
1334
+ *
1335
+ * The handler is the runtime's, on the runtime's side of the boundary, and it
1336
+ * is where the human-only `reviewSample` lives — same shape as
1337
+ * {@link onDecision} and {@link onCheckpoint}, for the same reason: a channel
1338
+ * that appended an `audit.reviewed` of its own would be a supervision backlog
1339
+ * emptying itself through its own transport.
1340
+ *
1341
+ * Registering it, like registering a command handler, is what makes this
1342
+ * channel read `message` updates at all: the note a `loved` or `disliked`
1343
+ * asks for arrives as a reply, and an inline keyboard has no text input.
1344
+ */
1345
+ onReview(handler: ReviewTapHandler): void;
1346
+ /**
1347
+ * Put one retrospective review card in the chat (APRV-299).
1348
+ *
1349
+ * A unit like a checkpoint prompt: one thing to read, never grouped into a
1350
+ * digest, and never delivered through {@link notify} — a digest is a set of
1351
+ * similar pending REQUESTS decided together, and a sample is neither pending
1352
+ * nor a request. It sends ONE message: no payload region, and a keyboard
1353
+ * whose six buttons collect a verdict and a grade and mint nothing.
1354
+ *
1355
+ * Refuses when no handler is registered, rather than sending a dead button.
1356
+ */
1357
+ offerReview(card: ReviewCard): Promise<DeliveryId>;
1358
+ /**
1359
+ * Register what to do with `/queue`, `/skip` and `/next` (APRV-216).
1360
+ *
1361
+ * **Registering is what makes this channel read messages at all.** Until a
1362
+ * handler is here, `getUpdates` asks for `callback_query` only, exactly as it
1363
+ * did before this task, so a listener in `burst` delivery consumes no message
1364
+ * updates — which matters because `approval setup channel telegram`
1365
+ * discovers the approver chat by reading one (APRV-74), and a listener that
1366
+ * swallowed it would break the bootstrap of the very channel it runs on.
1367
+ *
1368
+ * The handler owns whatever answer the human gets. This class sends nothing
1369
+ * of its own for a command: it holds no queue to summarise (SPEC.md §10.3),
1370
+ * so the sentence a command produces is written where the pending set is
1371
+ * re-derived, in `cli/channel-telegram.ts`.
1372
+ */
1373
+ onCommand(handler: (command: TelegramCommand) => Promise<void> | void): void;
1374
+ health(): ChannelHealth;
1375
+ /** The rendering split of the most recent `notify`, for the conformance suite. */
1376
+ lastRendered(): RenderedRequest[];
1377
+ /** Delivery, decision and anomaly counters. Live; read from anywhere. */
1378
+ stats(): TelegramStats;
1379
+ /** Ignored callbacks so far. Exposed for `health()` and for operators. */
1380
+ anomalyCount(kind?: TelegramAnomalyKind): number;
1381
+ /**
1382
+ * Put a request, or a set of them, in front of the approver.
1383
+ *
1384
+ * One request is one prompt: its header, its payload chunks, and the
1385
+ * Approve/Reject keyboard on the last message, whose `message_id` is the
1386
+ * delivery id. A {@link ChannelBatch} goes through {@link notifyBatch} and
1387
+ * comes back as a digest when it can be one; either way it gets one shared
1388
+ * batch delivery id, which is what this returns and what every resulting
1389
+ * event will carry.
1390
+ */
1391
+ notify(target: ChannelRequest | ChannelBatch): Promise<DeliveryId>;
1392
+ /**
1393
+ * Deliver a set as one digest, or as one message per member when it cannot
1394
+ * be one (APRV-115).
1395
+ *
1396
+ * The fallback is taken for a set of fewer than two, and for one whose digest
1397
+ * text would not fit inside {@link TELEGRAM_MAX_MESSAGE_CHARS}. Both are the
1398
+ * same rule: the approver sees every member before any button that decides
1399
+ * more than one appears, and when that cannot be arranged the channel sends
1400
+ * MORE messages rather than fewer.
1401
+ *
1402
+ * Not atomic, and it cannot be: a `sendMessage` that fails part way leaves
1403
+ * the messages already sent in the chat, and this throws. Nothing is armed —
1404
+ * the member nonces are registered only once the digest message carrying
1405
+ * their buttons exists — so the caller's retry re-sends the set and the
1406
+ * approver gets a duplicate prompt, never a live button on a half-sent one.
1407
+ */
1408
+ notifyBatch(batch: ChannelBatch): Promise<TelegramBatchDelivery>;
1409
+ /**
1410
+ * Deliver a set of stale pending requests as ONE message with a reject-all
1411
+ * button (APRV-287).
1412
+ *
1413
+ * Returns `null` when the message would not fit, and the caller then leaves
1414
+ * the members undelivered so the next cycle shows them the ordinary way:
1415
+ * SPEC.md §10.3's rule for this bookkeeping is that losing it degrades to
1416
+ * showing a request again, never to a pending request nobody is shown.
1417
+ */
1418
+ notifyStale(members: ChannelRequest[], stale: StaleSummary): Promise<TelegramBatchDelivery | null>;
1419
+ /**
1420
+ * The digest itself: every member's prompt and payload, then the one message
1421
+ * that carries the buttons.
1422
+ *
1423
+ * Returns `null` when the digest message would not fit, so the caller falls
1424
+ * back — and it decides that BEFORE sending anything, because a fallback
1425
+ * discovered after four member prompts had gone out would double them.
1426
+ *
1427
+ * `stale` (APRV-287) makes it the collapsed re-delivery instead: no member
1428
+ * prompts, no payload, one reject-all button. See {@link StaleSummary}.
1429
+ */
1430
+ private deliverDigest;
1431
+ /**
1432
+ * Send one request's messages: the computed header, the payload chunks, then
1433
+ * the claimed block, with `keyboard` (when there is one) on the last.
1434
+ *
1435
+ * The claimed block goes last because it is the human-meaningful description
1436
+ * of the act, and the message a reader answers on should be the one that says
1437
+ * what they are answering about; bookkeeping above it is context, not the
1438
+ * question. SPEC §10.3 allows claimed material to sit around the canonical
1439
+ * block while it stays visibly separated and labelled, which the heading on
1440
+ * every claimed message keeps. It is always sent, so a missing summary is a
1441
+ * visible "(none given)" rather than an absent message, and so the keyboard
1442
+ * has one message it can always ride on.
1443
+ *
1444
+ * Shared by the ordinary prompt and by a digest member, which differ in
1445
+ * exactly two things: the heading, and whether anything is armed.
1446
+ */
1447
+ private sendPrompt;
1448
+ private deliverOne;
1449
+ /**
1450
+ * Forget every nonce issued for `deliveryId`, and report the action key it
1451
+ * was issued for.
1452
+ *
1453
+ * Called by {@link annotate} before the edit goes out, so a tap on a button
1454
+ * the edit does not manage to remove resolves to nothing and is answered as
1455
+ * a `stale-copy` rather than carried to the gate as a decision attempt.
1456
+ * Forgetting is never the channel growing state, and forgetting a SETTLED
1457
+ * request is what stops APRV-196's action-reference fallback from finding it:
1458
+ * the ladder rescues a tap on an old copy of a request still open here, and
1459
+ * a decided one is not that.
1460
+ */
1461
+ private disarm;
1462
+ /**
1463
+ * Drop the delivery bookkeeping no callback can still be honoured against
1464
+ * (APRV-135).
1465
+ *
1466
+ * The condition is both halves of the sentence, evaluated per entry:
1467
+ *
1468
+ * 1. **Every member is terminal.** For a digest that means every member
1469
+ * carries a `settled` outcome; for a unit delivery it is automatic in the
1470
+ * other direction, since annotating a decided, expired or withdrawn
1471
+ * request already forgets its nonces ({@link disarm}), so a delivery still
1472
+ * in the map is one this process has not seen settled. A request past its
1473
+ * approval TTL is terminal too — the gate refuses every decision on it —
1474
+ * which is what lets an unannotated delivery be swept at all.
1475
+ * 2. **Older than the retention window**, which is the policy's approval TTL
1476
+ * when it declares one and {@link TELEGRAM_DEFAULT_RETENTION_MS} when it
1477
+ * does not. Measured from the moment THIS process delivered the message,
1478
+ * which is at or after the `approval.requested` the TTL actually runs
1479
+ * from, so the window this sweep waits out is never shorter than the one
1480
+ * the gate enforces.
1481
+ *
1482
+ * Both together are what makes forgetting safe: a live button can never
1483
+ * reference a dropped entry, because the state in which no callback can still
1484
+ * be honoured is exactly the state in which the entry is dropped. A tap that
1485
+ * arrives anyway is answered by the stale-callback path a restarted
1486
+ * listener's buttons already take: `stale-copy` since APRV-196, counted,
1487
+ * toasted with what the log says became of the request, never carried to the
1488
+ * gate.
1489
+ *
1490
+ * Process memory only. No event, no message edit, no log read. `nowMs`
1491
+ * defaults to the configured clock and is a parameter so a test can run a
1492
+ * simulated week without one.
1493
+ */
1494
+ sweep(nowMs?: number): {
1495
+ deliveries: number;
1496
+ digests: number;
1497
+ };
1498
+ /** How many entries the bookkeeping holds. For tests and for operators. */
1499
+ bookkeepingSize(): {
1500
+ deliveries: number;
1501
+ digests: number;
1502
+ allNonces: number;
1503
+ reviewCards: number;
1504
+ };
1505
+ /**
1506
+ * Mark one digest member settled and redraw the digest (APRV-115).
1507
+ *
1508
+ * The member's own nonce is forgotten first, so a tap on a button the redraw
1509
+ * does not manage to remove resolves to nothing rather than reaching the
1510
+ * gate. The other members keep theirs: a partially decided digest is a real
1511
+ * state and the rest of it is still answerable.
1512
+ */
1513
+ private settleMember;
1514
+ /** One `editMessageText` that replaces a digest's text and its keyboard. */
1515
+ private redraw;
1516
+ /**
1517
+ * Edit a delivered message to say what became of its question, and remove the
1518
+ * buttons (APRV-106 for withdrawal, generalized in APRV-113 to every terminal
1519
+ * state).
1520
+ *
1521
+ * ONE `editMessageText` call, not two. Telegram's `editMessageText` replaces
1522
+ * the reply markup along with the text, and omitting `reply_markup` clears
1523
+ * it — so the annotation and the disarming land together, and there is no
1524
+ * window in which the message reads "approved" and still offers a tap.
1525
+ *
1526
+ * The text is REPLACED rather than appended to, because this class does not
1527
+ * remember what it sent (it remembers a nonce and a message id) and refetching
1528
+ * a message to append to it would be the channel reconstructing state it is
1529
+ * not supposed to hold. What the approver keeps is the outcome, the action key
1530
+ * and the detail lines, which is what a chat transcript needs to stay readable.
1531
+ *
1532
+ * `outcome` is a headline word (see {@link TELEGRAM_TERMINAL_HEADLINES}) and
1533
+ * `detail` the lines under it; both are HTML-escaped here, and neither may
1534
+ * carry an execution token — no caller in this repository has one to give,
1535
+ * since {@link DecisionOutcome} deliberately does not carry it.
1536
+ *
1537
+ * Best effort: {@link TelegramApiError} propagates to the caller, which logs
1538
+ * it and carries on. A message that could not be edited is a cosmetic
1539
+ * problem — the log has already settled the request, so a tap on the stale
1540
+ * buttons is refused by the gate and answered with the refusal toast.
1541
+ */
1542
+ annotate(deliveryId: DeliveryId, outcome: string, detail: string[],
1543
+ /**
1544
+ * Which request this settles, when `deliveryId` names a digest (APRV-115).
1545
+ * A digest holds several, so an annotation without one can only mean the
1546
+ * whole delivery is over — which is handled by falling through to the
1547
+ * message-replacing path below, buttons and all.
1548
+ */
1549
+ actionKey?: string): Promise<void>;
1550
+ /**
1551
+ * The withdrawal case of {@link annotate} (APRV-106), and the one the
1552
+ * {@link Channel} interface names. Its wording is unchanged.
1553
+ */
1554
+ retract(deliveryId: DeliveryId, reason: string, actionKey?: string): Promise<void>;
1555
+ /**
1556
+ * Send one plain message that carries no question (APRV-196).
1557
+ *
1558
+ * Used for the re-delivery banner the listener puts in front of a startup
1559
+ * batch. It arms nothing, remembers nothing, and names no action key: a
1560
+ * banner is a sentence about the messages that follow, and a reader who
1561
+ * mistook it for a request would be a reader the banner had made worse off.
1562
+ * `lines` are escaped here, exactly as everything else interpolated into an
1563
+ * HTML-mode message is.
1564
+ */
1565
+ announce(lines: string[]): Promise<DeliveryId>;
1566
+ /**
1567
+ * Long-poll `getUpdates` until {@link stop} is called (or one batch, with
1568
+ * `once`).
1569
+ *
1570
+ * **The loop survives the network.** A poll that times out, is refused, drops
1571
+ * its socket, returns a 5xx, or answers with something that is not JSON is
1572
+ * counted, complained about on stderr, and retried after a doubling backoff.
1573
+ * There is no failure mode in which the listener quietly stops listening: the
1574
+ * whole value of a push channel is that a human's inbox keeps receiving, and
1575
+ * a listener that died at 3am on a transient 502 would fail exactly when the
1576
+ * queue was filling up.
1577
+ *
1578
+ * Each iteration begins with {@link TelegramListenOptions.beforePoll} when
1579
+ * one is supplied, which is where the runtime's dispatch cycle runs: the
1580
+ * loop is therefore "deliver anything newly pending, then wait for a
1581
+ * decision", not "deliver once at startup, then wait forever".
1582
+ */
1583
+ listen(options?: TelegramListenOptions): Promise<void>;
1584
+ /** Stop the loop and abort any in-flight request. */
1585
+ stop(): void;
1586
+ /**
1587
+ * One `getUpdates` batch, processed. Throws on a transport failure — which is
1588
+ * what {@link listen} catches and retries.
1589
+ */
1590
+ pollOnce(): Promise<TelegramPollResult>;
1591
+ /**
1592
+ * Exactly one `answerCallbackQuery` per callback query, on every path
1593
+ * (APRV-196).
1594
+ *
1595
+ * The incident this closes: a tap that reached no branch with a toast on it
1596
+ * spun on the approver's phone until Telegram gave up, and the human — with
1597
+ * no way to tell a swallowed tap from a slow one — tapped again. So the ack
1598
+ * is a property of the WRAPPER rather than of each branch: every route below
1599
+ * still writes its own, better sentence, and anything that fails to (a throw
1600
+ * halfway through, a branch a later change forgets) is caught here and
1601
+ * answered with {@link TELEGRAM_ACK_FALLBACK}.
1602
+ *
1603
+ * A thrown handler is answered and swallowed rather than propagated, and that
1604
+ * is deliberate: `pollOnce` throwing puts `listen` into its backoff, so one
1605
+ * malformed update would cost the whole batch and the poll after it. Nothing
1606
+ * is lost by continuing — the gate has already appended whatever it appended,
1607
+ * and the log is what says so.
1608
+ *
1609
+ * APRV-206 moved WHEN that one answer is sent on the decision path: it now
1610
+ * goes out before the gate runs, so the spinner on the phone is one Bot API
1611
+ * call long instead of one decision long. The guarantee is unchanged and is
1612
+ * now enforced in one place — {@link safeAnswer} answers a query at most once,
1613
+ * so the fallback below cannot follow an early ack with a second call.
1614
+ */
1615
+ private handleUpdate;
1616
+ /**
1617
+ * A `message` update: the bot-command path (APRV-216).
1618
+ *
1619
+ * Three rules, in this order, and each of them is a refusal to act on
1620
+ * something the network said:
1621
+ *
1622
+ * 1. **No handler, no reading.** A channel with no command handler wants no
1623
+ * message updates and did not ask for any; one that arrives anyway (a
1624
+ * webhook backlog, a poll issued before the handler was registered) is
1625
+ * dropped without a counter, because there is nothing wrong with it.
1626
+ * 2. **The configured chat only.** A message from anywhere else is counted
1627
+ * `foreign-chat` and answered with nothing at all. Not even a refusal
1628
+ * reply: a stranger who can reach the bot learns from silence that the
1629
+ * bot is there, and learns from a reply what it is for.
1630
+ * 3. **A closed vocabulary.** `/queue`, `/skip`, `/next`. Anything else
1631
+ * beginning with `/` is counted `unknown-command`; anything not beginning
1632
+ * with `/` is ordinary chat and is ignored silently.
1633
+ *
1634
+ * A command decides nothing and appends nothing — it cannot, because it
1635
+ * never reaches {@link handler}. The handler it does reach reorders what the
1636
+ * runtime shows next, which is process memory on the runtime's side of the
1637
+ * boundary (SPEC.md §10.3).
1638
+ */
1639
+ private handleMessage;
1640
+ private routeCallback;
1641
+ /**
1642
+ * One tap over every still-open member of a digest (APRV-115).
1643
+ *
1644
+ * **N decisions, never one.** Each member is turned into its own
1645
+ * {@link ChannelDecision} — its own action key, its own payload binding — and
1646
+ * handed to the runtime's handler on its own, which records it through the
1647
+ * gate's compare-and-append on its own. There is no code path here that could
1648
+ * produce a single event covering two actions, because there is no call here
1649
+ * that writes anything at all.
1650
+ *
1651
+ * A member that refuses (already decided elsewhere, expired, withdrawn) does
1652
+ * not stop the rest, for the reason `channels/batch.ts` sets out: abandoning
1653
+ * four answers because the fifth had lapsed would discard a human's decision,
1654
+ * and un-appending the ones already written is not a thing the log permits.
1655
+ * The toast says how many landed and how many did not.
1656
+ *
1657
+ * The digest is redrawn ONCE at the end rather than per member: N edits of
1658
+ * the same message would show the approver their own decisions arriving one
1659
+ * at a time, and would spend N Bot API calls to end in the same place.
1660
+ */
1661
+ private handleDigestAll;
1662
+ /**
1663
+ * {@link annotate}, with a failed edit complained about rather than thrown
1664
+ * (APRV-206).
1665
+ *
1666
+ * Every caller on the decision path wants the same thing from a failed edit:
1667
+ * say so on the operator's terminal and carry on, because whatever the gate
1668
+ * did or did not append has already happened and no chat message changes it.
1669
+ *
1670
+ * The exception is {@link isMessageNotModified}, which says the message
1671
+ * already reads the way this call wanted it to read (APRV-277). Nothing is
1672
+ * printed for it: there is no staleness to warn about.
1673
+ */
1674
+ private annotateQuietly;
1675
+ /**
1676
+ * What a refused tap is told, in the message edit (APRV-206; it was the toast
1677
+ * until the single answer moved to the early ack).
1678
+ *
1679
+ * The duplicate case is the one worth naming: a second tap on a request the
1680
+ * gate has already decided produces `already-decided`, no second event, and
1681
+ * this text. Telegram redelivers callbacks on its own, so this path is
1682
+ * ordinary traffic, not an error.
1683
+ *
1684
+ * The sentences themselves moved to `channels/contract.ts` in APRV-235, so
1685
+ * that this message edit and the line the terminal channel prints are the
1686
+ * same words and cannot drift apart: a human who taps on their phone and
1687
+ * then reads the operator's terminal should not have to decide which of two
1688
+ * wordings to believe. The edit puts {@link TELEGRAM_NOT_RECORDED} above it
1689
+ * and clears the buttons, in `annotate`'s single call.
1690
+ */
1691
+ private answerFor;
1692
+ /**
1693
+ * A tap on `Sign` or `Not now` (APRV-257).
1694
+ *
1695
+ * The nonce is authoritative and there is no fallback ladder underneath it:
1696
+ * a checkpoint names no request, so there is no action reference to rescue a
1697
+ * stale copy with, and a tap this process cannot resolve is answered as
1698
+ * `unknown-callback` rather than guessed at. The cost is one dead button
1699
+ * after a restart, and the listener offers again on its next lapse.
1700
+ *
1701
+ * The nonce is consumed BEFORE the handler runs, so a double tap cannot
1702
+ * produce two records: the second tap finds nothing and says so. Even if it
1703
+ * did, `appendCheckpointAt` is a compare-and-append and the log would carry
1704
+ * two honest checkpoints over the same head, which is harmless — but a human
1705
+ * who taps twice should be told what happened rather than shown two
1706
+ * successes.
1707
+ *
1708
+ * The ack goes out FIRST (APRV-206's rule), because signing reads a vault
1709
+ * and appends to a log, and a spinner that lasted a decision long is what
1710
+ * that task removed.
1711
+ */
1712
+ private handleCheckpointTap;
1713
+ /**
1714
+ * A tap on one of a review card's six buttons (APRV-299).
1715
+ *
1716
+ * The nonce is authoritative and there is no fallback ladder underneath it,
1717
+ * for the reason {@link reviewCallbackData} gives: a review is never urgent,
1718
+ * a card this process is not holding leaves its sample open, and the next
1719
+ * cycle offers a fresh one. An unresolvable tap is answered
1720
+ * `unknown-callback` rather than guessed at.
1721
+ *
1722
+ * Which combinations are legal is decided by `core/audit.ts` and by nothing
1723
+ * here. A denied review that says the human loved the work is refused by
1724
+ * `reviewSample` before it reads the log, with the code SPEC.md §11.2 names,
1725
+ * and this method's job is to put that pair in front of it rather than to
1726
+ * re-implement the rule. The one thing this method owns is the ARMING, which
1727
+ * is process memory that appends nothing.
1728
+ */
1729
+ private handleReviewTap;
1730
+ /**
1731
+ * Send the ForceReply prompt a `loved` or `disliked` needs, and remember what
1732
+ * its reply will record (APRV-299).
1733
+ *
1734
+ * The pending grade lives HERE and not in the reply's own text, exactly as a
1735
+ * checkpoint's head lives in this process rather than in the callback bytes:
1736
+ * what gets recorded is what this process put on the screen. Losing the map
1737
+ * to a restart costs the reply its meaning — nothing is appended, the sample
1738
+ * stays open, and a fresh card is offered — and can never cost a record
1739
+ * nobody asked for.
1740
+ *
1741
+ * A second prompt replaces the first: only one grade can be outstanding on
1742
+ * one card, and the older prompt stops resolving so a late reply to it lands
1743
+ * nowhere rather than recording a grade the human moved on from.
1744
+ */
1745
+ private askForNote;
1746
+ /**
1747
+ * A message replying to an outstanding note prompt (APRV-299).
1748
+ *
1749
+ * Returns `true` when this update was a note reply and has been dealt with,
1750
+ * so the command path below never sees it. Three refusals to act on something
1751
+ * the network said, in order: a reply naming no prompt this process issued is
1752
+ * not ours, a reply from another chat is counted `foreign-chat` and answered
1753
+ * with nothing at all, and the words themselves are passed to the runtime
1754
+ * verbatim — a blank one included, because whether blank is a note is
1755
+ * `core/audit.ts`'s rule and not this channel's.
1756
+ */
1757
+ private handleNoteReply;
1758
+ /**
1759
+ * Hand one review tap to the runtime and redraw the card from its answer
1760
+ * (APRV-299).
1761
+ *
1762
+ * A recorded review settles the card and forgets its nonce, so a tap on a
1763
+ * button the edit does not manage to remove resolves to nothing rather than
1764
+ * recording a second human observation of one item. A REFUSAL does neither:
1765
+ * nothing was appended, the sample is still open, and the codes that get here
1766
+ * are ones the reviewer can act on — `reaction-conflicts-verdict` asks them
1767
+ * to say which half they meant, and `note-required` asks for words — so the
1768
+ * buttons stay, with the refusal rendered above them and the arming intact.
1769
+ */
1770
+ private recordReview;
1771
+ /** One `editMessageText` that replaces a review card's text and its keyboard. */
1772
+ private redrawReview;
1773
+ private ignore;
1774
+ private answer;
1775
+ /**
1776
+ * Answer, and never throw (APRV-196).
1777
+ *
1778
+ * A toast is a courtesy on every path, including the successful one: the
1779
+ * decision is already in the log by the time the ack is attempted, and an
1780
+ * `answerCallbackQuery` that fails (Telegram drops a query after its own
1781
+ * window, and a phone on a train produces plenty of late taps) must not
1782
+ * abandon the annotation or push the poll loop into backoff.
1783
+ *
1784
+ * The attempt is recorded either way, so {@link handleUpdate}'s guarantee
1785
+ * does not turn one failed ack into a second doomed call.
1786
+ *
1787
+ * **Idempotent per callback query since APRV-206.** A query that has already
1788
+ * been answered in this handling is not answered again: the early ack the
1789
+ * decision path sends is THE answer, and every later sentence — a branch's
1790
+ * own toast, the wrapper's fallback — becomes a no-op rather than a second
1791
+ * `answerCallbackQuery`. APRV-196's "exactly one per callback" therefore
1792
+ * holds structurally, in this one method, instead of by every branch
1793
+ * remembering to return.
1794
+ */
1795
+ private safeAnswer;
1796
+ /**
1797
+ * The delivery this process is holding open for an action reference, if any
1798
+ * (APRV-196).
1799
+ *
1800
+ * A linear walk of the delivery map rather than a second index: the map is
1801
+ * bounded by the pending queue and swept (APRV-135), this runs only on the
1802
+ * uncommon path where a nonce did not resolve, and a second map would be a
1803
+ * second thing to keep in step with `disarm`, `settleMember` and `sweep` —
1804
+ * three places where forgetting is the safety property.
1805
+ *
1806
+ * Digest members are eligible: a member's nonce is deleted the moment it is
1807
+ * settled, so a member still in the map is one still armed on a live message.
1808
+ */
1809
+ private liveDeliveryFor;
1810
+ /** Replace the token with a placeholder anywhere it appears in `text`. */
1811
+ private redact;
1812
+ private describe;
1813
+ /**
1814
+ * The Bot API's own `description` for a failed response, redacted (APRV-277).
1815
+ *
1816
+ * `null` whenever there is nothing trustworthy to quote: the body could not
1817
+ * be read, it was not JSON, or it carried no description. Every failure mode
1818
+ * here is silent by design, because this runs on a path that is already
1819
+ * reporting a failure and a second one thrown from the diagnostic would
1820
+ * replace the real reason with a worse one.
1821
+ */
1822
+ private describeFailure;
1823
+ /**
1824
+ * One Bot API call.
1825
+ *
1826
+ * The token is in the URL, which is how the Bot API works — there is no
1827
+ * header form. It is therefore never put in a message body, an error string,
1828
+ * or a log line: {@link redact} scrubs everything that leaves this class, and
1829
+ * the test suite scans every request body and every log byte for it.
1830
+ */
1831
+ private call;
1832
+ }