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
@@ -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
@@ -389,6 +407,13 @@ const DEFAULT_MAX_BACKOFF_MS = 30_000;
389
407
  export const TELEGRAM_DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
390
408
  /** Least time between two sweeps. A sweep is O(map); once a minute is plenty. */
391
409
  export const TELEGRAM_SWEEP_INTERVAL_MS = 60_000;
410
+ /**
411
+ * How many refused cards' nonces a channel remembers as dead (APRV-442).
412
+ *
413
+ * A bound on memory and nothing else: a nonce that falls off the end reaches
414
+ * the action-reference fallback, and the gate judges that tap in full.
415
+ */
416
+ export const TELEGRAM_REFUSED_NONCE_CAP = 1024;
392
417
  // ---------------------------------------------------------------------------
393
418
  // Anomalies
394
419
  // ---------------------------------------------------------------------------
@@ -1572,6 +1597,37 @@ export function isPollConflict(cause) {
1572
1597
  cause.method === "getUpdates" &&
1573
1598
  (cause.status === 409 || (cause.description !== null && TELEGRAM_POLL_CONFLICT.test(cause.description))));
1574
1599
  }
1600
+ /** A fresh, empty batch report. One shape for both transports (APRV-424). */
1601
+ function emptyPollResult() {
1602
+ return { updates: 0, outcomes: [], ignored: [], commands: [], reviews: [] };
1603
+ }
1604
+ /**
1605
+ * Why a transport claim was refused. Frozen, per SPEC §11.1 invariant 6.
1606
+ *
1607
+ * One code, because there is one fact: this channel is already receiving
1608
+ * updates by the other transport.
1609
+ */
1610
+ export const TELEGRAM_TRANSPORT_REFUSAL_CODES = ["transport-conflict"];
1611
+ /**
1612
+ * The in-process half of "long-poll and webhook are mutually exclusive"
1613
+ * (APRV-424).
1614
+ *
1615
+ * It is a belt on the braces and says so. The exclusion that matters is per
1616
+ * BOT and lives above this class, where a process can ask the Bot API
1617
+ * (`getWebhookInfo`) and the per-machine ownership registry who else is
1618
+ * receiving this bot's updates; a channel object knows only about itself.
1619
+ * What this rules out is the one failure a caller could cause here and
1620
+ * nowhere else: feeding one channel instance from both a poll loop and an
1621
+ * HTTP handler, where `handleUpdate`'s single ack slot and the delivery maps
1622
+ * would be written by two arrivals nobody ordered.
1623
+ */
1624
+ export class TelegramTransportError extends Error {
1625
+ code = "transport-conflict";
1626
+ constructor(message) {
1627
+ super(message);
1628
+ this.name = "TelegramTransportError";
1629
+ }
1630
+ }
1575
1631
  export class TelegramChannel {
1576
1632
  name = "telegram";
1577
1633
  token;
@@ -1647,11 +1703,60 @@ export class TelegramChannel {
1647
1703
  digests = new Map();
1648
1704
  /** "All" nonce -> the digest message it was issued for. */
1649
1705
  allNonces = new Map();
1706
+ /**
1707
+ * Action keys whose card this process disarmed on a tap the gate did NOT
1708
+ * settle (APRV-442): a refusal (`sender-unmapped`, a budget, an unattested
1709
+ * policy, anything `ok: false`) or a handler that threw. Drained by
1710
+ * {@link takeReleased}.
1711
+ *
1712
+ * The disarm is right and stays, and {@link refusedNonces} keeps it final:
1713
+ * the refused card's bytes resolve to nothing, not even through the
1714
+ * action-reference fallback to the card that replaces it. What the disarm
1715
+ * must not do is leave the listener believing the REQUEST is on the
1716
+ * approver's phone, which is what `DispatchState.delivered` goes on saying
1717
+ * until something tells it otherwise. This set is that something: it names
1718
+ * a card, never an outcome, and the listener re-derives from the verified
1719
+ * log whether the request is still pending before it offers a new card.
1720
+ * Nothing here decides, and losing it to a restart costs nothing, because a
1721
+ * restarted listener re-sends everything pending anyway.
1722
+ */
1723
+ released = new Set();
1724
+ /**
1725
+ * Nonces of cards a refused or failed tap disarmed (APRV-442), oldest first,
1726
+ * capped at {@link TELEGRAM_REFUSED_NONCE_CAP}.
1727
+ *
1728
+ * Without this, a released request's fresh card would give the refused
1729
+ * card's bytes somewhere to go: the nonce misses, the action reference
1730
+ * resolves to the new live card, and a Telegram redelivery of the refused
1731
+ * tap (a webhook retry) or a client replaying the removed buttons would be
1732
+ * refused again, append again, disarm the new card and cause another
1733
+ * re-send, once per replay. A nonce in this set never takes the fallback, so
1734
+ * a refused card stays exactly as dead as it was before APRV-442 and only
1735
+ * the REQUEST is offered again. Forgetting an entry past the cap degrades to
1736
+ * the fallback, whose tap the gate still judges in full: volume, never a
1737
+ * decision.
1738
+ */
1739
+ refusedNonces = new Set();
1650
1740
  rendered = [];
1651
1741
  offset = 0;
1652
1742
  counter = 0;
1653
1743
  stopped = false;
1654
1744
  inFlight = null;
1745
+ /**
1746
+ * The transport this channel has started receiving updates by, or `null`
1747
+ * before the first one (APRV-424). Sticky for the life of the object: a
1748
+ * process that stopped polling has not stopped being a poller, and the
1749
+ * question this answers is which arrival path owns the maps below.
1750
+ */
1751
+ transport = null;
1752
+ /**
1753
+ * Everything {@link redact} scrubs out of anything that leaves this class.
1754
+ *
1755
+ * The bot token from construction, plus any secret a later call was handed:
1756
+ * {@link registerWebhook} adds the `secret_token` before it sends it, so a
1757
+ * Bot API error quoting the request cannot put it on an operator's terminal.
1758
+ */
1759
+ secrets;
1655
1760
  counters = {
1656
1761
  notified: 0,
1657
1762
  updates: 0,
@@ -1671,6 +1776,8 @@ export class TelegramChannel {
1671
1776
  };
1672
1777
  constructor(config) {
1673
1778
  this.token = config.token;
1779
+ this.secrets =
1780
+ config.token.length === 0 ? [] : [{ value: config.token, label: "<token redacted>" }];
1674
1781
  this.chatId = String(config.chatId);
1675
1782
  this.apiBase = (config.apiBase ?? TELEGRAM_DEFAULT_API_BASE).replace(/\/+$/u, "");
1676
1783
  this.fetchImpl = config.fetch ?? globalThis.fetch;
@@ -1933,8 +2040,15 @@ export class TelegramChannel {
1933
2040
  async deliverDigest(members, batchDeliveryId, stale = null) {
1934
2041
  const allNonce = this.makeNonce();
1935
2042
  const deliveredAtMs = this.now();
2043
+ // The digest's window is the widest member's: it may be forgotten only when
2044
+ // every member is past deciding, and a member nothing bounds keeps it.
2045
+ const windows = members.map((member) => this.retentionWindowMs(member));
2046
+ const windowMs = windows.includes(null)
2047
+ ? null
2048
+ : Math.max(...windows.filter((window) => window !== null));
1936
2049
  const state = {
1937
2050
  deliveredAtMs,
2051
+ windowMs,
1938
2052
  // Assigned once the message exists; nothing consults it before then.
1939
2053
  deliveryId: "",
1940
2054
  batchDeliveryId,
@@ -1994,13 +2108,14 @@ export class TelegramChannel {
1994
2108
  state.deliveryId = deliveryId;
1995
2109
  // Armed only now, and all at once: until the message with the buttons on it
1996
2110
  // exists there is nothing a callback could legitimately answer.
1997
- for (const member of state.members) {
2111
+ for (const [index, member] of state.members.entries()) {
1998
2112
  this.deliveries.set(member.nonce, {
1999
2113
  actionKey: member.actionKey,
2000
2114
  actionRef: actionRefOf(member.actionKey),
2001
2115
  deliveryId,
2002
2116
  batchDeliveryId,
2003
2117
  deliveredAtMs,
2118
+ windowMs: windows[index] ?? null,
2004
2119
  });
2005
2120
  }
2006
2121
  this.digests.set(deliveryId, state);
@@ -2092,6 +2207,7 @@ export class TelegramChannel {
2092
2207
  actionRef: actionRefOf(actionKey),
2093
2208
  deliveryId: sent.deliveryId,
2094
2209
  deliveredAtMs: this.now(),
2210
+ windowMs: this.retentionWindowMs(request),
2095
2211
  ...(batchDeliveryId === undefined ? {} : { batchDeliveryId }),
2096
2212
  });
2097
2213
  return {
@@ -2132,6 +2248,31 @@ export class TelegramChannel {
2132
2248
  }
2133
2249
  return actionKey;
2134
2250
  }
2251
+ /**
2252
+ * How long after delivery the request behind a prompt can still be decided,
2253
+ * on this process's clock, or `null` when nothing bounds it (APRV-423, second
2254
+ * review pass).
2255
+ *
2256
+ * The SHORTER of two bounds, each of which is at or past the gate's own
2257
+ * lapse, so the sweep never forgets a button a decision could still reach:
2258
+ *
2259
+ * - the request's `ttl_remaining_ms`, computed by the listener from the
2260
+ * verified log against the window the gate judges by (the policy's TTL
2261
+ * narrowed by the harness cap the requesting hook declared). It was
2262
+ * measured at tagging time, and delivery is at or after tagging, so
2263
+ * counting it from delivery ends at or after the real lapse;
2264
+ * - the policy's own TTL ({@link TelegramConfig.approvalTtlMs}), counted from
2265
+ * delivery, which is at or after the `approval.requested` it really runs
2266
+ * from. This was the only bound before APRV-423 and it stays as one.
2267
+ *
2268
+ * A request neither bounds is decidable forever, and its entry is dropped
2269
+ * only on an observed settlement. This channel still reads no policy and no
2270
+ * log: both numbers arrive computed, by the verb and by the tagger.
2271
+ */
2272
+ retentionWindowMs(request) {
2273
+ const bounds = [request.ttl_remaining_ms.value, this.approvalTtlMs].filter((bound) => bound !== null);
2274
+ return bounds.length === 0 ? null : Math.min(...bounds);
2275
+ }
2135
2276
  /**
2136
2277
  * Drop the delivery bookkeeping no callback can still be honoured against
2137
2278
  * (APRV-135).
@@ -2166,17 +2307,23 @@ export class TelegramChannel {
2166
2307
  */
2167
2308
  sweep(nowMs = this.now()) {
2168
2309
  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;
2310
+ // APRV-423 (second review pass): both halves read the ENTRY's own window,
2311
+ // which is the effective one the gate judges its request by, and fall back
2312
+ // to the policy's TTL and then the default only for an entry that carries
2313
+ // none (a review card, which has no request).
2314
+ const retentionOf = (windowMs) => windowMs ?? this.approvalTtlMs ?? TELEGRAM_DEFAULT_RETENTION_MS;
2315
+ const expired = (deliveredAtMs, windowMs) => nowMs - deliveredAtMs >= retentionOf(windowMs);
2316
+ // Past its window the gate refuses every decision on the request, so it is
2317
+ // terminal whether or not this process saw it settle. With nothing bounding
2318
+ // it nothing expires, and only an observed settlement makes it droppable.
2319
+ const lapsed = (deliveredAtMs, windowMs) => windowMs !== null && nowMs - deliveredAtMs >= windowMs;
2175
2320
  let digests = 0;
2176
2321
  for (const [deliveryId, digest] of this.digests) {
2177
2322
  const terminal = digest.members.every((member) => member.settled !== null);
2178
- if (!(terminal || lapsed(digest.deliveredAtMs)) || !expired(digest.deliveredAtMs))
2323
+ if (!(terminal || lapsed(digest.deliveredAtMs, digest.windowMs)) ||
2324
+ !expired(digest.deliveredAtMs, digest.windowMs)) {
2179
2325
  continue;
2326
+ }
2180
2327
  this.digests.delete(deliveryId);
2181
2328
  this.allNonces.delete(digest.allNonce);
2182
2329
  digests += 1;
@@ -2188,8 +2335,10 @@ export class TelegramChannel {
2188
2335
  // decides, and it was just judged above.
2189
2336
  if (this.digests.has(delivery.deliveryId))
2190
2337
  continue;
2191
- if (!lapsed(delivery.deliveredAtMs) || !expired(delivery.deliveredAtMs))
2338
+ if (!lapsed(delivery.deliveredAtMs, delivery.windowMs) ||
2339
+ !expired(delivery.deliveredAtMs, delivery.windowMs)) {
2192
2340
  continue;
2341
+ }
2193
2342
  this.deliveries.delete(nonce);
2194
2343
  deliveries += 1;
2195
2344
  }
@@ -2201,7 +2350,7 @@ export class TelegramChannel {
2201
2350
  // this map holds, so keeping the buttons alive costs a map entry and
2202
2351
  // dropping them early would cost a human their thumb.
2203
2352
  for (const [deliveryId, card] of this.reviewCards) {
2204
- if (card.settled === null || !expired(card.deliveredAtMs))
2353
+ if (card.settled === null || !expired(card.deliveredAtMs, null))
2205
2354
  continue;
2206
2355
  this.reviewCards.delete(deliveryId);
2207
2356
  this.reviewNonces.delete(card.nonce);
@@ -2311,6 +2460,51 @@ export class TelegramChannel {
2311
2460
  async retract(deliveryId, reason, actionKey) {
2312
2461
  await this.annotate(deliveryId, TELEGRAM_TERMINAL_HEADLINES.withdrawn, [reason], actionKey);
2313
2462
  }
2463
+ /**
2464
+ * The action keys whose card a refused or failed tap disarmed since the last
2465
+ * call, and forget them (APRV-442).
2466
+ *
2467
+ * The listener's dispatch cycle calls this once per cycle and offers a fresh
2468
+ * card for every key the verified log still calls pending. It is a statement
2469
+ * about this process's cards and nothing else: it carries no decision, no
2470
+ * sender and no outcome, and a key in it that the log has since settled is
2471
+ * simply not offered.
2472
+ */
2473
+ takeReleased() {
2474
+ const keys = [...this.released];
2475
+ this.released.clear();
2476
+ return keys;
2477
+ }
2478
+ /**
2479
+ * Mark `delivery`'s card dead and its request released, BEFORE the
2480
+ * annotation that disarms it (APRV-442).
2481
+ *
2482
+ * Every nonce still armed for this request on this message is recorded in
2483
+ * {@link refusedNonces}, together with the nonce the tap carried (which
2484
+ * differs when the tap came from an earlier copy through the fallback), so
2485
+ * none of those bytes can later resolve to the fresh card. On a digest only
2486
+ * this member's nonce is taken: its siblings stay armed on the digest.
2487
+ */
2488
+ release(delivery, tapNonce) {
2489
+ const dead = [tapNonce];
2490
+ for (const [nonce, entry] of this.deliveries) {
2491
+ if (entry.deliveryId === delivery.deliveryId && entry.actionKey === delivery.actionKey) {
2492
+ dead.push(nonce);
2493
+ }
2494
+ }
2495
+ for (const nonce of dead) {
2496
+ // Re-inserted so a nonce marked again counts as the newest.
2497
+ this.refusedNonces.delete(nonce);
2498
+ this.refusedNonces.add(nonce);
2499
+ }
2500
+ while (this.refusedNonces.size > TELEGRAM_REFUSED_NONCE_CAP) {
2501
+ const oldest = this.refusedNonces.values().next().value;
2502
+ if (oldest === undefined)
2503
+ break;
2504
+ this.refusedNonces.delete(oldest);
2505
+ }
2506
+ this.released.add(delivery.actionKey);
2507
+ }
2314
2508
  /**
2315
2509
  * Send one plain message that carries no question (APRV-196).
2316
2510
  *
@@ -2379,6 +2573,7 @@ export class TelegramChannel {
2379
2573
  * decision", not "deliver once at startup, then wait forever".
2380
2574
  */
2381
2575
  async listen(options = {}) {
2576
+ this.claimOrThrow("poll");
2382
2577
  this.stopped = false;
2383
2578
  let backoff = this.backoffMs;
2384
2579
  /**
@@ -2447,6 +2642,7 @@ export class TelegramChannel {
2447
2642
  * what {@link listen} catches and retries.
2448
2643
  */
2449
2644
  async pollOnce() {
2645
+ this.claimOrThrow("poll");
2450
2646
  // APRV-135. Before the long poll, not after: this is where the loop is
2451
2647
  // about to block for up to `pollTimeoutSeconds`, and a sweep that ran after
2452
2648
  // the block would be a sweep that never runs on a quiet chat. Rate-limited
@@ -2458,35 +2654,163 @@ export class TelegramChannel {
2458
2654
  const updates = await this.call("getUpdates", {
2459
2655
  offset: this.offset,
2460
2656
  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"],
2657
+ allowed_updates: this.allowedUpdates(),
2471
2658
  }, this.requestTimeoutMs ?? (this.pollTimeoutSeconds + 10) * 1000);
2472
- const result = {
2473
- updates: 0,
2474
- outcomes: [],
2475
- ignored: [],
2476
- commands: [],
2477
- reviews: [],
2478
- };
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);
2487
- }
2659
+ const result = emptyPollResult();
2660
+ for (const raw of updates)
2661
+ await this.intake(raw, result);
2488
2662
  return result;
2489
2663
  }
2664
+ /**
2665
+ * Which update types this channel asks for, on either transport (APRV-424).
2666
+ *
2667
+ * APRV-216. `message` is asked for only while a command handler is
2668
+ * registered: see {@link onCommand} for why a listener that reads messages
2669
+ * nobody asked for would break `approval setup channel telegram`'s chat
2670
+ * discovery. APRV-299 adds the second reason to read messages: a `loved` or
2671
+ * `disliked` on a review card collects the human's words as a REPLY, because
2672
+ * an inline keyboard has no text input.
2673
+ *
2674
+ * One function since APRV-424, because `setWebhook` takes the same list as
2675
+ * `getUpdates` and two spellings of it would be two answers to "what does
2676
+ * this listener read" — with the webhook's answer frozen at registration
2677
+ * time, where nobody would look for it.
2678
+ */
2679
+ allowedUpdates() {
2680
+ return this.commandHandler === null && this.reviewHandler === null
2681
+ ? ["callback_query"]
2682
+ : ["callback_query", "message"];
2683
+ }
2684
+ /**
2685
+ * One update, from whichever transport carried it (APRV-424).
2686
+ *
2687
+ * The whole of what the webhook adds is the ARRIVAL. Everything a tap then
2688
+ * passes through — the chat check, `senderOf`, the checkpoint and review
2689
+ * parsers, the resolution ladder, the early ack, `this.handler` and the
2690
+ * annotation written from the record the gate appended — is
2691
+ * {@link handleUpdate}, called from here and from {@link pollOnce}, and
2692
+ * there is no second copy of any of it. `tests/telegram-webhook.test.ts`
2693
+ * drives one callback through both entry points and compares the records the
2694
+ * log ends up holding.
2695
+ *
2696
+ * The sweep is `pollOnce`'s, for `pollOnce`'s reason: this is the moment the
2697
+ * process is about to be idle, and a channel that only ever wakes for taps
2698
+ * would otherwise never run one.
2699
+ */
2700
+ async deliverUpdate(update) {
2701
+ this.claimOrThrow("webhook");
2702
+ const nowMs = this.now();
2703
+ if (nowMs - this.lastSweepMs >= TELEGRAM_SWEEP_INTERVAL_MS)
2704
+ this.sweep(nowMs);
2705
+ const result = emptyPollResult();
2706
+ await this.intake(update, result);
2707
+ return result;
2708
+ }
2709
+ /** The per-update bookkeeping both transports do, before `handleUpdate`. */
2710
+ async intake(raw, result) {
2711
+ const update = (raw ?? {});
2712
+ const id = update["update_id"];
2713
+ // Meaningless on the webhook, where Telegram acknowledges by the HTTP
2714
+ // response, and harmless: the offset is read by `getUpdates` alone, and a
2715
+ // channel that has claimed the webhook transport never issues one.
2716
+ if (typeof id === "number")
2717
+ this.offset = Math.max(this.offset, id + 1);
2718
+ this.counters.updates += 1;
2719
+ result.updates += 1;
2720
+ await this.handleUpdate(update, result);
2721
+ }
2722
+ /**
2723
+ * Bind this channel to one transport, or say which one already holds it
2724
+ * (APRV-424).
2725
+ *
2726
+ * Idempotent for the transport already claimed, so a webhook server that
2727
+ * claims at start-up and then delivers, or a driver calling `pollOnce` in a
2728
+ * loop, pays nothing. Not released by {@link stop}: a process that stopped
2729
+ * polling has not stopped being a poller, and re-pointing a live channel's
2730
+ * delivery maps at a second arrival path is the state this exists to refuse.
2731
+ */
2732
+ claimTransport(transport) {
2733
+ if (this.transport === null || this.transport === transport) {
2734
+ this.transport = transport;
2735
+ return { ok: true, transport };
2736
+ }
2737
+ return {
2738
+ ok: false,
2739
+ code: "transport-conflict",
2740
+ 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`,
2741
+ };
2742
+ }
2743
+ /** {@link claimTransport}, for the paths where a refusal is a caller's bug. */
2744
+ claimOrThrow(transport) {
2745
+ const claim = this.claimTransport(transport);
2746
+ if (!claim.ok)
2747
+ throw new TelegramTransportError(claim.message);
2748
+ }
2749
+ // -------------------------------------------------------------------------
2750
+ // Webhook registration (APRV-424)
2751
+ // -------------------------------------------------------------------------
2752
+ /**
2753
+ * Point this bot's updates at `url`, and require `secret` on every post.
2754
+ *
2755
+ * Three properties, and each is a refusal to be clever:
2756
+ *
2757
+ * - **The secret is Telegram's own mechanism**, `secret_token`, echoed back
2758
+ * on every delivery in the `X-Telegram-Bot-Api-Secret-Token` header. The
2759
+ * receiver compares it and refuses anything else, so a URL that leaks (a
2760
+ * proxy log, a referrer, a screenshot) is a URL that still decides
2761
+ * nothing.
2762
+ * - **It is redacted from this moment on.** The value is added to the
2763
+ * channel's secret list BEFORE the call, so a Bot API error that quotes
2764
+ * the request cannot print it.
2765
+ * - **Pending updates are kept.** `drop_pending_updates` is left false:
2766
+ * taps that arrived while the process was down are the approver's answers,
2767
+ * and the gate is what decides whether they are still honourable.
2768
+ */
2769
+ async registerWebhook(url, secret) {
2770
+ if (secret.length > 0 && !this.secrets.some((held) => held.value === secret)) {
2771
+ this.secrets.push({ value: secret, label: "<webhook secret redacted>" });
2772
+ }
2773
+ await this.call("setWebhook", {
2774
+ url,
2775
+ secret_token: secret,
2776
+ allowed_updates: this.allowedUpdates(),
2777
+ drop_pending_updates: false,
2778
+ });
2779
+ }
2780
+ /**
2781
+ * Take this bot's webhook away, so `getUpdates` works again.
2782
+ *
2783
+ * Called on a clean stop of the webhook runner. Pending updates are kept for
2784
+ * {@link registerWebhook}'s reason: the next process to start — a poller or
2785
+ * another webhook — is entitled to the taps that arrived in between.
2786
+ *
2787
+ * `timeoutMs` is what the stop path passes, and it is shorter than this
2788
+ * channel's ordinary request timeout on purpose (APRV-424, second review,
2789
+ * note 6). A stop already waits for the receiver to drain, and an operator
2790
+ * pressing Ctrl-C a second time is asking for the process to end: half a
2791
+ * minute of an unreachable Bot API on top of that reads as a hang, and the
2792
+ * webhook it failed to remove is reported rather than silently retried.
2793
+ */
2794
+ async deleteWebhook(timeoutMs) {
2795
+ await this.call("deleteWebhook", { drop_pending_updates: false }, ...(timeoutMs === undefined ? [] : [timeoutMs]));
2796
+ }
2797
+ /**
2798
+ * What the Bot API says is receiving this bot's updates right now.
2799
+ *
2800
+ * The one authority on the question, and the reason it is asked at start-up
2801
+ * by both transports: the ownership registry knows about processes on THIS
2802
+ * machine, and a webhook registered from a laptop in another timezone is
2803
+ * still a webhook swallowing every tap this poller is waiting for.
2804
+ */
2805
+ async webhookInfo() {
2806
+ const result = await this.call("getWebhookInfo", {});
2807
+ const url = result["url"];
2808
+ const pending = result["pending_update_count"];
2809
+ return {
2810
+ url: typeof url === "string" ? url : "",
2811
+ pendingUpdateCount: typeof pending === "number" ? pending : 0,
2812
+ };
2813
+ }
2490
2814
  /**
2491
2815
  * Exactly one `answerCallbackQuery` per callback query, on every path
2492
2816
  * (APRV-196).
@@ -2676,7 +3000,12 @@ export class TelegramChannel {
2676
3000
  // the log when neither is holding the action open.
2677
3001
  let delivery = this.deliveries.get(parsed.nonce);
2678
3002
  let viaStaleCopy = false;
2679
- if (delivery === undefined && parsed.actionRef !== null) {
3003
+ // APRV-442. A card a refused tap disarmed stays dead: its bytes take no
3004
+ // fallback to the fresh card the listener sends for the same request, so
3005
+ // a redelivered or replayed refused tap reaches neither the gate nor the
3006
+ // new card, exactly as it did before that card existed.
3007
+ const refusedCard = delivery === undefined && this.refusedNonces.has(parsed.nonce);
3008
+ if (delivery === undefined && parsed.actionRef !== null && !refusedCard) {
2680
3009
  // The pre-restart copy. Its nonce died with the process that issued it,
2681
3010
  // but the request it names is one THIS process has since re-delivered, so
2682
3011
  // the tap decides that request — on the live copy's message, which is
@@ -2687,18 +3016,21 @@ export class TelegramChannel {
2687
3016
  viaStaleCopy = delivery !== undefined;
2688
3017
  }
2689
3018
  if (delivery === undefined) {
2690
- if (parsed.actionRef !== null) {
3019
+ if (parsed.actionRef !== null && !refusedCard) {
2691
3020
  // Nothing open here for that action. Say what the log says, which is
2692
3021
  // the only thing that knows: decided, lapsed, withdrawn, or unknown.
2693
3022
  const described = this.describeAction?.(parsed.actionRef) ?? null;
2694
3023
  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
3024
  return;
2696
3025
  }
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.
3026
+ await this.ignore(result, callbackId, "unknown-callback", refusedCard
3027
+ ? `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`
3028
+ : `no delivery for nonce ${JSON.stringify(parsed.nonce)} (a restarted listener forgets its buttons; the pending queue is re-sent on start)`,
3029
+ // Three ways to get here, and the reply has to serve all of them: a
3030
+ // button this process never issued (a restart forgot it), a button on a
3031
+ // message this process has already annotated (APRV-113 forgets the
3032
+ // nonce with the edit), and a button on a card a refused tap disarmed
3033
+ // (APRV-442). Either way the message text is the thing to read.
2702
3034
  "This button is no longer live — read the message for the outcome, or the newest message for the request.");
2703
3035
  return;
2704
3036
  }
@@ -2739,6 +3071,9 @@ export class TelegramChannel {
2739
3071
  outcome = this.handler(decision);
2740
3072
  }
2741
3073
  catch (cause) {
3074
+ // APRV-442: the card is about to be disarmed and nothing settled the
3075
+ // request. Marked BEFORE the annotation, which forgets the nonces.
3076
+ this.release(delivery, parsed.nonce);
2742
3077
  await this.annotateQuietly(delivery.deliveryId, delivery.actionKey, TELEGRAM_NOT_RECORDED, [
2743
3078
  TELEGRAM_HANDLER_FAILED,
2744
3079
  ]);
@@ -2763,6 +3098,18 @@ export class TelegramChannel {
2763
3098
  // message, which is right in both directions: a terminal request has no
2764
3099
  // decision left to collect, and a still-pending one is re-delivered as a
2765
3100
  // fresh prompt by the next dispatch cycle.
3101
+ //
3102
+ // APRV-442. That re-delivery used to be a promise nothing kept: the
3103
+ // disarm forgets the card's nonces, but the listener's own bookkeeping
3104
+ // went on saying the request was on the phone, so the cycle never sent
3105
+ // it again and the original card's action-reference fallback found
3106
+ // nothing live. A wrong account's tap made the prompt dead until a
3107
+ // restart. Releasing names this CARD as gone and nothing else; the cycle
3108
+ // re-derives from the verified log whether the request is still pending
3109
+ // before it offers a new one. Marked BEFORE the annotation, which forgets
3110
+ // the nonces it records. The refusal itself, its record and its wording
3111
+ // are untouched.
3112
+ this.release(delivery, parsed.nonce);
2766
3113
  await this.annotateQuietly(delivery.deliveryId, delivery.actionKey, TELEGRAM_NOT_RECORDED, [
2767
3114
  this.answerFor(outcome),
2768
3115
  ]);
@@ -3274,9 +3621,20 @@ export class TelegramChannel {
3274
3621
  // -------------------------------------------------------------------------
3275
3622
  // Transport
3276
3623
  // -------------------------------------------------------------------------
3277
- /** Replace the token with a placeholder anywhere it appears in `text`. */
3624
+ /**
3625
+ * Replace every secret this channel holds with a placeholder in `text`.
3626
+ *
3627
+ * The bot token always, and since APRV-424 the webhook `secret_token` too,
3628
+ * from the moment {@link registerWebhook} is handed it. Both are values that
3629
+ * only ever travel TO the Bot API, so anything coming back that quotes one
3630
+ * is a diagnostic about to print a credential.
3631
+ */
3278
3632
  redact(text) {
3279
- return this.token.length === 0 ? text : text.split(this.token).join("<token redacted>");
3633
+ let redacted = text;
3634
+ for (const secret of this.secrets) {
3635
+ redacted = redacted.split(secret.value).join(secret.label);
3636
+ }
3637
+ return redacted;
3280
3638
  }
3281
3639
  describe(cause) {
3282
3640
  return this.redact(cause instanceof Error ? cause.message : String(cause));