approval-md 0.3.0 → 0.4.1

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 (270) hide show
  1. package/README.md +83 -914
  2. package/SPEC.md +101 -13
  3. package/cli.js +134 -7
  4. package/dist/src/adapters/smtp.d.ts +18 -0
  5. package/dist/src/adapters/smtp.js +24 -3
  6. package/dist/src/adapters/smtp.js.map +1 -1
  7. package/dist/src/channels/contract.d.ts +72 -0
  8. package/dist/src/channels/contract.js +161 -1
  9. package/dist/src/channels/contract.js.map +1 -1
  10. package/dist/src/channels/relay-server.d.ts +90 -0
  11. package/dist/src/channels/relay-server.js +295 -0
  12. package/dist/src/channels/relay-server.js.map +1 -0
  13. package/dist/src/channels/relay.d.ts +273 -0
  14. package/dist/src/channels/relay.js +564 -0
  15. package/dist/src/channels/relay.js.map +1 -0
  16. package/dist/src/channels/tagging.d.ts +25 -0
  17. package/dist/src/channels/tagging.js +63 -6
  18. package/dist/src/channels/tagging.js.map +1 -1
  19. package/dist/src/channels/telegram-minimal.d.ts +251 -0
  20. package/dist/src/channels/telegram-minimal.js +618 -0
  21. package/dist/src/channels/telegram-minimal.js.map +1 -0
  22. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  23. package/dist/src/channels/telegram-webhook.js +536 -0
  24. package/dist/src/channels/telegram-webhook.js.map +1 -0
  25. package/dist/src/channels/telegram.d.ts +725 -20
  26. package/dist/src/channels/telegram.js +1332 -123
  27. package/dist/src/channels/telegram.js.map +1 -1
  28. package/dist/src/cli/amend.js +358 -5
  29. package/dist/src/cli/amend.js.map +1 -1
  30. package/dist/src/cli/attest.js +43 -6
  31. package/dist/src/cli/attest.js.map +1 -1
  32. package/dist/src/cli/audit-card.d.ts +1 -1
  33. package/dist/src/cli/audit-card.js +40 -1
  34. package/dist/src/cli/audit-card.js.map +1 -1
  35. package/dist/src/cli/audit.d.ts +7 -5
  36. package/dist/src/cli/audit.js +22 -10
  37. package/dist/src/cli/audit.js.map +1 -1
  38. package/dist/src/cli/channel-relay.d.ts +108 -0
  39. package/dist/src/cli/channel-relay.js +334 -0
  40. package/dist/src/cli/channel-relay.js.map +1 -0
  41. package/dist/src/cli/channel-telegram-webhook.d.ts +275 -0
  42. package/dist/src/cli/channel-telegram-webhook.js +716 -0
  43. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  44. package/dist/src/cli/channel-telegram.d.ts +347 -28
  45. package/dist/src/cli/channel-telegram.js +648 -56
  46. package/dist/src/cli/channel-telegram.js.map +1 -1
  47. package/dist/src/cli/channel.d.ts +1 -1
  48. package/dist/src/cli/channel.js +5 -1
  49. package/dist/src/cli/channel.js.map +1 -1
  50. package/dist/src/cli/daemon.js +37 -5
  51. package/dist/src/cli/daemon.js.map +1 -1
  52. package/dist/src/cli/doctor.d.ts +63 -0
  53. package/dist/src/cli/doctor.js +856 -111
  54. package/dist/src/cli/doctor.js.map +1 -1
  55. package/dist/src/cli/execute.d.ts +29 -1
  56. package/dist/src/cli/execute.js +387 -21
  57. package/dist/src/cli/execute.js.map +1 -1
  58. package/dist/src/cli/exit-codes.d.ts +12 -0
  59. package/dist/src/cli/exit-codes.js +13 -0
  60. package/dist/src/cli/exit-codes.js.map +1 -1
  61. package/dist/src/cli/gate.d.ts +2 -0
  62. package/dist/src/cli/gate.js +233 -2
  63. package/dist/src/cli/gate.js.map +1 -1
  64. package/dist/src/cli/help.d.ts +10 -1
  65. package/dist/src/cli/help.js +283 -54
  66. package/dist/src/cli/help.js.map +1 -1
  67. package/dist/src/cli/hook.d.ts +485 -6
  68. package/dist/src/cli/hook.js +2370 -70
  69. package/dist/src/cli/hook.js.map +1 -1
  70. package/dist/src/cli/init.js +16 -7
  71. package/dist/src/cli/init.js.map +1 -1
  72. package/dist/src/cli/instructions.js +24 -3
  73. package/dist/src/cli/instructions.js.map +1 -1
  74. package/dist/src/cli/log-anchor.d.ts +55 -0
  75. package/dist/src/cli/log-anchor.js +199 -60
  76. package/dist/src/cli/log-anchor.js.map +1 -1
  77. package/dist/src/cli/log-unlock.d.ts +23 -0
  78. package/dist/src/cli/log-unlock.js +109 -0
  79. package/dist/src/cli/log-unlock.js.map +1 -0
  80. package/dist/src/cli/main.d.ts +0 -12
  81. package/dist/src/cli/main.js +72 -4
  82. package/dist/src/cli/main.js.map +1 -1
  83. package/dist/src/cli/muse.d.ts +2 -0
  84. package/dist/src/cli/muse.js +62 -0
  85. package/dist/src/cli/muse.js.map +1 -0
  86. package/dist/src/cli/payload.js +79 -1
  87. package/dist/src/cli/payload.js.map +1 -1
  88. package/dist/src/cli/scaffold.js +6 -0
  89. package/dist/src/cli/scaffold.js.map +1 -1
  90. package/dist/src/cli/serve.d.ts +53 -0
  91. package/dist/src/cli/serve.js +270 -0
  92. package/dist/src/cli/serve.js.map +1 -0
  93. package/dist/src/cli/up.js +26 -2
  94. package/dist/src/cli/up.js.map +1 -1
  95. package/dist/src/cli/verb-registry.d.ts +2 -0
  96. package/dist/src/cli/verb-registry.js +338 -39
  97. package/dist/src/cli/verb-registry.js.map +1 -1
  98. package/dist/src/cli/wordmark.d.ts +2 -2
  99. package/dist/src/cli/wordmark.js +2 -2
  100. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  101. package/dist/src/core/attest-bootstrap.js +135 -0
  102. package/dist/src/core/attest-bootstrap.js.map +1 -0
  103. package/dist/src/core/attest.d.ts +20 -3
  104. package/dist/src/core/attest.js +25 -0
  105. package/dist/src/core/attest.js.map +1 -1
  106. package/dist/src/core/audit.d.ts +153 -12
  107. package/dist/src/core/audit.js +415 -20
  108. package/dist/src/core/audit.js.map +1 -1
  109. package/dist/src/core/channel-lease.d.ts +322 -0
  110. package/dist/src/core/channel-lease.js +663 -0
  111. package/dist/src/core/channel-lease.js.map +1 -0
  112. package/dist/src/core/command-class.d.ts +51 -0
  113. package/dist/src/core/command-class.js +766 -8
  114. package/dist/src/core/command-class.js.map +1 -1
  115. package/dist/src/core/daemon-host.d.ts +178 -0
  116. package/dist/src/core/daemon-host.js +228 -0
  117. package/dist/src/core/daemon-host.js.map +1 -0
  118. package/dist/src/core/daemon-identity.d.ts +238 -0
  119. package/dist/src/core/daemon-identity.js +252 -0
  120. package/dist/src/core/daemon-identity.js.map +1 -0
  121. package/dist/src/core/gate.d.ts +364 -6
  122. package/dist/src/core/gate.js +802 -27
  123. package/dist/src/core/gate.js.map +1 -1
  124. package/dist/src/core/harness-version.d.ts +128 -4
  125. package/dist/src/core/harness-version.js +179 -12
  126. package/dist/src/core/harness-version.js.map +1 -1
  127. package/dist/src/core/harness-wait.d.ts +82 -3
  128. package/dist/src/core/harness-wait.js +89 -3
  129. package/dist/src/core/harness-wait.js.map +1 -1
  130. package/dist/src/core/log-lock.d.ts +435 -0
  131. package/dist/src/core/log-lock.js +1093 -0
  132. package/dist/src/core/log-lock.js.map +1 -0
  133. package/dist/src/core/log-subscribe.d.ts +21 -0
  134. package/dist/src/core/log-subscribe.js +16 -6
  135. package/dist/src/core/log-subscribe.js.map +1 -1
  136. package/dist/src/core/log-write-layer.d.ts +55 -0
  137. package/dist/src/core/log-write-layer.js +91 -0
  138. package/dist/src/core/log-write-layer.js.map +1 -0
  139. package/dist/src/core/log.d.ts +130 -22
  140. package/dist/src/core/log.js +473 -50
  141. package/dist/src/core/log.js.map +1 -1
  142. package/dist/src/core/payload-census.d.ts +51 -0
  143. package/dist/src/core/payload-census.js +125 -2
  144. package/dist/src/core/payload-census.js.map +1 -1
  145. package/dist/src/core/payload-store.d.ts +31 -0
  146. package/dist/src/core/payload-store.js +113 -8
  147. package/dist/src/core/payload-store.js.map +1 -1
  148. package/dist/src/core/payload.d.ts +18 -3
  149. package/dist/src/core/payload.js +20 -4
  150. package/dist/src/core/payload.js.map +1 -1
  151. package/dist/src/core/policy-diff.d.ts +10 -0
  152. package/dist/src/core/policy-diff.js +10 -2
  153. package/dist/src/core/policy-diff.js.map +1 -1
  154. package/dist/src/core/policy-explain.d.ts +7 -1
  155. package/dist/src/core/policy-explain.js +16 -1
  156. package/dist/src/core/policy-explain.js.map +1 -1
  157. package/dist/src/core/policy-load.d.ts +24 -0
  158. package/dist/src/core/policy-load.js.map +1 -1
  159. package/dist/src/core/policy-match.d.ts +38 -0
  160. package/dist/src/core/policy-match.js +35 -0
  161. package/dist/src/core/policy-match.js.map +1 -1
  162. package/dist/src/core/policy-proposal.d.ts +40 -0
  163. package/dist/src/core/policy-proposal.js +37 -16
  164. package/dist/src/core/policy-proposal.js.map +1 -1
  165. package/dist/src/core/prompt-layout.d.ts +186 -1
  166. package/dist/src/core/prompt-layout.js +345 -3
  167. package/dist/src/core/prompt-layout.js.map +1 -1
  168. package/dist/src/core/read-scope.d.ts +10 -0
  169. package/dist/src/core/read-scope.js +4 -0
  170. package/dist/src/core/read-scope.js.map +1 -1
  171. package/dist/src/core/run-payload.d.ts +160 -0
  172. package/dist/src/core/run-payload.js +304 -0
  173. package/dist/src/core/run-payload.js.map +1 -0
  174. package/dist/src/core/sender-identity.d.ts +59 -11
  175. package/dist/src/core/sender-identity.js +79 -11
  176. package/dist/src/core/sender-identity.js.map +1 -1
  177. package/dist/src/core/state.d.ts +52 -1
  178. package/dist/src/core/state.js +52 -6
  179. package/dist/src/core/state.js.map +1 -1
  180. package/dist/src/core/telegram-config.d.ts +79 -0
  181. package/dist/src/core/telegram-config.js +108 -0
  182. package/dist/src/core/telegram-config.js.map +1 -1
  183. package/dist/src/core/validate.d.ts +20 -6
  184. package/dist/src/core/validate.js +0 -0
  185. package/dist/src/core/validate.js.map +1 -1
  186. package/dist/src/core/verify.d.ts +23 -1
  187. package/dist/src/core/verify.js +20 -2
  188. package/dist/src/core/verify.js.map +1 -1
  189. package/dist/src/daemon/audit.d.ts +80 -5
  190. package/dist/src/daemon/audit.js +126 -6
  191. package/dist/src/daemon/audit.js.map +1 -1
  192. package/dist/src/daemon/daemon.d.ts +174 -2
  193. package/dist/src/daemon/daemon.js +298 -7
  194. package/dist/src/daemon/daemon.js.map +1 -1
  195. package/dist/src/daemon/git-evidence.d.ts +2 -2
  196. package/dist/src/daemon/git-evidence.js +1 -1
  197. package/dist/src/daemon/projection.d.ts +13 -2
  198. package/dist/src/daemon/projection.js +39 -6
  199. package/dist/src/daemon/projection.js.map +1 -1
  200. package/dist/src/mcp/server.d.ts +39 -3
  201. package/dist/src/mcp/server.js +22 -3
  202. package/dist/src/mcp/server.js.map +1 -1
  203. package/dist/src/muse/server.d.ts +16 -0
  204. package/dist/src/muse/server.js +238 -0
  205. package/dist/src/muse/server.js.map +1 -0
  206. package/dist/src/serve/archive.d.ts +166 -0
  207. package/dist/src/serve/archive.js +363 -0
  208. package/dist/src/serve/archive.js.map +1 -0
  209. package/dist/src/serve/arguments.d.ts +93 -0
  210. package/dist/src/serve/arguments.js +228 -0
  211. package/dist/src/serve/arguments.js.map +1 -0
  212. package/dist/src/serve/credentials.d.ts +91 -0
  213. package/dist/src/serve/credentials.js +143 -0
  214. package/dist/src/serve/credentials.js.map +1 -0
  215. package/dist/src/serve/follow.d.ts +86 -0
  216. package/dist/src/serve/follow.js +101 -0
  217. package/dist/src/serve/follow.js.map +1 -0
  218. package/dist/src/serve/hook-thread.d.ts +134 -0
  219. package/dist/src/serve/hook-thread.js +333 -0
  220. package/dist/src/serve/hook-thread.js.map +1 -0
  221. package/dist/src/serve/hook-worker.d.ts +134 -0
  222. package/dist/src/serve/hook-worker.js +173 -0
  223. package/dist/src/serve/hook-worker.js.map +1 -0
  224. package/dist/src/serve/server.d.ts +412 -0
  225. package/dist/src/serve/server.js +1251 -0
  226. package/dist/src/serve/server.js.map +1 -0
  227. package/docs/README-extended.md +768 -0
  228. package/docs/cli-reference.md +2139 -67
  229. package/package.json +4 -2
  230. package/schema/event.schema.json +413 -30
  231. package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
  232. package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
  233. package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
  234. package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
  235. package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
  236. package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
  237. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
  238. package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
  239. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
  240. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
  241. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
  242. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
  243. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
  244. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
  245. package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
  246. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  247. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  248. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  249. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  250. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  251. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  252. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  253. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  254. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  255. package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
  256. package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
  257. package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
  258. package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
  259. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
  260. package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
  261. package/schema/fixtures/event/valid/audit-sampled.json +2 -1
  262. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  263. package/schema/fixtures/event/valid/daemon-identity.json +16 -0
  264. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  265. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  266. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  267. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  268. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  269. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  270. package/schema/policy.schema.json +102 -6
@@ -34,6 +34,24 @@
34
34
  * notify, long-poll, callbacks, failure modes — against a local mock Bot API
35
35
  * server and never touches the real network.
36
36
  *
37
+ * ## Two transports, one update path (APRV-424)
38
+ *
39
+ * An update reaches this channel either because it long-polled for it
40
+ * ({@link TelegramChannel.pollOnce}) or because Telegram posted it to a URL the
41
+ * runtime registered ({@link TelegramChannel.deliverUpdate}, fed by
42
+ * `channels/telegram-webhook.ts`). That is the WHOLE of the difference. Both
43
+ * entry points hand the update to the same {@link TelegramChannel.handleUpdate}
44
+ * and therefore to the same chat check, the same `senderOf`, the same
45
+ * resolution ladder, the same `onDecision` handler and the same
46
+ * annotate-after-decision edit; a decision does not know which door it came
47
+ * through, and there is no second copy of any of it to drift.
48
+ *
49
+ * The two are mutually exclusive per bot, and the Bot API says so first:
50
+ * `getUpdates` is refused outright while a webhook is set.
51
+ * {@link TelegramChannel.claimTransport} refuses the in-process form of the
52
+ * mistake, and the verbs above ask `getWebhookInfo` and the ownership registry
53
+ * about the cross-process one.
54
+ *
37
55
  * ## Config-declared identity — SPEC.md §11
38
56
  *
39
57
  * > Human identity in v0.1 is config-declared (an environment variable or
@@ -201,15 +219,16 @@
201
219
  * process memory and nothing else: no event is appended, no message is edited,
202
220
  * and the log is not opened.
203
221
  */
204
- import { createHash } from "node:crypto";
222
+ import { createHash, randomBytes } from "node:crypto";
205
223
  import { GLOSS_UNVERIFIED_SUFFIX, refusedDecisionLine } from "./contract.js";
206
224
  // APRV-299. The reaction vocabulary and the verdict type, imported for the
207
225
  // reason `core/audit.ts` states where it exports them: they are shown here and
208
226
  // decided nowhere. Nothing in this file branches on a reaction, and SPEC.md
209
227
  // §11.1 invariant 10's guard scans the modules that decide, which this is not.
210
228
  import { REACTIONS } from "../core/audit.js";
211
- import { TELEGRAM_PROMPT_LAYOUT, } from "../core/prompt-layout.js";
229
+ import { DEFAULT_PROMPT_STYLE, TELEGRAM_PROMPT_LAYOUT, } from "../core/prompt-layout.js";
212
230
  import { commandPayloadView, payloadRegionText } from "./payload-view.js";
231
+ import { MINIMAL_DENY_LABEL, renderTelegramMinimal } from "./telegram-minimal.js";
213
232
  // ---------------------------------------------------------------------------
214
233
  // Configuration
215
234
  // ---------------------------------------------------------------------------
@@ -389,6 +408,13 @@ const DEFAULT_MAX_BACKOFF_MS = 30_000;
389
408
  export const TELEGRAM_DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
390
409
  /** Least time between two sweeps. A sweep is O(map); once a minute is plenty. */
391
410
  export const TELEGRAM_SWEEP_INTERVAL_MS = 60_000;
411
+ /**
412
+ * How many refused cards' nonces a channel remembers as dead (APRV-442).
413
+ *
414
+ * A bound on memory and nothing else: a nonce that falls off the end reaches
415
+ * the action-reference fallback, and the gate judges that tap in full.
416
+ */
417
+ export const TELEGRAM_REFUSED_NONCE_CAP = 1024;
392
418
  // ---------------------------------------------------------------------------
393
419
  // Anomalies
394
420
  // ---------------------------------------------------------------------------
@@ -740,6 +766,81 @@ export function renderTelegram(request, heading = TELEGRAM_PROMPT_HEADING, layou
740
766
  : `${payload.truncated ? `--- payload TRUNCATED at render (sha256 ${payload.hash}) — no canonical rendering exists; do not grant on this ---\n` : ""}${payloadRegionText(payload, request.class.value)}`,
741
767
  };
742
768
  }
769
+ /**
770
+ * The settle edit of a minimal card (APRV-489): the outcome, the card's
771
+ * headline, the detail lines, and the collapsed block WHOLE.
772
+ *
773
+ * The edit replaces the only message that holds the canonical rendering, so
774
+ * the rendering is never what gives way to fit: the runtime's own detail lines
775
+ * are shortened instead, with a marker, and the outcome headline always stays.
776
+ * The card was at most 3800 characters with its quote box and
777
+ * deadline line, which the edit drops, so headline plus collapsed block always
778
+ * fit under {@link TELEGRAM_SETTLE_BUDGET} (the card budget is `MINIMAL_MESSAGE_BUDGET` in `telegram-minimal.ts`).
779
+ */
780
+ export function minimalSettleText(outcome, detail, card) {
781
+ const head = [`<b>${escapeHtml(outcome)}</b>`, card.headline];
782
+ // One unit throughout (fix round 2, S3): UTF-16 code units of the HTML,
783
+ // which is what `.length` counts and never less than the parsed text
784
+ // Telegram limits. The cut is taken between grapheme clusters of the
785
+ // UNESCAPED text, so it never splits a surrogate pair, a cluster or an entity.
786
+ const fixed = [...head, card.details].join("\n").length + 1;
787
+ const room = Math.max(0, TELEGRAM_SETTLE_BUDGET - fixed);
788
+ const raw = detail.join("\n");
789
+ let lines = escapeHtml(raw);
790
+ if (lines.length > room)
791
+ lines = shortenHtml(raw, room, SETTLE_SHORTENED_MARK);
792
+ return [...head, ...(lines.length === 0 ? [] : [lines]), card.details].join("\n");
793
+ }
794
+ /** What a shortened run of detail lines ends with. */
795
+ const SETTLE_SHORTENED_MARK = "… (shortened; the log holds the full record)";
796
+ /** Grapheme clusters, for cuts that never split one. */
797
+ const SETTLE_GRAPHEMES = new Intl.Segmenter("en", { granularity: "grapheme" });
798
+ /** `raw` escaped and cut between clusters so the result, marker included, is at most `budget` UTF-16 units. */
799
+ function shortenHtml(raw, budget, marker) {
800
+ const limit = Math.max(0, budget - marker.length);
801
+ let kept = "";
802
+ for (const { segment } of SETTLE_GRAPHEMES.segment(raw)) {
803
+ const escaped = escapeHtml(segment);
804
+ if (kept.length + escaped.length > limit)
805
+ break;
806
+ kept += escaped;
807
+ }
808
+ return `${kept}${marker}`;
809
+ }
810
+ /**
811
+ * The settle edit of last resort (fix round 2, S3): sent when Telegram refused
812
+ * the full settle edit of a minimal card, so a decided card never keeps looking
813
+ * undecided. The outcome, the request's key, the first detail line (who, when,
814
+ * which record) cut to fit, and where the rest is. Always under 1000 units.
815
+ */
816
+ export function shortSettleText(outcome, actionKey, detail) {
817
+ const first = detail[0] ?? "";
818
+ return [
819
+ `<b>${escapeHtml(outcome)}</b>`,
820
+ `<code>${shortenHtml(actionKey, 200, "")}</code>`,
821
+ "",
822
+ ...(first.length === 0 ? [] : [escapeHtml(first).length <= 400 ? escapeHtml(first) : shortenHtml(first, 400, "…")]),
823
+ "The full record is in the approval log (approval log, approval queue).",
824
+ ].join("\n");
825
+ }
826
+ /**
827
+ * Whether a Bot API failure is a refusal of THIS message rather than a passing
828
+ * failure (fix round 2, S2): any 4xx but 429. A 429, a 5xx and a transport
829
+ * failure are retried by the dispatch cycle as before; a refusal never will
830
+ * succeed with the same text, so the minimal card gives way to the technical one.
831
+ */
832
+ export function isMessageRefusal(cause) {
833
+ return (cause instanceof TelegramApiError &&
834
+ cause.status !== null &&
835
+ cause.status >= 400 &&
836
+ cause.status < 500 &&
837
+ cause.status !== 429);
838
+ }
839
+ /**
840
+ * The longest settle edit of a minimal card, in characters of HTML: under
841
+ * Telegram's 4096 with room for a relay's `Agent: <name>` line.
842
+ */
843
+ export const TELEGRAM_SETTLE_BUDGET = 3990;
743
844
  /**
744
845
  * Split `text` so every chunk survives HTML escaping inside the message limit.
745
846
  *
@@ -1311,6 +1412,15 @@ export const TELEGRAM_REVIEW_DENIED = "✗ REVIEWED — DENIED";
1311
1412
  * been recorded.
1312
1413
  */
1313
1414
  export const TELEGRAM_REVIEW_ARMED = "DENY ARMED — nothing is recorded yet";
1415
+ /**
1416
+ * The headline a card wears while a grade is held and no verdict has been
1417
+ * given (APRV-482). A grade is not a verdict: tapping one with nothing armed
1418
+ * records nothing, and the card keeps the grade so the OK or the Deny that
1419
+ * follows records it.
1420
+ */
1421
+ export function telegramReviewGradeHeld(reaction) {
1422
+ return `GRADE ${reaction.toUpperCase()} HELD — nothing is recorded until you tap OK or Deny`;
1423
+ }
1314
1424
  /**
1315
1425
  * What a review tap's single answer says (APRV-302).
1316
1426
  *
@@ -1326,8 +1436,29 @@ export const TELEGRAM_REVIEW_ARMED = "DENY ARMED — nothing is recorded yet";
1326
1436
  export const TELEGRAM_REVIEW_ACK = "Heard — recording your review. The card will say what the log recorded.";
1327
1437
  /** The toast a first Deny tap gets: it says plainly that nothing was written. */
1328
1438
  export const TELEGRAM_REVIEW_ARM_TOAST = "Deny armed — nothing recorded. Tap Deny again to record it, or a reaction to record it with a grade.";
1439
+ /**
1440
+ * The card line and toast for a tap that would finish another account's
1441
+ * half-finished review (PR #614 refutation F3): an armed Deny or a held grade
1442
+ * belongs to the account that tapped it, and nothing is recorded. A held grade
1443
+ * is replaced by the tapper's own grade (a grade finishes nothing), which the
1444
+ * line says (PR #614 recheck NF-1, minor). Under 200 characters, the Bot API's
1445
+ * limit for a callback answer.
1446
+ */
1447
+ export const TELEGRAM_REVIEW_OTHER_SENDER = "Another account's unfinished review (armed Deny or held grade) is on this card. Nothing was recorded. Tap your own grade to replace a held one; an armed Deny is theirs to finish.";
1329
1448
  /** The toast a reaction that needs the human's own words gets. */
1330
1449
  export const TELEGRAM_REVIEW_NOTE_TOAST = "Heard — reply to the prompt with why. Nothing is recorded until it arrives.";
1450
+ /**
1451
+ * The card line for a note prompt that could not be sent (APRV-492). The toast
1452
+ * went out before the send and told the human to reply to a prompt, so the
1453
+ * card says the prompt is not coming and how to be asked again: a grade tap is
1454
+ * what the relay licenses a prompt for. With Deny armed the grade alone asks;
1455
+ * otherwise the grade is held and OK asks.
1456
+ */
1457
+ export function telegramReviewNoteUnasked(verdict) {
1458
+ return verdict === "denied"
1459
+ ? "The prompt asking why could not be sent, so nothing was recorded. Tap your grade again to be asked."
1460
+ : "The prompt asking why could not be sent, so nothing was recorded. Tap your grade again, then OK, to be asked.";
1461
+ }
1331
1462
  /**
1332
1463
  * What the ForceReply prompt asks for.
1333
1464
  *
@@ -1420,6 +1551,255 @@ function trimNotice(text) {
1420
1551
  ? text
1421
1552
  : `${text.slice(0, REVIEW_NOTICE_MAX)}… (cut to fit one message; the whole refusal is on the listener's stderr)`;
1422
1553
  }
1554
+ /**
1555
+ * How much payload, in ESCAPED characters, a review card carries whole
1556
+ * (APRV-480).
1557
+ *
1558
+ * A card is one message edited in place, so it cannot spill the payload over
1559
+ * several messages the way a prompt does: the edit that settles it replaces one
1560
+ * message, and a second message holding the rest of the bytes would outlive the
1561
+ * card it belonged to. So the bytes are shown whole or not at all. Never cut:
1562
+ * a review that recorded a payload hash over bytes the reviewer saw half of
1563
+ * would claim more than they read. This is the CEILING; the budget a given
1564
+ * card gets is {@link reviewPayloadBudget}, which is this or the headroom the
1565
+ * card's own rows leave under {@link TELEGRAM_MAX_MESSAGE_CHARS}, whichever is
1566
+ * smaller (PR #614 refutation F4).
1567
+ */
1568
+ export const REVIEW_PAYLOAD_BUDGET = 2000;
1569
+ /**
1570
+ * The longest agent-written summary a review card shows, in characters (PR
1571
+ * #614 refutation F4). The summary is the requesting party's own claim and its
1572
+ * schema sets no length, so before this cap an agent could write a summary
1573
+ * that pushed the card past Telegram's limit, which failed the send and held
1574
+ * the review queue. Longer text is cut with a marker saying how much is not
1575
+ * shown; the registration record keeps it whole.
1576
+ */
1577
+ export const REVIEW_SUMMARY_MAX = 400;
1578
+ /**
1579
+ * How much of each row a review card shows, in characters (PR #614 refutation
1580
+ * F4), tried in order until the card fits one message with the longest
1581
+ * heading, the hash-only payload region and the largest notice a tap can add.
1582
+ * `row` caps the key, class, task, command breakdown, gloss, run time and
1583
+ * outcome; `summary` caps the agent's summary (never above
1584
+ * {@link REVIEW_SUMMARY_MAX}); `origin` caps an author or source label. An
1585
+ * ordinary card fits at the first level and shows its rows as they are; only a
1586
+ * card whose rows are pathologically long is cut further, and the last level
1587
+ * fits whatever the rows hold (pinned by the worst-case test in
1588
+ * `tests/channels-telegram.test.ts`).
1589
+ */
1590
+ const REVIEW_CAP_LEVELS = [
1591
+ { row: 300, summary: REVIEW_SUMMARY_MAX, origin: 60 },
1592
+ { row: 160, summary: 240, origin: 40 },
1593
+ { row: 80, summary: 120, origin: 30 },
1594
+ { row: 40, summary: 60, origin: 20 },
1595
+ ];
1596
+ /** The longest notice headline a review card shows (F4). Headlines are constants. */
1597
+ const REVIEW_NOTICE_HEADLINE_MAX = 100;
1598
+ /**
1599
+ * The most characters a review card's notice region (a refusal, or the arming
1600
+ * line) shows, all its lines together (PR #614 refutation F4). Each line was
1601
+ * already cut at {@link REVIEW_NOTICE_MAX}; the region had no total, so a
1602
+ * refusal with several long lines could take the card over the limit and make
1603
+ * the redraw that carries it fail. The whole refusal is on the listener's
1604
+ * stderr.
1605
+ */
1606
+ export const REVIEW_NOTICE_REGION_MAX = 600;
1607
+ /** The most characters a settled card's detail lines show together (F4). */
1608
+ const REVIEW_SETTLED_REGION_MAX = 3000;
1609
+ /**
1610
+ * The visible length of HTML this channel wrote: tags removed and each entity
1611
+ * {@link escapeHtml} produces counted as the one character it stands for.
1612
+ * Telegram measures its limit after entity parsing, so this is the number the
1613
+ * limit applies to.
1614
+ */
1615
+ export function telegramVisibleLength(html) {
1616
+ return html.replace(/<[^>]*>/gu, "").replace(/&(?:amp|lt|gt);/gu, "x").length;
1617
+ }
1618
+ /** `text` cut at `max` characters with a marker saying how much is not shown. */
1619
+ function capReviewText(text, max) {
1620
+ if (text.length <= max)
1621
+ return text;
1622
+ let cut = text.slice(0, max);
1623
+ const last = cut.charCodeAt(cut.length - 1);
1624
+ if (last >= 0xd800 && last <= 0xdbff)
1625
+ cut = cut.slice(0, -1);
1626
+ return `${cut}${reviewCutMarker(text.length - cut.length)}`;
1627
+ }
1628
+ /** The marker a cut row ends with. */
1629
+ function reviewCutMarker(hidden) {
1630
+ return `… [+${String(hidden)} chars not shown]`;
1631
+ }
1632
+ /** The longest {@link reviewCutMarker}: a JavaScript string is under 2^53 long. */
1633
+ const REVIEW_CUT_MARKER_MAX = reviewCutMarker(Number.MAX_SAFE_INTEGER).length;
1634
+ /** The line a region ends with when lines were left out. */
1635
+ const REVIEW_REGION_OVERFLOW = "… [more on the listener's stderr]";
1636
+ /**
1637
+ * Lines cut so that together, counting the newline before each, they show at
1638
+ * most `max` characters plus one cut marker and the overflow line.
1639
+ */
1640
+ function capReviewRegion(lines, max) {
1641
+ const out = [];
1642
+ let used = 0;
1643
+ for (const entry of lines) {
1644
+ const room = max - used - 1;
1645
+ if (room <= 0) {
1646
+ out.push(REVIEW_REGION_OVERFLOW);
1647
+ break;
1648
+ }
1649
+ const shown = capReviewText(entry, room);
1650
+ out.push(shown);
1651
+ used += shown.length + 1;
1652
+ }
1653
+ return out;
1654
+ }
1655
+ /**
1656
+ * The longest payload text, in characters, a review card will even try to
1657
+ * render (APRV-480 refutation: review-delivery resource cap).
1658
+ *
1659
+ * Supervised payloads carry no size cap of their own (`PROPOSE_PAYLOAD_MAX_BYTES`
1660
+ * binds proposals only), and the party under review writes them. Without this
1661
+ * bound every render of a card, every redraw after a tap, and every tap's
1662
+ * {@link reviewPayloadView} would run the canonical renderer and HTML escaping
1663
+ * over the whole payload only to conclude "too long", so an agent could make
1664
+ * each review gesture cost work proportional to bytes it chose. Text past this
1665
+ * length is `too-long` without being rendered at all; the card builder
1666
+ * (`cli/audit-card.ts`) keeps no more than this much text in the card it holds.
1667
+ * Eight times the card budget, because a canonical rendering is never that much
1668
+ * shorter than the JSON it renders, and erring here only ever shows the hash.
1669
+ */
1670
+ export const REVIEW_RENDER_INPUT_MAX = REVIEW_PAYLOAD_BUDGET * 8;
1671
+ /** The heading over a payload the card shows whole (APRV-480). */
1672
+ export const TELEGRAM_REVIEW_PAYLOAD_BYTES = "PAYLOAD — the bytes that ran, shown whole; this review covers them";
1673
+ /** The heading over a payload the card names by hash only (APRV-480). */
1674
+ export const TELEGRAM_REVIEW_PAYLOAD_HASH_ONLY = "PAYLOAD — NOT SHOWN, hash only; this review does not cover the bytes";
1675
+ /** The heading on a card whose execution recorded no payload binding (APRV-480). */
1676
+ export const TELEGRAM_REVIEW_PAYLOAD_NONE = "PAYLOAD — none recorded; the execution bound to no payload hash";
1677
+ export function reviewPayloadView(card) {
1678
+ const rendering = card.fields.fullPayload.value;
1679
+ const bound = card.fields.payload_hash?.value ?? rendering?.hash ?? null;
1680
+ if (rendering !== null && (bound === null || bound === rendering.hash)) {
1681
+ // Bounded before any rendering work: see REVIEW_RENDER_INPUT_MAX.
1682
+ if (rendering.truncated || rendering.text.length > REVIEW_RENDER_INPUT_MAX) {
1683
+ return { kind: "hash", hash: rendering.hash, reason: "too-long" };
1684
+ }
1685
+ const text = payloadRegionText(rendering, card.fields.class.value);
1686
+ if (escapeHtml(text).length <= reviewPayloadBudget(card)) {
1687
+ return { kind: "bytes", hash: rendering.hash, text };
1688
+ }
1689
+ return { kind: "hash", hash: rendering.hash, reason: "too-long" };
1690
+ }
1691
+ if (bound === null)
1692
+ return { kind: "none" };
1693
+ return { kind: "hash", hash: bound, reason: "unavailable" };
1694
+ }
1695
+ /** The payload region of a review card, as HTML lines (APRV-480). */
1696
+ function reviewPayloadLines(view) {
1697
+ switch (view.kind) {
1698
+ case "bytes":
1699
+ return [`<b>${escapeHtml(TELEGRAM_REVIEW_PAYLOAD_BYTES)}</b>`, `<pre>${escapeHtml(view.text)}</pre>`];
1700
+ case "hash":
1701
+ return [
1702
+ `<b>${escapeHtml(TELEGRAM_REVIEW_PAYLOAD_HASH_ONLY)}</b>`,
1703
+ escapeHtml(view.reason === "too-long"
1704
+ ? `The bytes are longer than one card carries, so none of them are shown. sha256 ${view.hash}`
1705
+ : `This runtime does not hold the bytes the execution bound to. sha256 ${view.hash}`),
1706
+ ];
1707
+ default:
1708
+ return [`<b>${escapeHtml(TELEGRAM_REVIEW_PAYLOAD_NONE)}</b>`];
1709
+ }
1710
+ }
1711
+ /**
1712
+ * A review card's HTML lines around the payload region, every row cut to its
1713
+ * cap (PR #614 refutation F4): `top` is the key and the computed rows, `bottom`
1714
+ * the claimed ones. Pure over the card, so the payload budget computed from it
1715
+ * and the card drawn from it cannot disagree.
1716
+ */
1717
+ function reviewCardFrame(card) {
1718
+ let frame = reviewCardFrameAt(card, REVIEW_CAP_LEVELS[0]);
1719
+ for (const level of REVIEW_CAP_LEVELS) {
1720
+ frame = reviewCardFrameAt(card, level);
1721
+ if (reviewFixedLength(frame) + REVIEW_HASH_REGION_MAX <= TELEGRAM_MAX_MESSAGE_CHARS)
1722
+ break;
1723
+ }
1724
+ return frame;
1725
+ }
1726
+ function reviewCardFrameAt(card, caps) {
1727
+ const computedLines = [];
1728
+ const claimedLines = [];
1729
+ for (const row of REVIEW_CARD_ROWS) {
1730
+ const candidate = reviewRow(card.fields, row);
1731
+ if (candidate === null)
1732
+ continue;
1733
+ if (candidate.line.kind === "computed")
1734
+ computedLines.push(candidate.line);
1735
+ else
1736
+ claimedLines.push(candidate.line);
1737
+ }
1738
+ computedLines.push(line("ran_at", card.ranAt, "ran at", card.ranAt.value));
1739
+ computedLines.push(line("verdict", card.verdict, "verdict", card.verdict.value));
1740
+ const render = (entry) => `• <b>${escapeHtml(entry.label)}:</b> ${escapeHtml(capReviewText(entry.text, entry.field === "summary" ? caps.summary : caps.row))} <i>(${escapeHtml(capReviewText(entry.origin, caps.origin))})</i>`;
1741
+ const author = capReviewText(originOf(card.fields.summary), caps.origin);
1742
+ return {
1743
+ top: [
1744
+ `<code>${escapeHtml(capReviewText(card.fields.action_key.value, caps.row))}</code>`,
1745
+ "",
1746
+ "<b>COMPUTED — derived by the runtime from the log, the policy and the payload bytes</b>",
1747
+ ...computedLines.map(render),
1748
+ "",
1749
+ ],
1750
+ bottom: [
1751
+ "",
1752
+ `<b>CLAIMED — authored by ${escapeHtml(author)}, NOT verified by the runtime</b>`,
1753
+ ...claimedLines.map(render),
1754
+ ],
1755
+ };
1756
+ }
1757
+ /** The longest heading a live card can wear: Deny armed and a grade held. */
1758
+ const REVIEW_HEADING_MAX = Math.max(...REACTIONS.map((reaction) => [TELEGRAM_REVIEW_HEADING, TELEGRAM_REVIEW_ARMED, telegramReviewGradeHeld(reaction)].join(" — ")
1759
+ .length));
1760
+ /**
1761
+ * What a notice can add to a card at most: the blank line and the headline,
1762
+ * each after a newline, then the region (newlines counted), one cut marker and
1763
+ * the overflow line after its newline.
1764
+ */
1765
+ const REVIEW_NOTICE_RESERVE = 2 +
1766
+ REVIEW_NOTICE_HEADLINE_MAX +
1767
+ REVIEW_CUT_MARKER_MAX +
1768
+ REVIEW_NOTICE_REGION_MAX +
1769
+ REVIEW_CUT_MARKER_MAX +
1770
+ 1 +
1771
+ REVIEW_REGION_OVERFLOW.length;
1772
+ /** The longest hash-only or none payload region, with the newlines around it. */
1773
+ const REVIEW_HASH_REGION_MAX = Math.max(...[
1774
+ { kind: "hash", hash: "0".repeat(64), reason: "too-long" },
1775
+ { kind: "hash", hash: "0".repeat(64), reason: "unavailable" },
1776
+ { kind: "none" },
1777
+ ].map((view) => telegramVisibleLength(reviewPayloadLines(view).join("\n")) + 2));
1778
+ /**
1779
+ * Everything a live card can hold except the payload region: the longest
1780
+ * heading, the frame, the largest notice, and the newlines joining them.
1781
+ */
1782
+ function reviewFixedLength(frame) {
1783
+ return (REVIEW_HEADING_MAX +
1784
+ 1 +
1785
+ telegramVisibleLength([...frame.top, ...frame.bottom].join("\n")) +
1786
+ REVIEW_NOTICE_RESERVE);
1787
+ }
1788
+ /**
1789
+ * How many escaped characters of payload this card can show whole (PR #614
1790
+ * refutation F4): {@link REVIEW_PAYLOAD_BUDGET}, or the room the card's own
1791
+ * rows leave under {@link TELEGRAM_MAX_MESSAGE_CHARS} after the longest heading,
1792
+ * the payload heading and the largest notice a tap can add, whichever is
1793
+ * smaller. Pure over the card, so the view a card is drawn with and the view a
1794
+ * tap records are the same view.
1795
+ */
1796
+ export function reviewPayloadBudget(card) {
1797
+ const fixed = reviewFixedLength(reviewCardFrame(card)) +
1798
+ // The bytes heading and the newlines around the heading and the payload.
1799
+ TELEGRAM_REVIEW_PAYLOAD_BYTES.length +
1800
+ 3;
1801
+ return Math.max(0, Math.min(REVIEW_PAYLOAD_BUDGET, TELEGRAM_MAX_MESSAGE_CHARS - fixed));
1802
+ }
1423
1803
  /**
1424
1804
  * The card's message: the rows, whatever notice the last tap produced, and the
1425
1805
  * keyboard.
@@ -1432,13 +1812,15 @@ function trimNotice(text) {
1432
1812
  * every card said the same thing to a reader who had already read them once, and
1433
1813
  * pushed the rows a review is actually about off the first screen.
1434
1814
  *
1435
- * Pure. Two things it deliberately does NOT carry, and both are the same rule
1436
- * read twice: no payload region, and no approve button. SPEC.md §10.3 requires
1437
- * the canonical rendering in front of an approver before a DECISION is
1438
- * collected, and this collects none — the action ran, the review says only what
1439
- * a person thought of it, and a card that offered an approve would be
1440
- * presenting a settled fact as a live authorization. A sample is never
1441
- * delivered as an approval request and never accepts a token.
1815
+ * Pure. It carries no approve button: the action ran, and a card that offered
1816
+ * an approve would be presenting a settled fact as a live authorization. A
1817
+ * sample is never delivered as an approval request and never accepts a token.
1818
+ *
1819
+ * It DOES carry a payload region since APRV-480, between the computed rows and
1820
+ * the claimed ones, exactly where a prompt puts its own. Under supervised-retro
1821
+ * the review is the individual approval, so the reviewer reads the bytes that
1822
+ * ran, or is told plainly that the card shows only their hash and why
1823
+ * ({@link reviewPayloadView}). The heading says which, every time.
1442
1824
  */
1443
1825
  export function renderReviewCard(state) {
1444
1826
  const card = state.card;
@@ -1446,41 +1828,27 @@ export function renderReviewCard(state) {
1446
1828
  if (state.settled !== null) {
1447
1829
  return {
1448
1830
  text: [
1449
- `<b>${escapeHtml(state.settled.headline)}</b>`,
1450
- `<code>${escapeHtml(key)}</code>`,
1831
+ `<b>${escapeHtml(capReviewText(state.settled.headline, REVIEW_NOTICE_HEADLINE_MAX))}</b>`,
1832
+ `<code>${escapeHtml(capReviewText(key, REVIEW_CAP_LEVELS[0].row))}</code>`,
1451
1833
  "",
1452
- ...state.settled.detail.map((entry) => escapeHtml(trimNotice(entry))),
1834
+ ...capReviewRegion(state.settled.detail.map(trimNotice), REVIEW_SETTLED_REGION_MAX).map((entry) => escapeHtml(entry)),
1453
1835
  ].join("\n"),
1454
1836
  keyboard: null,
1455
1837
  };
1456
1838
  }
1457
- const computedLines = [];
1458
- const claimedLines = [];
1459
- for (const row of REVIEW_CARD_ROWS) {
1460
- const candidate = reviewRow(card.fields, row);
1461
- if (candidate === null)
1462
- continue;
1463
- if (candidate.line.kind === "computed")
1464
- computedLines.push(candidate.line);
1465
- else
1466
- claimedLines.push(candidate.line);
1467
- }
1468
- computedLines.push(line("ran_at", card.ranAt, "ran at", card.ranAt.value));
1469
- computedLines.push(line("verdict", card.verdict, "verdict", card.verdict.value));
1470
- const render = (entry) => `• <b>${escapeHtml(entry.label)}:</b> ${escapeHtml(entry.text)} <i>(${escapeHtml(entry.origin)})</i>`;
1471
- const author = originOf(card.fields.summary);
1839
+ const frame = reviewCardFrame(card);
1472
1840
  const lines = [
1473
- `<b>${escapeHtml(state.denyArmed ? `${TELEGRAM_REVIEW_HEADING} — ${TELEGRAM_REVIEW_ARMED}` : TELEGRAM_REVIEW_HEADING)}</b>`,
1474
- `<code>${escapeHtml(key)}</code>`,
1475
- "",
1476
- "<b>COMPUTED — derived by the runtime from the log, the policy and the payload bytes</b>",
1477
- ...computedLines.map(render),
1478
- "",
1479
- `<b>CLAIMED — authored by ${escapeHtml(author)}, NOT verified by the runtime</b>`,
1480
- ...claimedLines.map(render),
1841
+ `<b>${escapeHtml([
1842
+ TELEGRAM_REVIEW_HEADING,
1843
+ ...(state.denyArmed ? [TELEGRAM_REVIEW_ARMED] : []),
1844
+ ...(state.heldReaction === null ? [] : [telegramReviewGradeHeld(state.heldReaction)]),
1845
+ ].join(" — "))}</b>`,
1846
+ ...frame.top,
1847
+ ...reviewPayloadLines(reviewPayloadView(card)),
1848
+ ...frame.bottom,
1481
1849
  ];
1482
1850
  if (state.notice !== null) {
1483
- lines.push("", `<b>${escapeHtml(state.notice.headline)}</b>`, ...state.notice.lines.map((entry) => escapeHtml(trimNotice(entry))));
1851
+ lines.push("", `<b>${escapeHtml(capReviewText(state.notice.headline, REVIEW_NOTICE_HEADLINE_MAX))}</b>`, ...capReviewRegion(state.notice.lines.map(trimNotice), REVIEW_NOTICE_REGION_MAX).map((entry) => escapeHtml(entry)));
1484
1852
  }
1485
1853
  const button = (choice) => ({
1486
1854
  text: REVIEW_BUTTON_LABELS[choice],
@@ -1504,6 +1872,7 @@ export class TelegramApiError extends Error {
1504
1872
  method;
1505
1873
  status;
1506
1874
  description;
1875
+ retryAfterSeconds;
1507
1876
  constructor(message, method,
1508
1877
  /**
1509
1878
  * The HTTP status, when the failure was an HTTP one. `null` for a transport
@@ -1516,14 +1885,84 @@ export class TelegramApiError extends Error {
1516
1885
  * error body carried one. `null` when the body was absent, unreadable, not
1517
1886
  * JSON, or carried no description.
1518
1887
  */
1519
- description = null) {
1888
+ description = null,
1889
+ /**
1890
+ * The Bot API's `parameters.retry_after`, in seconds, when the failure
1891
+ * carried one (PR #614 recheck NF-2). Telegram sends it with 429 "Too Many
1892
+ * Requests" and means it: a send before it elapses is refused again.
1893
+ * `null` when the body named none.
1894
+ */
1895
+ retryAfterSeconds = null) {
1520
1896
  super(message);
1521
1897
  this.method = method;
1522
1898
  this.status = status;
1523
1899
  this.description = description;
1900
+ this.retryAfterSeconds = retryAfterSeconds;
1524
1901
  this.name = "TelegramApiError";
1525
1902
  }
1526
1903
  }
1904
+ /**
1905
+ * The Bot API refusals that are a fact about the message itself (PR #614
1906
+ * recheck NF-2). Each is a 400 whose `description` says the text, its markup
1907
+ * or its keyboard is unacceptable, so resending the same card meets the same
1908
+ * refusal however long the sender waits:
1909
+ *
1910
+ * - `message is too long`: the text is over 4096 characters after entities;
1911
+ * - `can't parse entities`: the HTML the card was drawn with does not parse;
1912
+ * - `message text is empty` / `text must be non-empty`: nothing to send;
1913
+ * - `reply markup is too long`: the inline keyboard is over its byte limit;
1914
+ * - `BUTTON_DATA_INVALID`: a button's callback data is over 64 bytes or empty;
1915
+ * - `ENTITIES_TOO_LONG` / `entities too long`: too many formatting entities.
1916
+ *
1917
+ * Deliberately absent, because the card cannot cause them and a retry can
1918
+ * clear them: `chat not found`, `bot was blocked`, `not enough rights` (the
1919
+ * chat's state, which a human repairs), every 429, every 5xx, every timeout and
1920
+ * network failure.
1921
+ */
1922
+ const TELEGRAM_DETERMINISTIC_SEND_REFUSALS = [
1923
+ /message is too long/iu,
1924
+ /can't parse entities/iu,
1925
+ /message text is empty|text must be non-empty/iu,
1926
+ /reply markup is too long/iu,
1927
+ /BUTTON_DATA_INVALID/u,
1928
+ /ENTITIES_TOO_LONG|entities too long/iu,
1929
+ ];
1930
+ /**
1931
+ * Whether a failed send is the Bot API refusing the message itself, so that
1932
+ * sending the same message again can only fail again (PR #614 recheck NF-2).
1933
+ *
1934
+ * Only an HTTP 400 whose description matches
1935
+ * {@link TELEGRAM_DETERMINISTIC_SEND_REFUSALS} qualifies. Anything else, an
1936
+ * error that is not a {@link TelegramApiError} included, is treated as
1937
+ * transient, which is the direction that keeps a card in front of the
1938
+ * approver: a transient failure misread as deterministic hides a sample from
1939
+ * the phone, and the converse costs a bounded number of retries.
1940
+ */
1941
+ export function isDeterministicSendRefusal(cause) {
1942
+ return (cause instanceof TelegramApiError &&
1943
+ cause.status === 400 &&
1944
+ cause.description !== null &&
1945
+ TELEGRAM_DETERMINISTIC_SEND_REFUSALS.some((pattern) => pattern.test(cause.description)));
1946
+ }
1947
+ /**
1948
+ * The wait, in milliseconds, a failed call's `retry_after` asks for, or `null`
1949
+ * when it named none (PR #614 recheck NF-2).
1950
+ */
1951
+ export function retryAfterMsOf(cause) {
1952
+ if (!(cause instanceof TelegramApiError) || cause.retryAfterSeconds === null)
1953
+ return null;
1954
+ return cause.retryAfterSeconds * 1000;
1955
+ }
1956
+ /** `parameters.retry_after` from a parsed Bot API body, when it is a positive integer. */
1957
+ function retryAfterFrom(parsed) {
1958
+ if (parsed === null || typeof parsed !== "object")
1959
+ return null;
1960
+ const parameters = parsed["parameters"];
1961
+ if (parameters === null || typeof parameters !== "object")
1962
+ return null;
1963
+ const value = parameters["retry_after"];
1964
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
1965
+ }
1527
1966
  /**
1528
1967
  * Telegram's wording for "that edit would have changed nothing" (APRV-277).
1529
1968
  *
@@ -1572,6 +2011,37 @@ export function isPollConflict(cause) {
1572
2011
  cause.method === "getUpdates" &&
1573
2012
  (cause.status === 409 || (cause.description !== null && TELEGRAM_POLL_CONFLICT.test(cause.description))));
1574
2013
  }
2014
+ /** A fresh, empty batch report. One shape for both transports (APRV-424). */
2015
+ function emptyPollResult() {
2016
+ return { updates: 0, outcomes: [], ignored: [], commands: [], reviews: [], refused: [] };
2017
+ }
2018
+ /**
2019
+ * Why a transport claim was refused. Frozen, per SPEC §11.1 invariant 6.
2020
+ *
2021
+ * One code, because there is one fact: this channel is already receiving
2022
+ * updates by the other transport.
2023
+ */
2024
+ export const TELEGRAM_TRANSPORT_REFUSAL_CODES = ["transport-conflict"];
2025
+ /**
2026
+ * The in-process half of "long-poll and webhook are mutually exclusive"
2027
+ * (APRV-424).
2028
+ *
2029
+ * It is a belt on the braces and says so. The exclusion that matters is per
2030
+ * BOT and lives above this class, where a process can ask the Bot API
2031
+ * (`getWebhookInfo`) and the per-machine ownership registry who else is
2032
+ * receiving this bot's updates; a channel object knows only about itself.
2033
+ * What this rules out is the one failure a caller could cause here and
2034
+ * nowhere else: feeding one channel instance from both a poll loop and an
2035
+ * HTTP handler, where `handleUpdate`'s single ack slot and the delivery maps
2036
+ * would be written by two arrivals nobody ordered.
2037
+ */
2038
+ export class TelegramTransportError extends Error {
2039
+ code = "transport-conflict";
2040
+ constructor(message) {
2041
+ super(message);
2042
+ this.name = "TelegramTransportError";
2043
+ }
2044
+ }
1575
2045
  export class TelegramChannel {
1576
2046
  name = "telegram";
1577
2047
  token;
@@ -1591,6 +2061,12 @@ export class TelegramChannel {
1591
2061
  describeAction;
1592
2062
  /** The policy's row layout for this channel (APRV-218). Read-only, and pure input to the renderer. */
1593
2063
  layout;
2064
+ /** The policy's prompt style for this channel (APRV-489). */
2065
+ promptStyle;
2066
+ /** The policy's `say` declarations (APRV-489). Pure input to the minimal renderer. */
2067
+ say;
2068
+ /** The per-delivery resolver (fix round 2, S4), or null for the fixed values above. */
2069
+ promptFor;
1594
2070
  /** When {@link sweep} last ran, so the poll loop can call it every cycle. */
1595
2071
  lastSweepMs = Number.NEGATIVE_INFINITY;
1596
2072
  /**
@@ -1600,6 +2076,25 @@ export class TelegramChannel {
1600
2076
  */
1601
2077
  ack = null;
1602
2078
  handler = null;
2079
+ /**
2080
+ * APRV-196's stale-copy fallback, on or off (APRV-456). Fixed at
2081
+ * construction from {@link TelegramConfig.staleCopy}; nothing an update
2082
+ * carries can change it.
2083
+ */
2084
+ staleCopy;
2085
+ /**
2086
+ * Where a `nonce-not-issued` refusal is recorded (APRV-456). The runtime's,
2087
+ * like every other handler here; absent, the refusal still happens and only
2088
+ * the record is missing.
2089
+ */
2090
+ refusedDecisionHandler = null;
2091
+ /**
2092
+ * Nonces already refused `nonce-not-issued` (APRV-456), oldest first, capped
2093
+ * at {@link TELEGRAM_REFUSED_NONCE_CAP}. A redelivered or replayed tap on the
2094
+ * same button is refused again with the same toast and records nothing, so
2095
+ * one button costs the log at most one record.
2096
+ */
2097
+ unissuedRefused = new Set();
1603
2098
  /**
1604
2099
  * What to do with a bot command (APRV-216). Absent unless the runtime asked
1605
2100
  * for commands, and its absence is what keeps `message` out of
@@ -1647,11 +2142,60 @@ export class TelegramChannel {
1647
2142
  digests = new Map();
1648
2143
  /** "All" nonce -> the digest message it was issued for. */
1649
2144
  allNonces = new Map();
2145
+ /**
2146
+ * Action keys whose card this process disarmed on a tap the gate did NOT
2147
+ * settle (APRV-442): a refusal (`sender-unmapped`, a budget, an unattested
2148
+ * policy, anything `ok: false`) or a handler that threw. Drained by
2149
+ * {@link takeReleased}.
2150
+ *
2151
+ * The disarm is right and stays, and {@link refusedNonces} keeps it final:
2152
+ * the refused card's bytes resolve to nothing, not even through the
2153
+ * action-reference fallback to the card that replaces it. What the disarm
2154
+ * must not do is leave the listener believing the REQUEST is on the
2155
+ * approver's phone, which is what `DispatchState.delivered` goes on saying
2156
+ * until something tells it otherwise. This set is that something: it names
2157
+ * a card, never an outcome, and the listener re-derives from the verified
2158
+ * log whether the request is still pending before it offers a new card.
2159
+ * Nothing here decides, and losing it to a restart costs nothing, because a
2160
+ * restarted listener re-sends everything pending anyway.
2161
+ */
2162
+ released = new Set();
2163
+ /**
2164
+ * Nonces of cards a refused or failed tap disarmed (APRV-442), oldest first,
2165
+ * capped at {@link TELEGRAM_REFUSED_NONCE_CAP}.
2166
+ *
2167
+ * Without this, a released request's fresh card would give the refused
2168
+ * card's bytes somewhere to go: the nonce misses, the action reference
2169
+ * resolves to the new live card, and a Telegram redelivery of the refused
2170
+ * tap (a webhook retry) or a client replaying the removed buttons would be
2171
+ * refused again, append again, disarm the new card and cause another
2172
+ * re-send, once per replay. A nonce in this set never takes the fallback, so
2173
+ * a refused card stays exactly as dead as it was before APRV-442 and only
2174
+ * the REQUEST is offered again. Forgetting an entry past the cap degrades to
2175
+ * the fallback, whose tap the gate still judges in full: volume, never a
2176
+ * decision.
2177
+ */
2178
+ refusedNonces = new Set();
1650
2179
  rendered = [];
1651
2180
  offset = 0;
1652
2181
  counter = 0;
1653
2182
  stopped = false;
1654
2183
  inFlight = null;
2184
+ /**
2185
+ * The transport this channel has started receiving updates by, or `null`
2186
+ * before the first one (APRV-424). Sticky for the life of the object: a
2187
+ * process that stopped polling has not stopped being a poller, and the
2188
+ * question this answers is which arrival path owns the maps below.
2189
+ */
2190
+ transport = null;
2191
+ /**
2192
+ * Everything {@link redact} scrubs out of anything that leaves this class.
2193
+ *
2194
+ * The bot token from construction, plus any secret a later call was handed:
2195
+ * {@link registerWebhook} adds the `secret_token` before it sends it, so a
2196
+ * Bot API error quoting the request cannot put it on an operator's terminal.
2197
+ */
2198
+ secrets;
1655
2199
  counters = {
1656
2200
  notified: 0,
1657
2201
  updates: 0,
@@ -1666,11 +2210,14 @@ export class TelegramChannel {
1666
2210
  "unknown-command": 0,
1667
2211
  },
1668
2212
  staleCopyDecisions: 0,
2213
+ staleCopyRefusals: 0,
1669
2214
  commands: 0,
1670
2215
  reviews: 0,
1671
2216
  };
1672
2217
  constructor(config) {
1673
2218
  this.token = config.token;
2219
+ this.secrets =
2220
+ config.token.length === 0 ? [] : [{ value: config.token, label: "<token redacted>" }];
1674
2221
  this.chatId = String(config.chatId);
1675
2222
  this.apiBase = (config.apiBase ?? TELEGRAM_DEFAULT_API_BASE).replace(/\/+$/u, "");
1676
2223
  this.fetchImpl = config.fetch ?? globalThis.fetch;
@@ -1687,11 +2234,22 @@ export class TelegramChannel {
1687
2234
  this.now = config.now ?? (() => Date.now());
1688
2235
  this.describeAction = config.describeAction ?? null;
1689
2236
  this.layout = config.layout ?? TELEGRAM_PROMPT_LAYOUT;
2237
+ this.promptStyle = config.promptStyle ?? DEFAULT_PROMPT_STYLE;
2238
+ this.say = config.say ?? {};
2239
+ this.promptFor = config.promptFor ?? null;
2240
+ // APRV-456. Only an explicit `true` or an absent key leaves the fallback on;
2241
+ // anything else a caller passes turns it off, the stricter direction.
2242
+ this.staleCopy = config.staleCopy === undefined || config.staleCopy === true;
1690
2243
  this.makeNonce =
1691
2244
  config.nonce ??
1692
2245
  (() => {
1693
2246
  this.counter += 1;
1694
- return `${this.counter.toString(36)}${Math.random().toString(36).slice(2, 8)}`;
2247
+ // APRV-456 refutation S2. On a relayed channel the nonce is the only
2248
+ // thing binding a button to this process, so it comes from the CSPRNG
2249
+ // (72 bits) rather than `Math.random`, whose state a reader of a few old
2250
+ // buttons could in principle recover. base64url carries no `:`, and the
2251
+ // whole `<verb>:<nonce>:<ref>` stays well inside the 64-byte cap.
2252
+ return `${this.counter.toString(36)}${randomBytes(9).toString("base64url")}`;
1695
2253
  });
1696
2254
  }
1697
2255
  // -------------------------------------------------------------------------
@@ -1700,6 +2258,25 @@ export class TelegramChannel {
1700
2258
  onDecision(handler) {
1701
2259
  this.handler = handler;
1702
2260
  }
2261
+ /**
2262
+ * Register where a tap this channel refused on its own is recorded
2263
+ * (APRV-456).
2264
+ *
2265
+ * Separate from {@link onDecision} on purpose: that handler's job is to call
2266
+ * the gate, and a `nonce-not-issued` tap must never reach the gate. The
2267
+ * runtime wires this to an audit-tier append that grants nothing.
2268
+ */
2269
+ onRefusedDecision(handler) {
2270
+ this.refusedDecisionHandler = handler;
2271
+ }
2272
+ /**
2273
+ * Whether APRV-196's stale-copy fallback is on (APRV-456). Read by the
2274
+ * listener for the restart banner, which must not promise that earlier
2275
+ * copies still decide when they do not.
2276
+ */
2277
+ staleCopyEnabled() {
2278
+ return this.staleCopy;
2279
+ }
1703
2280
  /**
1704
2281
  * Register what to do with a checkpoint tap (APRV-257).
1705
2282
  *
@@ -1770,8 +2347,9 @@ export class TelegramChannel {
1770
2347
  * A unit like a checkpoint prompt: one thing to read, never grouped into a
1771
2348
  * digest, and never delivered through {@link notify} — a digest is a set of
1772
2349
  * similar pending REQUESTS decided together, and a sample is neither pending
1773
- * nor a request. It sends ONE message: no payload region, and a keyboard
1774
- * whose six buttons collect a verdict and a grade and mint nothing.
2350
+ * nor a request. It sends ONE message: the rows, the payload whole or by hash
2351
+ * (APRV-480, never split across messages), and a keyboard whose six buttons
2352
+ * collect a verdict and a grade and mint nothing.
1775
2353
  *
1776
2354
  * Refuses when no handler is registered, rather than sending a dead button.
1777
2355
  */
@@ -1786,6 +2364,9 @@ export class TelegramChannel {
1786
2364
  card,
1787
2365
  nonce,
1788
2366
  denyArmed: false,
2367
+ heldReaction: null,
2368
+ armedBy: null,
2369
+ heldBy: null,
1789
2370
  settled: null,
1790
2371
  notice: null,
1791
2372
  awaitingNote: null,
@@ -1933,8 +2514,15 @@ export class TelegramChannel {
1933
2514
  async deliverDigest(members, batchDeliveryId, stale = null) {
1934
2515
  const allNonce = this.makeNonce();
1935
2516
  const deliveredAtMs = this.now();
2517
+ // The digest's window is the widest member's: it may be forgotten only when
2518
+ // every member is past deciding, and a member nothing bounds keeps it.
2519
+ const windows = members.map((member) => this.retentionWindowMs(member));
2520
+ const windowMs = windows.includes(null)
2521
+ ? null
2522
+ : Math.max(...windows.filter((window) => window !== null));
1936
2523
  const state = {
1937
2524
  deliveredAtMs,
2525
+ windowMs,
1938
2526
  // Assigned once the message exists; nothing consults it before then.
1939
2527
  deliveryId: "",
1940
2528
  batchDeliveryId,
@@ -1994,13 +2582,16 @@ export class TelegramChannel {
1994
2582
  state.deliveryId = deliveryId;
1995
2583
  // Armed only now, and all at once: until the message with the buttons on it
1996
2584
  // exists there is nothing a callback could legitimately answer.
1997
- for (const member of state.members) {
2585
+ const rendering = this.digestRendering(stale !== null);
2586
+ for (const [index, member] of state.members.entries()) {
1998
2587
  this.deliveries.set(member.nonce, {
1999
2588
  actionKey: member.actionKey,
2000
2589
  actionRef: actionRefOf(member.actionKey),
2001
2590
  deliveryId,
2002
2591
  batchDeliveryId,
2003
2592
  deliveredAtMs,
2593
+ windowMs: windows[index] ?? null,
2594
+ ...(rendering === undefined ? {} : { rendering }),
2004
2595
  });
2005
2596
  }
2006
2597
  this.digests.set(deliveryId, state);
@@ -2085,14 +2676,49 @@ export class TelegramChannel {
2085
2676
  ],
2086
2677
  ],
2087
2678
  };
2088
- const sent = await this.sendPrompt(request, TELEGRAM_PROMPT_HEADING, keyboard);
2679
+ // APRV-489. The minimal card is tried only when the policy asked for it,
2680
+ // and anything it will not draw honestly is sent as the technical card,
2681
+ // exactly as a technical policy sends it, with the reason kept for the
2682
+ // decision record and said on stderr.
2683
+ let sent;
2684
+ let rendering;
2685
+ let card;
2686
+ const prompt = this.currentPrompt();
2687
+ // Fix round 2, B2: a request delivered as part of a batch is always the
2688
+ // technical card, as SPEC §10.3 (amended) says of a grouped delivery.
2689
+ const minimal = prompt.fallback !== undefined
2690
+ ? { ok: false, reason: prompt.fallback }
2691
+ : prompt.style !== "minimal"
2692
+ ? null
2693
+ : batchDeliveryId !== undefined
2694
+ ? { ok: false, reason: "batch" }
2695
+ : await this.sendMinimal(request, nonce, prompt.say);
2696
+ if (minimal !== null && minimal.ok) {
2697
+ sent = minimal.sent;
2698
+ // R3-S1: the record notes a `say` phrase the runtime overrode with its own.
2699
+ rendering = minimal.sayDoesIgnored ? { style: "minimal", say_does_ignored: true } : { style: "minimal" };
2700
+ card = minimal.card;
2701
+ if (minimal.sayDoesIgnored) {
2702
+ this.complain(`approval: telegram drew its own phrase for ${actionKey}, not the say entry's does: the runtime reads this payload itself`);
2703
+ }
2704
+ }
2705
+ else {
2706
+ if (minimal !== null) {
2707
+ rendering = { style: "technical", fallback: minimal.reason };
2708
+ this.complain(`approval: telegram sent the technical card for ${actionKey}, not the minimal one: ${minimal.reason}`);
2709
+ }
2710
+ sent = await this.sendPrompt(request, TELEGRAM_PROMPT_HEADING, keyboard);
2711
+ }
2089
2712
  this.counters.notified += 1;
2090
2713
  this.deliveries.set(nonce, {
2091
2714
  actionKey,
2092
2715
  actionRef: actionRefOf(actionKey),
2093
2716
  deliveryId: sent.deliveryId,
2094
2717
  deliveredAtMs: this.now(),
2718
+ windowMs: this.retentionWindowMs(request),
2095
2719
  ...(batchDeliveryId === undefined ? {} : { batchDeliveryId }),
2720
+ ...(rendering === undefined ? {} : { rendering }),
2721
+ ...(card === undefined ? {} : { card }),
2096
2722
  });
2097
2723
  return {
2098
2724
  deliveryId: sent.deliveryId,
@@ -2102,6 +2728,95 @@ export class TelegramChannel {
2102
2728
  },
2103
2729
  };
2104
2730
  }
2731
+ /**
2732
+ * Send `request` as ONE minimal card (APRV-489), or report why it was not.
2733
+ *
2734
+ * The card is drawn from the same `renderTelegram` output the technical card
2735
+ * would send, under the same layout, so the collapsed block IS the technical
2736
+ * card. The buttons carry the same callback data as the technical card's; only
2737
+ * the reject label reads "Deny" (R7). Nothing is sent when the card is refused,
2738
+ * so the caller's technical send is the only send.
2739
+ */
2740
+ async sendMinimal(request, nonce, say) {
2741
+ const actionKey = request.action_key.value;
2742
+ const rendering = renderTelegram(request, TELEGRAM_PROMPT_HEADING, this.layout);
2743
+ const drawn = renderTelegramMinimal(request, {
2744
+ header: rendering.header,
2745
+ payloadText: rendering.payloadText,
2746
+ claimedText: rendering.claimedText,
2747
+ anomalous: rendering.lines.some((entry) => entry.label.startsWith(TELEGRAM_ANOMALY_MARK)),
2748
+ payloadLabel: `<b>${PAYLOAD_CHUNK_LABEL}</b>`,
2749
+ }, say);
2750
+ if (!drawn.ok)
2751
+ return { ok: false, reason: drawn.reason };
2752
+ let result;
2753
+ try {
2754
+ result = await this.call("sendMessage", {
2755
+ chat_id: this.chatId,
2756
+ text: drawn.text,
2757
+ parse_mode: "HTML",
2758
+ disable_web_page_preview: true,
2759
+ reply_markup: {
2760
+ inline_keyboard: [
2761
+ [
2762
+ { text: "✅ Approve", callback_data: callbackData("g", nonce, actionKey) },
2763
+ { text: MINIMAL_DENY_LABEL, callback_data: callbackData("r", nonce, actionKey) },
2764
+ ],
2765
+ ],
2766
+ },
2767
+ });
2768
+ }
2769
+ catch (cause) {
2770
+ // Fix round 2, S2: a refusal of the card itself (a Bot API that cannot
2771
+ // parse the expandable block, an entity limit, a relay rule) would refuse
2772
+ // the same card on every cycle and leave the approver with nothing. The
2773
+ // technical card goes instead, once, and the record says why. A passing
2774
+ // failure (429, 5xx, network) is thrown and retried as before.
2775
+ if (isMessageRefusal(cause))
2776
+ return { ok: false, reason: "send-refused" };
2777
+ throw cause;
2778
+ }
2779
+ const fields = rendering.lines.map((entry) => ({
2780
+ field: entry.field,
2781
+ kind: entry.kind,
2782
+ text: entry.text,
2783
+ }));
2784
+ return {
2785
+ ok: true,
2786
+ sent: {
2787
+ deliveryId: String(result.message_id),
2788
+ rendered: { action_key: actionKey, fields, fullPayloadText: rendering.payloadText },
2789
+ },
2790
+ card: { headline: drawn.headline, details: drawn.details },
2791
+ sayDoesIgnored: drawn.sayDoesIgnored === true,
2792
+ };
2793
+ }
2794
+ /**
2795
+ * The style and `say` map in force for the delivery about to be drawn (fix
2796
+ * round 2, S4): the resolver's answer when the verb gave one, else the fixed
2797
+ * configuration. A resolver that throws is `technical`, the side that shows more.
2798
+ */
2799
+ currentPrompt() {
2800
+ if (this.promptFor === null)
2801
+ return { style: this.promptStyle, say: this.say };
2802
+ try {
2803
+ return this.promptFor();
2804
+ }
2805
+ catch (cause) {
2806
+ this.complain(`approval: telegram could not read the prompt style; drawing technical: ${this.describe(cause)}`);
2807
+ return { style: DEFAULT_PROMPT_STYLE, say: {} };
2808
+ }
2809
+ }
2810
+ /**
2811
+ * What a decision on a digest records about its layout (APRV-489): nothing
2812
+ * under a technical policy, and the technical card with its reason under a
2813
+ * minimal one, since a digest is always drawn technical.
2814
+ */
2815
+ digestRendering(stale) {
2816
+ if (this.currentPrompt().style === "technical")
2817
+ return undefined;
2818
+ return { style: "technical", fallback: stale ? "stale-summary" : "digest" };
2819
+ }
2105
2820
  /**
2106
2821
  * Forget every nonce issued for `deliveryId`, and report the action key it
2107
2822
  * was issued for.
@@ -2116,6 +2831,7 @@ export class TelegramChannel {
2116
2831
  */
2117
2832
  disarm(deliveryId) {
2118
2833
  let actionKey = "";
2834
+ let card;
2119
2835
  // Deleting the current entry mid-iteration is defined behaviour for a Map,
2120
2836
  // and every nonce for this message id goes — a re-notify of the same
2121
2837
  // message would otherwise leave an older nonce still resolving.
@@ -2123,6 +2839,7 @@ export class TelegramChannel {
2123
2839
  if (delivery.deliveryId !== deliveryId)
2124
2840
  continue;
2125
2841
  actionKey = delivery.actionKey;
2842
+ card = delivery.card ?? card;
2126
2843
  this.deliveries.delete(nonce);
2127
2844
  }
2128
2845
  const digest = this.digests.get(deliveryId);
@@ -2130,7 +2847,32 @@ export class TelegramChannel {
2130
2847
  this.allNonces.delete(digest.allNonce);
2131
2848
  this.digests.delete(deliveryId);
2132
2849
  }
2133
- return actionKey;
2850
+ return { actionKey, card };
2851
+ }
2852
+ /**
2853
+ * How long after delivery the request behind a prompt can still be decided,
2854
+ * on this process's clock, or `null` when nothing bounds it (APRV-423, second
2855
+ * review pass).
2856
+ *
2857
+ * The SHORTER of two bounds, each of which is at or past the gate's own
2858
+ * lapse, so the sweep never forgets a button a decision could still reach:
2859
+ *
2860
+ * - the request's `ttl_remaining_ms`, computed by the listener from the
2861
+ * verified log against the window the gate judges by (the policy's TTL
2862
+ * narrowed by the harness cap the requesting hook declared). It was
2863
+ * measured at tagging time, and delivery is at or after tagging, so
2864
+ * counting it from delivery ends at or after the real lapse;
2865
+ * - the policy's own TTL ({@link TelegramConfig.approvalTtlMs}), counted from
2866
+ * delivery, which is at or after the `approval.requested` it really runs
2867
+ * from. This was the only bound before APRV-423 and it stays as one.
2868
+ *
2869
+ * A request neither bounds is decidable forever, and its entry is dropped
2870
+ * only on an observed settlement. This channel still reads no policy and no
2871
+ * log: both numbers arrive computed, by the verb and by the tagger.
2872
+ */
2873
+ retentionWindowMs(request) {
2874
+ const bounds = [request.ttl_remaining_ms.value, this.approvalTtlMs].filter((bound) => bound !== null);
2875
+ return bounds.length === 0 ? null : Math.min(...bounds);
2134
2876
  }
2135
2877
  /**
2136
2878
  * Drop the delivery bookkeeping no callback can still be honoured against
@@ -2166,17 +2908,23 @@ export class TelegramChannel {
2166
2908
  */
2167
2909
  sweep(nowMs = this.now()) {
2168
2910
  this.lastSweepMs = nowMs;
2169
- const retention = this.approvalTtlMs ?? TELEGRAM_DEFAULT_RETENTION_MS;
2170
- const expired = (deliveredAtMs) => nowMs - deliveredAtMs >= retention;
2171
- // Past the approval TTL the gate refuses every decision, so the request is
2172
- // terminal whether or not this process saw it settle. With no TTL declared
2173
- // nothing expires, and only an observed settlement makes an entry droppable.
2174
- const lapsed = (deliveredAtMs) => this.approvalTtlMs !== null && nowMs - deliveredAtMs >= this.approvalTtlMs;
2911
+ // APRV-423 (second review pass): both halves read the ENTRY's own window,
2912
+ // which is the effective one the gate judges its request by, and fall back
2913
+ // to the policy's TTL and then the default only for an entry that carries
2914
+ // none (a review card, which has no request).
2915
+ const retentionOf = (windowMs) => windowMs ?? this.approvalTtlMs ?? TELEGRAM_DEFAULT_RETENTION_MS;
2916
+ const expired = (deliveredAtMs, windowMs) => nowMs - deliveredAtMs >= retentionOf(windowMs);
2917
+ // Past its window the gate refuses every decision on the request, so it is
2918
+ // terminal whether or not this process saw it settle. With nothing bounding
2919
+ // it nothing expires, and only an observed settlement makes it droppable.
2920
+ const lapsed = (deliveredAtMs, windowMs) => windowMs !== null && nowMs - deliveredAtMs >= windowMs;
2175
2921
  let digests = 0;
2176
2922
  for (const [deliveryId, digest] of this.digests) {
2177
2923
  const terminal = digest.members.every((member) => member.settled !== null);
2178
- if (!(terminal || lapsed(digest.deliveredAtMs)) || !expired(digest.deliveredAtMs))
2924
+ if (!(terminal || lapsed(digest.deliveredAtMs, digest.windowMs)) ||
2925
+ !expired(digest.deliveredAtMs, digest.windowMs)) {
2179
2926
  continue;
2927
+ }
2180
2928
  this.digests.delete(deliveryId);
2181
2929
  this.allNonces.delete(digest.allNonce);
2182
2930
  digests += 1;
@@ -2188,8 +2936,10 @@ export class TelegramChannel {
2188
2936
  // decides, and it was just judged above.
2189
2937
  if (this.digests.has(delivery.deliveryId))
2190
2938
  continue;
2191
- if (!lapsed(delivery.deliveredAtMs) || !expired(delivery.deliveredAtMs))
2939
+ if (!lapsed(delivery.deliveredAtMs, delivery.windowMs) ||
2940
+ !expired(delivery.deliveredAtMs, delivery.windowMs)) {
2192
2941
  continue;
2942
+ }
2193
2943
  this.deliveries.delete(nonce);
2194
2944
  deliveries += 1;
2195
2945
  }
@@ -2201,7 +2951,7 @@ export class TelegramChannel {
2201
2951
  // this map holds, so keeping the buttons alive costs a map entry and
2202
2952
  // dropping them early would cost a human their thumb.
2203
2953
  for (const [deliveryId, card] of this.reviewCards) {
2204
- if (card.settled === null || !expired(card.deliveredAtMs))
2954
+ if (card.settled === null || !expired(card.deliveredAtMs, null))
2205
2955
  continue;
2206
2956
  this.reviewCards.delete(deliveryId);
2207
2957
  this.reviewNonces.delete(card.nonce);
@@ -2289,20 +3039,41 @@ export class TelegramChannel {
2289
3039
  await this.settleMember(digest, actionKey, outcome, detail);
2290
3040
  return;
2291
3041
  }
2292
- const settledKey = this.disarm(deliveryId);
2293
- const text = [
3042
+ const settled = this.disarm(deliveryId);
3043
+ const technicalText = [
2294
3044
  `<b>${escapeHtml(outcome)}</b>`,
2295
- `<code>${escapeHtml(actionKey ?? settledKey)}</code>`,
3045
+ `<code>${escapeHtml(actionKey ?? settled.actionKey)}</code>`,
2296
3046
  "",
2297
3047
  ...detail.map((entry) => escapeHtml(entry)),
2298
3048
  ].join("\n");
2299
- await this.call("editMessageText", {
3049
+ // APRV-489. A minimal card keeps its headline and its collapsed details
3050
+ // under the outcome, so the settled message still holds the canonical
3051
+ // rendering the approver answered on. Should that not fit, the technical
3052
+ // annotation is the fallback: the outcome is what the edit must carry.
3053
+ const text = settled.card === undefined ? technicalText : minimalSettleText(outcome, detail, settled.card);
3054
+ const edit = (body) => this.call("editMessageText", {
2300
3055
  chat_id: this.chatId,
2301
3056
  message_id: Number(deliveryId),
2302
- text,
3057
+ text: body,
2303
3058
  parse_mode: "HTML",
2304
3059
  disable_web_page_preview: true,
2305
3060
  });
3061
+ if (settled.card === undefined) {
3062
+ await edit(text);
3063
+ return;
3064
+ }
3065
+ // Fix round 2, S3: a minimal card is the only message holding its request,
3066
+ // so a refused settle edit would leave a live-looking card on a decided
3067
+ // request. The short settle text always fits and always says the outcome.
3068
+ try {
3069
+ await edit(text);
3070
+ }
3071
+ catch (cause) {
3072
+ if (!isMessageRefusal(cause) || isMessageNotModified(cause))
3073
+ throw cause;
3074
+ this.complain(`approval: telegram refused the settle edit of ${settled.actionKey}; sending the short form`);
3075
+ await edit(shortSettleText(outcome, actionKey ?? settled.actionKey, detail));
3076
+ }
2306
3077
  }
2307
3078
  /**
2308
3079
  * The withdrawal case of {@link annotate} (APRV-106), and the one the
@@ -2311,6 +3082,51 @@ export class TelegramChannel {
2311
3082
  async retract(deliveryId, reason, actionKey) {
2312
3083
  await this.annotate(deliveryId, TELEGRAM_TERMINAL_HEADLINES.withdrawn, [reason], actionKey);
2313
3084
  }
3085
+ /**
3086
+ * The action keys whose card a refused or failed tap disarmed since the last
3087
+ * call, and forget them (APRV-442).
3088
+ *
3089
+ * The listener's dispatch cycle calls this once per cycle and offers a fresh
3090
+ * card for every key the verified log still calls pending. It is a statement
3091
+ * about this process's cards and nothing else: it carries no decision, no
3092
+ * sender and no outcome, and a key in it that the log has since settled is
3093
+ * simply not offered.
3094
+ */
3095
+ takeReleased() {
3096
+ const keys = [...this.released];
3097
+ this.released.clear();
3098
+ return keys;
3099
+ }
3100
+ /**
3101
+ * Mark `delivery`'s card dead and its request released, BEFORE the
3102
+ * annotation that disarms it (APRV-442).
3103
+ *
3104
+ * Every nonce still armed for this request on this message is recorded in
3105
+ * {@link refusedNonces}, together with the nonce the tap carried (which
3106
+ * differs when the tap came from an earlier copy through the fallback), so
3107
+ * none of those bytes can later resolve to the fresh card. On a digest only
3108
+ * this member's nonce is taken: its siblings stay armed on the digest.
3109
+ */
3110
+ release(delivery, tapNonce) {
3111
+ const dead = [tapNonce];
3112
+ for (const [nonce, entry] of this.deliveries) {
3113
+ if (entry.deliveryId === delivery.deliveryId && entry.actionKey === delivery.actionKey) {
3114
+ dead.push(nonce);
3115
+ }
3116
+ }
3117
+ for (const nonce of dead) {
3118
+ // Re-inserted so a nonce marked again counts as the newest.
3119
+ this.refusedNonces.delete(nonce);
3120
+ this.refusedNonces.add(nonce);
3121
+ }
3122
+ while (this.refusedNonces.size > TELEGRAM_REFUSED_NONCE_CAP) {
3123
+ const oldest = this.refusedNonces.values().next().value;
3124
+ if (oldest === undefined)
3125
+ break;
3126
+ this.refusedNonces.delete(oldest);
3127
+ }
3128
+ this.released.add(delivery.actionKey);
3129
+ }
2314
3130
  /**
2315
3131
  * Send one plain message that carries no question (APRV-196).
2316
3132
  *
@@ -2379,6 +3195,7 @@ export class TelegramChannel {
2379
3195
  * decision", not "deliver once at startup, then wait forever".
2380
3196
  */
2381
3197
  async listen(options = {}) {
3198
+ this.claimOrThrow("poll");
2382
3199
  this.stopped = false;
2383
3200
  let backoff = this.backoffMs;
2384
3201
  /**
@@ -2447,6 +3264,7 @@ export class TelegramChannel {
2447
3264
  * what {@link listen} catches and retries.
2448
3265
  */
2449
3266
  async pollOnce() {
3267
+ this.claimOrThrow("poll");
2450
3268
  // APRV-135. Before the long poll, not after: this is where the loop is
2451
3269
  // about to block for up to `pollTimeoutSeconds`, and a sweep that ran after
2452
3270
  // the block would be a sweep that never runs on a quiet chat. Rate-limited
@@ -2458,34 +3276,162 @@ export class TelegramChannel {
2458
3276
  const updates = await this.call("getUpdates", {
2459
3277
  offset: this.offset,
2460
3278
  timeout: this.pollTimeoutSeconds,
2461
- // APRV-216. `message` is asked for only while a command handler is
2462
- // registered: see {@link onCommand} for why a listener that reads
2463
- // messages nobody asked for would break `approval setup channel
2464
- // telegram`'s chat discovery.
2465
- // APRV-299 adds the second reason to read messages: a `loved` or
2466
- // `disliked` on a review card collects the human's words as a REPLY,
2467
- // because an inline keyboard has no text input.
2468
- allowed_updates: this.commandHandler === null && this.reviewHandler === null
2469
- ? ["callback_query"]
2470
- : ["callback_query", "message"],
3279
+ allowed_updates: this.allowedUpdates(),
2471
3280
  }, this.requestTimeoutMs ?? (this.pollTimeoutSeconds + 10) * 1000);
2472
- const result = {
2473
- updates: 0,
2474
- outcomes: [],
2475
- ignored: [],
2476
- commands: [],
2477
- reviews: [],
3281
+ const result = emptyPollResult();
3282
+ for (const raw of updates)
3283
+ await this.intake(raw, result);
3284
+ return result;
3285
+ }
3286
+ /**
3287
+ * Which update types this channel asks for, on either transport (APRV-424).
3288
+ *
3289
+ * APRV-216. `message` is asked for only while a command handler is
3290
+ * registered: see {@link onCommand} for why a listener that reads messages
3291
+ * nobody asked for would break `approval setup channel telegram`'s chat
3292
+ * discovery. APRV-299 adds the second reason to read messages: a `loved` or
3293
+ * `disliked` on a review card collects the human's words as a REPLY, because
3294
+ * an inline keyboard has no text input.
3295
+ *
3296
+ * One function since APRV-424, because `setWebhook` takes the same list as
3297
+ * `getUpdates` and two spellings of it would be two answers to "what does
3298
+ * this listener read" — with the webhook's answer frozen at registration
3299
+ * time, where nobody would look for it.
3300
+ */
3301
+ allowedUpdates() {
3302
+ return this.commandHandler === null && this.reviewHandler === null
3303
+ ? ["callback_query"]
3304
+ : ["callback_query", "message"];
3305
+ }
3306
+ /**
3307
+ * One update, from whichever transport carried it (APRV-424).
3308
+ *
3309
+ * The whole of what the webhook adds is the ARRIVAL. Everything a tap then
3310
+ * passes through — the chat check, `senderOf`, the checkpoint and review
3311
+ * parsers, the resolution ladder, the early ack, `this.handler` and the
3312
+ * annotation written from the record the gate appended — is
3313
+ * {@link handleUpdate}, called from here and from {@link pollOnce}, and
3314
+ * there is no second copy of any of it. `tests/telegram-webhook.test.ts`
3315
+ * drives one callback through both entry points and compares the records the
3316
+ * log ends up holding.
3317
+ *
3318
+ * The sweep is `pollOnce`'s, for `pollOnce`'s reason: this is the moment the
3319
+ * process is about to be idle, and a channel that only ever wakes for taps
3320
+ * would otherwise never run one.
3321
+ */
3322
+ async deliverUpdate(update) {
3323
+ this.claimOrThrow("webhook");
3324
+ const nowMs = this.now();
3325
+ if (nowMs - this.lastSweepMs >= TELEGRAM_SWEEP_INTERVAL_MS)
3326
+ this.sweep(nowMs);
3327
+ const result = emptyPollResult();
3328
+ await this.intake(update, result);
3329
+ return result;
3330
+ }
3331
+ /** The per-update bookkeeping both transports do, before `handleUpdate`. */
3332
+ async intake(raw, result) {
3333
+ const update = (raw ?? {});
3334
+ const id = update["update_id"];
3335
+ // Meaningless on the webhook, where Telegram acknowledges by the HTTP
3336
+ // response, and harmless: the offset is read by `getUpdates` alone, and a
3337
+ // channel that has claimed the webhook transport never issues one.
3338
+ if (typeof id === "number")
3339
+ this.offset = Math.max(this.offset, id + 1);
3340
+ this.counters.updates += 1;
3341
+ result.updates += 1;
3342
+ await this.handleUpdate(update, result);
3343
+ }
3344
+ /**
3345
+ * Bind this channel to one transport, or say which one already holds it
3346
+ * (APRV-424).
3347
+ *
3348
+ * Idempotent for the transport already claimed, so a webhook server that
3349
+ * claims at start-up and then delivers, or a driver calling `pollOnce` in a
3350
+ * loop, pays nothing. Not released by {@link stop}: a process that stopped
3351
+ * polling has not stopped being a poller, and re-pointing a live channel's
3352
+ * delivery maps at a second arrival path is the state this exists to refuse.
3353
+ */
3354
+ claimTransport(transport) {
3355
+ if (this.transport === null || this.transport === transport) {
3356
+ this.transport = transport;
3357
+ return { ok: true, transport };
3358
+ }
3359
+ return {
3360
+ ok: false,
3361
+ code: "transport-conflict",
3362
+ message: `this channel is already receiving updates by ${this.transport}, so it cannot also receive them by ${transport}. Long polling and a webhook are alternatives per bot: the Bot API refuses getUpdates outright while a webhook is set, and a process running both would answer a tap from whichever half reached it first`,
2478
3363
  };
2479
- for (const raw of updates) {
2480
- const update = (raw ?? {});
2481
- const id = update["update_id"];
2482
- if (typeof id === "number")
2483
- this.offset = Math.max(this.offset, id + 1);
2484
- this.counters.updates += 1;
2485
- result.updates += 1;
2486
- await this.handleUpdate(update, result);
3364
+ }
3365
+ /** {@link claimTransport}, for the paths where a refusal is a caller's bug. */
3366
+ claimOrThrow(transport) {
3367
+ const claim = this.claimTransport(transport);
3368
+ if (!claim.ok)
3369
+ throw new TelegramTransportError(claim.message);
3370
+ }
3371
+ // -------------------------------------------------------------------------
3372
+ // Webhook registration (APRV-424)
3373
+ // -------------------------------------------------------------------------
3374
+ /**
3375
+ * Point this bot's updates at `url`, and require `secret` on every post.
3376
+ *
3377
+ * Three properties, and each is a refusal to be clever:
3378
+ *
3379
+ * - **The secret is Telegram's own mechanism**, `secret_token`, echoed back
3380
+ * on every delivery in the `X-Telegram-Bot-Api-Secret-Token` header. The
3381
+ * receiver compares it and refuses anything else, so a URL that leaks (a
3382
+ * proxy log, a referrer, a screenshot) is a URL that still decides
3383
+ * nothing.
3384
+ * - **It is redacted from this moment on.** The value is added to the
3385
+ * channel's secret list BEFORE the call, so a Bot API error that quotes
3386
+ * the request cannot print it.
3387
+ * - **Pending updates are kept.** `drop_pending_updates` is left false:
3388
+ * taps that arrived while the process was down are the approver's answers,
3389
+ * and the gate is what decides whether they are still honourable.
3390
+ */
3391
+ async registerWebhook(url, secret) {
3392
+ if (secret.length > 0 && !this.secrets.some((held) => held.value === secret)) {
3393
+ this.secrets.push({ value: secret, label: "<webhook secret redacted>" });
2487
3394
  }
2488
- return result;
3395
+ await this.call("setWebhook", {
3396
+ url,
3397
+ secret_token: secret,
3398
+ allowed_updates: this.allowedUpdates(),
3399
+ drop_pending_updates: false,
3400
+ });
3401
+ }
3402
+ /**
3403
+ * Take this bot's webhook away, so `getUpdates` works again.
3404
+ *
3405
+ * Called on a clean stop of the webhook runner. Pending updates are kept for
3406
+ * {@link registerWebhook}'s reason: the next process to start — a poller or
3407
+ * another webhook — is entitled to the taps that arrived in between.
3408
+ *
3409
+ * `timeoutMs` is what the stop path passes, and it is shorter than this
3410
+ * channel's ordinary request timeout on purpose (APRV-424, second review,
3411
+ * note 6). A stop already waits for the receiver to drain, and an operator
3412
+ * pressing Ctrl-C a second time is asking for the process to end: half a
3413
+ * minute of an unreachable Bot API on top of that reads as a hang, and the
3414
+ * webhook it failed to remove is reported rather than silently retried.
3415
+ */
3416
+ async deleteWebhook(timeoutMs) {
3417
+ await this.call("deleteWebhook", { drop_pending_updates: false }, ...(timeoutMs === undefined ? [] : [timeoutMs]));
3418
+ }
3419
+ /**
3420
+ * What the Bot API says is receiving this bot's updates right now.
3421
+ *
3422
+ * The one authority on the question, and the reason it is asked at start-up
3423
+ * by both transports: the ownership registry knows about processes on THIS
3424
+ * machine, and a webhook registered from a laptop in another timezone is
3425
+ * still a webhook swallowing every tap this poller is waiting for.
3426
+ */
3427
+ async webhookInfo() {
3428
+ const result = await this.call("getWebhookInfo", {});
3429
+ const url = result["url"];
3430
+ const pending = result["pending_update_count"];
3431
+ return {
3432
+ url: typeof url === "string" ? url : "",
3433
+ pendingUpdateCount: typeof pending === "number" ? pending : 0,
3434
+ };
2489
3435
  }
2490
3436
  /**
2491
3437
  * Exactly one `answerCallbackQuery` per callback query, on every path
@@ -2676,7 +3622,21 @@ export class TelegramChannel {
2676
3622
  // the log when neither is holding the action open.
2677
3623
  let delivery = this.deliveries.get(parsed.nonce);
2678
3624
  let viaStaleCopy = false;
2679
- if (delivery === undefined && parsed.actionRef !== null) {
3625
+ // APRV-442. A card a refused tap disarmed stays dead: its bytes take no
3626
+ // fallback to the fresh card the listener sends for the same request, so
3627
+ // a redelivered or replayed refused tap reaches neither the gate nor the
3628
+ // new card, exactly as it did before that card existed.
3629
+ const refusedCard = delivery === undefined && this.refusedNonces.has(parsed.nonce);
3630
+ // APRV-456. With the fallback off, a nonce this process is not holding is
3631
+ // the end of the road: no action-reference lookup, no log probe, and never
3632
+ // the decision handler. Behind a relay, a button carrying a real action's
3633
+ // reference may sit under text this gate never wrote, and the approver's
3634
+ // genuine tap would otherwise pass the chat check and the sender mapping.
3635
+ if (delivery === undefined && !refusedCard && !this.staleCopy) {
3636
+ await this.refuseUnissued(parsed, callbackId, result, sender);
3637
+ return;
3638
+ }
3639
+ if (delivery === undefined && parsed.actionRef !== null && !refusedCard) {
2680
3640
  // The pre-restart copy. Its nonce died with the process that issued it,
2681
3641
  // but the request it names is one THIS process has since re-delivered, so
2682
3642
  // the tap decides that request — on the live copy's message, which is
@@ -2687,18 +3647,21 @@ export class TelegramChannel {
2687
3647
  viaStaleCopy = delivery !== undefined;
2688
3648
  }
2689
3649
  if (delivery === undefined) {
2690
- if (parsed.actionRef !== null) {
3650
+ if (parsed.actionRef !== null && !refusedCard) {
2691
3651
  // Nothing open here for that action. Say what the log says, which is
2692
3652
  // the only thing that knows: decided, lapsed, withdrawn, or unknown.
2693
3653
  const described = this.describeAction?.(parsed.actionRef) ?? null;
2694
3654
  await this.ignore(result, callbackId, "stale-copy", `no open delivery for action ref ${JSON.stringify(parsed.actionRef)} (an earlier copy of a request this listener is not holding open)`, described ?? TELEGRAM_STALE_UNKNOWN);
2695
3655
  return;
2696
3656
  }
2697
- await this.ignore(result, callbackId, "unknown-callback", `no delivery for nonce ${JSON.stringify(parsed.nonce)} (a restarted listener forgets its buttons; the pending queue is re-sent on start)`,
2698
- // Two ways to get here, and the reply has to serve both: a button this
2699
- // process never issued (a restart forgot it), and a button on a message
2700
- // this process has already annotated (APRV-113 forgets the nonce with
2701
- // the edit). Either way the message text is the thing to read.
3657
+ await this.ignore(result, callbackId, "unknown-callback", refusedCard
3658
+ ? `nonce ${JSON.stringify(parsed.nonce)} is on a card a refused tap disarmed (APRV-442); the request, if still pending, is on a newer card`
3659
+ : `no delivery for nonce ${JSON.stringify(parsed.nonce)} (a restarted listener forgets its buttons; the pending queue is re-sent on start)`,
3660
+ // Three ways to get here, and the reply has to serve all of them: a
3661
+ // button this process never issued (a restart forgot it), a button on a
3662
+ // message this process has already annotated (APRV-113 forgets the
3663
+ // nonce with the edit), and a button on a card a refused tap disarmed
3664
+ // (APRV-442). Either way the message text is the thing to read.
2702
3665
  "This button is no longer live — read the message for the outcome, or the newest message for the request.");
2703
3666
  return;
2704
3667
  }
@@ -2714,6 +3677,9 @@ export class TelegramChannel {
2714
3677
  ...(delivery.batchDeliveryId === undefined
2715
3678
  ? {}
2716
3679
  : { batchDeliveryId: delivery.batchDeliveryId }),
3680
+ // APRV-489. The layout THIS card was drawn in, from this process's own
3681
+ // record of what it sent; nothing in the tap chooses it.
3682
+ ...(delivery.rendering === undefined ? {} : { rendering: delivery.rendering }),
2717
3683
  ...(parsed.decision === "reject"
2718
3684
  ? { note: `${TELEGRAM_REJECT_NOTE} (callback ${callbackId})` }
2719
3685
  : {}),
@@ -2739,6 +3705,9 @@ export class TelegramChannel {
2739
3705
  outcome = this.handler(decision);
2740
3706
  }
2741
3707
  catch (cause) {
3708
+ // APRV-442: the card is about to be disarmed and nothing settled the
3709
+ // request. Marked BEFORE the annotation, which forgets the nonces.
3710
+ this.release(delivery, parsed.nonce);
2742
3711
  await this.annotateQuietly(delivery.deliveryId, delivery.actionKey, TELEGRAM_NOT_RECORDED, [
2743
3712
  TELEGRAM_HANDLER_FAILED,
2744
3713
  ]);
@@ -2763,6 +3732,18 @@ export class TelegramChannel {
2763
3732
  // message, which is right in both directions: a terminal request has no
2764
3733
  // decision left to collect, and a still-pending one is re-delivered as a
2765
3734
  // fresh prompt by the next dispatch cycle.
3735
+ //
3736
+ // APRV-442. That re-delivery used to be a promise nothing kept: the
3737
+ // disarm forgets the card's nonces, but the listener's own bookkeeping
3738
+ // went on saying the request was on the phone, so the cycle never sent
3739
+ // it again and the original card's action-reference fallback found
3740
+ // nothing live. A wrong account's tap made the prompt dead until a
3741
+ // restart. Releasing names this CARD as gone and nothing else; the cycle
3742
+ // re-derives from the verified log whether the request is still pending
3743
+ // before it offers a new one. Marked BEFORE the annotation, which forgets
3744
+ // the nonces it records. The refusal itself, its record and its wording
3745
+ // are untouched.
3746
+ this.release(delivery, parsed.nonce);
2766
3747
  await this.annotateQuietly(delivery.deliveryId, delivery.actionKey, TELEGRAM_NOT_RECORDED, [
2767
3748
  this.answerFor(outcome),
2768
3749
  ]);
@@ -2825,12 +3806,14 @@ export class TelegramChannel {
2825
3806
  let landed = 0;
2826
3807
  const refusals = [];
2827
3808
  for (const member of open) {
3809
+ const allRendering = this.digestRendering(digest.stale !== null);
2828
3810
  const one = {
2829
3811
  action_key: member.actionKey,
2830
3812
  decision,
2831
3813
  deliveryId: digest.deliveryId,
2832
3814
  batchDeliveryId: digest.batchDeliveryId,
2833
3815
  ...(sender === undefined ? {} : { sender }),
3816
+ ...(allRendering === undefined ? {} : { rendering: allRendering }),
2834
3817
  ...(decision === "reject"
2835
3818
  ? { note: `${TELEGRAM_REJECT_NOTE} (callback ${callbackId}, all)` }
2836
3819
  : {}),
@@ -3000,31 +3983,88 @@ export class TelegramChannel {
3000
3983
  await this.ignore(result, callbackId, "unknown-callback", "a review tap arrived before the runtime registered a review handler", "The runtime is not ready to record reviews.");
3001
3984
  return;
3002
3985
  }
3986
+ // PR #614 refutation F3. An armed Deny and a held grade are one account's
3987
+ // unfinished review. A tap that would finish either (any verdict, or a
3988
+ // grade landing on an armed Deny) from a different account is refused on
3989
+ // the card, so a record never carries a grade or a denial its reviewer did
3990
+ // not tap. A grade tapped with nothing armed only replaces the held grade
3991
+ // and is not refused: it finishes nothing, and its new holder is the tapper.
3992
+ const tapper = sender === undefined ? null : sender.id;
3993
+ const finishes = tap.choice === "ok" || tap.choice === "deny" || state.denyArmed;
3994
+ const othersArm = state.denyArmed && state.armedBy !== tapper;
3995
+ const othersGrade = state.heldReaction !== null && state.heldBy !== tapper;
3996
+ if (finishes && (othersArm || (othersGrade && (tap.choice === "ok" || tap.choice === "deny")))) {
3997
+ state.notice = { headline: TELEGRAM_NOT_RECORDED, lines: [TELEGRAM_REVIEW_OTHER_SENDER] };
3998
+ await this.safeAnswer(callbackId, TELEGRAM_REVIEW_OTHER_SENDER);
3999
+ await this.redrawReview(state);
4000
+ return;
4001
+ }
3003
4002
  // The first Deny tap arms and writes nothing. Stated on the card, so the
3004
4003
  // approver reads the state rather than inferring it from a toast.
4004
+ //
4005
+ // PR #614 recheck NF-1: arming is earned the way a held grade is. The tap
4006
+ // goes to the runtime with NO verdict, and the card arms only when the
4007
+ // answer is `verdict-required`, which the runtime gives only after the
4008
+ // sender and the class's roster have passed. An unmapped or off-roster
4009
+ // account's Deny is refused on the card with its own code and arms
4010
+ // nothing, so it cannot leave a half-finished denial that blocks the
4011
+ // approvers' taps.
3005
4012
  if (tap.choice === "deny" && !state.denyArmed) {
3006
- state.denyArmed = true;
3007
- state.notice = null;
3008
- await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ARM_TOAST);
3009
- await this.redrawReview(state);
4013
+ const answered = await this.recordReview(state, {
4014
+ sampleSeq: state.card.sampleSeq,
4015
+ ...(sender === undefined ? {} : { sender }),
4016
+ }, result, { kind: "deny", by: tapper });
4017
+ await this.safeAnswer(callbackId, state.denyArmed ? TELEGRAM_REVIEW_ARM_TOAST : answered?.toast ?? TELEGRAM_NOT_RECORDED);
3010
4018
  return;
3011
4019
  }
3012
- const verdict = state.denyArmed ? "denied" : "ok";
3013
4020
  if (tap.choice === "ok" || tap.choice === "deny") {
3014
4021
  // `ok` with deny armed is a correction, and it disarms: a human who
3015
4022
  // reached for Deny and then chose OK meant OK, and nothing was written in
3016
4023
  // between for the change of mind to contradict.
3017
4024
  const chosen = tap.choice === "deny" ? "denied" : "ok";
3018
4025
  state.denyArmed = chosen === "denied";
4026
+ if (!state.denyArmed)
4027
+ state.armedBy = null;
4028
+ // APRV-482: a grade held from before the verdict rides with it, and a
4029
+ // grade that wants words asks for them now, exactly as a grade tapped
4030
+ // after an armed Deny always has. The pair core refuses outright is sent
4031
+ // straight through, so no note is collected for a record that will not
4032
+ // exist.
4033
+ const held = state.heldReaction;
4034
+ if (held !== null) {
4035
+ const conflicts = chosen === "denied" && (held === "liked" || held === "loved");
4036
+ if (!conflicts && (held === "loved" || held === "disliked")) {
4037
+ await this.safeAnswer(callbackId, TELEGRAM_REVIEW_NOTE_TOAST);
4038
+ await this.askForNote(state, chosen, held, sender);
4039
+ return;
4040
+ }
4041
+ }
3019
4042
  await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
3020
4043
  await this.recordReview(state, {
3021
4044
  sampleSeq: state.card.sampleSeq,
3022
4045
  verdict: chosen,
4046
+ ...(held === null ? {} : { reaction: held }),
3023
4047
  ...(sender === undefined ? {} : { sender }),
3024
4048
  }, result);
3025
4049
  return;
3026
4050
  }
3027
4051
  const reaction = tap.choice;
4052
+ if (!state.denyArmed) {
4053
+ // APRV-482. A grade with no verdict is not an approval. The card holds
4054
+ // the grade (process memory, stated in its heading) and the tap still
4055
+ // goes to the runtime with NO verdict, so the refusal on the card is the
4056
+ // core's own `verdict-required` and its words, and the operator's stderr
4057
+ // says the same. No note is asked for: words for a record that is about
4058
+ // to be refused would be attention spent on nothing.
4059
+ await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
4060
+ await this.recordReview(state, {
4061
+ sampleSeq: state.card.sampleSeq,
4062
+ reaction,
4063
+ ...(sender === undefined ? {} : { sender }),
4064
+ }, result, { kind: "grade", reaction, by: tapper });
4065
+ return;
4066
+ }
4067
+ const verdict = "denied";
3028
4068
  // The two grades that require the human's own words ask for them FIRST, and
3029
4069
  // nothing is appended until the reply arrives. The exception is the pair
3030
4070
  // `core/audit.ts` refuses outright: a denied review that says liked or
@@ -3056,26 +4096,75 @@ export class TelegramChannel {
3056
4096
  * stays open, and a fresh card is offered — and can never cost a record
3057
4097
  * nobody asked for.
3058
4098
  *
3059
- * A second prompt replaces the first: only one grade can be outstanding on
3060
- * one card, and the older prompt stops resolving so a late reply to it lands
3061
- * nowhere rather than recording a grade the human moved on from.
4099
+ * Only one grade can be outstanding on one card, so a second prompt that is
4100
+ * SENT replaces the first, and the older prompt stops resolving: a late reply
4101
+ * to it lands nowhere rather than recording a grade the human moved on from.
4102
+ *
4103
+ * APRV-492, and the relay licenses one prompt per GRADE tap, so which taps
4104
+ * get a prompt sent is not this method's choice alone. Three rules:
4105
+ *
4106
+ * - A tap that would ask the SAME question again (same verdict, same grade,
4107
+ * same account: a doubled OK or a doubled second Deny) sends nothing. The
4108
+ * prompt on screen already collects those words, and a second send would
4109
+ * be refused.
4110
+ * - A prompt for a DIFFERENT VERDICT retires the old one BEFORE the send. A
4111
+ * verdict change is not a grade tap, so the relay refuses that send, and an
4112
+ * old prompt left live would record the verdict the human just changed. A
4113
+ * note may be lost this way; a wrong verdict is never recorded.
4114
+ * - A prompt for the same verdict and a different grade follows a grade tap,
4115
+ * which the relay licenses, and retires the old one only once the new send
4116
+ * has succeeded. A send that fails anyway (the network, not the relay)
4117
+ * leaves the old prompt live, and a reply to it records the grade the old
4118
+ * prompt names in its own text, not the one the card now holds: an
4119
+ * accepted residual (PR #619 refutation S2).
4120
+ *
4121
+ * A send that fails says so on the card, because the toast already told the
4122
+ * human to reply to a prompt: the note was not asked for, nothing was
4123
+ * recorded, and a grade tap asks again. Then it throws, so the operator hears
4124
+ * of it. A later prompt that is sent clears that line.
3062
4125
  */
3063
4126
  async askForNote(state, verdict, reaction, sender) {
3064
- if (state.awaitingNote !== null)
3065
- this.reviewNotePrompts.delete(state.awaitingNote.promptId);
4127
+ const waiting = state.awaitingNote;
4128
+ if (waiting !== null &&
4129
+ waiting.verdict === verdict &&
4130
+ waiting.reaction === reaction &&
4131
+ waiting.sender?.id === sender?.id &&
4132
+ this.reviewNotePrompts.get(waiting.promptId) === state.deliveryId) {
4133
+ return;
4134
+ }
4135
+ if (waiting !== null && waiting.verdict !== verdict) {
4136
+ this.reviewNotePrompts.delete(waiting.promptId);
4137
+ state.awaitingNote = null;
4138
+ }
3066
4139
  const lines = reviewNotePromptLines(reaction, verdict, state.card.fields.action_key.value);
3067
- const sent = await this.call("sendMessage", {
3068
- chat_id: this.chatId,
3069
- text: lines
3070
- .map((entry, index) => (index === 0 ? `<b>${escapeHtml(entry)}</b>` : escapeHtml(entry)))
3071
- .join("\n"),
3072
- parse_mode: "HTML",
3073
- disable_web_page_preview: true,
3074
- reply_markup: { force_reply: true },
3075
- });
4140
+ let sent;
4141
+ try {
4142
+ sent = await this.call("sendMessage", {
4143
+ chat_id: this.chatId,
4144
+ text: lines
4145
+ .map((entry, index) => (index === 0 ? `<b>${escapeHtml(entry)}</b>` : escapeHtml(entry)))
4146
+ .join("\n"),
4147
+ parse_mode: "HTML",
4148
+ disable_web_page_preview: true,
4149
+ reply_markup: { force_reply: true },
4150
+ });
4151
+ }
4152
+ catch (cause) {
4153
+ state.notice = { headline: TELEGRAM_NOT_RECORDED, lines: [telegramReviewNoteUnasked(verdict)] };
4154
+ await this.redrawReview(state);
4155
+ throw cause;
4156
+ }
3076
4157
  const promptId = String(sent.message_id);
4158
+ if (state.awaitingNote !== null)
4159
+ this.reviewNotePrompts.delete(state.awaitingNote.promptId);
3077
4160
  state.awaitingNote = { promptId, verdict, reaction, ...(sender === undefined ? {} : { sender }) };
3078
4161
  this.reviewNotePrompts.set(promptId, state.deliveryId);
4162
+ const unasked = state.notice?.lines;
4163
+ if (unasked?.length === 1 &&
4164
+ (unasked[0] === telegramReviewNoteUnasked("ok") || unasked[0] === telegramReviewNoteUnasked("denied"))) {
4165
+ state.notice = null;
4166
+ await this.redrawReview(state);
4167
+ }
3079
4168
  }
3080
4169
  /**
3081
4170
  * A message replying to an outstanding note prompt (APRV-299).
@@ -3147,13 +4236,25 @@ export class TelegramChannel {
3147
4236
  * to say which half they meant, and `note-required` asks for words — so the
3148
4237
  * buttons stay, with the refusal rendered above them and the arming intact.
3149
4238
  */
3150
- async recordReview(state, tap, result) {
4239
+ async recordReview(state, tap, result,
4240
+ /**
4241
+ * A lone grade to hold, or a Deny to arm, if the runtime answers
4242
+ * `verdict-required` (PR #614 refutation N6/F3, recheck NF-1): only then
4243
+ * has the tapper passed the sender and roster checks, so only then is the
4244
+ * half-finished review theirs to finish.
4245
+ */
4246
+ hold) {
3151
4247
  const handler = this.reviewHandler;
3152
4248
  if (handler === null)
3153
- return;
4249
+ return null;
4250
+ // APRV-481: whether the bytes were on this card is the card's own fact,
4251
+ // decided by the same pure view that drew it, so the record's payload hash
4252
+ // can never name more than the reviewer was shown.
4253
+ const view = reviewPayloadView(state.card);
4254
+ const sent = view.kind === "bytes" ? { ...tap, payloadHash: view.hash } : tap;
3154
4255
  let response;
3155
4256
  try {
3156
- response = await handler(tap);
4257
+ response = await handler(sent);
3157
4258
  }
3158
4259
  catch (cause) {
3159
4260
  state.notice = { headline: TELEGRAM_NOT_RECORDED, lines: [TELEGRAM_HANDLER_FAILED] };
@@ -3161,11 +4262,14 @@ export class TelegramChannel {
3161
4262
  throw cause;
3162
4263
  }
3163
4264
  this.counters.reviews += 1;
3164
- result.reviews.push({ tap, ok: response.ok });
4265
+ result.reviews.push({ tap: sent, ok: response.ok });
3165
4266
  if (response.ok) {
3166
4267
  state.settled = { headline: response.headline, detail: response.detail };
3167
4268
  state.notice = null;
3168
4269
  state.denyArmed = false;
4270
+ state.heldReaction = null;
4271
+ state.armedBy = null;
4272
+ state.heldBy = null;
3169
4273
  this.reviewNonces.delete(state.nonce);
3170
4274
  if (state.awaitingNote !== null) {
3171
4275
  this.reviewNotePrompts.delete(state.awaitingNote.promptId);
@@ -3174,8 +4278,23 @@ export class TelegramChannel {
3174
4278
  }
3175
4279
  else {
3176
4280
  state.notice = { headline: response.headline, lines: response.detail };
4281
+ if (hold !== undefined && response.code === "verdict-required") {
4282
+ if (hold.kind === "grade") {
4283
+ state.heldReaction = hold.reaction;
4284
+ state.heldBy = hold.by;
4285
+ }
4286
+ else {
4287
+ // The armed state is the card's message; the refusal that earned it
4288
+ // is the runtime's word for "a verdict is still needed", which the
4289
+ // armed heading already says.
4290
+ state.denyArmed = true;
4291
+ state.armedBy = hold.by;
4292
+ state.notice = null;
4293
+ }
4294
+ }
3177
4295
  }
3178
4296
  await this.redrawReview(state);
4297
+ return response;
3179
4298
  }
3180
4299
  /** One `editMessageText` that replaces a review card's text and its keyboard. */
3181
4300
  async redrawReview(state) {
@@ -3200,6 +4319,81 @@ export class TelegramChannel {
3200
4319
  this.complain(`approval: telegram could not redraw the review card for sample seq ${String(state.card.sampleSeq)} (message ${state.deliveryId}): ${this.describe(cause)} — the log is what it is; only the message is stale`);
3201
4320
  }
3202
4321
  }
4322
+ /**
4323
+ * Refuse a decision tap whose nonce this process is not holding, with the
4324
+ * stale-copy fallback off (APRV-456).
4325
+ *
4326
+ * What it does, in order, and what it never does:
4327
+ *
4328
+ * - It never calls the decision handler, so the gate is never asked. That is
4329
+ * the property: a button this process did not put on the screen cannot
4330
+ * carry a human's tap to `decide()`, whatever reference it carries.
4331
+ * - It looks the reference up among the deliveries THIS process holds open,
4332
+ * and only to name the action on the audit record. A reference that names
4333
+ * nothing open here produces no record: there is no action key this
4334
+ * process could truthfully write, and inventing one from bytes that came
4335
+ * off the network is the self-report §11.1 invariant 4 forbids.
4336
+ * - It hands at most one record per nonce to {@link onRefusedDecision}: a
4337
+ * redelivered or replayed tap on the same button is refused with the same
4338
+ * toast and records nothing.
4339
+ * - It leaves the live card alone. The request stays pending, and the card
4340
+ * this process did send still decides it.
4341
+ *
4342
+ * "Not holding" covers a nonce this process never issued and one it issued
4343
+ * and has since forgotten (an annotated card, a swept delivery, a restart).
4344
+ * It does not try to tell them apart: a set of every nonce ever issued would
4345
+ * reopen the hole for anyone who has seen one genuine button's bytes, because
4346
+ * an old nonce with a new reference would pass it. The cost is that a tap on
4347
+ * an earlier copy after a restart is refused on this shape and the approver
4348
+ * taps the newest card, which the restart banner says.
4349
+ */
4350
+ async refuseUnissued(parsed, callbackId, result, sender) {
4351
+ this.counters.staleCopyRefusals += 1;
4352
+ const live = parsed.actionRef === null ? undefined : this.liveDeliveryFor(parsed.actionRef);
4353
+ const repeat = this.unissuedRefused.has(parsed.nonce);
4354
+ // Re-inserted so a nonce refused again counts as the newest, and capped:
4355
+ // memory, never a decision. A nonce that falls off the end is refused again
4356
+ // and may record again, which is volume and nothing else.
4357
+ this.unissuedRefused.delete(parsed.nonce);
4358
+ this.unissuedRefused.add(parsed.nonce);
4359
+ while (this.unissuedRefused.size > TELEGRAM_REFUSED_NONCE_CAP) {
4360
+ const oldest = this.unissuedRefused.values().next().value;
4361
+ if (oldest === undefined)
4362
+ break;
4363
+ this.unissuedRefused.delete(oldest);
4364
+ }
4365
+ // APRV-456 refutation S4: the nonce is NOT quoted here. This message becomes
4366
+ // `payload.message` on the audit record, and the nonce is bytes whoever
4367
+ // made the card chose; quoting it would let a forger write text of their
4368
+ // choosing into the hash-chained log, one tap at a time.
4369
+ const message = `${parsed.decision} tap refused: its button carries a nonce this listener is not holding, and this listener's stale-copy fallback is off (a relayed channel, or --no-stale-copy). It was not carried to the gate by its action reference, because behind a relay a button with a real request's reference can sit under text this gate never sent. Nothing was decided; the request, if pending, is still open on the newest card this listener sent.`;
4370
+ const refusal = { ok: false, code: "nonce-not-issued", message };
4371
+ result.refused.push({
4372
+ code: refusal.code,
4373
+ action_key: live?.actionKey ?? null,
4374
+ detail: message,
4375
+ });
4376
+ this.complain(`approval: telegram refused a callback (nonce-not-issued): nonce ${JSON.stringify(parsed.nonce)}${live === undefined ? "" : ` for ${live.actionKey}`}${repeat ? " (again; not recorded twice)" : ""}`);
4377
+ // The toast first: it is the only answer this query gets, and the record
4378
+ // below changes nothing about what the person is told.
4379
+ await this.safeAnswer(callbackId, refusedDecisionLine(refusal.code));
4380
+ if (live === undefined || repeat || this.refusedDecisionHandler === null)
4381
+ return;
4382
+ const decision = {
4383
+ action_key: live.actionKey,
4384
+ decision: parsed.decision,
4385
+ deliveryId: live.deliveryId,
4386
+ ...(sender === undefined ? {} : { sender }),
4387
+ };
4388
+ try {
4389
+ this.refusedDecisionHandler({ decision, refusal });
4390
+ }
4391
+ catch (cause) {
4392
+ // Best effort by contract: the refusal has happened and been told; only
4393
+ // the record is lost, and the operator hears that here.
4394
+ this.complain(`approval: telegram could not record a nonce-not-issued refusal for ${live.actionKey}: ${this.describe(cause)}`);
4395
+ }
4396
+ }
3203
4397
  async ignore(result, callbackId, kind, detail, reply) {
3204
4398
  this.counters.anomalies[kind] += 1;
3205
4399
  result.ignored.push({ kind, detail });
@@ -3274,9 +4468,20 @@ export class TelegramChannel {
3274
4468
  // -------------------------------------------------------------------------
3275
4469
  // Transport
3276
4470
  // -------------------------------------------------------------------------
3277
- /** Replace the token with a placeholder anywhere it appears in `text`. */
4471
+ /**
4472
+ * Replace every secret this channel holds with a placeholder in `text`.
4473
+ *
4474
+ * The bot token always, and since APRV-424 the webhook `secret_token` too,
4475
+ * from the moment {@link registerWebhook} is handed it. Both are values that
4476
+ * only ever travel TO the Bot API, so anything coming back that quotes one
4477
+ * is a diagnostic about to print a credential.
4478
+ */
3278
4479
  redact(text) {
3279
- return this.token.length === 0 ? text : text.split(this.token).join("<token redacted>");
4480
+ let redacted = text;
4481
+ for (const secret of this.secrets) {
4482
+ redacted = redacted.split(secret.value).join(secret.label);
4483
+ }
4484
+ return redacted;
3280
4485
  }
3281
4486
  describe(cause) {
3282
4487
  return this.redact(cause instanceof Error ? cause.message : String(cause));
@@ -3291,26 +4496,30 @@ export class TelegramChannel {
3291
4496
  * replace the real reason with a worse one.
3292
4497
  */
3293
4498
  async describeFailure(response) {
4499
+ const nothing = { description: null, retryAfterSeconds: null };
3294
4500
  let body;
3295
4501
  try {
3296
4502
  body = await response.text();
3297
4503
  }
3298
4504
  catch {
3299
- return null;
4505
+ return nothing;
3300
4506
  }
3301
4507
  let parsed;
3302
4508
  try {
3303
4509
  parsed = JSON.parse(body);
3304
4510
  }
3305
4511
  catch {
3306
- return null;
4512
+ return nothing;
3307
4513
  }
3308
4514
  if (parsed === null || typeof parsed !== "object")
3309
- return null;
4515
+ return nothing;
3310
4516
  const description = parsed["description"];
3311
- if (typeof description !== "string" || description.length === 0)
3312
- return null;
3313
- return this.redact(description);
4517
+ return {
4518
+ description: typeof description !== "string" || description.length === 0 ? null : this.redact(description),
4519
+ // PR #614 recheck NF-2: a 429 says how long to wait, and the review
4520
+ // walkthrough honours it rather than spending its retry budget early.
4521
+ retryAfterSeconds: retryAfterFrom(parsed),
4522
+ };
3314
4523
  }
3315
4524
  /**
3316
4525
  * One Bot API call.
@@ -3340,10 +4549,10 @@ export class TelegramChannel {
3340
4549
  // was thrown out of were the same "HTTP 400" on the operator's
3341
4550
  // terminal. Read best effort — a status is still worth reporting when
3342
4551
  // the body is missing, truncated, or not JSON at all.
3343
- const description = await this.describeFailure(response);
4552
+ const { description, retryAfterSeconds } = await this.describeFailure(response);
3344
4553
  throw new TelegramApiError(description === null
3345
4554
  ? `${method}: HTTP ${response.status}`
3346
- : `${method}: HTTP ${response.status} (${description})`, method, response.status, description);
4555
+ : `${method}: HTTP ${response.status} (${description})`, method, response.status, description, retryAfterSeconds);
3347
4556
  }
3348
4557
  raw = await response.text();
3349
4558
  }
@@ -3377,7 +4586,7 @@ export class TelegramChannel {
3377
4586
  // No HTTP status: this envelope arrived on a 2xx. The description is
3378
4587
  // carried anyway so a caller reads the same field whichever shape the
3379
4588
  // failure took (APRV-277).
3380
- null, description);
4589
+ null, description, retryAfterFrom(envelope));
3381
4590
  }
3382
4591
  return envelope["result"];
3383
4592
  }