approval-md 0.4.0 → 0.4.2

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 (190) hide show
  1. package/SPEC.md +11 -0
  2. package/cli.js +72 -4
  3. package/dist/src/channels/contract.d.ts +53 -0
  4. package/dist/src/channels/contract.js +56 -0
  5. package/dist/src/channels/contract.js.map +1 -1
  6. package/dist/src/channels/tagging.d.ts +25 -0
  7. package/dist/src/channels/tagging.js +57 -5
  8. package/dist/src/channels/tagging.js.map +1 -1
  9. package/dist/src/channels/telegram-minimal.d.ts +251 -0
  10. package/dist/src/channels/telegram-minimal.js +618 -0
  11. package/dist/src/channels/telegram-minimal.js.map +1 -0
  12. package/dist/src/channels/telegram.d.ts +454 -19
  13. package/dist/src/channels/telegram.js +931 -80
  14. package/dist/src/channels/telegram.js.map +1 -1
  15. package/dist/src/cli/amend.js +8 -1
  16. package/dist/src/cli/amend.js.map +1 -1
  17. package/dist/src/cli/attest.js +2 -1
  18. package/dist/src/cli/attest.js.map +1 -1
  19. package/dist/src/cli/audit-card.d.ts +1 -1
  20. package/dist/src/cli/audit-card.js +40 -1
  21. package/dist/src/cli/audit-card.js.map +1 -1
  22. package/dist/src/cli/audit.d.ts +7 -5
  23. package/dist/src/cli/audit.js +25 -12
  24. package/dist/src/cli/audit.js.map +1 -1
  25. package/dist/src/cli/channel-telegram-webhook.d.ts +2 -0
  26. package/dist/src/cli/channel-telegram-webhook.js +7 -0
  27. package/dist/src/cli/channel-telegram-webhook.js.map +1 -1
  28. package/dist/src/cli/channel-telegram.d.ts +75 -2
  29. package/dist/src/cli/channel-telegram.js +172 -16
  30. package/dist/src/cli/channel-telegram.js.map +1 -1
  31. package/dist/src/cli/channel-web.js +2 -1
  32. package/dist/src/cli/channel-web.js.map +1 -1
  33. package/dist/src/cli/channel.js +2 -1
  34. package/dist/src/cli/channel.js.map +1 -1
  35. package/dist/src/cli/doctor.js +1 -1
  36. package/dist/src/cli/doctor.js.map +1 -1
  37. package/dist/src/cli/execute.d.ts +12 -0
  38. package/dist/src/cli/execute.js +50 -4
  39. package/dist/src/cli/execute.js.map +1 -1
  40. package/dist/src/cli/gate.js +2 -1
  41. package/dist/src/cli/gate.js.map +1 -1
  42. package/dist/src/cli/help.d.ts +1 -0
  43. package/dist/src/cli/help.js +39 -18
  44. package/dist/src/cli/help.js.map +1 -1
  45. package/dist/src/cli/hook.d.ts +121 -3
  46. package/dist/src/cli/hook.js +719 -79
  47. package/dist/src/cli/hook.js.map +1 -1
  48. package/dist/src/cli/log-checkpoint.js +2 -1
  49. package/dist/src/cli/log-checkpoint.js.map +1 -1
  50. package/dist/src/cli/log-unlock.d.ts +23 -0
  51. package/dist/src/cli/log-unlock.js +110 -0
  52. package/dist/src/cli/log-unlock.js.map +1 -0
  53. package/dist/src/cli/main.js +23 -6
  54. package/dist/src/cli/main.js.map +1 -1
  55. package/dist/src/cli/setup-common.js +2 -1
  56. package/dist/src/cli/setup-common.js.map +1 -1
  57. package/dist/src/cli/up.js +5 -0
  58. package/dist/src/cli/up.js.map +1 -1
  59. package/dist/src/cli/vault.js +2 -1
  60. package/dist/src/cli/vault.js.map +1 -1
  61. package/dist/src/cli/verb-registry.js +1 -0
  62. package/dist/src/cli/verb-registry.js.map +1 -1
  63. package/dist/src/cli/wordmark.d.ts +2 -2
  64. package/dist/src/cli/wordmark.js +2 -2
  65. package/dist/src/core/attest-bootstrap.js +2 -1
  66. package/dist/src/core/attest-bootstrap.js.map +1 -1
  67. package/dist/src/core/attest.js +5 -4
  68. package/dist/src/core/attest.js.map +1 -1
  69. package/dist/src/core/audit.d.ts +113 -9
  70. package/dist/src/core/audit.js +382 -18
  71. package/dist/src/core/audit.js.map +1 -1
  72. package/dist/src/core/budgets.d.ts +12 -0
  73. package/dist/src/core/budgets.js +54 -1
  74. package/dist/src/core/budgets.js.map +1 -1
  75. package/dist/src/core/checkpoint.js +3 -2
  76. package/dist/src/core/checkpoint.js.map +1 -1
  77. package/dist/src/core/command-class.js +6 -0
  78. package/dist/src/core/command-class.js.map +1 -1
  79. package/dist/src/core/decision-refusal.js +2 -1
  80. package/dist/src/core/decision-refusal.js.map +1 -1
  81. package/dist/src/core/delegation.d.ts +109 -0
  82. package/dist/src/core/delegation.js +244 -0
  83. package/dist/src/core/delegation.js.map +1 -0
  84. package/dist/src/core/execute.js +12 -3
  85. package/dist/src/core/execute.js.map +1 -1
  86. package/dist/src/core/gate-window.js +4 -3
  87. package/dist/src/core/gate-window.js.map +1 -1
  88. package/dist/src/core/gate.d.ts +84 -43
  89. package/dist/src/core/gate.js +114 -6
  90. package/dist/src/core/gate.js.map +1 -1
  91. package/dist/src/core/gesture-refusal.js +2 -1
  92. package/dist/src/core/gesture-refusal.js.map +1 -1
  93. package/dist/src/core/identity.d.ts +105 -0
  94. package/dist/src/core/identity.js +122 -0
  95. package/dist/src/core/identity.js.map +1 -0
  96. package/dist/src/core/log-lock.d.ts +435 -0
  97. package/dist/src/core/log-lock.js +1093 -0
  98. package/dist/src/core/log-lock.js.map +1 -0
  99. package/dist/src/core/log.d.ts +82 -3
  100. package/dist/src/core/log.js +285 -40
  101. package/dist/src/core/log.js.map +1 -1
  102. package/dist/src/core/loop.d.ts +11 -0
  103. package/dist/src/core/loop.js +63 -4
  104. package/dist/src/core/loop.js.map +1 -1
  105. package/dist/src/core/policy-diff.js +20 -1
  106. package/dist/src/core/policy-diff.js.map +1 -1
  107. package/dist/src/core/policy-explain.js +18 -2
  108. package/dist/src/core/policy-explain.js.map +1 -1
  109. package/dist/src/core/policy-load.d.ts +38 -1
  110. package/dist/src/core/policy-load.js +34 -2
  111. package/dist/src/core/policy-load.js.map +1 -1
  112. package/dist/src/core/policy-match.js +30 -5
  113. package/dist/src/core/policy-match.js.map +1 -1
  114. package/dist/src/core/policy-proposal.d.ts +17 -0
  115. package/dist/src/core/policy-proposal.js +32 -16
  116. package/dist/src/core/policy-proposal.js.map +1 -1
  117. package/dist/src/core/prompt-layout.d.ts +186 -1
  118. package/dist/src/core/prompt-layout.js +345 -3
  119. package/dist/src/core/prompt-layout.js.map +1 -1
  120. package/dist/src/core/sender-identity.d.ts +24 -1
  121. package/dist/src/core/sender-identity.js +31 -0
  122. package/dist/src/core/sender-identity.js.map +1 -1
  123. package/dist/src/core/token.js +11 -0
  124. package/dist/src/core/token.js.map +1 -1
  125. package/dist/src/core/tool-map.d.ts +125 -0
  126. package/dist/src/core/tool-map.js +179 -0
  127. package/dist/src/core/tool-map.js.map +1 -0
  128. package/dist/src/core/validate.d.ts +20 -6
  129. package/dist/src/core/validate.js +0 -0
  130. package/dist/src/core/validate.js.map +1 -1
  131. package/dist/src/daemon/git-evidence.d.ts +2 -2
  132. package/dist/src/daemon/git-evidence.js +1 -1
  133. package/dist/src/serve/archive.d.ts +8 -0
  134. package/dist/src/serve/archive.js +11 -2
  135. package/dist/src/serve/archive.js.map +1 -1
  136. package/docs/README-extended.md +15 -0
  137. package/docs/cli-reference.md +571 -28
  138. package/package.json +2 -2
  139. package/schema/event.schema.json +387 -29
  140. package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
  141. package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
  142. package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
  143. package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
  144. package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
  145. package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
  146. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
  147. package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
  148. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
  149. package/schema/fixtures/event/invalid/audit-reviewed-model-actor.json +20 -0
  150. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
  151. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
  152. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
  153. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
  154. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
  155. package/schema/fixtures/event/invalid/audit-reviewed-verdict-source-model.json +20 -0
  156. package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
  157. package/schema/fixtures/event/invalid/execution-started-harness-tool-space.json +18 -0
  158. package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
  159. package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
  160. package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
  161. package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
  162. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
  163. package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
  164. package/schema/fixtures/event/valid/audit-sampled.json +2 -1
  165. package/schema/fixtures/event/valid/daemon-identity.json +2 -1
  166. package/schema/fixtures/event/valid/execution-started-harness-tool.json +19 -0
  167. package/schema/fixtures/policy/invalid/delegation-advice-string.json +15 -0
  168. package/schema/fixtures/policy/invalid/delegation-class-duplicate.json +18 -0
  169. package/schema/fixtures/policy/invalid/delegation-class-wildcard.json +17 -0
  170. package/schema/fixtures/policy/invalid/delegation-daily-cap-fractional.json +15 -0
  171. package/schema/fixtures/policy/invalid/delegation-daily-cap-negative.json +15 -0
  172. package/schema/fixtures/policy/invalid/delegation-daily-cap-too-high.json +15 -0
  173. package/schema/fixtures/policy/invalid/delegation-escalate-duplicate.json +18 -0
  174. package/schema/fixtures/policy/invalid/delegation-escalate-unknown.json +17 -0
  175. package/schema/fixtures/policy/invalid/delegation-max-autonomy-alias.json +15 -0
  176. package/schema/fixtures/policy/invalid/delegation-max-autonomy-autonomous.json +15 -0
  177. package/schema/fixtures/policy/invalid/delegation-model-no-version.json +15 -0
  178. package/schema/fixtures/policy/invalid/delegation-model-provider-form.json +15 -0
  179. package/schema/fixtures/policy/invalid/delegation-null.json +7 -0
  180. package/schema/fixtures/policy/invalid/delegation-reviewer-agent.json +17 -0
  181. package/schema/fixtures/policy/invalid/delegation-reviewer-bare-id.json +17 -0
  182. package/schema/fixtures/policy/invalid/delegation-unknown-key.json +16 -0
  183. package/schema/fixtures/policy/invalid/tools-class-wildcard.json +9 -0
  184. package/schema/fixtures/policy/invalid/tools-match-double-star.json +9 -0
  185. package/schema/fixtures/policy/invalid/unmapped-tool-unknown.json +7 -0
  186. package/schema/fixtures/policy/valid/delegation-empty.json +7 -0
  187. package/schema/fixtures/policy/valid/delegation-grammar-full.json +41 -0
  188. package/schema/fixtures/policy/valid/delegation-off.json +15 -0
  189. package/schema/fixtures/policy/valid/tools-mapping.json +17 -0
  190. package/schema/policy.schema.json +178 -0
@@ -112,10 +112,10 @@ import { isAbsolute, resolve as resolvePathSegments } from "node:path";
112
112
  import { HUMAN_ACTOR_ENV, resolveHumanActor } from "../core/attest.js";
113
113
  import { isProposalTask } from "../core/gate.js";
114
114
  import { assembleBatch } from "../channels/batch.js";
115
- import { recordChannelDecision, refusedDecisionLine, } from "../channels/contract.js";
116
- import { ageText, buildPendingQueue, } from "../channels/tagging.js";
115
+ import { recordChannelDecision, recordSurfaceRefusal, refusedDecisionLine, } from "../channels/contract.js";
116
+ import { ageText, attestedPromptOf, buildPendingQueue, } from "../channels/tagging.js";
117
117
  import { checkpointOfferFor, checkpointPromptLines, checkpointSignedLines, signCheckpointOffer, } from "./checkpoint-tap.js";
118
- import { actionRefOf, decidedLine, groupForDigest, isMessageNotModified, isTelegramTerminalState, TelegramChannel, telegramChatEnvFor, telegramTokenEnvFor, TELEGRAM_NOT_RECORDED, TELEGRAM_REVIEW_DENIED, TELEGRAM_REVIEW_RECORDED, TELEGRAM_DEFAULT_API_BASE, TELEGRAM_TERMINAL_HEADLINES, utcClock, } from "../channels/telegram.js";
118
+ import { actionRefOf, decidedLine, groupForDigest, isDeterministicSendRefusal, isMessageNotModified, isTelegramTerminalState, retryAfterMsOf, TelegramChannel, telegramChatEnvFor, telegramTokenEnvFor, TELEGRAM_NOT_RECORDED, TELEGRAM_REVIEW_DENIED, TELEGRAM_REVIEW_RECORDED, TELEGRAM_DEFAULT_API_BASE, TELEGRAM_TERMINAL_HEADLINES, utcClock, } from "../channels/telegram.js";
119
119
  import { openReviewCards } from "./audit-card.js";
120
120
  import { reviewSample } from "../core/audit.js";
121
121
  import { abandonedAfterMs, HOOK_DEFAULT_WAIT_MS, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
@@ -128,7 +128,7 @@ import { claimBot, describeOwners, otherOwnersOf, ownedBot, } from "../core/chan
128
128
  import { actorForSender, recordedSenderFor, senderKeyFrom, } from "../core/sender-identity.js";
129
129
  import { recordRefusedGesture, } from "../core/gesture-refusal.js";
130
130
  import { attestationRefusal, checkAttestation } from "../core/attest.js";
131
- import { promptLayoutFor } from "../core/prompt-layout.js";
131
+ import { promptLayoutFor, promptSayFor, promptStyleFor } from "../core/prompt-layout.js";
132
132
  import { passphraseEnvFor } from "../core/vault.js";
133
133
  import { isAttestationActionKey, proposalRecords, proposalState, } from "../core/policy-proposal.js";
134
134
  import { payloadOf, readVerifiedRecords, requestState } from "../core/state.js";
@@ -141,6 +141,7 @@ import { TELEGRAM_HEALTH_HELP, TELEGRAM_HELP, TELEGRAM_LISTEN_HELP, } from "./he
141
141
  import { DEFAULT_LOG_PATH, preflightLog, resolvePath } from "./paths.js";
142
142
  import { style, tokenPanel, TOKEN_NOTICE_TELEGRAM } from "./style.js";
143
143
  import { usageErrorText } from "./usage.js";
144
+ import { reservedKindNote } from "../core/identity.js";
144
145
  const LISTEN_FLAGS = {
145
146
  "--log": "string",
146
147
  "--policy": "string",
@@ -148,6 +149,8 @@ const LISTEN_FLAGS = {
148
149
  "--as": "string",
149
150
  "--payloads": "string",
150
151
  "--api-base": "string",
152
+ /** APRV-456: refuse a tap on a button this process is not holding. */
153
+ "--no-stale-copy": "boolean",
151
154
  "--poll-timeout": "string",
152
155
  "--once": "boolean",
153
156
  /** APRV-390: start on a credential this instance did not configure. */
@@ -604,7 +607,7 @@ export function prepareListen(request) {
604
607
  code: "no-identity",
605
608
  message: request.as === null
606
609
  ? `no human identity: set ${HUMAN_ACTOR_ENV}=human:<id> or pass --as human:<id>. Every decision this listener records is recorded against it, and nothing here authenticates it`
607
- : `--as expects a human identity matching human:<id>, got ${JSON.stringify(request.as)}; approvals are human-only`,
610
+ : `--as expects a human identity matching human:<id>, got ${JSON.stringify(request.as)}${reservedKindNote(request.as)}; approvals are human-only`,
608
611
  };
609
612
  }
610
613
  const pollFlag = request.pollTimeout;
@@ -645,6 +648,22 @@ export function prepareListen(request) {
645
648
  // default, because a layout is not a permission and an unrelated typo in a
646
649
  // class rule must not silently redecorate a phone screen.
647
650
  layout: promptLayoutFor(policyLoad, "telegram"),
651
+ // APRV-489. The card style and the operator's plain-words declarations.
652
+ // Fix round 2 (S4, and the security follow-up on it): resolved for EACH
653
+ // delivery, and only from ATTESTED bytes: `attestedPromptOf` reads the
654
+ // policy file once, requires those bytes to hash to the latest attestation
655
+ // in the verified log, and parses style and `say` from the same buffer. A
656
+ // re-attested setting reaches the next card without a restart; an edited,
657
+ // unattested file draws the technical card. The two fixed values below are
658
+ // only what a channel without the resolver would use.
659
+ promptStyle: promptStyleFor(policyLoad, "telegram"),
660
+ say: promptSayFor(policyLoad, "telegram"),
661
+ promptFor: () => attestedPromptOf(request.logPath, { policy: request.policy }, "telegram"),
662
+ // APRV-456. From the launch flags alone: a relayed channel (an --api-base
663
+ // that is not the Bot API) or --no-stale-copy turns APRV-196's fallback
664
+ // off. The policy loaded above is deliberately not consulted, so nothing
665
+ // an agent can propose into a policy file can turn it back on.
666
+ staleCopy: staleCopyFor(request.apiBase, request.noStaleCopy === true),
648
667
  };
649
668
  return {
650
669
  ok: true,
@@ -776,6 +795,8 @@ function setUp(argv, streams, cwd) {
776
795
  as: stringFlag(flags, "--as"),
777
796
  payloads: payloadsFlag === null ? null : absolute(payloadsFlag, cwd),
778
797
  apiBase: stringFlag(flags, "--api-base"),
798
+ // APRV-456.
799
+ noStaleCopy: boolFlag(flags, "--no-stale-copy"),
779
800
  pollTimeout: stringFlag(flags, "--poll-timeout"),
780
801
  once: boolFlag(flags, "--once"),
781
802
  json,
@@ -852,15 +873,47 @@ export const DISPATCH_RETENTION_MS = 24 * 60 * 60 * 1000;
852
873
  * two apart, having deliberately kept nothing that would let it, and a first
853
874
  * start that claimed to be a restart would be this channel's own text lying
854
875
  * about the system's history.
876
+ *
877
+ * **With the stale-copy fallback off (APRV-456)** the second line says the
878
+ * opposite, because the earlier copies' buttons no longer decide anything: a
879
+ * relayed listener refuses a nonce it is not holding (`nonce-not-issued`), so
880
+ * the banner tells the approver to use the copies below it. A banner that kept
881
+ * promising working older copies would be this channel's own text sending the
882
+ * approver to a button that refuses them.
855
883
  */
856
- export function bannerLines(pending) {
884
+ export function bannerLines(pending, staleCopy = true) {
857
885
  const plural = pending === 1 ? "request" : "requests";
886
+ const live = `The ${pending === 1 ? "message" : "messages"} below ${pending === 1 ? "is" : "are"} the live ${pending === 1 ? "copy" : "copies"}.`;
858
887
  return [
859
888
  `LISTENER STARTED — re-sending ${pending} pending ${plural}.`,
860
- `The ${pending === 1 ? "message" : "messages"} below ${pending === 1 ? "is" : "are"} the live ${pending === 1 ? "copy" : "copies"}. If an earlier copy of the same request is further up this chat, its buttons still decide the same request; nothing is decided twice.`,
889
+ staleCopy
890
+ ? `${live} If an earlier copy of the same request is further up this chat, its buttons still decide the same request; nothing is decided twice.`
891
+ : `${live} Buttons on earlier copies further up this chat no longer decide anything on this listener; tap the ${pending === 1 ? "copy" : "copies"} below.`,
861
892
  "Which requests are pending is read from the log on every cycle, never from this chat.",
862
893
  ];
863
894
  }
895
+ /**
896
+ * Whether APRV-196's stale-copy fallback is on for a listener launched with
897
+ * this `--api-base` and this `--no-stale-copy` (APRV-456).
898
+ *
899
+ * On only for the direct-bot shape: no `--api-base`, or one that names the
900
+ * Bot API itself, and no `--no-stale-copy`. Any other base is treated as a
901
+ * relay, because the fallback's safety rests on only the bot token being able
902
+ * to put a button in front of the approver, and a base this process does not
903
+ * recognise as Telegram's own is one where that cannot be assumed. A
904
+ * self-hosted Bot API server is therefore treated as a relay too: the strict
905
+ * reading, and the cost is only that earlier copies stop deciding.
906
+ *
907
+ * Launch configuration and nothing else. No policy key reads into it and
908
+ * nothing in an update can reach it.
909
+ */
910
+ export function staleCopyFor(apiBase, noStaleCopy) {
911
+ if (noStaleCopy)
912
+ return false;
913
+ if (apiBase === null)
914
+ return true;
915
+ return apiBase.replace(/\/+$/u, "") === TELEGRAM_DEFAULT_API_BASE;
916
+ }
864
917
  // ---------------------------------------------------------------------------
865
918
  // Paced delivery (APRV-216) — one question at a time
866
919
  // ---------------------------------------------------------------------------
@@ -1042,6 +1095,9 @@ export function newDispatchState() {
1042
1095
  order: [],
1043
1096
  current: null,
1044
1097
  delivered: new Map(),
1098
+ terminalOnly: new Set(),
1099
+ offerFailures: new Map(),
1100
+ offersPausedUntilMs: null,
1045
1101
  summarySent: false,
1046
1102
  announced: 0,
1047
1103
  logSize: null,
@@ -1413,7 +1469,7 @@ export async function dispatchPending(setup, streams, state, now) {
1413
1469
  state.banner.sent = true;
1414
1470
  if (announcing) {
1415
1471
  try {
1416
- const deliveryId = await setup.channel.announce(bannerLines(undelivered.length));
1472
+ const deliveryId = await setup.channel.announce(bannerLines(undelivered.length, setup.channel.staleCopyEnabled()));
1417
1473
  result.banner = { delivery_id: deliveryId, pending: undelivered.length };
1418
1474
  }
1419
1475
  catch (cause) {
@@ -1544,6 +1600,14 @@ async function dispatchReviews(setup, streams, state, result, now) {
1544
1600
  if (!openSeqs.has(seq))
1545
1601
  review.delivered.delete(seq);
1546
1602
  }
1603
+ for (const seq of review.terminalOnly) {
1604
+ if (!openSeqs.has(seq))
1605
+ review.terminalOnly.delete(seq);
1606
+ }
1607
+ for (const seq of review.offerFailures.keys()) {
1608
+ if (!openSeqs.has(seq))
1609
+ review.offerFailures.delete(seq);
1610
+ }
1547
1611
  if (review.current !== null && !openSeqs.has(review.current))
1548
1612
  review.current = null;
1549
1613
  // A count the approver was told that is now too high is the number they
@@ -1553,7 +1617,16 @@ async function dispatchReviews(setup, streams, state, result, now) {
1553
1617
  return;
1554
1618
  if (setup.delivery === "paced" && state.paced.current !== null)
1555
1619
  return;
1556
- const nextSeq = review.order.find((seq) => !review.delivered.has(seq));
1620
+ // PR #614 recheck NF-2: a transient offer failure pauses the walkthrough for
1621
+ // Telegram's `retry_after` or the backoff, whichever is longer. Nothing is
1622
+ // offered, summary included, until it passes.
1623
+ const nowMs = Date.parse(now);
1624
+ if (review.offersPausedUntilMs !== null) {
1625
+ if (Number.isFinite(nowMs) && nowMs < review.offersPausedUntilMs)
1626
+ return;
1627
+ review.offersPausedUntilMs = null;
1628
+ }
1629
+ const nextSeq = review.order.find((seq) => !review.delivered.has(seq) && !review.terminalOnly.has(seq));
1557
1630
  if (nextSeq === undefined)
1558
1631
  return;
1559
1632
  const card = open.find((entry) => entry.sampleSeq === nextSeq);
@@ -1577,6 +1650,7 @@ async function dispatchReviews(setup, streams, state, result, now) {
1577
1650
  try {
1578
1651
  const deliveryId = await setup.channel.offerReview(card);
1579
1652
  review.delivered.set(nextSeq, deliveryId);
1653
+ review.offerFailures.delete(nextSeq);
1580
1654
  review.current = nextSeq;
1581
1655
  result.reviewCard = {
1582
1656
  delivery_id: deliveryId,
@@ -1596,10 +1670,55 @@ async function dispatchReviews(setup, streams, state, result, now) {
1596
1670
  }
1597
1671
  }
1598
1672
  catch (cause) {
1599
- // The sample stays open and undelivered, so the next cycle offers it again.
1600
- streams.err(`approval: telegram could not offer the review of sample seq ${String(nextSeq)} (${actionKey}): ${cause instanceof Error ? cause.message : String(cause)} — the sample stays open and the next cycle tries again\n`);
1673
+ const reason = cause instanceof Error ? cause.message : String(cause);
1674
+ const attempts = (review.offerFailures.get(nextSeq) ?? 0) + 1;
1675
+ const deterministic = isDeterministicSendRefusal(cause);
1676
+ if (deterministic || attempts >= REVIEW_OFFER_ATTEMPTS) {
1677
+ // PR #614 refutation F4, narrowed by recheck NF-2. The sample stays open,
1678
+ // and this listener stops offering it: either Telegram refused the card
1679
+ // itself, so the next cycle would build the same card from the same log
1680
+ // and meet the same refusal, or the retry budget is spent. The queue
1681
+ // behind it moves on, and the coded line names the repair.
1682
+ review.offerFailures.delete(nextSeq);
1683
+ review.terminalOnly.add(nextSeq);
1684
+ streams.err(`approval: telegram review-offer-failed: could not offer the review of sample seq ${String(nextSeq)} (${actionKey}): ${reason} — ${deterministic
1685
+ ? "Telegram refused the card itself, so every resend would meet the same refusal"
1686
+ : `${String(attempts)} attempts failed`}; the sample stays open for a terminal review (approval audit review ${String(nextSeq)} --ok or --deny), and this listener offers the next sample instead\n`);
1687
+ return;
1688
+ }
1689
+ // PR #614 recheck NF-2. Anything else (a 429, a timeout, a 5xx, a network
1690
+ // error) can clear on its own, so the sample keeps its place and the
1691
+ // walkthrough pauses: for Telegram's `retry_after` when it named one, and
1692
+ // never less than the backoff for this sample's attempt count.
1693
+ review.offerFailures.set(nextSeq, attempts);
1694
+ // NF-5: capped, because `retry_after` is the network's number. A huge one
1695
+ // would otherwise park reviews for years, or overflow the Date below and
1696
+ // throw out of a dispatch cycle that is documented never to throw.
1697
+ const waitMs = Math.min(Math.max(reviewOfferBackoffMs(attempts), retryAfterMsOf(cause) ?? 0), REVIEW_OFFER_MAX_WAIT_MS);
1698
+ const base = Number.isFinite(nowMs) ? nowMs : Date.now();
1699
+ review.offersPausedUntilMs = base + waitMs;
1700
+ streams.err(`approval: telegram review-offer-retry: could not offer the review of sample seq ${String(nextSeq)} (${actionKey}), attempt ${String(attempts)} of ${String(REVIEW_OFFER_ATTEMPTS)}: ${reason} — review cards pause until ${new Date(base + waitMs).toISOString()} and then offer it again\n`);
1601
1701
  }
1602
1702
  }
1703
+ /**
1704
+ * How many failed offers a sample gets before it is left for a terminal review
1705
+ * (PR #614 recheck NF-2). With {@link reviewOfferBackoffMs} the budget spans
1706
+ * about fifteen minutes of a dark or rate-limited chat (1 + 2 + 4 + 8 minutes
1707
+ * between the five attempts), and a Bot API refusal of the card itself skips
1708
+ * it.
1709
+ */
1710
+ export const REVIEW_OFFER_ATTEMPTS = 5;
1711
+ /**
1712
+ * The longest review cards ever pause after one failed offer, whatever
1713
+ * `retry_after` said (PR #614 recheck NF-5): one hour.
1714
+ */
1715
+ export const REVIEW_OFFER_MAX_WAIT_MS = 3_600_000;
1716
+ /** The first pause after a transient offer failure; it doubles per attempt. */
1717
+ export const REVIEW_OFFER_BACKOFF_MS = 60_000;
1718
+ /** The pause after the `attempts`-th failed offer of one sample, before `retry_after`. */
1719
+ export function reviewOfferBackoffMs(attempts) {
1720
+ return REVIEW_OFFER_BACKOFF_MS * 2 ** Math.max(0, attempts - 1);
1721
+ }
1603
1722
  /**
1604
1723
  * How old a pending request must be, on a listener's first cycle, to be one
1605
1724
  * nobody is waiting on (APRV-287).
@@ -2084,6 +2203,32 @@ export function reportCycle(result, streams) {
2084
2203
  streams.err(`approval: telegram sendMessage failed for ${failure.action_key} (attempt ${failure.attempts}): ${failure.message} — still pending, retrying next cycle\n`);
2085
2204
  }
2086
2205
  }
2206
+ /**
2207
+ * Where a tap the channel refused on its own is recorded (APRV-456).
2208
+ *
2209
+ * One `audit.decision_refused`, through `recordSurfaceRefusal`, which never
2210
+ * calls the gate. The operator's terminal says what happened either way,
2211
+ * because a card nobody here sent being tapped on a relayed gate is the line
2212
+ * an operator most needs to see.
2213
+ */
2214
+ function refusedDecisionHandlerFor(setup, streams) {
2215
+ return ({ decision, refusal }) => {
2216
+ recordSurfaceRefusal(setup.logPath, decision, refusal, { actor: setup.actor, channel: "telegram" }, setup.gateOptions);
2217
+ if (setup.json) {
2218
+ streams.out(`${JSON.stringify({
2219
+ event: "decision",
2220
+ action_key: decision.action_key,
2221
+ decision: decision.decision,
2222
+ ok: false,
2223
+ code: refusal.code,
2224
+ token_issued: false,
2225
+ })}\n`);
2226
+ }
2227
+ else {
2228
+ streams.err(`approval: telegram decision refused (${refusal.code}): ${refusal.message}\n`);
2229
+ }
2230
+ };
2231
+ }
2087
2232
  /** The decision handler: the only thing this process does with a button press. */
2088
2233
  function handlerFor(setup, streams) {
2089
2234
  return (decision) => {
@@ -2316,12 +2461,18 @@ export function reviewHandlerFor(setup, streams) {
2316
2461
  headline: TELEGRAM_NOT_RECORDED,
2317
2462
  detail: [refusedDecisionLine(resolved.code)],
2318
2463
  toast: "Not recorded.",
2464
+ code: resolved.code,
2319
2465
  };
2320
2466
  }
2321
2467
  const result = reviewSample(setup.logPath, { kind: "seq", seq: tap.sampleSeq }, resolved.actor, tap.note ?? null, {
2322
2468
  ...(setup.gateOptions.policy === undefined ? {} : { policy: setup.gateOptions.policy }),
2323
- verdict: tap.verdict,
2469
+ // APRV-482: passed only when the human gave one. A grade alone arrives
2470
+ // with none and `reviewSample` refuses it `verdict-required`.
2471
+ ...(tap.verdict === undefined ? {} : { verdict: tap.verdict }),
2324
2472
  ...(tap.reaction === undefined ? {} : { reaction: tap.reaction }),
2473
+ // APRV-481: the card's own statement that it showed the bytes whole,
2474
+ // which `reviewSample` checks against the log before recording it.
2475
+ ...(tap.payloadHash === undefined ? {} : { renderedPayloadHash: tap.payloadHash }),
2325
2476
  ...(resolved.sender === undefined
2326
2477
  ? {}
2327
2478
  : {
@@ -2341,7 +2492,7 @@ export function reviewHandlerFor(setup, streams) {
2341
2492
  event: "review",
2342
2493
  ok: false,
2343
2494
  sample_seq: tap.sampleSeq,
2344
- verdict: tap.verdict,
2495
+ verdict: tap.verdict ?? null,
2345
2496
  reaction: tap.reaction ?? null,
2346
2497
  code: result.code,
2347
2498
  })}\n`);
@@ -2351,6 +2502,7 @@ export function reviewHandlerFor(setup, streams) {
2351
2502
  headline: TELEGRAM_NOT_RECORDED,
2352
2503
  detail: [result.code, result.message],
2353
2504
  toast: "Not recorded — the card says why.",
2505
+ code: result.code,
2354
2506
  };
2355
2507
  }
2356
2508
  const obligation = result.obligation;
@@ -2361,7 +2513,7 @@ export function reviewHandlerFor(setup, streams) {
2361
2513
  seq: result.record.seq,
2362
2514
  sample_seq: result.subject.seq,
2363
2515
  action_key: result.subject.actionKey,
2364
- verdict: tap.verdict,
2516
+ verdict: tap.verdict ?? null,
2365
2517
  reaction: tap.reaction ?? null,
2366
2518
  obligation_seq: obligation === null ? null : obligation.seq,
2367
2519
  })}\n`);
@@ -2370,7 +2522,7 @@ export function reviewHandlerFor(setup, streams) {
2370
2522
  streams.out(`reviewed sample at seq ${String(result.subject.seq)} (action ${result.subject.actionKey ?? "-"}) at seq ${String(result.record.seq)} by ${setup.actor} via telegram${tap.verdict === "denied" ? " — DENIED" : ""}${tap.reaction === undefined ? "" : ` — reaction: ${tap.reaction} (guidance, not policy)`}\n`);
2371
2523
  }
2372
2524
  const detail = [
2373
- `recorded at seq ${String(result.record.seq)} by ${setup.actor} · verdict ${tap.verdict}`,
2525
+ `recorded at seq ${String(result.record.seq)} by ${setup.actor} · verdict ${tap.verdict ?? "-"}`,
2374
2526
  ...(tap.reaction === undefined
2375
2527
  ? []
2376
2528
  : [`reaction: ${tap.reaction} — guidance, not policy; it changes no verdict and no budget`]),
@@ -2488,7 +2640,7 @@ clock = () => new Date().toISOString()) {
2488
2640
  : `Review of sample ${String(card)} passed over — this listener sends no further card for it.`,
2489
2641
  "Nothing was recorded. The sample is still open: it is listed by `approval audit list` and reviewable with `approval audit review " +
2490
2642
  String(card) +
2491
- "`, and the card already in this chat keeps its buttons.",
2643
+ " --ok` (or `--deny`), and the card already in this chat keeps its buttons.",
2492
2644
  ]);
2493
2645
  return;
2494
2646
  }
@@ -2546,6 +2698,10 @@ clock = () => new Date().toISOString()) {
2546
2698
  export function wireListener(setup, streams) {
2547
2699
  const { channel } = setup;
2548
2700
  channel.onDecision(handlerFor(setup, streams));
2701
+ // APRV-456. Where a tap the channel refused on its own (`nonce-not-issued`)
2702
+ // is recorded. A separate function from the decision handler because that
2703
+ // one calls the gate and this one must not.
2704
+ channel.onRefusedDecision(refusedDecisionHandlerFor(setup, streams));
2549
2705
  // APRV-257. Registered unconditionally, because whether a checkpoint is ever
2550
2706
  // OFFERED is the policy's answer and a handler that exists for a prompt
2551
2707
  // nobody sends costs nothing. Registering it here is also what makes the