approval-md 0.2.0 → 0.3.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 (235) hide show
  1. package/README.md +63 -24
  2. package/SPEC.md +57 -11
  3. package/dist/src/channels/contract.d.ts +34 -1
  4. package/dist/src/channels/contract.js +200 -7
  5. package/dist/src/channels/contract.js.map +1 -1
  6. package/dist/src/channels/telegram.d.ts +123 -11
  7. package/dist/src/channels/telegram.js +218 -23
  8. package/dist/src/channels/telegram.js.map +1 -1
  9. package/dist/src/channels/web.d.ts +9 -0
  10. package/dist/src/channels/web.js +17 -0
  11. package/dist/src/channels/web.js.map +1 -1
  12. package/dist/src/cli/amend.js +214 -30
  13. package/dist/src/cli/amend.js.map +1 -1
  14. package/dist/src/cli/attest.d.ts +9 -0
  15. package/dist/src/cli/attest.js +134 -7
  16. package/dist/src/cli/attest.js.map +1 -1
  17. package/dist/src/cli/channel-telegram.d.ts +99 -26
  18. package/dist/src/cli/channel-telegram.js +311 -13
  19. package/dist/src/cli/channel-telegram.js.map +1 -1
  20. package/dist/src/cli/channel.d.ts +9 -0
  21. package/dist/src/cli/channel.js +9 -0
  22. package/dist/src/cli/channel.js.map +1 -1
  23. package/dist/src/cli/codex-bridge.d.ts +819 -0
  24. package/dist/src/cli/codex-bridge.js +1607 -0
  25. package/dist/src/cli/codex-bridge.js.map +1 -0
  26. package/dist/src/cli/codex.d.ts +1 -1
  27. package/dist/src/cli/codex.js +304 -7
  28. package/dist/src/cli/codex.js.map +1 -1
  29. package/dist/src/cli/daemon.js +4 -1
  30. package/dist/src/cli/daemon.js.map +1 -1
  31. package/dist/src/cli/doctor.js +467 -12
  32. package/dist/src/cli/doctor.js.map +1 -1
  33. package/dist/src/cli/execute.js +25 -2
  34. package/dist/src/cli/execute.js.map +1 -1
  35. package/dist/src/cli/help.d.ts +6 -2
  36. package/dist/src/cli/help.js +165 -60
  37. package/dist/src/cli/help.js.map +1 -1
  38. package/dist/src/cli/hook-codex.d.ts +49 -1
  39. package/dist/src/cli/hook-codex.js +60 -1
  40. package/dist/src/cli/hook-codex.js.map +1 -1
  41. package/dist/src/cli/hook.d.ts +459 -3
  42. package/dist/src/cli/hook.js +1062 -114
  43. package/dist/src/cli/hook.js.map +1 -1
  44. package/dist/src/cli/import.js +1 -1
  45. package/dist/src/cli/import.js.map +1 -1
  46. package/dist/src/cli/main.js +5 -3
  47. package/dist/src/cli/main.js.map +1 -1
  48. package/dist/src/cli/policy-apply.d.ts +195 -0
  49. package/dist/src/cli/policy-apply.js +573 -0
  50. package/dist/src/cli/policy-apply.js.map +1 -0
  51. package/dist/src/cli/policy.js +14 -1
  52. package/dist/src/cli/policy.js.map +1 -1
  53. package/dist/src/cli/preflight.d.ts +151 -13
  54. package/dist/src/cli/preflight.js +398 -41
  55. package/dist/src/cli/preflight.js.map +1 -1
  56. package/dist/src/cli/sandbox.js +17 -1
  57. package/dist/src/cli/sandbox.js.map +1 -1
  58. package/dist/src/cli/scaffold.d.ts +1 -1
  59. package/dist/src/cli/scaffold.js +1 -1
  60. package/dist/src/cli/setup-channel.d.ts +9 -0
  61. package/dist/src/cli/setup-channel.js +28 -1
  62. package/dist/src/cli/setup-channel.js.map +1 -1
  63. package/dist/src/cli/setup-common.d.ts +3 -1
  64. package/dist/src/cli/setup-common.js +3 -2
  65. package/dist/src/cli/setup-common.js.map +1 -1
  66. package/dist/src/cli/setup.d.ts +2 -0
  67. package/dist/src/cli/setup.js +94 -2
  68. package/dist/src/cli/setup.js.map +1 -1
  69. package/dist/src/cli/up.js +115 -51
  70. package/dist/src/cli/up.js.map +1 -1
  71. package/dist/src/cli/values.js +3 -4
  72. package/dist/src/cli/values.js.map +1 -1
  73. package/dist/src/cli/verb-registry.js +174 -9
  74. package/dist/src/cli/verb-registry.js.map +1 -1
  75. package/dist/src/cli/wordmark.d.ts +2 -2
  76. package/dist/src/cli/wordmark.js +2 -2
  77. package/dist/src/codex/broker.d.ts +229 -0
  78. package/dist/src/codex/broker.js +548 -0
  79. package/dist/src/codex/broker.js.map +1 -0
  80. package/dist/src/codex/runner.d.ts +178 -0
  81. package/dist/src/codex/runner.js +231 -0
  82. package/dist/src/codex/runner.js.map +1 -0
  83. package/dist/src/codex/serve.d.ts +56 -0
  84. package/dist/src/codex/serve.js +98 -0
  85. package/dist/src/codex/serve.js.map +1 -0
  86. package/dist/src/codex/workspace-commit.d.ts +219 -0
  87. package/dist/src/codex/workspace-commit.js +549 -0
  88. package/dist/src/codex/workspace-commit.js.map +1 -0
  89. package/dist/src/core/advance-cycle.d.ts +51 -0
  90. package/dist/src/core/advance-cycle.js +66 -2
  91. package/dist/src/core/advance-cycle.js.map +1 -1
  92. package/dist/src/core/agents-md.d.ts +20 -18
  93. package/dist/src/core/agents-md.js +33 -31
  94. package/dist/src/core/agents-md.js.map +1 -1
  95. package/dist/src/core/attest.d.ts +215 -0
  96. package/dist/src/core/attest.js +317 -7
  97. package/dist/src/core/attest.js.map +1 -1
  98. package/dist/src/core/audit.d.ts +18 -0
  99. package/dist/src/core/audit.js +13 -0
  100. package/dist/src/core/audit.js.map +1 -1
  101. package/dist/src/core/channel-owner.d.ts +213 -0
  102. package/dist/src/core/channel-owner.js +358 -0
  103. package/dist/src/core/channel-owner.js.map +1 -0
  104. package/dist/src/core/command-class.d.ts +154 -0
  105. package/dist/src/core/command-class.js +673 -20
  106. package/dist/src/core/command-class.js.map +1 -1
  107. package/dist/src/core/commit-guard.d.ts +272 -0
  108. package/dist/src/core/commit-guard.js +424 -0
  109. package/dist/src/core/commit-guard.js.map +1 -0
  110. package/dist/src/core/daemon-actor.d.ts +45 -0
  111. package/dist/src/core/daemon-actor.js +54 -0
  112. package/dist/src/core/daemon-actor.js.map +1 -0
  113. package/dist/src/core/dark-session.d.ts +109 -8
  114. package/dist/src/core/dark-session.js +266 -82
  115. package/dist/src/core/dark-session.js.map +1 -1
  116. package/dist/src/core/decision-refusal.d.ts +23 -2
  117. package/dist/src/core/decision-refusal.js +24 -2
  118. package/dist/src/core/decision-refusal.js.map +1 -1
  119. package/dist/src/core/env-file.d.ts +5 -0
  120. package/dist/src/core/env-file.js +60 -1
  121. package/dist/src/core/env-file.js.map +1 -1
  122. package/dist/src/core/execute.d.ts +15 -2
  123. package/dist/src/core/execute.js +15 -2
  124. package/dist/src/core/execute.js.map +1 -1
  125. package/dist/src/core/gate.d.ts +86 -1
  126. package/dist/src/core/gate.js +81 -1
  127. package/dist/src/core/gate.js.map +1 -1
  128. package/dist/src/core/gesture-refusal.d.ts +166 -0
  129. package/dist/src/core/gesture-refusal.js +188 -0
  130. package/dist/src/core/gesture-refusal.js.map +1 -0
  131. package/dist/src/core/harness-version.d.ts +1 -1
  132. package/dist/src/core/harness-version.js +3 -1
  133. package/dist/src/core/harness-version.js.map +1 -1
  134. package/dist/src/core/instance.d.ts +59 -2
  135. package/dist/src/core/instance.js +113 -0
  136. package/dist/src/core/instance.js.map +1 -1
  137. package/dist/src/core/log.d.ts +39 -1
  138. package/dist/src/core/log.js.map +1 -1
  139. package/dist/src/core/policy-explain.d.ts +10 -0
  140. package/dist/src/core/policy-explain.js +32 -0
  141. package/dist/src/core/policy-explain.js.map +1 -1
  142. package/dist/src/core/policy-load.d.ts +41 -1
  143. package/dist/src/core/policy-load.js +21 -3
  144. package/dist/src/core/policy-load.js.map +1 -1
  145. package/dist/src/core/policy-match.d.ts +43 -0
  146. package/dist/src/core/policy-match.js +52 -0
  147. package/dist/src/core/policy-match.js.map +1 -1
  148. package/dist/src/core/policy-proposal.d.ts +52 -0
  149. package/dist/src/core/policy-proposal.js +102 -2
  150. package/dist/src/core/policy-proposal.js.map +1 -1
  151. package/dist/src/core/protected-path-guard.d.ts +117 -4
  152. package/dist/src/core/protected-path-guard.js +362 -48
  153. package/dist/src/core/protected-path-guard.js.map +1 -1
  154. package/dist/src/core/question-preempted.d.ts +141 -0
  155. package/dist/src/core/question-preempted.js +152 -0
  156. package/dist/src/core/question-preempted.js.map +1 -0
  157. package/dist/src/core/read-scope.d.ts +172 -0
  158. package/dist/src/core/read-scope.js +252 -0
  159. package/dist/src/core/read-scope.js.map +1 -0
  160. package/dist/src/core/sandbox.d.ts +81 -0
  161. package/dist/src/core/sandbox.js +190 -1
  162. package/dist/src/core/sandbox.js.map +1 -1
  163. package/dist/src/core/sender-identity.d.ts +476 -0
  164. package/dist/src/core/sender-identity.js +572 -0
  165. package/dist/src/core/sender-identity.js.map +1 -0
  166. package/dist/src/core/shlex.d.ts +102 -0
  167. package/dist/src/core/shlex.js +159 -0
  168. package/dist/src/core/shlex.js.map +1 -0
  169. package/dist/src/core/values.d.ts +18 -8
  170. package/dist/src/core/values.js +36 -1
  171. package/dist/src/core/values.js.map +1 -1
  172. package/dist/src/daemon/advance.d.ts +10 -0
  173. package/dist/src/daemon/advance.js +25 -4
  174. package/dist/src/daemon/advance.js.map +1 -1
  175. package/dist/src/daemon/daemon.js +9 -0
  176. package/dist/src/daemon/daemon.js.map +1 -1
  177. package/dist/src/daemon/git-evidence.d.ts +2 -2
  178. package/dist/src/daemon/git-evidence.js +1 -1
  179. package/dist/src/mcp/server.js +8 -0
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/docs/cli-reference.md +932 -32
  182. package/docs/codex-enforced-session.md +75 -2
  183. package/docs/codex-workspace-broker.md +118 -0
  184. package/package.json +3 -1
  185. package/schema/event.schema.json +538 -9
  186. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
  187. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
  188. package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
  189. package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
  190. package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
  191. package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
  192. package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
  193. package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
  194. package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
  195. package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
  196. package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
  197. package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
  198. package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
  199. package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
  200. package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
  201. package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
  202. package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
  203. package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
  204. package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
  205. package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
  206. package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
  207. package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
  208. package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
  209. package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
  210. package/schema/fixtures/policy/valid/canonical.json +1 -1
  211. package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
  212. package/schema/fixtures/policy-md/valid/canonical.md +1 -1
  213. package/schema/fixtures/policy-md/valid/with-values.md +5 -7
  214. package/schema/fixtures/values/invalid/class-shaped.json +1 -1
  215. package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
  216. package/schema/fixtures/values/invalid/non-string-item.json +1 -1
  217. package/schema/fixtures/values/invalid/over-cap.json +1 -1
  218. package/schema/fixtures/values/invalid/unknown-key.json +1 -1
  219. package/schema/fixtures/values/invalid/version-float.json +1 -0
  220. package/schema/fixtures/values/invalid/version-integer.json +1 -0
  221. package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
  222. package/schema/fixtures/values/valid/empty-lists.json +2 -3
  223. package/schema/fixtures/values/valid/full.json +5 -7
  224. package/schema/fixtures/values/valid/minimal.json +1 -1
  225. package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
  226. package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
  227. package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
  228. package/schema/fixtures/values-md/invalid/version-1.md +69 -0
  229. package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
  230. package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
  231. package/schema/fixtures/values-md/valid/absent.md +1 -1
  232. package/schema/fixtures/values-md/valid/with-values.md +5 -7
  233. package/schema/policy.schema.json +54 -2
  234. package/schema/values.schema.json +7 -11
  235. package/schema/fixtures/values/invalid/version-string.json +0 -1
@@ -40,16 +40,35 @@
40
40
  * > flag); the trust boundary is the local machine, and anyone who can set that
41
41
  * > configuration and write to the log is inside it.
42
42
  *
43
- * This channel does **not** authenticate the person who tapped the button. It
44
- * checks that the callback arrived from the configured chat id, and the
45
- * decision is then recorded against the human actor the *runtime* was
46
- * configured with (`APPROVAL_HUMAN` / `--as`), not against anything the
47
- * callback carried. So the guarantee is "someone with access to the configured
48
- * chat, on a runtime configured by someone with local control, tapped Approve"
49
- * — not "alice tapped Approve". Anyone in that chat can approve as the
50
- * configured actor. Use a private chat with the bot, and treat the chat's
51
- * membership as part of the trust boundary. Cryptographic identity is future
52
- * work and is not a v0.1 claim.
43
+ * What this channel authenticates, exactly: that the callback arrived **from
44
+ * the configured chat id**, and which **Telegram account** the Bot API
45
+ * attributes the tap to (`callback_query.from.id`). It authenticates no
46
+ * person, because no transport can: an account id is evidence about an
47
+ * account.
48
+ *
49
+ * What is done with the second fact is the operator's to decide, and since
50
+ * APRV-324 there are two settings:
51
+ *
52
+ * - **No `senders` block in the policy.** Nothing changes from every build
53
+ * before it. The decision is recorded against the human actor the *runtime*
54
+ * was configured with (`APPROVAL_HUMAN` / `--as`), so the guarantee is
55
+ * "someone with access to the configured chat, on a runtime configured by
56
+ * someone with local control, tapped Approve" — not "alice tapped Approve".
57
+ * Anyone in that chat can approve as the configured actor. Use a private chat
58
+ * with the bot, and treat the chat's membership as part of the trust
59
+ * boundary.
60
+ * - **A `senders` block mapping account ids to approvers.** The decision is
61
+ * recorded against the person the operator attested that account to, and a
62
+ * tap from an account the policy does not name is REFUSED rather than
63
+ * recorded under the listener's identity. The guarantee becomes "the account
64
+ * the operator attested to alice tapped Approve", which is a statement about
65
+ * Telegram's session handling and the operator's assertion, and still not a
66
+ * proof of personhood. Cryptographic identity is future work and is not a
67
+ * v0.1 claim.
68
+ *
69
+ * The channel itself resolves nothing either way. It reports the account it
70
+ * saw; `channels/contract.ts` resolves it against the attested policy, and
71
+ * `core/gate.ts` decides. See `design/channel-sender-identity.md`.
53
72
  *
54
73
  * ## Formatting: HTML, not MarkdownV2 — a deliberate choice
55
74
  *
@@ -1206,6 +1225,42 @@ export function parseCheckpointCallback(data) {
1206
1225
  return null;
1207
1226
  return { sign: verb === "k", nonce };
1208
1227
  }
1228
+ /**
1229
+ * The account the Bot API attributes a callback to (APRV-324, amended SPEC.md
1230
+ * §10.3).
1231
+ *
1232
+ * `callback_query.from.id` and nothing else. Telegram assembles the `from`
1233
+ * object itself, from the session the tap arrived on, which is why it is the
1234
+ * one field on an update this channel treats as evidence about a person. Three
1235
+ * neighbours are deliberately not read:
1236
+ *
1237
+ * - **`from.username`.** Mutable and reusable, so a mapping keyed on it would
1238
+ * hand an identity over with a handle. Never a key here and never recorded
1239
+ * (`core/sender-identity.ts` states the argument in full).
1240
+ * - **`message.from`.** The author of the message the button sits on — this bot
1241
+ * — rather than the person who pressed it.
1242
+ * - **`data`, and any text.** What the sender says about themselves, which
1243
+ * §11.1 invariant 4 says may raise scrutiny and never lower it. A callback
1244
+ * whose payload names a user id names it about itself; the id here comes from
1245
+ * the transport, and the two disagreeing changes nothing.
1246
+ *
1247
+ * `undefined` when the update carries no usable id: a sender this channel could
1248
+ * not read is not a sender it may guess at, and the decision then travels with
1249
+ * none, which the contract reads as today's configured attribution.
1250
+ */
1251
+ export function senderOf(query, channel) {
1252
+ const from = query["from"];
1253
+ if (typeof from !== "object" || from === null)
1254
+ return undefined;
1255
+ const id = from["id"];
1256
+ // Telegram sends a JSON number; a string is accepted for the same reason the
1257
+ // chat check accepts one, and nothing else is: an object or an array here is
1258
+ // a shape this channel does not understand rather than an id to stringify.
1259
+ if (typeof id !== "number" && typeof id !== "string")
1260
+ return undefined;
1261
+ const text = String(id);
1262
+ return text.length === 0 ? undefined : { channel, id: text };
1263
+ }
1209
1264
  const CALLBACK_VERBS = {
1210
1265
  g: { decision: "grant", scope: "one" },
1211
1266
  r: { decision: "reject", scope: "one" },
@@ -1494,6 +1549,29 @@ export function isMessageNotModified(cause) {
1494
1549
  cause.description !== null &&
1495
1550
  TELEGRAM_NOT_MODIFIED.test(cause.description));
1496
1551
  }
1552
+ /**
1553
+ * Telegram's wording for "somebody else is already long-polling this bot".
1554
+ *
1555
+ * Matched on the description as well as the status, for the same reason
1556
+ * {@link TELEGRAM_NOT_MODIFIED} is: 409 is a conflict, and the conflict this
1557
+ * project cares about is specifically the terminated-by-other-getUpdates one.
1558
+ */
1559
+ const TELEGRAM_POLL_CONFLICT = /terminated by other getupdates|make sure that only one bot/iu;
1560
+ /**
1561
+ * Whether a failed poll is the Bot API saying another process holds this bot
1562
+ * (APRV-390).
1563
+ *
1564
+ * It is the one poll failure that is not transient and not the network's
1565
+ * fault: retrying it forever produces an identical line every few seconds and
1566
+ * never recovers, because the other poller is not going to stop. {@link
1567
+ * TelegramChannel.listen} reports it once, with whatever the caller knows
1568
+ * about who the other process is, and then stops repeating itself.
1569
+ */
1570
+ export function isPollConflict(cause) {
1571
+ return (cause instanceof TelegramApiError &&
1572
+ cause.method === "getUpdates" &&
1573
+ (cause.status === 409 || (cause.description !== null && TELEGRAM_POLL_CONFLICT.test(cause.description))));
1574
+ }
1497
1575
  export class TelegramChannel {
1498
1576
  name = "telegram";
1499
1577
  token;
@@ -2255,6 +2333,32 @@ export class TelegramChannel {
2255
2333
  return String(result.message_id);
2256
2334
  }
2257
2335
  // -------------------------------------------------------------------------
2336
+ // Identity
2337
+ // -------------------------------------------------------------------------
2338
+ /**
2339
+ * Which bot this token is, from one `getMe` (APRV-390).
2340
+ *
2341
+ * `getMe` is the only Bot API call that answers it, and it is the call
2342
+ * `approval doctor` and `approval setup channel telegram` already make for
2343
+ * the same reason: it mutates nothing, sends nothing, and acknowledges
2344
+ * nothing. In particular it is NOT `getUpdates`, so asking who this bot is
2345
+ * consumes no update and steals no pending tap from a listener that is
2346
+ * already running — which matters here above all, because the answer is
2347
+ * what decides whether this process is allowed to poll at all.
2348
+ *
2349
+ * Throws {@link TelegramApiError} like every other call; the caller decides
2350
+ * whether an unreachable Bot API is a refusal or a shrug.
2351
+ */
2352
+ async identify() {
2353
+ const result = await this.call("getMe", {});
2354
+ const id = result["id"];
2355
+ const username = result["username"];
2356
+ return {
2357
+ id: typeof id === "number" || typeof id === "string" ? String(id) : "",
2358
+ username: typeof username === "string" ? `@${username}` : "the bot",
2359
+ };
2360
+ }
2361
+ // -------------------------------------------------------------------------
2258
2362
  // Long polling
2259
2363
  // -------------------------------------------------------------------------
2260
2364
  /**
@@ -2277,6 +2381,16 @@ export class TelegramChannel {
2277
2381
  async listen(options = {}) {
2278
2382
  this.stopped = false;
2279
2383
  let backoff = this.backoffMs;
2384
+ /**
2385
+ * APRV-390. The last failure reported, and how many identical ones have
2386
+ * been swallowed since. A 409 does not recover: the other poller is not
2387
+ * going to stop because this one asked again, so the pre-APRV-390 loop
2388
+ * printed the same sentence every few seconds until somebody read the
2389
+ * terminal. The retry itself stays — a listener that stops listening is
2390
+ * the failure this loop rules out — and only the COMPLAINING is collapsed.
2391
+ */
2392
+ let lastComplaint = null;
2393
+ let repeats = 0;
2280
2394
  while (!this.stopped) {
2281
2395
  try {
2282
2396
  if (options.beforePoll !== undefined) {
@@ -2286,6 +2400,11 @@ export class TelegramChannel {
2286
2400
  }
2287
2401
  await this.pollOnce();
2288
2402
  backoff = this.backoffMs;
2403
+ if (repeats > 0) {
2404
+ this.complain(`approval: telegram getUpdates recovered after ${String(repeats)} further identical failure(s)`);
2405
+ }
2406
+ lastComplaint = null;
2407
+ repeats = 0;
2289
2408
  if (options.once === true)
2290
2409
  return;
2291
2410
  }
@@ -2293,7 +2412,26 @@ export class TelegramChannel {
2293
2412
  if (this.stopped)
2294
2413
  return;
2295
2414
  this.counters.pollErrors += 1;
2296
- this.complain(`approval: telegram getUpdates failed (${this.describe(cause)}); retrying in ${backoff}ms — the listener is still up`);
2415
+ const conflict = isPollConflict(cause);
2416
+ // The 409's own sentence, said once. It names the fact ("another
2417
+ // process is polling this bot") rather than the HTTP status, because
2418
+ // the status is what a reader was already staring at, and it carries
2419
+ // whatever the caller knows about which two gates are involved.
2420
+ const advice = conflict ? (options.conflictAdvice?.() ?? null) : null;
2421
+ const message = conflict
2422
+ ? `approval: telegram getUpdates: another process is polling this bot${advice === null ? "" : ` — ${advice}`}; this listener keeps retrying and will not receive a tap until the other one stops`
2423
+ : `approval: telegram getUpdates failed (${this.describe(cause)}); retrying in ${backoff}ms — the listener is still up`;
2424
+ if (message === lastComplaint) {
2425
+ repeats += 1;
2426
+ }
2427
+ else {
2428
+ if (repeats > 0) {
2429
+ this.complain(`approval: telegram getUpdates: the previous line repeated ${String(repeats)} more time(s)`);
2430
+ }
2431
+ this.complain(message);
2432
+ lastComplaint = message;
2433
+ repeats = 0;
2434
+ }
2297
2435
  await sleep(backoff);
2298
2436
  backoff = Math.min(backoff * 2, this.maxBackoffMs);
2299
2437
  }
@@ -2478,17 +2616,37 @@ export class TelegramChannel {
2478
2616
  const chat = (message["chat"] ?? {});
2479
2617
  const chatId = chat["id"] === undefined ? "" : String(chat["id"]);
2480
2618
  // (a) Not our chat. Counted, answered, never decided, never logged.
2619
+ //
2620
+ // APRV-324 leaves this check exactly where it is, FIRST and ahead of the
2621
+ // sender, on purpose: the mapping widens WHO may decide and never WHERE
2622
+ // from. A callback carrying a perfectly mapped `from.id` but arriving in a
2623
+ // chat this listener does not answer to is ignored here, before anything
2624
+ // reads a sender, because a bot that accepted it would be one an operator
2625
+ // could not scope by conversation.
2481
2626
  if (chatId !== this.chatId) {
2482
2627
  await this.ignore(result, callbackId, "foreign-chat", `callback from chat ${JSON.stringify(chatId)}, which is not the configured approver chat`, "This bot only accepts decisions from its configured approval chat.");
2483
2628
  return;
2484
2629
  }
2630
+ // APRV-324. The one field on this update that identifies a PERSON rather
2631
+ // than a conversation, read once, here, directly after the chat check and
2632
+ // BEFORE every branch below. Its position is the fix for the follow-up
2633
+ // review: read later, three callback families — checkpoint signatures,
2634
+ // review cards and the decision ladder — would each have had their own
2635
+ // chance to keep the pre-mapping behaviour, and two of them are privileged
2636
+ // gestures. `callback_query.from` is the Bot API's own attribution of the
2637
+ // tap to an account; nothing inside `message`, `data` or any text is
2638
+ // consulted, because those are what the sender says about themselves. It is
2639
+ // an observation and nothing more: this channel resolves no identity and
2640
+ // chooses no actor, it reports what it saw and the runtime's handlers
2641
+ // resolve it against the attested policy.
2642
+ const sender = senderOf(query, this.name);
2485
2643
  // APRV-257, before the decision vocabulary and in a parser of its own. A
2486
2644
  // checkpoint button decides no request, so it must never reach the ladder
2487
2645
  // below — where an unresolved nonce falls back to an action reference, and
2488
2646
  // a signature gesture would start looking for something to approve.
2489
2647
  const checkpoint = parseCheckpointCallback(query["data"]);
2490
2648
  if (checkpoint !== null) {
2491
- await this.handleCheckpointTap(checkpoint, callbackId, result);
2649
+ await this.handleCheckpointTap(checkpoint, callbackId, result, sender);
2492
2650
  return;
2493
2651
  }
2494
2652
  // APRV-299, before the decision vocabulary and in a parser of its own, for
@@ -2498,7 +2656,7 @@ export class TelegramChannel {
2498
2656
  // happened would start looking for something to approve.
2499
2657
  const review = parseReviewCallback(query["data"]);
2500
2658
  if (review !== null) {
2501
- await this.handleReviewTap(review, callbackId, result);
2659
+ await this.handleReviewTap(review, callbackId, result, sender);
2502
2660
  return;
2503
2661
  }
2504
2662
  const parsed = parseCallbackData(query["data"]);
@@ -2510,7 +2668,7 @@ export class TelegramChannel {
2510
2668
  // decides is whatever is still open on that delivery right now, which this
2511
2669
  // process knows and the callback bytes deliberately do not say.
2512
2670
  if (parsed.scope === "all") {
2513
- await this.handleDigestAll(parsed.decision, parsed.nonce, callbackId, result);
2671
+ await this.handleDigestAll(parsed.decision, parsed.nonce, callbackId, result, sender);
2514
2672
  return;
2515
2673
  }
2516
2674
  // The resolution ladder (APRV-196). A tap is answered by the nonce when
@@ -2552,6 +2710,7 @@ export class TelegramChannel {
2552
2710
  action_key: delivery.actionKey,
2553
2711
  decision: parsed.decision,
2554
2712
  deliveryId: delivery.deliveryId,
2713
+ ...(sender === undefined ? {} : { sender }),
2555
2714
  ...(delivery.batchDeliveryId === undefined
2556
2715
  ? {}
2557
2716
  : { batchDeliveryId: delivery.batchDeliveryId }),
@@ -2643,7 +2802,11 @@ export class TelegramChannel {
2643
2802
  * the same message would show the approver their own decisions arriving one
2644
2803
  * at a time, and would spend N Bot API calls to end in the same place.
2645
2804
  */
2646
- async handleDigestAll(decision, nonce, callbackId, result) {
2805
+ async handleDigestAll(decision, nonce, callbackId, result,
2806
+ // APRV-324: one gesture, one sender, on every member it decides. An "all"
2807
+ // tap is N decisions by one person, so each of the N carries the account
2808
+ // the one tap came from.
2809
+ sender) {
2647
2810
  const deliveryId = this.allNonces.get(nonce);
2648
2811
  const digest = deliveryId === undefined ? undefined : this.digests.get(deliveryId);
2649
2812
  if (digest === undefined) {
@@ -2667,6 +2830,7 @@ export class TelegramChannel {
2667
2830
  decision,
2668
2831
  deliveryId: digest.deliveryId,
2669
2832
  batchDeliveryId: digest.batchDeliveryId,
2833
+ ...(sender === undefined ? {} : { sender }),
2670
2834
  ...(decision === "reject"
2671
2835
  ? { note: `${TELEGRAM_REJECT_NOTE} (callback ${callbackId}, all)` }
2672
2836
  : {}),
@@ -2771,7 +2935,11 @@ export class TelegramChannel {
2771
2935
  * and appends to a log, and a spinner that lasted a decision long is what
2772
2936
  * that task removed.
2773
2937
  */
2774
- async handleCheckpointTap(tap, callbackId, result) {
2938
+ async handleCheckpointTap(tap, callbackId, result,
2939
+ // APRV-324 follow-up. A checkpoint signature is a human-only act and says
2940
+ // this log's head is what this person saw, so it carries the account the
2941
+ // tap came from exactly as a decision does.
2942
+ sender) {
2775
2943
  const held = this.checkpointNonces.get(tap.nonce);
2776
2944
  if (held === undefined) {
2777
2945
  await this.ignore(result, callbackId, "unknown-callback", `checkpoint nonce ${JSON.stringify(tap.nonce)} was not issued by this process`, "This checkpoint prompt is from an earlier run. A fresh one is offered when the next is due.");
@@ -2784,7 +2952,11 @@ export class TelegramChannel {
2784
2952
  return;
2785
2953
  }
2786
2954
  await this.safeAnswer(callbackId, tap.sign ? "Heard — signing. The message will say what the log recorded." : "Not now.");
2787
- const response = await handler({ sign: tap.sign, head: held.head });
2955
+ const response = await handler({
2956
+ sign: tap.sign,
2957
+ head: held.head,
2958
+ ...(sender === undefined ? {} : { sender }),
2959
+ });
2788
2960
  // Edited here rather than through `annotate`, which renders an action key
2789
2961
  // under its headline. A checkpoint has none, and an empty `<code></code>`
2790
2962
  // where a request's key belongs would be this channel implying a request.
@@ -2817,7 +2989,7 @@ export class TelegramChannel {
2817
2989
  * re-implement the rule. The one thing this method owns is the ARMING, which
2818
2990
  * is process memory that appends nothing.
2819
2991
  */
2820
- async handleReviewTap(tap, callbackId, result) {
2992
+ async handleReviewTap(tap, callbackId, result, sender) {
2821
2993
  const deliveryId = this.reviewNonces.get(tap.nonce);
2822
2994
  const state = deliveryId === undefined ? undefined : this.reviewCards.get(deliveryId);
2823
2995
  if (state === undefined || state.settled !== null) {
@@ -2845,7 +3017,11 @@ export class TelegramChannel {
2845
3017
  const chosen = tap.choice === "deny" ? "denied" : "ok";
2846
3018
  state.denyArmed = chosen === "denied";
2847
3019
  await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
2848
- await this.recordReview(state, { sampleSeq: state.card.sampleSeq, verdict: chosen }, result);
3020
+ await this.recordReview(state, {
3021
+ sampleSeq: state.card.sampleSeq,
3022
+ verdict: chosen,
3023
+ ...(sender === undefined ? {} : { sender }),
3024
+ }, result);
2849
3025
  return;
2850
3026
  }
2851
3027
  const reaction = tap.choice;
@@ -2858,11 +3034,16 @@ export class TelegramChannel {
2858
3034
  const conflicts = verdict === "denied" && (reaction === "liked" || reaction === "loved");
2859
3035
  if (!conflicts && (reaction === "loved" || reaction === "disliked")) {
2860
3036
  await this.safeAnswer(callbackId, TELEGRAM_REVIEW_NOTE_TOAST);
2861
- await this.askForNote(state, verdict, reaction);
3037
+ await this.askForNote(state, verdict, reaction, sender);
2862
3038
  return;
2863
3039
  }
2864
3040
  await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
2865
- await this.recordReview(state, { sampleSeq: state.card.sampleSeq, verdict, reaction }, result);
3041
+ await this.recordReview(state, {
3042
+ sampleSeq: state.card.sampleSeq,
3043
+ verdict,
3044
+ reaction,
3045
+ ...(sender === undefined ? {} : { sender }),
3046
+ }, result);
2866
3047
  }
2867
3048
  /**
2868
3049
  * Send the ForceReply prompt a `loved` or `disliked` needs, and remember what
@@ -2879,7 +3060,7 @@ export class TelegramChannel {
2879
3060
  * one card, and the older prompt stops resolving so a late reply to it lands
2880
3061
  * nowhere rather than recording a grade the human moved on from.
2881
3062
  */
2882
- async askForNote(state, verdict, reaction) {
3063
+ async askForNote(state, verdict, reaction, sender) {
2883
3064
  if (state.awaitingNote !== null)
2884
3065
  this.reviewNotePrompts.delete(state.awaitingNote.promptId);
2885
3066
  const lines = reviewNotePromptLines(reaction, verdict, state.card.fields.action_key.value);
@@ -2893,7 +3074,7 @@ export class TelegramChannel {
2893
3074
  reply_markup: { force_reply: true },
2894
3075
  });
2895
3076
  const promptId = String(sent.message_id);
2896
- state.awaitingNote = { promptId, verdict, reaction };
3077
+ state.awaitingNote = { promptId, verdict, reaction, ...(sender === undefined ? {} : { sender }) };
2897
3078
  this.reviewNotePrompts.set(promptId, state.deliveryId);
2898
3079
  }
2899
3080
  /**
@@ -2928,6 +3109,19 @@ export class TelegramChannel {
2928
3109
  }
2929
3110
  const state = this.reviewCards.get(deliveryId);
2930
3111
  const pending = state?.awaitingNote ?? null;
3112
+ // APRV-324 follow-up, and checked BEFORE the prompt is forgotten, so a
3113
+ // reply from the wrong account costs the right one nothing. A note prompt
3114
+ // is a ForceReply addressed to the person who armed the grade, and its
3115
+ // words are recorded as that person's; a different account replying to it
3116
+ // is answering a question nobody asked them.
3117
+ const sender = senderOf(message, this.name);
3118
+ if (pending !== null &&
3119
+ pending.promptId === promptId &&
3120
+ pending.sender !== undefined &&
3121
+ (sender === undefined || sender.id !== pending.sender.id)) {
3122
+ this.complain("approval: telegram ignored a review note: it replied to a prompt issued for a different account, and the prompt is still open for the account that armed it");
3123
+ return true;
3124
+ }
2931
3125
  this.reviewNotePrompts.delete(promptId);
2932
3126
  if (state === undefined || pending === null || pending.promptId !== promptId)
2933
3127
  return true;
@@ -2937,6 +3131,7 @@ export class TelegramChannel {
2937
3131
  verdict: pending.verdict,
2938
3132
  reaction: pending.reaction,
2939
3133
  note: text,
3134
+ ...(sender === undefined ? {} : { sender }),
2940
3135
  }, result);
2941
3136
  return true;
2942
3137
  }