approval-md 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +83 -914
  2. package/SPEC.md +98 -13
  3. package/cli.js +66 -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 +19 -0
  8. package/dist/src/channels/contract.js +105 -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.js +6 -1
  17. package/dist/src/channels/tagging.js.map +1 -1
  18. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  19. package/dist/src/channels/telegram-webhook.js +536 -0
  20. package/dist/src/channels/telegram-webhook.js.map +1 -0
  21. package/dist/src/channels/telegram.d.ts +271 -1
  22. package/dist/src/channels/telegram.js +403 -45
  23. package/dist/src/channels/telegram.js.map +1 -1
  24. package/dist/src/cli/amend.js +350 -4
  25. package/dist/src/cli/amend.js.map +1 -1
  26. package/dist/src/cli/attest.js +43 -6
  27. package/dist/src/cli/attest.js.map +1 -1
  28. package/dist/src/cli/channel-relay.d.ts +108 -0
  29. package/dist/src/cli/channel-relay.js +334 -0
  30. package/dist/src/cli/channel-relay.js.map +1 -0
  31. package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
  32. package/dist/src/cli/channel-telegram-webhook.js +709 -0
  33. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  34. package/dist/src/cli/channel-telegram.d.ts +272 -26
  35. package/dist/src/cli/channel-telegram.js +478 -41
  36. package/dist/src/cli/channel-telegram.js.map +1 -1
  37. package/dist/src/cli/channel.d.ts +1 -1
  38. package/dist/src/cli/channel.js +5 -1
  39. package/dist/src/cli/channel.js.map +1 -1
  40. package/dist/src/cli/daemon.js +37 -5
  41. package/dist/src/cli/daemon.js.map +1 -1
  42. package/dist/src/cli/doctor.d.ts +63 -0
  43. package/dist/src/cli/doctor.js +856 -111
  44. package/dist/src/cli/doctor.js.map +1 -1
  45. package/dist/src/cli/execute.d.ts +17 -1
  46. package/dist/src/cli/execute.js +341 -20
  47. package/dist/src/cli/execute.js.map +1 -1
  48. package/dist/src/cli/exit-codes.d.ts +12 -0
  49. package/dist/src/cli/exit-codes.js +13 -0
  50. package/dist/src/cli/exit-codes.js.map +1 -1
  51. package/dist/src/cli/gate.d.ts +2 -0
  52. package/dist/src/cli/gate.js +233 -2
  53. package/dist/src/cli/gate.js.map +1 -1
  54. package/dist/src/cli/help.d.ts +9 -1
  55. package/dist/src/cli/help.js +246 -38
  56. package/dist/src/cli/help.js.map +1 -1
  57. package/dist/src/cli/hook.d.ts +371 -3
  58. package/dist/src/cli/hook.js +1926 -44
  59. package/dist/src/cli/hook.js.map +1 -1
  60. package/dist/src/cli/init.js +16 -7
  61. package/dist/src/cli/init.js.map +1 -1
  62. package/dist/src/cli/instructions.js +24 -3
  63. package/dist/src/cli/instructions.js.map +1 -1
  64. package/dist/src/cli/log-anchor.d.ts +55 -0
  65. package/dist/src/cli/log-anchor.js +199 -60
  66. package/dist/src/cli/log-anchor.js.map +1 -1
  67. package/dist/src/cli/main.d.ts +0 -12
  68. package/dist/src/cli/main.js +51 -0
  69. package/dist/src/cli/main.js.map +1 -1
  70. package/dist/src/cli/muse.d.ts +2 -0
  71. package/dist/src/cli/muse.js +62 -0
  72. package/dist/src/cli/muse.js.map +1 -0
  73. package/dist/src/cli/payload.js +79 -1
  74. package/dist/src/cli/payload.js.map +1 -1
  75. package/dist/src/cli/scaffold.js +6 -0
  76. package/dist/src/cli/scaffold.js.map +1 -1
  77. package/dist/src/cli/serve.d.ts +53 -0
  78. package/dist/src/cli/serve.js +270 -0
  79. package/dist/src/cli/serve.js.map +1 -0
  80. package/dist/src/cli/up.js +21 -2
  81. package/dist/src/cli/up.js.map +1 -1
  82. package/dist/src/cli/verb-registry.d.ts +2 -0
  83. package/dist/src/cli/verb-registry.js +337 -39
  84. package/dist/src/cli/verb-registry.js.map +1 -1
  85. package/dist/src/cli/wordmark.d.ts +2 -2
  86. package/dist/src/cli/wordmark.js +2 -2
  87. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  88. package/dist/src/core/attest-bootstrap.js +135 -0
  89. package/dist/src/core/attest-bootstrap.js.map +1 -0
  90. package/dist/src/core/attest.d.ts +20 -3
  91. package/dist/src/core/attest.js +25 -0
  92. package/dist/src/core/attest.js.map +1 -1
  93. package/dist/src/core/audit.d.ts +55 -3
  94. package/dist/src/core/audit.js +52 -5
  95. package/dist/src/core/audit.js.map +1 -1
  96. package/dist/src/core/channel-lease.d.ts +322 -0
  97. package/dist/src/core/channel-lease.js +663 -0
  98. package/dist/src/core/channel-lease.js.map +1 -0
  99. package/dist/src/core/command-class.d.ts +51 -0
  100. package/dist/src/core/command-class.js +760 -8
  101. package/dist/src/core/command-class.js.map +1 -1
  102. package/dist/src/core/daemon-host.d.ts +178 -0
  103. package/dist/src/core/daemon-host.js +228 -0
  104. package/dist/src/core/daemon-host.js.map +1 -0
  105. package/dist/src/core/daemon-identity.d.ts +238 -0
  106. package/dist/src/core/daemon-identity.js +252 -0
  107. package/dist/src/core/daemon-identity.js.map +1 -0
  108. package/dist/src/core/gate.d.ts +306 -5
  109. package/dist/src/core/gate.js +772 -24
  110. package/dist/src/core/gate.js.map +1 -1
  111. package/dist/src/core/harness-version.d.ts +128 -4
  112. package/dist/src/core/harness-version.js +179 -12
  113. package/dist/src/core/harness-version.js.map +1 -1
  114. package/dist/src/core/harness-wait.d.ts +82 -3
  115. package/dist/src/core/harness-wait.js +89 -3
  116. package/dist/src/core/harness-wait.js.map +1 -1
  117. package/dist/src/core/log-subscribe.d.ts +21 -0
  118. package/dist/src/core/log-subscribe.js +16 -6
  119. package/dist/src/core/log-subscribe.js.map +1 -1
  120. package/dist/src/core/log-write-layer.d.ts +55 -0
  121. package/dist/src/core/log-write-layer.js +91 -0
  122. package/dist/src/core/log-write-layer.js.map +1 -0
  123. package/dist/src/core/log.d.ts +50 -21
  124. package/dist/src/core/log.js +197 -19
  125. package/dist/src/core/log.js.map +1 -1
  126. package/dist/src/core/payload-census.d.ts +51 -0
  127. package/dist/src/core/payload-census.js +125 -2
  128. package/dist/src/core/payload-census.js.map +1 -1
  129. package/dist/src/core/payload-store.d.ts +31 -0
  130. package/dist/src/core/payload-store.js +113 -8
  131. package/dist/src/core/payload-store.js.map +1 -1
  132. package/dist/src/core/payload.d.ts +18 -3
  133. package/dist/src/core/payload.js +20 -4
  134. package/dist/src/core/payload.js.map +1 -1
  135. package/dist/src/core/policy-diff.d.ts +10 -0
  136. package/dist/src/core/policy-diff.js +10 -2
  137. package/dist/src/core/policy-diff.js.map +1 -1
  138. package/dist/src/core/policy-explain.d.ts +7 -1
  139. package/dist/src/core/policy-explain.js +16 -1
  140. package/dist/src/core/policy-explain.js.map +1 -1
  141. package/dist/src/core/policy-load.d.ts +24 -0
  142. package/dist/src/core/policy-load.js.map +1 -1
  143. package/dist/src/core/policy-match.d.ts +38 -0
  144. package/dist/src/core/policy-match.js +35 -0
  145. package/dist/src/core/policy-match.js.map +1 -1
  146. package/dist/src/core/policy-proposal.d.ts +23 -0
  147. package/dist/src/core/policy-proposal.js +8 -2
  148. package/dist/src/core/policy-proposal.js.map +1 -1
  149. package/dist/src/core/read-scope.d.ts +10 -0
  150. package/dist/src/core/read-scope.js +4 -0
  151. package/dist/src/core/read-scope.js.map +1 -1
  152. package/dist/src/core/run-payload.d.ts +160 -0
  153. package/dist/src/core/run-payload.js +304 -0
  154. package/dist/src/core/run-payload.js.map +1 -0
  155. package/dist/src/core/sender-identity.d.ts +35 -10
  156. package/dist/src/core/sender-identity.js +48 -11
  157. package/dist/src/core/sender-identity.js.map +1 -1
  158. package/dist/src/core/state.d.ts +52 -1
  159. package/dist/src/core/state.js +52 -6
  160. package/dist/src/core/state.js.map +1 -1
  161. package/dist/src/core/telegram-config.d.ts +79 -0
  162. package/dist/src/core/telegram-config.js +108 -0
  163. package/dist/src/core/telegram-config.js.map +1 -1
  164. package/dist/src/core/verify.d.ts +23 -1
  165. package/dist/src/core/verify.js +20 -2
  166. package/dist/src/core/verify.js.map +1 -1
  167. package/dist/src/daemon/audit.d.ts +80 -5
  168. package/dist/src/daemon/audit.js +126 -6
  169. package/dist/src/daemon/audit.js.map +1 -1
  170. package/dist/src/daemon/daemon.d.ts +174 -2
  171. package/dist/src/daemon/daemon.js +298 -7
  172. package/dist/src/daemon/daemon.js.map +1 -1
  173. package/dist/src/daemon/git-evidence.d.ts +2 -2
  174. package/dist/src/daemon/git-evidence.js +1 -1
  175. package/dist/src/daemon/projection.d.ts +13 -2
  176. package/dist/src/daemon/projection.js +39 -6
  177. package/dist/src/daemon/projection.js.map +1 -1
  178. package/dist/src/mcp/server.d.ts +39 -3
  179. package/dist/src/mcp/server.js +22 -3
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/dist/src/muse/server.d.ts +16 -0
  182. package/dist/src/muse/server.js +238 -0
  183. package/dist/src/muse/server.js.map +1 -0
  184. package/dist/src/serve/archive.d.ts +158 -0
  185. package/dist/src/serve/archive.js +354 -0
  186. package/dist/src/serve/archive.js.map +1 -0
  187. package/dist/src/serve/arguments.d.ts +93 -0
  188. package/dist/src/serve/arguments.js +228 -0
  189. package/dist/src/serve/arguments.js.map +1 -0
  190. package/dist/src/serve/credentials.d.ts +91 -0
  191. package/dist/src/serve/credentials.js +143 -0
  192. package/dist/src/serve/credentials.js.map +1 -0
  193. package/dist/src/serve/follow.d.ts +86 -0
  194. package/dist/src/serve/follow.js +101 -0
  195. package/dist/src/serve/follow.js.map +1 -0
  196. package/dist/src/serve/hook-thread.d.ts +134 -0
  197. package/dist/src/serve/hook-thread.js +333 -0
  198. package/dist/src/serve/hook-thread.js.map +1 -0
  199. package/dist/src/serve/hook-worker.d.ts +134 -0
  200. package/dist/src/serve/hook-worker.js +173 -0
  201. package/dist/src/serve/hook-worker.js.map +1 -0
  202. package/dist/src/serve/server.d.ts +412 -0
  203. package/dist/src/serve/server.js +1251 -0
  204. package/dist/src/serve/server.js.map +1 -0
  205. package/docs/README-extended.md +766 -0
  206. package/docs/cli-reference.md +1633 -43
  207. package/package.json +4 -2
  208. package/schema/event.schema.json +59 -2
  209. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  210. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  211. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  212. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  213. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  214. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  215. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  216. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  217. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  218. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  219. package/schema/fixtures/event/valid/daemon-identity.json +15 -0
  220. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  221. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  222. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  223. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  224. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  225. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  226. package/schema/policy.schema.json +50 -6
@@ -110,6 +110,7 @@
110
110
  import { readFileSync, statSync } from "node:fs";
111
111
  import { isAbsolute, resolve as resolvePathSegments } from "node:path";
112
112
  import { HUMAN_ACTOR_ENV, resolveHumanActor } from "../core/attest.js";
113
+ import { isProposalTask } from "../core/gate.js";
113
114
  import { assembleBatch } from "../channels/batch.js";
114
115
  import { recordChannelDecision, refusedDecisionLine, } from "../channels/contract.js";
115
116
  import { ageText, buildPendingQueue, } from "../channels/tagging.js";
@@ -118,7 +119,8 @@ import { actionRefOf, decidedLine, groupForDigest, isMessageNotModified, isTeleg
118
119
  import { openReviewCards } from "./audit-card.js";
119
120
  import { reviewSample } from "../core/audit.js";
120
121
  import { abandonedAfterMs, HOOK_DEFAULT_WAIT_MS, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
121
- import { telegramDeliveryFor } from "../core/telegram-config.js";
122
+ import { normaliseWebhookUrl, redactWebhookUrl, telegramDeliveryFor, } from "../core/telegram-config.js";
123
+ import { reclaimDetail, takeChannelLease, } from "../core/channel-lease.js";
122
124
  import { loadPolicy } from "../core/policy-load.js";
123
125
  import { valueFindings } from "../core/instance.js";
124
126
  import { defaultSourceRunner } from "../core/env-file.js";
@@ -216,38 +218,164 @@ function env(name) {
216
218
  return value === undefined || value.trim().length === 0 ? null : value.trim();
217
219
  }
218
220
  /**
219
- * Claim this listener's bot before it polls (APRV-390).
220
- *
221
- * ONE `getMe`, once per process, before the first `getUpdates`. Two things
222
- * come out of it: the bot's identity, which is the only thing that can tell
223
- * two instances holding two differently-named copies of one token apart; and
224
- * a claim in the per-machine registry, which is what makes the SECOND such
225
- * listener refuse instead of joining a 409 loop.
226
- *
227
- * ## Why an unreachable Bot API is not a refusal
228
- *
229
- * A `getMe` that fails says nothing about ownership. The machine may be behind
230
- * a captive portal, the API may be rate-limiting, the network may be down for
231
- * four seconds. Refusing to start on that would mean a transient network fault
232
- * took the phone channel down until somebody noticed, in exchange for no
233
- * safety: an unreachable Bot API is also a `getUpdates` that cannot conflict
234
- * with anything. So the preflight says what happened and lets the listener
235
- * start, where the existing retry loop is already the right behaviour. The
236
- * refusal is reserved for the case this task is about, which is a bot that is
237
- * demonstrably somebody else's.
221
+ * Claim this listener's bot before it polls (APRV-390, extended by APRV-424).
222
+ *
223
+ * Three questions, in order, and each one has its own authority:
224
+ *
225
+ * 1. **Does anything else in THIS gate already hold the Telegram transport?**
226
+ * A lockfile in the gate's own `daemon/` directory (`core/channel-lease.ts`)
227
+ * answers it, and the answer is per PROCESS. This is the check the review of
228
+ * APRV-424 added, and the hole it closes is the one neither of the other two
229
+ * could see: an `approval up` long-polling and an `approval channel telegram
230
+ * webhook` started in the same project pass both of them — `getWebhookInfo`
231
+ * is empty while the poller is the one running, and the ownership registry
232
+ * compares instance ids, which are equal because it IS the same instance —
233
+ * and then each runs its own dispatch cycle over its own state against one
234
+ * log, so every request reaches the phone twice under two nonces and only
235
+ * one copy can resolve a tap.
236
+ * 2. **What is the Bot API delivering this bot's updates to?** `getWebhookInfo`
237
+ * answers it, and it is the only thing that can see a webhook registered
238
+ * from another machine. A webhook beats a poller outright — `getUpdates` is
239
+ * refused while one is set — so any registration refuses a poller.
240
+ * 3. **Does another INSTANCE on this machine hold this bot?** `getMe` plus the
241
+ * per-machine registry answers it (APRV-390's incident: two gates, one
242
+ * token, two names, a 409 on every poll).
243
+ *
244
+ * ## An unreachable Bot API: fail-soft for a poller, fail-closed for a webhook
245
+ *
246
+ * A `getMe` or a `getWebhookInfo` that fails says nothing about ownership. The
247
+ * machine may be behind a captive portal, the API may be rate-limiting, the
248
+ * network may be down for four seconds. For a POLLER, refusing on that would
249
+ * mean a transient network fault took the phone channel down until somebody
250
+ * noticed, in exchange for no safety: an unreachable Bot API is also a
251
+ * `getUpdates` that cannot conflict with anything, and the HTTP 409 the loop
252
+ * already reports is the backstop. So the poller says what happened and starts.
253
+ *
254
+ * For the WEBHOOK VERB there is no such backstop, and the review of APRV-424
255
+ * turned this direction around. Its next act is `setWebhook`, which overwrites
256
+ * whatever registration exists without asking, so proceeding on an unknown
257
+ * answer is proceeding to take another host's taps with no error anywhere. A
258
+ * probe it could not make is therefore a refusal (`webhook-probe-failed`), and
259
+ * the operator retries or passes `--reclaim` deliberately.
238
260
  */
239
- export async function claimListenerBot(setup, report) {
261
+ export async function claimListenerBot(setup, report, options = {}) {
262
+ const mode = options.mode ?? (options.webhookUrl === undefined ? "poll" : "webhook");
263
+ // The local, deterministic question first: it needs no network, and a
264
+ // refusal here is one an operator can act on without waiting for a round
265
+ // trip. Released again below if a later check refuses, so a refused start
266
+ // never leaves a lease behind for the next one to reclaim.
267
+ const leased = takeChannelLease(setup.logPath, mode, {
268
+ note: (message) => report(`approval: ${message}`),
269
+ });
270
+ if (!leased.ok) {
271
+ return {
272
+ ok: false,
273
+ code: leased.code,
274
+ message: leased.message,
275
+ };
276
+ }
277
+ if (leased.reclaimed !== null && leased.reclaimedBecause !== null) {
278
+ report(`approval: ${reclaimDetail(leased.reclaimed, leased.reclaimedBecause)}`);
279
+ }
280
+ /**
281
+ * Did THIS gate's own webhook runner die without removing its registration?
282
+ *
283
+ * The evidence is the lease this take just reclaimed: a record in this
284
+ * gate's own lockfile, written by a webhook runner, whose process is no
285
+ * longer the one holding it. A SIGKILL leaves exactly that, plus a
286
+ * registration at the url the dead process had registered, and without this
287
+ * every restart would refuse `webhook-registered` until an operator baked
288
+ * `--reclaim` into the unit file — which would retire finding 6's protection
289
+ * for good, in exchange for a crash recovery.
290
+ *
291
+ * It is deliberately narrow. Same gate (it is the same lockfile), same
292
+ * transport (a dead poller proves nothing about a registration), the process
293
+ * demonstrably GONE, and the url still has to match after normalisation.
294
+ * Anything else keeps refusing.
295
+ *
296
+ * "Gone" is the only reclaim reason that counts (third review, finding 2).
297
+ * The other two are reclaims on a judgement about a NUMBER rather than a
298
+ * death: `foreign` means a pid this process cannot signal, and `recycled`
299
+ * means a pid whose process started after the lease was written. Both are
300
+ * the right call for the lease, which only has to stop being held; neither
301
+ * is evidence that the webhook runner that registered this url has stopped
302
+ * running, and on a shared machine a foreign pid is as likely to be the
303
+ * OTHER host's live runner. So they keep the refusal and the `--reclaim`
304
+ * demand.
305
+ */
306
+ const ownWebhookDied = leased.reclaimed !== null &&
307
+ leased.reclaimed.mode === "webhook" &&
308
+ leased.reclaimedBecause === "gone";
309
+ const release = () => leased.lease.release();
310
+ const refuse = (code, message) => {
311
+ release();
312
+ return { ok: false, code, message };
313
+ };
240
314
  let identity;
241
315
  try {
242
316
  identity = await setup.channel.identify();
243
317
  }
244
318
  catch (cause) {
245
- report(`approval: telegram getMe could not be reached (${cause instanceof Error ? cause.message : String(cause)}), so which bot this token names is unknown and no ownership was recorded; starting anyway`);
246
- return { ok: true };
319
+ const detail = cause instanceof Error ? cause.message : String(cause);
320
+ if (mode === "webhook") {
321
+ // Fail closed: see the module note above. `setWebhook` would overwrite a
322
+ // registration this process could not see.
323
+ return refuse("webhook-probe-failed", `telegram getMe could not be reached (${detail}), so which bot this token names and what is already receiving its updates are both unknown. This verb registers a webhook, and setWebhook overwrites whatever is registered without reporting it, so it does not run on an unknown answer: fix the connection and start again, or pass --reclaim to take the bot deliberately`);
324
+ }
325
+ report(`approval: telegram getMe could not be reached (${detail}), so which bot this token names is unknown and no ownership was recorded; starting anyway`);
326
+ return { ok: true, lease: leased.lease };
247
327
  }
248
328
  if (identity.id.length === 0) {
329
+ if (mode === "webhook") {
330
+ return refuse("webhook-probe-failed", "telegram getMe answered without a bot id, so which bot this token names is unknown. This verb registers a webhook over whatever is already registered, and will not do that on an unknown answer");
331
+ }
249
332
  report("approval: telegram getMe answered without a bot id, so no ownership was recorded; starting anyway");
250
- return { ok: true };
333
+ return { ok: true, lease: leased.lease };
334
+ }
335
+ // APRV-424, and before the ownership claim: the registry answers "which
336
+ // process on THIS machine holds this bot", and the Bot API answers "what is
337
+ // this bot's updates going to at all". A webhook beats both — `getUpdates`
338
+ // is refused while one is set — so a listener started here would receive
339
+ // nothing whoever else is or is not running locally.
340
+ const hooked = await webhookOwner(setup);
341
+ if (hooked.state === "unknown") {
342
+ if (mode === "webhook") {
343
+ return refuse("webhook-probe-failed", `telegram getWebhookInfo could not be reached (${hooked.detail}), so whether another host's webhook holds this bot is unknown. setWebhook would overwrite it without saying so, and the displaced receiver would simply stop being posted to, so this verb refuses rather than guessing: try again, or pass --reclaim to register over whatever is there`);
344
+ }
345
+ // The poller's documented fail-soft (see the module note): what a missed
346
+ // probe costs is the clear refusal, and the 409 path is still there.
347
+ report(`approval: telegram getWebhookInfo could not be reached (${hooked.detail}), so whether a webhook holds this bot is unknown; starting anyway`);
348
+ }
349
+ if (hooked.state === "registered") {
350
+ const queued = hooked.pendingUpdateCount === 0
351
+ ? ""
352
+ : ` (${String(hooked.pendingUpdateCount)} update(s) are queued for it)`;
353
+ // Origin plus a redacted path, never the url as the Bot API spelled it:
354
+ // this is somebody else's registration, its path may itself be a bearer
355
+ // value (review finding 10), and the origin is what tells hosts apart.
356
+ const shown = redactWebhookUrl(hooked.url);
357
+ if (options.webhookUrl === undefined) {
358
+ return refuse("webhook-registered", `telegram ${identity.username} delivers its updates to a webhook at ${shown}, and the Bot API refuses getUpdates while one is set, so this listener would receive no tap at all${queued}. Long polling and a webhook are alternatives per bot: stop the \`approval channel telegram webhook\` process, which removes the webhook as it exits, or give this instance its own bot with \`approval setup channel telegram\``);
359
+ }
360
+ const mine = normaliseWebhookUrl(options.webhookUrl);
361
+ const theirs = normaliseWebhookUrl(hooked.url);
362
+ const same = mine !== null && theirs !== null && mine === theirs;
363
+ if (same && ownWebhookDied) {
364
+ // The restart case, and the only one that needs no flag: this gate's own
365
+ // webhook runner was killed without the chance to call `deleteWebhook`,
366
+ // and what is registered is the url it left behind.
367
+ report(`approval: the webhook at ${shown} is the one this gate's own runner registered before it died (its lease was reclaimed from pid ${String(leased.reclaimed?.pid ?? 0)}); re-registering the same url, which needs no --reclaim`);
368
+ }
369
+ else if (options.reclaim !== true) {
370
+ return refuse("webhook-registered", `telegram ${identity.username} already delivers its updates to a webhook at ${shown}${queued}. ${same
371
+ ? "That is the url this process was asked to register, which looks like a restart and may not be one: two hosts running one configuration both read this as their own, the second overwrites the first's secret_token, and from then on every real tap reaches the first host as a forgery"
372
+ : "This process was asked to register a different url, and a bot has exactly one webhook: registering would silently take the taps away from whatever is serving that one"}. Telegram allows one webhook per bot and keeps the last registration. Stop the other process, or pass --reclaim to register over it deliberately`);
373
+ }
374
+ else {
375
+ report(same
376
+ ? `approval: --reclaim: re-registering ${shown}, which is the url already registered for ${identity.username}`
377
+ : `approval: --reclaim: registering over the webhook at ${shown}; whatever is serving it stops being posted to, because Telegram keeps only the last registration for ${identity.username}`);
378
+ }
251
379
  }
252
380
  const claim = claimBot(setup.logPath, {
253
381
  channel: "telegram",
@@ -257,16 +385,28 @@ export async function claimListenerBot(setup, report) {
257
385
  });
258
386
  if (!claim.ok) {
259
387
  if (!setup.allowCrossInstance) {
260
- return { ok: false, code: "bot-owned-elsewhere", message: claim.message };
388
+ return refuse("bot-owned-elsewhere", claim.message);
261
389
  }
262
390
  // The deliberate case. It says what it is doing, and it does NOT record a
263
391
  // second claim: the registry answers "who owns this bot", and two owners
264
392
  // is the state it exists to report rather than a state to write down.
265
393
  report(`approval: --allow-cross-instance: starting anyway — ${claim.message}`);
266
- return { ok: true };
394
+ return { ok: true, lease: leased.lease };
267
395
  }
268
396
  report(`approval: telegram ${identity.username} (bot id ${identity.id}) is this instance's own; no update was consumed by this check`);
269
- return { ok: true };
397
+ return { ok: true, lease: leased.lease };
398
+ }
399
+ async function webhookOwner(setup) {
400
+ let info;
401
+ try {
402
+ info = await setup.channel.webhookInfo();
403
+ }
404
+ catch (cause) {
405
+ return { state: "unknown", detail: cause instanceof Error ? cause.message : String(cause) };
406
+ }
407
+ return info.url.length === 0
408
+ ? { state: "none" }
409
+ : { state: "registered", url: info.url, pendingUpdateCount: info.pendingUpdateCount };
270
410
  }
271
411
  /**
272
412
  * What to append to a runtime 409, naming the other instance if one is known.
@@ -343,6 +483,54 @@ export const LISTEN_REFUSAL_CODES = [
343
483
  * which two gates are involved instead of an HTTP 409 loop.
344
484
  */
345
485
  "bot-owned-elsewhere",
486
+ /**
487
+ * This bot's updates are being delivered to a webhook (APRV-424).
488
+ *
489
+ * The two transports are alternatives per bot: the Bot API refuses
490
+ * `getUpdates` outright while a webhook is set, so a listener that started
491
+ * here would print a 409 every few seconds and receive not one tap. Refused
492
+ * BEFORE the first poll, in the same preflight and for the same reason
493
+ * `bot-owned-elsewhere` is, so the operator reads which transport owns the
494
+ * bot instead of a status code.
495
+ *
496
+ * Since the review of APRV-424 it carries a second, local source: the
497
+ * transport lease in this gate's own `daemon/` directory, when the process
498
+ * holding it is a webhook runner. Same fact, same repair, so the same code.
499
+ */
500
+ "webhook-registered",
501
+ /**
502
+ * A long-poll listener in THIS gate already holds the Telegram transport
503
+ * (APRV-424 review finding 1).
504
+ *
505
+ * The counterpart to `webhook-registered`, from the same lease. It is what
506
+ * the webhook verb meets when an `approval up` or an `approval channel
507
+ * telegram listen` is already running in the same project — the case
508
+ * `getWebhookInfo` cannot see (nothing is registered yet) and the ownership
509
+ * registry cannot see either (it is the same instance). Two of them on one
510
+ * log put every prompt on the phone twice, under two nonces, and only one
511
+ * copy can resolve a tap.
512
+ */
513
+ "telegram-poller-running",
514
+ /**
515
+ * The transport lease could not be taken at all (APRV-424 review finding 1).
516
+ *
517
+ * A `daemon/` directory that cannot be created, a lockfile that cannot be
518
+ * written. Unlike the bot registry, whose absence is the status quo, this
519
+ * file is the only thing keeping one gate from running two transports, so a
520
+ * gate that cannot hold one does not start.
521
+ */
522
+ "telegram-lease-unavailable",
523
+ /**
524
+ * The webhook verb could not learn what already holds this bot (APRV-424
525
+ * review finding 8).
526
+ *
527
+ * `getMe` or `getWebhookInfo` failed, and the next act would have been a
528
+ * `setWebhook` that overwrites a registration nobody saw. A poller keeps its
529
+ * fail-soft on the same failure, because an unreachable Bot API is also a
530
+ * `getUpdates` that cannot conflict and the HTTP 409 loop is its backstop;
531
+ * this verb has no backstop, so it refuses.
532
+ */
533
+ "webhook-probe-failed",
346
534
  ];
347
535
  /**
348
536
  * Everything that can fail without touching the network, in order.
@@ -903,7 +1091,14 @@ function terminalDeliveries(setup, state, now) {
903
1091
  // `granted`, a declined one `rejected`, a superseded one `withdrawn`
904
1092
  // (a newer proposal is the live question now), and a lapsed one `expired`.
905
1093
  if (isAttestationActionKey(actionKey)) {
906
- const proposal = proposalRecords(read.records).find((entry) => entry.action_key === actionKey);
1094
+ // The LATEST proposal under this key (APRV-455 refuter M2). Every
1095
+ // proposal of one policy hash shares `policy.attest:<sha256>`, and since
1096
+ // a relay may propose bytes already in force a second proposal of an
1097
+ // attested hash is ordinary: settling the live one by the first one's
1098
+ // answer would retire an open prompt as "granted" by somebody else.
1099
+ const proposal = proposalRecords(read.records)
1100
+ .filter((entry) => entry.action_key === actionKey)
1101
+ .at(-1);
907
1102
  const derived = proposal === undefined ? null : proposalState(read.records, proposal.seq, now);
908
1103
  if (derived === null || derived.state === "open")
909
1104
  continue;
@@ -1000,11 +1195,17 @@ export async function dispatchPending(setup, streams, state, now) {
1000
1195
  digests: [],
1001
1196
  pruned: [],
1002
1197
  };
1003
- const queue = buildPendingQueue(setup.logPath, setup.tagOptions, now);
1004
- if (!queue.ok) {
1005
- result.queueError = { code: queue.code, message: queue.message };
1198
+ const built = buildPendingQueue(setup.logPath, setup.tagOptions, now);
1199
+ if (!built.ok) {
1200
+ result.queueError = { code: built.code, message: built.message };
1006
1201
  return result;
1007
1202
  }
1203
+ // APRV-425. One ordering, applied once, so every pass below reads the same
1204
+ // sequence: the annotation sweep, the paced walkthrough's seeding, the
1205
+ // collapse, and the digest grouping. `orderPending` drops nothing and decides
1206
+ // nothing, so the SET this cycle works from is `buildPendingQueue`'s exactly;
1207
+ // what changes is which request an approver meets first.
1208
+ const queue = { ...built, requests: orderPending(built.requests, now) };
1008
1209
  // APRV-106 (withdrawal) generalized by APRV-113 (every terminal state),
1009
1210
  // before the sends. A request this process delivered and that the log now
1010
1211
  // says is settled — granted or rejected at any surface, revoked, expired by
@@ -1080,6 +1281,27 @@ export async function dispatchPending(setup, streams, state, now) {
1080
1281
  forget(state, actionKey);
1081
1282
  result.pruned.push({ action_key: actionKey, reason: "stale" });
1082
1283
  }
1284
+ // APRV-442. A tap the gate refused (a wrong account's, `sender-unmapped`, is
1285
+ // the case that found this) disarms its card, and the channel says which
1286
+ // cards it disarmed that way. The card is gone; the REQUEST is not, unless
1287
+ // the verified log says so. A released key the log still calls pending loses
1288
+ // its delivery bookkeeping here, so the sends below offer it again as a
1289
+ // fresh card in this same cycle. One the log has settled is left exactly
1290
+ // where it is, for the terminal annotation pass above to finish on the next
1291
+ // cycle as it always has. Nothing about the refusal changes: it was recorded
1292
+ // by the gate before this ran, and the new card's buttons reach the same gate.
1293
+ for (const actionKey of setup.channel.takeReleased()) {
1294
+ if (!pendingNow.has(actionKey) || !state.delivered.has(actionKey))
1295
+ continue;
1296
+ forget(state, actionKey);
1297
+ // Under `paced` the released card WAS the question in front of the
1298
+ // approver, and a selection that still believed it was showing would
1299
+ // decline to show anything. Clearing it lets the walkthrough re-offer the
1300
+ // request, which is the first pending key in its order not on the phone.
1301
+ if (state.paced.current?.includes(actionKey) === true)
1302
+ state.paced.current = null;
1303
+ result.pruned.push({ action_key: actionKey, reason: "released" });
1304
+ }
1083
1305
  for (const skipped of queue.skipped) {
1084
1306
  const token = `${skipped.action_key}:${skipped.code}`;
1085
1307
  if (state.warned.has(token))
@@ -1405,6 +1627,119 @@ export const COLLAPSE_STALE_AFTER_MS = abandonedAfterMs(HOOK_DEFAULT_WAIT_MS, HO
1405
1627
  * save nobody a message. A flood starts at two.
1406
1628
  */
1407
1629
  const COLLAPSE_MIN = 2;
1630
+ /**
1631
+ * Pending requests a NEWER pending request has superseded (APRV-425).
1632
+ *
1633
+ * Two requests naming the same payload bytes and the same class are two askings
1634
+ * of one question, and only the newer one has an asker. The shape that produces
1635
+ * them is APRV-287's own: a hook whose wait ran out leaves its request open for
1636
+ * the retry grace, the grace runs out, the hook withdraws it on its NEXT
1637
+ * invocation — and if that next invocation never comes while a later session
1638
+ * asks the same thing, both sit in the queue. Dogfooding produced exactly this
1639
+ * complaint (APRV-118): a live prompt buried behind dead ones, and an approver
1640
+ * rejecting the one they wanted.
1641
+ *
1642
+ * Keyed by `(payload_hash, class)` and not by task, because the task id a
1643
+ * harness adapter mints is fresh per tool call (SPEC.md §10.2), so two askings
1644
+ * of one command never share one. The bytes and the class are what a decision
1645
+ * binds to, which is what makes them the right identity here.
1646
+ *
1647
+ * Computed from the pending set alone, which is re-derived from the verified log
1648
+ * every cycle, so this decides nothing and remembers nothing. A superseded
1649
+ * request is COLLAPSED, never closed: it stays pending in the log, listable, and
1650
+ * decidable from any copy already delivered. Only a human's decision or the
1651
+ * requester's own withdrawal ends it.
1652
+ */
1653
+ export function supersededPending(requests) {
1654
+ const newest = new Map();
1655
+ const superseded = new Set();
1656
+ for (const request of requests) {
1657
+ // An attestation prompt is never a duplicate of anything: its identity is
1658
+ // the policy bytes it proposes, two proposals of the same bytes are refused
1659
+ // upstream, and one that reached here is the live one.
1660
+ if (request.policy_diff !== undefined)
1661
+ continue;
1662
+ // APRV-445 refutation (S2). A proposal is its own asking, held for days by
1663
+ // design and keyed by the intention it names; another request over the
1664
+ // same bytes is not a newer copy of it, and it is not a newer copy of one.
1665
+ if (isProposalTask(request.task.value))
1666
+ continue;
1667
+ const identity = JSON.stringify([request.payload_hash.value, request.class.value]);
1668
+ const key = request.action_key.value;
1669
+ const at = Date.parse(request.requested_ts.value);
1670
+ const previous = newest.get(identity);
1671
+ if (previous === undefined) {
1672
+ newest.set(identity, { key, at: Number.isNaN(at) ? 0 : at });
1673
+ continue;
1674
+ }
1675
+ // Later wins, and an unreadable instant loses: a request whose age cannot be
1676
+ // established is not the one to keep in front of an approver. Ties go to the
1677
+ // one later in log order, which is the one the list already put second.
1678
+ if (Number.isNaN(at) || at < previous.at) {
1679
+ superseded.add(key);
1680
+ continue;
1681
+ }
1682
+ superseded.add(previous.key);
1683
+ newest.set(identity, { key, at });
1684
+ }
1685
+ return superseded;
1686
+ }
1687
+ /**
1688
+ * The pending set in the order an approver should meet it (APRV-425).
1689
+ *
1690
+ * Three rules, in this order, and each is a complaint from running the gate:
1691
+ *
1692
+ * 1. **Attestation prompts last.** `buildPendingQueue` puts them there on
1693
+ * purpose: a policy amendment changes the rules every entry above it was
1694
+ * routed by, so an approver reading top to bottom answers the questions asked
1695
+ * under the current policy before changing what the current policy is. That
1696
+ * placement is not this function's to relitigate, so proposals are lifted
1697
+ * out, ordered among themselves by nothing at all (log order), and put back
1698
+ * at the end.
1699
+ * 2. **Live requests first, newest first.** Live means younger than
1700
+ * {@link COLLAPSE_STALE_AFTER_MS}, the hook's wait plus its retry grace,
1701
+ * which is the same boundary the collapse uses — one number, one meaning of
1702
+ * "somebody may still be holding this". Newest first because the newest is
1703
+ * the one most likely to have a tool call blocked on it right now, and the
1704
+ * observed failure was a live prompt buried behind dead ones.
1705
+ * 3. **Stale requests after them, oldest first.** They are what the collapse
1706
+ * takes on a first cycle, and where the collapse does not apply (fewer than
1707
+ * {@link COLLAPSE_MIN} of them) oldest-first is the order to work through a
1708
+ * backlog in.
1709
+ *
1710
+ * Stable inside each bucket, so log order breaks every tie and the result is
1711
+ * deterministic for one log and one instant. It is an ORDER and nothing else:
1712
+ * no request is dropped, none is decided, and the set that comes out is the set
1713
+ * that went in.
1714
+ */
1715
+ export function orderPending(requests, now) {
1716
+ const nowMs = Date.parse(now);
1717
+ if (Number.isNaN(nowMs))
1718
+ return requests;
1719
+ const proposals = [];
1720
+ const live = [];
1721
+ const stale = [];
1722
+ requests.forEach((request, index) => {
1723
+ if (request.policy_diff !== undefined) {
1724
+ proposals.push(request);
1725
+ return;
1726
+ }
1727
+ const parsed = Date.parse(request.requested_ts.value);
1728
+ // An unreadable instant is treated as ancient, which sorts it with the
1729
+ // requests nobody is waiting on. That is the strict side: a request whose
1730
+ // age cannot be established must not displace one whose age is known.
1731
+ const at = Number.isNaN(parsed) ? 0 : parsed;
1732
+ // APRV-445 refutation (S2): a proposal waits on a human for as long as its
1733
+ // TTL says (days), and its requester polls rather than blocks, so the
1734
+ // hook's wait-plus-grace boundary says nothing about whether anyone is
1735
+ // still holding it. It is always live.
1736
+ const stalePosition = nowMs - at >= COLLAPSE_STALE_AFTER_MS && !isProposalTask(request.task.value);
1737
+ (stalePosition ? stale : live).push({ request, at, index });
1738
+ });
1739
+ live.sort((a, b) => b.at - a.at || a.index - b.index);
1740
+ stale.sort((a, b) => a.at - b.at || a.index - b.index);
1741
+ return [...live.map((entry) => entry.request), ...stale.map((entry) => entry.request), ...proposals];
1742
+ }
1408
1743
  /**
1409
1744
  * A DURATION in words, which is not what {@link ageText} renders.
1410
1745
  *
@@ -1419,8 +1754,17 @@ function durationText(ms) {
1419
1754
  const minutes = Math.round(ms / 60_000);
1420
1755
  return `${String(minutes)}m`;
1421
1756
  }
1422
- /** The computed lines a collapsed re-delivery leads with (APRV-287). */
1423
- export function staleLines(requests, now) {
1757
+ /**
1758
+ * The computed lines a collapsed re-delivery leads with (APRV-287).
1759
+ *
1760
+ * `superseded` is the subset a newer pending request replaced (APRV-425), and
1761
+ * the first line states the split rather than claiming every member is old.
1762
+ * Before this the line said "all older than the hook's wait plus its retry
1763
+ * grace", which stopped being true the moment the collapse widened: a message
1764
+ * that overstates what it collapsed is the stale-documentation failure in
1765
+ * message form, and this one is read by the person deciding.
1766
+ */
1767
+ export function staleLines(requests, now, superseded = new Set()) {
1424
1768
  const nowMs = Date.parse(now);
1425
1769
  const ages = requests
1426
1770
  .map((request) => nowMs - Date.parse(request.requested_ts.value))
@@ -1431,18 +1775,25 @@ export function staleLines(requests, now) {
1431
1775
  const cls = request.class.value;
1432
1776
  tally.set(cls, (tally.get(cls) ?? 0) + 1);
1433
1777
  }
1778
+ const replaced = requests.filter((request) => superseded.has(request.action_key.value)).length;
1779
+ const abandoned = requests.length - replaced;
1780
+ const why = replaced === 0
1781
+ ? `all older than the hook's ${durationText(HOOK_DEFAULT_WAIT_MS)} wait plus its ${durationText(HOOK_RETRY_GRACE_MS)} retry grace`
1782
+ : abandoned === 0
1783
+ ? "all superseded by a newer pending request for the same bytes and class"
1784
+ : `${String(abandoned)} older than the hook's ${durationText(HOOK_DEFAULT_WAIT_MS)} wait plus its ${durationText(HOOK_RETRY_GRACE_MS)} retry grace, ${String(replaced)} superseded by a newer pending request for the same bytes and class`;
1434
1785
  return [
1435
- `${String(requests.length)} pending requests, all older than the hook's ${durationText(HOOK_DEFAULT_WAIT_MS)} wait plus its ${durationText(HOOK_RETRY_GRACE_MS)} retry grace`,
1786
+ `${String(requests.length)} pending requests, ${why}`,
1436
1787
  `oldest: ${oldest === null ? "unknown age" : ageText(oldest)}`,
1437
1788
  `classes: ${[...tally.entries()]
1438
1789
  .map(([cls, count]) => (count === 1 ? cls : `${cls} ×${String(count)}`))
1439
1790
  .join(", ")}`,
1440
- "collapsed into this one message because the tool calls that asked have stopped waiting; a decision on any of them can still authorize an identical retry",
1791
+ "collapsed into this one message because nothing is waiting on them; each one stays pending in the log, and a decision on any of them can still authorize an identical retry",
1441
1792
  ];
1442
1793
  }
1443
1794
  /**
1444
1795
  * Put the requests nobody is waiting on into ONE message, and hand back the
1445
- * ones that still get a message each (APRV-287).
1796
+ * ones that still get a message each (APRV-287, widened by APRV-425).
1446
1797
  *
1447
1798
  * Called on a process's first cycle only, which is exactly a daemon start or a
1448
1799
  * listener reconnect. Everything it does is bookkeeping in the sense SPEC.md
@@ -1450,7 +1801,50 @@ export function staleLines(requests, now) {
1450
1801
  * so a summary that fails to send, or a process that forgets it sent one,
1451
1802
  * degrades to showing those requests again, and never to a pending request
1452
1803
  * nobody is shown.
1804
+ *
1805
+ * ## Two ways in, one exit (APRV-425)
1806
+ *
1807
+ * A request is collapsed when it is older than the hook's wait plus its retry
1808
+ * grace (nobody is holding it), or when a NEWER pending request names the same
1809
+ * payload bytes and class ({@link supersededPending} — the newer one is the live
1810
+ * asking, whatever the age of either). The second is what answers the observed
1811
+ * complaint that a live prompt arrives buried behind dead ones.
1812
+ *
1813
+ * ## This is where the sent-record is rebuilt from the log (AC3)
1814
+ *
1815
+ * Every collapsed member is written into `state.delivered` and `state.sentAtMs`
1816
+ * below, and the set it is written from is the pending set re-derived from the
1817
+ * verified log. That is the whole of the rebuilding, and it is deliberately not
1818
+ * a second mechanism: the log is the only place the question "what is pending"
1819
+ * is answered.
1820
+ *
1821
+ * What it is NOT is evidence of delivery, and the difference is load-bearing.
1822
+ * Nothing reads this record to conclude that an approver saw anything: a
1823
+ * collapsed request stays pending in the log, is listed by `/queue`, and is
1824
+ * decidable from any copy already delivered, because APRV-196 made
1825
+ * `callback_data` carry a restart-stable action ref so a button on a
1826
+ * pre-restart copy still decides the request. Lose the record and the next
1827
+ * cycle shows the requests again, which is the degradation SPEC.md §10.3
1828
+ * requires and the one this code takes.
1829
+ */
1830
+ /**
1831
+ * The members a first-cycle collapse would put into one message: stale or
1832
+ * superseded, and never a proposal (APRV-445 refutation, S2). Proposals are
1833
+ * long-lived by design, so after a six-minute outage every one of them would
1834
+ * otherwise land in one digest whose only bulk answer is reject-all.
1453
1835
  */
1836
+ export function collapsibleStale(undecided, now, superseded = supersededPending([...undecided])) {
1837
+ const nowMs = Date.parse(now);
1838
+ if (Number.isNaN(nowMs))
1839
+ return [];
1840
+ return undecided.filter((request) => {
1841
+ if (isProposalTask(request.task.value))
1842
+ return false;
1843
+ const at = Date.parse(request.requested_ts.value);
1844
+ const age = Number.isNaN(at) ? 0 : nowMs - at;
1845
+ return age >= COLLAPSE_STALE_AFTER_MS || superseded.has(request.action_key.value);
1846
+ });
1847
+ }
1454
1848
  async function collapseStale(setup, streams, state, result, undecided, now) {
1455
1849
  const nowMs = Date.parse(now);
1456
1850
  if (Number.isNaN(nowMs))
@@ -1459,12 +1853,17 @@ async function collapseStale(setup, streams, state, result, undecided, now) {
1459
1853
  const at = Date.parse(request.requested_ts.value);
1460
1854
  return Number.isNaN(at) ? 0 : nowMs - at;
1461
1855
  };
1462
- const stale = undecided.filter((request) => age(request) >= COLLAPSE_STALE_AFTER_MS);
1856
+ // Computed over the WHOLE pending set rather than over `undecided`, so a
1857
+ // request this process has already delivered still supersedes an older twin.
1858
+ const superseded = supersededPending(undecided);
1859
+ const stale = collapsibleStale(undecided, now, superseded);
1463
1860
  if (stale.length < COLLAPSE_MIN)
1464
1861
  return undecided;
1465
1862
  let delivered = null;
1466
1863
  try {
1467
- delivered = await setup.channel.notifyStale(stale, { lines: staleLines(stale, now) });
1864
+ delivered = await setup.channel.notifyStale(stale, {
1865
+ lines: staleLines(stale, now, superseded),
1866
+ });
1468
1867
  }
1469
1868
  catch (cause) {
1470
1869
  delivered = null;
@@ -1673,7 +2072,7 @@ function report(setup, streams, digestId, members) {
1673
2072
  * Report a steady-state cycle's problems on stderr. Startup reports its own,
1674
2073
  * as exit codes, in {@link runListener}.
1675
2074
  */
1676
- function reportCycle(result, streams) {
2075
+ export function reportCycle(result, streams) {
1677
2076
  if (result.queueError !== undefined) {
1678
2077
  streams.err(`approval: telegram cannot read the pending queue (${result.queueError.code}): ${result.queueError.message} — retrying next cycle\n`);
1679
2078
  }
@@ -2060,7 +2459,9 @@ clock = () => new Date().toISOString()) {
2060
2459
  // and `/queue` is the verb that says what that is. Appended rather than
2061
2460
  // interleaved, because the two lists answer different questions: one is
2062
2461
  // what is waiting on the approver, the other what already happened.
2063
- const lines = queueLines(queue.requests, now, state.paced.current ?? []);
2462
+ // APRV-425: the SAME ordering the dispatch cycle uses, because `/queue` is
2463
+ // the approver asking what is waiting and two orders would be two answers.
2464
+ const lines = queueLines(orderPending(queue.requests, now), now, state.paced.current ?? []);
2064
2465
  const built = openReviewCards(setup.logPath);
2065
2466
  if (built.ok && built.cards.length > 0)
2066
2467
  lines.push(...reviewSummaryLines(built.cards, now));
@@ -2125,7 +2526,24 @@ clock = () => new Date().toISOString()) {
2125
2526
  * queue from the verified log and re-sends everything still pending, exactly as
2126
2527
  * a restarted process would. A duplicate on the phone, never a silence.
2127
2528
  */
2128
- export function startListener(setup, streams) {
2529
+ /**
2530
+ * Give the channel every handler the runtime answers a gesture with, and hand
2531
+ * back the fresh dispatch bookkeeping (APRV-424).
2532
+ *
2533
+ * Extracted from {@link startListener} so the webhook runner wires the SAME
2534
+ * four handlers rather than a second set that looks like them. This is the
2535
+ * CLI-side half of the property APRV-424 is about: the transport decides where
2536
+ * an update arrives, and nothing else. A tap that arrives by webhook is
2537
+ * recorded by `handlerFor`'s `recordChannelDecision` — the same actor, the
2538
+ * same sender resolution, the same gate — because it is the same function
2539
+ * object, registered here.
2540
+ *
2541
+ * A FRESH {@link DispatchState} per call, which is the whole of the restart
2542
+ * story: a supervisor that restarts a fallen runner re-derives the pending
2543
+ * queue from the verified log and re-sends everything still pending. A
2544
+ * duplicate on the phone, never a silence.
2545
+ */
2546
+ export function wireListener(setup, streams) {
2129
2547
  const { channel } = setup;
2130
2548
  channel.onDecision(handlerFor(setup, streams));
2131
2549
  // APRV-257. Registered unconditionally, because whether a checkpoint is ever
@@ -2156,6 +2574,11 @@ export function startListener(setup, streams) {
2156
2574
  if (setup.delivery === "paced") {
2157
2575
  channel.onCommand(commandHandlerFor(setup, streams, state));
2158
2576
  }
2577
+ return state;
2578
+ }
2579
+ export function startListener(setup, streams) {
2580
+ const { channel } = setup;
2581
+ const state = wireListener(setup, streams);
2159
2582
  let stopping = false;
2160
2583
  const stop = () => {
2161
2584
  stopping = true;
@@ -2218,6 +2641,11 @@ async function runListener(setup, streams) {
2218
2641
  finally {
2219
2642
  process.off("SIGINT", stop);
2220
2643
  process.off("SIGTERM", stop);
2644
+ // The transport lease goes back on every exit, clean or not (APRV-424
2645
+ // review finding 1). A listener that crashed leaves a lockfile whose pid
2646
+ // is gone and the next taker reclaims it; a listener that stopped politely
2647
+ // leaves nothing to reclaim.
2648
+ claimed.lease.release();
2221
2649
  }
2222
2650
  switch (outcome.kind) {
2223
2651
  case "stopped":
@@ -2352,6 +2780,15 @@ export function commandTelegram(argv, streams, cwd) {
2352
2780
  switch (sub) {
2353
2781
  case "listen":
2354
2782
  return commandTelegramListen(rest, streams, cwd);
2783
+ case "webhook":
2784
+ // APRV-424, and a DYNAMIC import on purpose. The webhook runner reaches
2785
+ // back into this module for `prepareListen`, `wireListener` and
2786
+ // `dispatchPending` — it is the same listener with another arrival — so
2787
+ // a static import here would close the dispatcher/dispatched circle.
2788
+ // An ESM cycle is not a compile error; it is a binding that is
2789
+ // `undefined` in one direction on the day initialisation order changes.
2790
+ // `cli/serve.ts` and `cli/mcp.ts` state the same rule.
2791
+ return import("./channel-telegram-webhook.js").then(({ commandTelegramWebhook }) => commandTelegramWebhook(rest, streams, cwd));
2355
2792
  case "health":
2356
2793
  return commandTelegramHealth(rest, streams, cwd);
2357
2794
  default: