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
@@ -219,15 +219,16 @@
219
219
  * process memory and nothing else: no event is appended, no message is edited,
220
220
  * and the log is not opened.
221
221
  */
222
- import { createHash } from "node:crypto";
222
+ import { createHash, randomBytes } from "node:crypto";
223
223
  import { GLOSS_UNVERIFIED_SUFFIX, refusedDecisionLine } from "./contract.js";
224
224
  // APRV-299. The reaction vocabulary and the verdict type, imported for the
225
225
  // reason `core/audit.ts` states where it exports them: they are shown here and
226
226
  // decided nowhere. Nothing in this file branches on a reaction, and SPEC.md
227
227
  // §11.1 invariant 10's guard scans the modules that decide, which this is not.
228
228
  import { REACTIONS } from "../core/audit.js";
229
- import { TELEGRAM_PROMPT_LAYOUT, } from "../core/prompt-layout.js";
229
+ import { DEFAULT_PROMPT_STYLE, TELEGRAM_PROMPT_LAYOUT, } from "../core/prompt-layout.js";
230
230
  import { commandPayloadView, payloadRegionText } from "./payload-view.js";
231
+ import { MINIMAL_DENY_LABEL, renderTelegramMinimal } from "./telegram-minimal.js";
231
232
  // ---------------------------------------------------------------------------
232
233
  // Configuration
233
234
  // ---------------------------------------------------------------------------
@@ -765,6 +766,81 @@ export function renderTelegram(request, heading = TELEGRAM_PROMPT_HEADING, layou
765
766
  : `${payload.truncated ? `--- payload TRUNCATED at render (sha256 ${payload.hash}) — no canonical rendering exists; do not grant on this ---\n` : ""}${payloadRegionText(payload, request.class.value)}`,
766
767
  };
767
768
  }
769
+ /**
770
+ * The settle edit of a minimal card (APRV-489): the outcome, the card's
771
+ * headline, the detail lines, and the collapsed block WHOLE.
772
+ *
773
+ * The edit replaces the only message that holds the canonical rendering, so
774
+ * the rendering is never what gives way to fit: the runtime's own detail lines
775
+ * are shortened instead, with a marker, and the outcome headline always stays.
776
+ * The card was at most 3800 characters with its quote box and
777
+ * deadline line, which the edit drops, so headline plus collapsed block always
778
+ * fit under {@link TELEGRAM_SETTLE_BUDGET} (the card budget is `MINIMAL_MESSAGE_BUDGET` in `telegram-minimal.ts`).
779
+ */
780
+ export function minimalSettleText(outcome, detail, card) {
781
+ const head = [`<b>${escapeHtml(outcome)}</b>`, card.headline];
782
+ // One unit throughout (fix round 2, S3): UTF-16 code units of the HTML,
783
+ // which is what `.length` counts and never less than the parsed text
784
+ // Telegram limits. The cut is taken between grapheme clusters of the
785
+ // UNESCAPED text, so it never splits a surrogate pair, a cluster or an entity.
786
+ const fixed = [...head, card.details].join("\n").length + 1;
787
+ const room = Math.max(0, TELEGRAM_SETTLE_BUDGET - fixed);
788
+ const raw = detail.join("\n");
789
+ let lines = escapeHtml(raw);
790
+ if (lines.length > room)
791
+ lines = shortenHtml(raw, room, SETTLE_SHORTENED_MARK);
792
+ return [...head, ...(lines.length === 0 ? [] : [lines]), card.details].join("\n");
793
+ }
794
+ /** What a shortened run of detail lines ends with. */
795
+ const SETTLE_SHORTENED_MARK = "… (shortened; the log holds the full record)";
796
+ /** Grapheme clusters, for cuts that never split one. */
797
+ const SETTLE_GRAPHEMES = new Intl.Segmenter("en", { granularity: "grapheme" });
798
+ /** `raw` escaped and cut between clusters so the result, marker included, is at most `budget` UTF-16 units. */
799
+ function shortenHtml(raw, budget, marker) {
800
+ const limit = Math.max(0, budget - marker.length);
801
+ let kept = "";
802
+ for (const { segment } of SETTLE_GRAPHEMES.segment(raw)) {
803
+ const escaped = escapeHtml(segment);
804
+ if (kept.length + escaped.length > limit)
805
+ break;
806
+ kept += escaped;
807
+ }
808
+ return `${kept}${marker}`;
809
+ }
810
+ /**
811
+ * The settle edit of last resort (fix round 2, S3): sent when Telegram refused
812
+ * the full settle edit of a minimal card, so a decided card never keeps looking
813
+ * undecided. The outcome, the request's key, the first detail line (who, when,
814
+ * which record) cut to fit, and where the rest is. Always under 1000 units.
815
+ */
816
+ export function shortSettleText(outcome, actionKey, detail) {
817
+ const first = detail[0] ?? "";
818
+ return [
819
+ `<b>${escapeHtml(outcome)}</b>`,
820
+ `<code>${shortenHtml(actionKey, 200, "")}</code>`,
821
+ "",
822
+ ...(first.length === 0 ? [] : [escapeHtml(first).length <= 400 ? escapeHtml(first) : shortenHtml(first, 400, "…")]),
823
+ "The full record is in the approval log (approval log, approval queue).",
824
+ ].join("\n");
825
+ }
826
+ /**
827
+ * Whether a Bot API failure is a refusal of THIS message rather than a passing
828
+ * failure (fix round 2, S2): any 4xx but 429. A 429, a 5xx and a transport
829
+ * failure are retried by the dispatch cycle as before; a refusal never will
830
+ * succeed with the same text, so the minimal card gives way to the technical one.
831
+ */
832
+ export function isMessageRefusal(cause) {
833
+ return (cause instanceof TelegramApiError &&
834
+ cause.status !== null &&
835
+ cause.status >= 400 &&
836
+ cause.status < 500 &&
837
+ cause.status !== 429);
838
+ }
839
+ /**
840
+ * The longest settle edit of a minimal card, in characters of HTML: under
841
+ * Telegram's 4096 with room for a relay's `Agent: <name>` line.
842
+ */
843
+ export const TELEGRAM_SETTLE_BUDGET = 3990;
768
844
  /**
769
845
  * Split `text` so every chunk survives HTML escaping inside the message limit.
770
846
  *
@@ -1336,6 +1412,15 @@ export const TELEGRAM_REVIEW_DENIED = "✗ REVIEWED — DENIED";
1336
1412
  * been recorded.
1337
1413
  */
1338
1414
  export const TELEGRAM_REVIEW_ARMED = "DENY ARMED — nothing is recorded yet";
1415
+ /**
1416
+ * The headline a card wears while a grade is held and no verdict has been
1417
+ * given (APRV-482). A grade is not a verdict: tapping one with nothing armed
1418
+ * records nothing, and the card keeps the grade so the OK or the Deny that
1419
+ * follows records it.
1420
+ */
1421
+ export function telegramReviewGradeHeld(reaction) {
1422
+ return `GRADE ${reaction.toUpperCase()} HELD — nothing is recorded until you tap OK or Deny`;
1423
+ }
1339
1424
  /**
1340
1425
  * What a review tap's single answer says (APRV-302).
1341
1426
  *
@@ -1351,8 +1436,29 @@ export const TELEGRAM_REVIEW_ARMED = "DENY ARMED — nothing is recorded yet";
1351
1436
  export const TELEGRAM_REVIEW_ACK = "Heard — recording your review. The card will say what the log recorded.";
1352
1437
  /** The toast a first Deny tap gets: it says plainly that nothing was written. */
1353
1438
  export const TELEGRAM_REVIEW_ARM_TOAST = "Deny armed — nothing recorded. Tap Deny again to record it, or a reaction to record it with a grade.";
1439
+ /**
1440
+ * The card line and toast for a tap that would finish another account's
1441
+ * half-finished review (PR #614 refutation F3): an armed Deny or a held grade
1442
+ * belongs to the account that tapped it, and nothing is recorded. A held grade
1443
+ * is replaced by the tapper's own grade (a grade finishes nothing), which the
1444
+ * line says (PR #614 recheck NF-1, minor). Under 200 characters, the Bot API's
1445
+ * limit for a callback answer.
1446
+ */
1447
+ export const TELEGRAM_REVIEW_OTHER_SENDER = "Another account's unfinished review (armed Deny or held grade) is on this card. Nothing was recorded. Tap your own grade to replace a held one; an armed Deny is theirs to finish.";
1354
1448
  /** The toast a reaction that needs the human's own words gets. */
1355
1449
  export const TELEGRAM_REVIEW_NOTE_TOAST = "Heard — reply to the prompt with why. Nothing is recorded until it arrives.";
1450
+ /**
1451
+ * The card line for a note prompt that could not be sent (APRV-492). The toast
1452
+ * went out before the send and told the human to reply to a prompt, so the
1453
+ * card says the prompt is not coming and how to be asked again: a grade tap is
1454
+ * what the relay licenses a prompt for. With Deny armed the grade alone asks;
1455
+ * otherwise the grade is held and OK asks.
1456
+ */
1457
+ export function telegramReviewNoteUnasked(verdict) {
1458
+ return verdict === "denied"
1459
+ ? "The prompt asking why could not be sent, so nothing was recorded. Tap your grade again to be asked."
1460
+ : "The prompt asking why could not be sent, so nothing was recorded. Tap your grade again, then OK, to be asked.";
1461
+ }
1356
1462
  /**
1357
1463
  * What the ForceReply prompt asks for.
1358
1464
  *
@@ -1445,6 +1551,255 @@ function trimNotice(text) {
1445
1551
  ? text
1446
1552
  : `${text.slice(0, REVIEW_NOTICE_MAX)}… (cut to fit one message; the whole refusal is on the listener's stderr)`;
1447
1553
  }
1554
+ /**
1555
+ * How much payload, in ESCAPED characters, a review card carries whole
1556
+ * (APRV-480).
1557
+ *
1558
+ * A card is one message edited in place, so it cannot spill the payload over
1559
+ * several messages the way a prompt does: the edit that settles it replaces one
1560
+ * message, and a second message holding the rest of the bytes would outlive the
1561
+ * card it belonged to. So the bytes are shown whole or not at all. Never cut:
1562
+ * a review that recorded a payload hash over bytes the reviewer saw half of
1563
+ * would claim more than they read. This is the CEILING; the budget a given
1564
+ * card gets is {@link reviewPayloadBudget}, which is this or the headroom the
1565
+ * card's own rows leave under {@link TELEGRAM_MAX_MESSAGE_CHARS}, whichever is
1566
+ * smaller (PR #614 refutation F4).
1567
+ */
1568
+ export const REVIEW_PAYLOAD_BUDGET = 2000;
1569
+ /**
1570
+ * The longest agent-written summary a review card shows, in characters (PR
1571
+ * #614 refutation F4). The summary is the requesting party's own claim and its
1572
+ * schema sets no length, so before this cap an agent could write a summary
1573
+ * that pushed the card past Telegram's limit, which failed the send and held
1574
+ * the review queue. Longer text is cut with a marker saying how much is not
1575
+ * shown; the registration record keeps it whole.
1576
+ */
1577
+ export const REVIEW_SUMMARY_MAX = 400;
1578
+ /**
1579
+ * How much of each row a review card shows, in characters (PR #614 refutation
1580
+ * F4), tried in order until the card fits one message with the longest
1581
+ * heading, the hash-only payload region and the largest notice a tap can add.
1582
+ * `row` caps the key, class, task, command breakdown, gloss, run time and
1583
+ * outcome; `summary` caps the agent's summary (never above
1584
+ * {@link REVIEW_SUMMARY_MAX}); `origin` caps an author or source label. An
1585
+ * ordinary card fits at the first level and shows its rows as they are; only a
1586
+ * card whose rows are pathologically long is cut further, and the last level
1587
+ * fits whatever the rows hold (pinned by the worst-case test in
1588
+ * `tests/channels-telegram.test.ts`).
1589
+ */
1590
+ const REVIEW_CAP_LEVELS = [
1591
+ { row: 300, summary: REVIEW_SUMMARY_MAX, origin: 60 },
1592
+ { row: 160, summary: 240, origin: 40 },
1593
+ { row: 80, summary: 120, origin: 30 },
1594
+ { row: 40, summary: 60, origin: 20 },
1595
+ ];
1596
+ /** The longest notice headline a review card shows (F4). Headlines are constants. */
1597
+ const REVIEW_NOTICE_HEADLINE_MAX = 100;
1598
+ /**
1599
+ * The most characters a review card's notice region (a refusal, or the arming
1600
+ * line) shows, all its lines together (PR #614 refutation F4). Each line was
1601
+ * already cut at {@link REVIEW_NOTICE_MAX}; the region had no total, so a
1602
+ * refusal with several long lines could take the card over the limit and make
1603
+ * the redraw that carries it fail. The whole refusal is on the listener's
1604
+ * stderr.
1605
+ */
1606
+ export const REVIEW_NOTICE_REGION_MAX = 600;
1607
+ /** The most characters a settled card's detail lines show together (F4). */
1608
+ const REVIEW_SETTLED_REGION_MAX = 3000;
1609
+ /**
1610
+ * The visible length of HTML this channel wrote: tags removed and each entity
1611
+ * {@link escapeHtml} produces counted as the one character it stands for.
1612
+ * Telegram measures its limit after entity parsing, so this is the number the
1613
+ * limit applies to.
1614
+ */
1615
+ export function telegramVisibleLength(html) {
1616
+ return html.replace(/<[^>]*>/gu, "").replace(/&(?:amp|lt|gt);/gu, "x").length;
1617
+ }
1618
+ /** `text` cut at `max` characters with a marker saying how much is not shown. */
1619
+ function capReviewText(text, max) {
1620
+ if (text.length <= max)
1621
+ return text;
1622
+ let cut = text.slice(0, max);
1623
+ const last = cut.charCodeAt(cut.length - 1);
1624
+ if (last >= 0xd800 && last <= 0xdbff)
1625
+ cut = cut.slice(0, -1);
1626
+ return `${cut}${reviewCutMarker(text.length - cut.length)}`;
1627
+ }
1628
+ /** The marker a cut row ends with. */
1629
+ function reviewCutMarker(hidden) {
1630
+ return `… [+${String(hidden)} chars not shown]`;
1631
+ }
1632
+ /** The longest {@link reviewCutMarker}: a JavaScript string is under 2^53 long. */
1633
+ const REVIEW_CUT_MARKER_MAX = reviewCutMarker(Number.MAX_SAFE_INTEGER).length;
1634
+ /** The line a region ends with when lines were left out. */
1635
+ const REVIEW_REGION_OVERFLOW = "… [more on the listener's stderr]";
1636
+ /**
1637
+ * Lines cut so that together, counting the newline before each, they show at
1638
+ * most `max` characters plus one cut marker and the overflow line.
1639
+ */
1640
+ function capReviewRegion(lines, max) {
1641
+ const out = [];
1642
+ let used = 0;
1643
+ for (const entry of lines) {
1644
+ const room = max - used - 1;
1645
+ if (room <= 0) {
1646
+ out.push(REVIEW_REGION_OVERFLOW);
1647
+ break;
1648
+ }
1649
+ const shown = capReviewText(entry, room);
1650
+ out.push(shown);
1651
+ used += shown.length + 1;
1652
+ }
1653
+ return out;
1654
+ }
1655
+ /**
1656
+ * The longest payload text, in characters, a review card will even try to
1657
+ * render (APRV-480 refutation: review-delivery resource cap).
1658
+ *
1659
+ * Supervised payloads carry no size cap of their own (`PROPOSE_PAYLOAD_MAX_BYTES`
1660
+ * binds proposals only), and the party under review writes them. Without this
1661
+ * bound every render of a card, every redraw after a tap, and every tap's
1662
+ * {@link reviewPayloadView} would run the canonical renderer and HTML escaping
1663
+ * over the whole payload only to conclude "too long", so an agent could make
1664
+ * each review gesture cost work proportional to bytes it chose. Text past this
1665
+ * length is `too-long` without being rendered at all; the card builder
1666
+ * (`cli/audit-card.ts`) keeps no more than this much text in the card it holds.
1667
+ * Eight times the card budget, because a canonical rendering is never that much
1668
+ * shorter than the JSON it renders, and erring here only ever shows the hash.
1669
+ */
1670
+ export const REVIEW_RENDER_INPUT_MAX = REVIEW_PAYLOAD_BUDGET * 8;
1671
+ /** The heading over a payload the card shows whole (APRV-480). */
1672
+ export const TELEGRAM_REVIEW_PAYLOAD_BYTES = "PAYLOAD — the bytes that ran, shown whole; this review covers them";
1673
+ /** The heading over a payload the card names by hash only (APRV-480). */
1674
+ export const TELEGRAM_REVIEW_PAYLOAD_HASH_ONLY = "PAYLOAD — NOT SHOWN, hash only; this review does not cover the bytes";
1675
+ /** The heading on a card whose execution recorded no payload binding (APRV-480). */
1676
+ export const TELEGRAM_REVIEW_PAYLOAD_NONE = "PAYLOAD — none recorded; the execution bound to no payload hash";
1677
+ export function reviewPayloadView(card) {
1678
+ const rendering = card.fields.fullPayload.value;
1679
+ const bound = card.fields.payload_hash?.value ?? rendering?.hash ?? null;
1680
+ if (rendering !== null && (bound === null || bound === rendering.hash)) {
1681
+ // Bounded before any rendering work: see REVIEW_RENDER_INPUT_MAX.
1682
+ if (rendering.truncated || rendering.text.length > REVIEW_RENDER_INPUT_MAX) {
1683
+ return { kind: "hash", hash: rendering.hash, reason: "too-long" };
1684
+ }
1685
+ const text = payloadRegionText(rendering, card.fields.class.value);
1686
+ if (escapeHtml(text).length <= reviewPayloadBudget(card)) {
1687
+ return { kind: "bytes", hash: rendering.hash, text };
1688
+ }
1689
+ return { kind: "hash", hash: rendering.hash, reason: "too-long" };
1690
+ }
1691
+ if (bound === null)
1692
+ return { kind: "none" };
1693
+ return { kind: "hash", hash: bound, reason: "unavailable" };
1694
+ }
1695
+ /** The payload region of a review card, as HTML lines (APRV-480). */
1696
+ function reviewPayloadLines(view) {
1697
+ switch (view.kind) {
1698
+ case "bytes":
1699
+ return [`<b>${escapeHtml(TELEGRAM_REVIEW_PAYLOAD_BYTES)}</b>`, `<pre>${escapeHtml(view.text)}</pre>`];
1700
+ case "hash":
1701
+ return [
1702
+ `<b>${escapeHtml(TELEGRAM_REVIEW_PAYLOAD_HASH_ONLY)}</b>`,
1703
+ escapeHtml(view.reason === "too-long"
1704
+ ? `The bytes are longer than one card carries, so none of them are shown. sha256 ${view.hash}`
1705
+ : `This runtime does not hold the bytes the execution bound to. sha256 ${view.hash}`),
1706
+ ];
1707
+ default:
1708
+ return [`<b>${escapeHtml(TELEGRAM_REVIEW_PAYLOAD_NONE)}</b>`];
1709
+ }
1710
+ }
1711
+ /**
1712
+ * A review card's HTML lines around the payload region, every row cut to its
1713
+ * cap (PR #614 refutation F4): `top` is the key and the computed rows, `bottom`
1714
+ * the claimed ones. Pure over the card, so the payload budget computed from it
1715
+ * and the card drawn from it cannot disagree.
1716
+ */
1717
+ function reviewCardFrame(card) {
1718
+ let frame = reviewCardFrameAt(card, REVIEW_CAP_LEVELS[0]);
1719
+ for (const level of REVIEW_CAP_LEVELS) {
1720
+ frame = reviewCardFrameAt(card, level);
1721
+ if (reviewFixedLength(frame) + REVIEW_HASH_REGION_MAX <= TELEGRAM_MAX_MESSAGE_CHARS)
1722
+ break;
1723
+ }
1724
+ return frame;
1725
+ }
1726
+ function reviewCardFrameAt(card, caps) {
1727
+ const computedLines = [];
1728
+ const claimedLines = [];
1729
+ for (const row of REVIEW_CARD_ROWS) {
1730
+ const candidate = reviewRow(card.fields, row);
1731
+ if (candidate === null)
1732
+ continue;
1733
+ if (candidate.line.kind === "computed")
1734
+ computedLines.push(candidate.line);
1735
+ else
1736
+ claimedLines.push(candidate.line);
1737
+ }
1738
+ computedLines.push(line("ran_at", card.ranAt, "ran at", card.ranAt.value));
1739
+ computedLines.push(line("verdict", card.verdict, "verdict", card.verdict.value));
1740
+ const render = (entry) => `• <b>${escapeHtml(entry.label)}:</b> ${escapeHtml(capReviewText(entry.text, entry.field === "summary" ? caps.summary : caps.row))} <i>(${escapeHtml(capReviewText(entry.origin, caps.origin))})</i>`;
1741
+ const author = capReviewText(originOf(card.fields.summary), caps.origin);
1742
+ return {
1743
+ top: [
1744
+ `<code>${escapeHtml(capReviewText(card.fields.action_key.value, caps.row))}</code>`,
1745
+ "",
1746
+ "<b>COMPUTED — derived by the runtime from the log, the policy and the payload bytes</b>",
1747
+ ...computedLines.map(render),
1748
+ "",
1749
+ ],
1750
+ bottom: [
1751
+ "",
1752
+ `<b>CLAIMED — authored by ${escapeHtml(author)}, NOT verified by the runtime</b>`,
1753
+ ...claimedLines.map(render),
1754
+ ],
1755
+ };
1756
+ }
1757
+ /** The longest heading a live card can wear: Deny armed and a grade held. */
1758
+ const REVIEW_HEADING_MAX = Math.max(...REACTIONS.map((reaction) => [TELEGRAM_REVIEW_HEADING, TELEGRAM_REVIEW_ARMED, telegramReviewGradeHeld(reaction)].join(" — ")
1759
+ .length));
1760
+ /**
1761
+ * What a notice can add to a card at most: the blank line and the headline,
1762
+ * each after a newline, then the region (newlines counted), one cut marker and
1763
+ * the overflow line after its newline.
1764
+ */
1765
+ const REVIEW_NOTICE_RESERVE = 2 +
1766
+ REVIEW_NOTICE_HEADLINE_MAX +
1767
+ REVIEW_CUT_MARKER_MAX +
1768
+ REVIEW_NOTICE_REGION_MAX +
1769
+ REVIEW_CUT_MARKER_MAX +
1770
+ 1 +
1771
+ REVIEW_REGION_OVERFLOW.length;
1772
+ /** The longest hash-only or none payload region, with the newlines around it. */
1773
+ const REVIEW_HASH_REGION_MAX = Math.max(...[
1774
+ { kind: "hash", hash: "0".repeat(64), reason: "too-long" },
1775
+ { kind: "hash", hash: "0".repeat(64), reason: "unavailable" },
1776
+ { kind: "none" },
1777
+ ].map((view) => telegramVisibleLength(reviewPayloadLines(view).join("\n")) + 2));
1778
+ /**
1779
+ * Everything a live card can hold except the payload region: the longest
1780
+ * heading, the frame, the largest notice, and the newlines joining them.
1781
+ */
1782
+ function reviewFixedLength(frame) {
1783
+ return (REVIEW_HEADING_MAX +
1784
+ 1 +
1785
+ telegramVisibleLength([...frame.top, ...frame.bottom].join("\n")) +
1786
+ REVIEW_NOTICE_RESERVE);
1787
+ }
1788
+ /**
1789
+ * How many escaped characters of payload this card can show whole (PR #614
1790
+ * refutation F4): {@link REVIEW_PAYLOAD_BUDGET}, or the room the card's own
1791
+ * rows leave under {@link TELEGRAM_MAX_MESSAGE_CHARS} after the longest heading,
1792
+ * the payload heading and the largest notice a tap can add, whichever is
1793
+ * smaller. Pure over the card, so the view a card is drawn with and the view a
1794
+ * tap records are the same view.
1795
+ */
1796
+ export function reviewPayloadBudget(card) {
1797
+ const fixed = reviewFixedLength(reviewCardFrame(card)) +
1798
+ // The bytes heading and the newlines around the heading and the payload.
1799
+ TELEGRAM_REVIEW_PAYLOAD_BYTES.length +
1800
+ 3;
1801
+ return Math.max(0, Math.min(REVIEW_PAYLOAD_BUDGET, TELEGRAM_MAX_MESSAGE_CHARS - fixed));
1802
+ }
1448
1803
  /**
1449
1804
  * The card's message: the rows, whatever notice the last tap produced, and the
1450
1805
  * keyboard.
@@ -1457,13 +1812,15 @@ function trimNotice(text) {
1457
1812
  * every card said the same thing to a reader who had already read them once, and
1458
1813
  * pushed the rows a review is actually about off the first screen.
1459
1814
  *
1460
- * Pure. Two things it deliberately does NOT carry, and both are the same rule
1461
- * read twice: no payload region, and no approve button. SPEC.md §10.3 requires
1462
- * the canonical rendering in front of an approver before a DECISION is
1463
- * collected, and this collects none — the action ran, the review says only what
1464
- * a person thought of it, and a card that offered an approve would be
1465
- * presenting a settled fact as a live authorization. A sample is never
1466
- * delivered as an approval request and never accepts a token.
1815
+ * Pure. It carries no approve button: the action ran, and a card that offered
1816
+ * an approve would be presenting a settled fact as a live authorization. A
1817
+ * sample is never delivered as an approval request and never accepts a token.
1818
+ *
1819
+ * It DOES carry a payload region since APRV-480, between the computed rows and
1820
+ * the claimed ones, exactly where a prompt puts its own. Under supervised-retro
1821
+ * the review is the individual approval, so the reviewer reads the bytes that
1822
+ * ran, or is told plainly that the card shows only their hash and why
1823
+ * ({@link reviewPayloadView}). The heading says which, every time.
1467
1824
  */
1468
1825
  export function renderReviewCard(state) {
1469
1826
  const card = state.card;
@@ -1471,41 +1828,27 @@ export function renderReviewCard(state) {
1471
1828
  if (state.settled !== null) {
1472
1829
  return {
1473
1830
  text: [
1474
- `<b>${escapeHtml(state.settled.headline)}</b>`,
1475
- `<code>${escapeHtml(key)}</code>`,
1831
+ `<b>${escapeHtml(capReviewText(state.settled.headline, REVIEW_NOTICE_HEADLINE_MAX))}</b>`,
1832
+ `<code>${escapeHtml(capReviewText(key, REVIEW_CAP_LEVELS[0].row))}</code>`,
1476
1833
  "",
1477
- ...state.settled.detail.map((entry) => escapeHtml(trimNotice(entry))),
1834
+ ...capReviewRegion(state.settled.detail.map(trimNotice), REVIEW_SETTLED_REGION_MAX).map((entry) => escapeHtml(entry)),
1478
1835
  ].join("\n"),
1479
1836
  keyboard: null,
1480
1837
  };
1481
1838
  }
1482
- const computedLines = [];
1483
- const claimedLines = [];
1484
- for (const row of REVIEW_CARD_ROWS) {
1485
- const candidate = reviewRow(card.fields, row);
1486
- if (candidate === null)
1487
- continue;
1488
- if (candidate.line.kind === "computed")
1489
- computedLines.push(candidate.line);
1490
- else
1491
- claimedLines.push(candidate.line);
1492
- }
1493
- computedLines.push(line("ran_at", card.ranAt, "ran at", card.ranAt.value));
1494
- computedLines.push(line("verdict", card.verdict, "verdict", card.verdict.value));
1495
- const render = (entry) => `• <b>${escapeHtml(entry.label)}:</b> ${escapeHtml(entry.text)} <i>(${escapeHtml(entry.origin)})</i>`;
1496
- const author = originOf(card.fields.summary);
1839
+ const frame = reviewCardFrame(card);
1497
1840
  const lines = [
1498
- `<b>${escapeHtml(state.denyArmed ? `${TELEGRAM_REVIEW_HEADING} — ${TELEGRAM_REVIEW_ARMED}` : TELEGRAM_REVIEW_HEADING)}</b>`,
1499
- `<code>${escapeHtml(key)}</code>`,
1500
- "",
1501
- "<b>COMPUTED — derived by the runtime from the log, the policy and the payload bytes</b>",
1502
- ...computedLines.map(render),
1503
- "",
1504
- `<b>CLAIMED — authored by ${escapeHtml(author)}, NOT verified by the runtime</b>`,
1505
- ...claimedLines.map(render),
1841
+ `<b>${escapeHtml([
1842
+ TELEGRAM_REVIEW_HEADING,
1843
+ ...(state.denyArmed ? [TELEGRAM_REVIEW_ARMED] : []),
1844
+ ...(state.heldReaction === null ? [] : [telegramReviewGradeHeld(state.heldReaction)]),
1845
+ ].join(" — "))}</b>`,
1846
+ ...frame.top,
1847
+ ...reviewPayloadLines(reviewPayloadView(card)),
1848
+ ...frame.bottom,
1506
1849
  ];
1507
1850
  if (state.notice !== null) {
1508
- lines.push("", `<b>${escapeHtml(state.notice.headline)}</b>`, ...state.notice.lines.map((entry) => escapeHtml(trimNotice(entry))));
1851
+ lines.push("", `<b>${escapeHtml(capReviewText(state.notice.headline, REVIEW_NOTICE_HEADLINE_MAX))}</b>`, ...capReviewRegion(state.notice.lines.map(trimNotice), REVIEW_NOTICE_REGION_MAX).map((entry) => escapeHtml(entry)));
1509
1852
  }
1510
1853
  const button = (choice) => ({
1511
1854
  text: REVIEW_BUTTON_LABELS[choice],
@@ -1529,6 +1872,7 @@ export class TelegramApiError extends Error {
1529
1872
  method;
1530
1873
  status;
1531
1874
  description;
1875
+ retryAfterSeconds;
1532
1876
  constructor(message, method,
1533
1877
  /**
1534
1878
  * The HTTP status, when the failure was an HTTP one. `null` for a transport
@@ -1541,14 +1885,84 @@ export class TelegramApiError extends Error {
1541
1885
  * error body carried one. `null` when the body was absent, unreadable, not
1542
1886
  * JSON, or carried no description.
1543
1887
  */
1544
- description = null) {
1888
+ description = null,
1889
+ /**
1890
+ * The Bot API's `parameters.retry_after`, in seconds, when the failure
1891
+ * carried one (PR #614 recheck NF-2). Telegram sends it with 429 "Too Many
1892
+ * Requests" and means it: a send before it elapses is refused again.
1893
+ * `null` when the body named none.
1894
+ */
1895
+ retryAfterSeconds = null) {
1545
1896
  super(message);
1546
1897
  this.method = method;
1547
1898
  this.status = status;
1548
1899
  this.description = description;
1900
+ this.retryAfterSeconds = retryAfterSeconds;
1549
1901
  this.name = "TelegramApiError";
1550
1902
  }
1551
1903
  }
1904
+ /**
1905
+ * The Bot API refusals that are a fact about the message itself (PR #614
1906
+ * recheck NF-2). Each is a 400 whose `description` says the text, its markup
1907
+ * or its keyboard is unacceptable, so resending the same card meets the same
1908
+ * refusal however long the sender waits:
1909
+ *
1910
+ * - `message is too long`: the text is over 4096 characters after entities;
1911
+ * - `can't parse entities`: the HTML the card was drawn with does not parse;
1912
+ * - `message text is empty` / `text must be non-empty`: nothing to send;
1913
+ * - `reply markup is too long`: the inline keyboard is over its byte limit;
1914
+ * - `BUTTON_DATA_INVALID`: a button's callback data is over 64 bytes or empty;
1915
+ * - `ENTITIES_TOO_LONG` / `entities too long`: too many formatting entities.
1916
+ *
1917
+ * Deliberately absent, because the card cannot cause them and a retry can
1918
+ * clear them: `chat not found`, `bot was blocked`, `not enough rights` (the
1919
+ * chat's state, which a human repairs), every 429, every 5xx, every timeout and
1920
+ * network failure.
1921
+ */
1922
+ const TELEGRAM_DETERMINISTIC_SEND_REFUSALS = [
1923
+ /message is too long/iu,
1924
+ /can't parse entities/iu,
1925
+ /message text is empty|text must be non-empty/iu,
1926
+ /reply markup is too long/iu,
1927
+ /BUTTON_DATA_INVALID/u,
1928
+ /ENTITIES_TOO_LONG|entities too long/iu,
1929
+ ];
1930
+ /**
1931
+ * Whether a failed send is the Bot API refusing the message itself, so that
1932
+ * sending the same message again can only fail again (PR #614 recheck NF-2).
1933
+ *
1934
+ * Only an HTTP 400 whose description matches
1935
+ * {@link TELEGRAM_DETERMINISTIC_SEND_REFUSALS} qualifies. Anything else, an
1936
+ * error that is not a {@link TelegramApiError} included, is treated as
1937
+ * transient, which is the direction that keeps a card in front of the
1938
+ * approver: a transient failure misread as deterministic hides a sample from
1939
+ * the phone, and the converse costs a bounded number of retries.
1940
+ */
1941
+ export function isDeterministicSendRefusal(cause) {
1942
+ return (cause instanceof TelegramApiError &&
1943
+ cause.status === 400 &&
1944
+ cause.description !== null &&
1945
+ TELEGRAM_DETERMINISTIC_SEND_REFUSALS.some((pattern) => pattern.test(cause.description)));
1946
+ }
1947
+ /**
1948
+ * The wait, in milliseconds, a failed call's `retry_after` asks for, or `null`
1949
+ * when it named none (PR #614 recheck NF-2).
1950
+ */
1951
+ export function retryAfterMsOf(cause) {
1952
+ if (!(cause instanceof TelegramApiError) || cause.retryAfterSeconds === null)
1953
+ return null;
1954
+ return cause.retryAfterSeconds * 1000;
1955
+ }
1956
+ /** `parameters.retry_after` from a parsed Bot API body, when it is a positive integer. */
1957
+ function retryAfterFrom(parsed) {
1958
+ if (parsed === null || typeof parsed !== "object")
1959
+ return null;
1960
+ const parameters = parsed["parameters"];
1961
+ if (parameters === null || typeof parameters !== "object")
1962
+ return null;
1963
+ const value = parameters["retry_after"];
1964
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
1965
+ }
1552
1966
  /**
1553
1967
  * Telegram's wording for "that edit would have changed nothing" (APRV-277).
1554
1968
  *
@@ -1599,7 +2013,7 @@ export function isPollConflict(cause) {
1599
2013
  }
1600
2014
  /** A fresh, empty batch report. One shape for both transports (APRV-424). */
1601
2015
  function emptyPollResult() {
1602
- return { updates: 0, outcomes: [], ignored: [], commands: [], reviews: [] };
2016
+ return { updates: 0, outcomes: [], ignored: [], commands: [], reviews: [], refused: [] };
1603
2017
  }
1604
2018
  /**
1605
2019
  * Why a transport claim was refused. Frozen, per SPEC §11.1 invariant 6.
@@ -1647,6 +2061,12 @@ export class TelegramChannel {
1647
2061
  describeAction;
1648
2062
  /** The policy's row layout for this channel (APRV-218). Read-only, and pure input to the renderer. */
1649
2063
  layout;
2064
+ /** The policy's prompt style for this channel (APRV-489). */
2065
+ promptStyle;
2066
+ /** The policy's `say` declarations (APRV-489). Pure input to the minimal renderer. */
2067
+ say;
2068
+ /** The per-delivery resolver (fix round 2, S4), or null for the fixed values above. */
2069
+ promptFor;
1650
2070
  /** When {@link sweep} last ran, so the poll loop can call it every cycle. */
1651
2071
  lastSweepMs = Number.NEGATIVE_INFINITY;
1652
2072
  /**
@@ -1656,6 +2076,25 @@ export class TelegramChannel {
1656
2076
  */
1657
2077
  ack = null;
1658
2078
  handler = null;
2079
+ /**
2080
+ * APRV-196's stale-copy fallback, on or off (APRV-456). Fixed at
2081
+ * construction from {@link TelegramConfig.staleCopy}; nothing an update
2082
+ * carries can change it.
2083
+ */
2084
+ staleCopy;
2085
+ /**
2086
+ * Where a `nonce-not-issued` refusal is recorded (APRV-456). The runtime's,
2087
+ * like every other handler here; absent, the refusal still happens and only
2088
+ * the record is missing.
2089
+ */
2090
+ refusedDecisionHandler = null;
2091
+ /**
2092
+ * Nonces already refused `nonce-not-issued` (APRV-456), oldest first, capped
2093
+ * at {@link TELEGRAM_REFUSED_NONCE_CAP}. A redelivered or replayed tap on the
2094
+ * same button is refused again with the same toast and records nothing, so
2095
+ * one button costs the log at most one record.
2096
+ */
2097
+ unissuedRefused = new Set();
1659
2098
  /**
1660
2099
  * What to do with a bot command (APRV-216). Absent unless the runtime asked
1661
2100
  * for commands, and its absence is what keeps `message` out of
@@ -1771,6 +2210,7 @@ export class TelegramChannel {
1771
2210
  "unknown-command": 0,
1772
2211
  },
1773
2212
  staleCopyDecisions: 0,
2213
+ staleCopyRefusals: 0,
1774
2214
  commands: 0,
1775
2215
  reviews: 0,
1776
2216
  };
@@ -1794,11 +2234,22 @@ export class TelegramChannel {
1794
2234
  this.now = config.now ?? (() => Date.now());
1795
2235
  this.describeAction = config.describeAction ?? null;
1796
2236
  this.layout = config.layout ?? TELEGRAM_PROMPT_LAYOUT;
2237
+ this.promptStyle = config.promptStyle ?? DEFAULT_PROMPT_STYLE;
2238
+ this.say = config.say ?? {};
2239
+ this.promptFor = config.promptFor ?? null;
2240
+ // APRV-456. Only an explicit `true` or an absent key leaves the fallback on;
2241
+ // anything else a caller passes turns it off, the stricter direction.
2242
+ this.staleCopy = config.staleCopy === undefined || config.staleCopy === true;
1797
2243
  this.makeNonce =
1798
2244
  config.nonce ??
1799
2245
  (() => {
1800
2246
  this.counter += 1;
1801
- return `${this.counter.toString(36)}${Math.random().toString(36).slice(2, 8)}`;
2247
+ // APRV-456 refutation S2. On a relayed channel the nonce is the only
2248
+ // thing binding a button to this process, so it comes from the CSPRNG
2249
+ // (72 bits) rather than `Math.random`, whose state a reader of a few old
2250
+ // buttons could in principle recover. base64url carries no `:`, and the
2251
+ // whole `<verb>:<nonce>:<ref>` stays well inside the 64-byte cap.
2252
+ return `${this.counter.toString(36)}${randomBytes(9).toString("base64url")}`;
1802
2253
  });
1803
2254
  }
1804
2255
  // -------------------------------------------------------------------------
@@ -1807,6 +2258,25 @@ export class TelegramChannel {
1807
2258
  onDecision(handler) {
1808
2259
  this.handler = handler;
1809
2260
  }
2261
+ /**
2262
+ * Register where a tap this channel refused on its own is recorded
2263
+ * (APRV-456).
2264
+ *
2265
+ * Separate from {@link onDecision} on purpose: that handler's job is to call
2266
+ * the gate, and a `nonce-not-issued` tap must never reach the gate. The
2267
+ * runtime wires this to an audit-tier append that grants nothing.
2268
+ */
2269
+ onRefusedDecision(handler) {
2270
+ this.refusedDecisionHandler = handler;
2271
+ }
2272
+ /**
2273
+ * Whether APRV-196's stale-copy fallback is on (APRV-456). Read by the
2274
+ * listener for the restart banner, which must not promise that earlier
2275
+ * copies still decide when they do not.
2276
+ */
2277
+ staleCopyEnabled() {
2278
+ return this.staleCopy;
2279
+ }
1810
2280
  /**
1811
2281
  * Register what to do with a checkpoint tap (APRV-257).
1812
2282
  *
@@ -1877,8 +2347,9 @@ export class TelegramChannel {
1877
2347
  * A unit like a checkpoint prompt: one thing to read, never grouped into a
1878
2348
  * digest, and never delivered through {@link notify} — a digest is a set of
1879
2349
  * similar pending REQUESTS decided together, and a sample is neither pending
1880
- * nor a request. It sends ONE message: no payload region, and a keyboard
1881
- * whose six buttons collect a verdict and a grade and mint nothing.
2350
+ * nor a request. It sends ONE message: the rows, the payload whole or by hash
2351
+ * (APRV-480, never split across messages), and a keyboard whose six buttons
2352
+ * collect a verdict and a grade and mint nothing.
1882
2353
  *
1883
2354
  * Refuses when no handler is registered, rather than sending a dead button.
1884
2355
  */
@@ -1893,6 +2364,9 @@ export class TelegramChannel {
1893
2364
  card,
1894
2365
  nonce,
1895
2366
  denyArmed: false,
2367
+ heldReaction: null,
2368
+ armedBy: null,
2369
+ heldBy: null,
1896
2370
  settled: null,
1897
2371
  notice: null,
1898
2372
  awaitingNote: null,
@@ -2108,6 +2582,7 @@ export class TelegramChannel {
2108
2582
  state.deliveryId = deliveryId;
2109
2583
  // Armed only now, and all at once: until the message with the buttons on it
2110
2584
  // exists there is nothing a callback could legitimately answer.
2585
+ const rendering = this.digestRendering(stale !== null);
2111
2586
  for (const [index, member] of state.members.entries()) {
2112
2587
  this.deliveries.set(member.nonce, {
2113
2588
  actionKey: member.actionKey,
@@ -2116,6 +2591,7 @@ export class TelegramChannel {
2116
2591
  batchDeliveryId,
2117
2592
  deliveredAtMs,
2118
2593
  windowMs: windows[index] ?? null,
2594
+ ...(rendering === undefined ? {} : { rendering }),
2119
2595
  });
2120
2596
  }
2121
2597
  this.digests.set(deliveryId, state);
@@ -2200,7 +2676,39 @@ export class TelegramChannel {
2200
2676
  ],
2201
2677
  ],
2202
2678
  };
2203
- const sent = await this.sendPrompt(request, TELEGRAM_PROMPT_HEADING, keyboard);
2679
+ // APRV-489. The minimal card is tried only when the policy asked for it,
2680
+ // and anything it will not draw honestly is sent as the technical card,
2681
+ // exactly as a technical policy sends it, with the reason kept for the
2682
+ // decision record and said on stderr.
2683
+ let sent;
2684
+ let rendering;
2685
+ let card;
2686
+ const prompt = this.currentPrompt();
2687
+ // Fix round 2, B2: a request delivered as part of a batch is always the
2688
+ // technical card, as SPEC §10.3 (amended) says of a grouped delivery.
2689
+ const minimal = prompt.fallback !== undefined
2690
+ ? { ok: false, reason: prompt.fallback }
2691
+ : prompt.style !== "minimal"
2692
+ ? null
2693
+ : batchDeliveryId !== undefined
2694
+ ? { ok: false, reason: "batch" }
2695
+ : await this.sendMinimal(request, nonce, prompt.say);
2696
+ if (minimal !== null && minimal.ok) {
2697
+ sent = minimal.sent;
2698
+ // R3-S1: the record notes a `say` phrase the runtime overrode with its own.
2699
+ rendering = minimal.sayDoesIgnored ? { style: "minimal", say_does_ignored: true } : { style: "minimal" };
2700
+ card = minimal.card;
2701
+ if (minimal.sayDoesIgnored) {
2702
+ this.complain(`approval: telegram drew its own phrase for ${actionKey}, not the say entry's does: the runtime reads this payload itself`);
2703
+ }
2704
+ }
2705
+ else {
2706
+ if (minimal !== null) {
2707
+ rendering = { style: "technical", fallback: minimal.reason };
2708
+ this.complain(`approval: telegram sent the technical card for ${actionKey}, not the minimal one: ${minimal.reason}`);
2709
+ }
2710
+ sent = await this.sendPrompt(request, TELEGRAM_PROMPT_HEADING, keyboard);
2711
+ }
2204
2712
  this.counters.notified += 1;
2205
2713
  this.deliveries.set(nonce, {
2206
2714
  actionKey,
@@ -2209,6 +2717,8 @@ export class TelegramChannel {
2209
2717
  deliveredAtMs: this.now(),
2210
2718
  windowMs: this.retentionWindowMs(request),
2211
2719
  ...(batchDeliveryId === undefined ? {} : { batchDeliveryId }),
2720
+ ...(rendering === undefined ? {} : { rendering }),
2721
+ ...(card === undefined ? {} : { card }),
2212
2722
  });
2213
2723
  return {
2214
2724
  deliveryId: sent.deliveryId,
@@ -2218,6 +2728,95 @@ export class TelegramChannel {
2218
2728
  },
2219
2729
  };
2220
2730
  }
2731
+ /**
2732
+ * Send `request` as ONE minimal card (APRV-489), or report why it was not.
2733
+ *
2734
+ * The card is drawn from the same `renderTelegram` output the technical card
2735
+ * would send, under the same layout, so the collapsed block IS the technical
2736
+ * card. The buttons carry the same callback data as the technical card's; only
2737
+ * the reject label reads "Deny" (R7). Nothing is sent when the card is refused,
2738
+ * so the caller's technical send is the only send.
2739
+ */
2740
+ async sendMinimal(request, nonce, say) {
2741
+ const actionKey = request.action_key.value;
2742
+ const rendering = renderTelegram(request, TELEGRAM_PROMPT_HEADING, this.layout);
2743
+ const drawn = renderTelegramMinimal(request, {
2744
+ header: rendering.header,
2745
+ payloadText: rendering.payloadText,
2746
+ claimedText: rendering.claimedText,
2747
+ anomalous: rendering.lines.some((entry) => entry.label.startsWith(TELEGRAM_ANOMALY_MARK)),
2748
+ payloadLabel: `<b>${PAYLOAD_CHUNK_LABEL}</b>`,
2749
+ }, say);
2750
+ if (!drawn.ok)
2751
+ return { ok: false, reason: drawn.reason };
2752
+ let result;
2753
+ try {
2754
+ result = await this.call("sendMessage", {
2755
+ chat_id: this.chatId,
2756
+ text: drawn.text,
2757
+ parse_mode: "HTML",
2758
+ disable_web_page_preview: true,
2759
+ reply_markup: {
2760
+ inline_keyboard: [
2761
+ [
2762
+ { text: "✅ Approve", callback_data: callbackData("g", nonce, actionKey) },
2763
+ { text: MINIMAL_DENY_LABEL, callback_data: callbackData("r", nonce, actionKey) },
2764
+ ],
2765
+ ],
2766
+ },
2767
+ });
2768
+ }
2769
+ catch (cause) {
2770
+ // Fix round 2, S2: a refusal of the card itself (a Bot API that cannot
2771
+ // parse the expandable block, an entity limit, a relay rule) would refuse
2772
+ // the same card on every cycle and leave the approver with nothing. The
2773
+ // technical card goes instead, once, and the record says why. A passing
2774
+ // failure (429, 5xx, network) is thrown and retried as before.
2775
+ if (isMessageRefusal(cause))
2776
+ return { ok: false, reason: "send-refused" };
2777
+ throw cause;
2778
+ }
2779
+ const fields = rendering.lines.map((entry) => ({
2780
+ field: entry.field,
2781
+ kind: entry.kind,
2782
+ text: entry.text,
2783
+ }));
2784
+ return {
2785
+ ok: true,
2786
+ sent: {
2787
+ deliveryId: String(result.message_id),
2788
+ rendered: { action_key: actionKey, fields, fullPayloadText: rendering.payloadText },
2789
+ },
2790
+ card: { headline: drawn.headline, details: drawn.details },
2791
+ sayDoesIgnored: drawn.sayDoesIgnored === true,
2792
+ };
2793
+ }
2794
+ /**
2795
+ * The style and `say` map in force for the delivery about to be drawn (fix
2796
+ * round 2, S4): the resolver's answer when the verb gave one, else the fixed
2797
+ * configuration. A resolver that throws is `technical`, the side that shows more.
2798
+ */
2799
+ currentPrompt() {
2800
+ if (this.promptFor === null)
2801
+ return { style: this.promptStyle, say: this.say };
2802
+ try {
2803
+ return this.promptFor();
2804
+ }
2805
+ catch (cause) {
2806
+ this.complain(`approval: telegram could not read the prompt style; drawing technical: ${this.describe(cause)}`);
2807
+ return { style: DEFAULT_PROMPT_STYLE, say: {} };
2808
+ }
2809
+ }
2810
+ /**
2811
+ * What a decision on a digest records about its layout (APRV-489): nothing
2812
+ * under a technical policy, and the technical card with its reason under a
2813
+ * minimal one, since a digest is always drawn technical.
2814
+ */
2815
+ digestRendering(stale) {
2816
+ if (this.currentPrompt().style === "technical")
2817
+ return undefined;
2818
+ return { style: "technical", fallback: stale ? "stale-summary" : "digest" };
2819
+ }
2221
2820
  /**
2222
2821
  * Forget every nonce issued for `deliveryId`, and report the action key it
2223
2822
  * was issued for.
@@ -2232,6 +2831,7 @@ export class TelegramChannel {
2232
2831
  */
2233
2832
  disarm(deliveryId) {
2234
2833
  let actionKey = "";
2834
+ let card;
2235
2835
  // Deleting the current entry mid-iteration is defined behaviour for a Map,
2236
2836
  // and every nonce for this message id goes — a re-notify of the same
2237
2837
  // message would otherwise leave an older nonce still resolving.
@@ -2239,6 +2839,7 @@ export class TelegramChannel {
2239
2839
  if (delivery.deliveryId !== deliveryId)
2240
2840
  continue;
2241
2841
  actionKey = delivery.actionKey;
2842
+ card = delivery.card ?? card;
2242
2843
  this.deliveries.delete(nonce);
2243
2844
  }
2244
2845
  const digest = this.digests.get(deliveryId);
@@ -2246,7 +2847,7 @@ export class TelegramChannel {
2246
2847
  this.allNonces.delete(digest.allNonce);
2247
2848
  this.digests.delete(deliveryId);
2248
2849
  }
2249
- return actionKey;
2850
+ return { actionKey, card };
2250
2851
  }
2251
2852
  /**
2252
2853
  * How long after delivery the request behind a prompt can still be decided,
@@ -2438,20 +3039,41 @@ export class TelegramChannel {
2438
3039
  await this.settleMember(digest, actionKey, outcome, detail);
2439
3040
  return;
2440
3041
  }
2441
- const settledKey = this.disarm(deliveryId);
2442
- const text = [
3042
+ const settled = this.disarm(deliveryId);
3043
+ const technicalText = [
2443
3044
  `<b>${escapeHtml(outcome)}</b>`,
2444
- `<code>${escapeHtml(actionKey ?? settledKey)}</code>`,
3045
+ `<code>${escapeHtml(actionKey ?? settled.actionKey)}</code>`,
2445
3046
  "",
2446
3047
  ...detail.map((entry) => escapeHtml(entry)),
2447
3048
  ].join("\n");
2448
- await this.call("editMessageText", {
3049
+ // APRV-489. A minimal card keeps its headline and its collapsed details
3050
+ // under the outcome, so the settled message still holds the canonical
3051
+ // rendering the approver answered on. Should that not fit, the technical
3052
+ // annotation is the fallback: the outcome is what the edit must carry.
3053
+ const text = settled.card === undefined ? technicalText : minimalSettleText(outcome, detail, settled.card);
3054
+ const edit = (body) => this.call("editMessageText", {
2449
3055
  chat_id: this.chatId,
2450
3056
  message_id: Number(deliveryId),
2451
- text,
3057
+ text: body,
2452
3058
  parse_mode: "HTML",
2453
3059
  disable_web_page_preview: true,
2454
3060
  });
3061
+ if (settled.card === undefined) {
3062
+ await edit(text);
3063
+ return;
3064
+ }
3065
+ // Fix round 2, S3: a minimal card is the only message holding its request,
3066
+ // so a refused settle edit would leave a live-looking card on a decided
3067
+ // request. The short settle text always fits and always says the outcome.
3068
+ try {
3069
+ await edit(text);
3070
+ }
3071
+ catch (cause) {
3072
+ if (!isMessageRefusal(cause) || isMessageNotModified(cause))
3073
+ throw cause;
3074
+ this.complain(`approval: telegram refused the settle edit of ${settled.actionKey}; sending the short form`);
3075
+ await edit(shortSettleText(outcome, actionKey ?? settled.actionKey, detail));
3076
+ }
2455
3077
  }
2456
3078
  /**
2457
3079
  * The withdrawal case of {@link annotate} (APRV-106), and the one the
@@ -3005,6 +3627,15 @@ export class TelegramChannel {
3005
3627
  // a redelivered or replayed refused tap reaches neither the gate nor the
3006
3628
  // new card, exactly as it did before that card existed.
3007
3629
  const refusedCard = delivery === undefined && this.refusedNonces.has(parsed.nonce);
3630
+ // APRV-456. With the fallback off, a nonce this process is not holding is
3631
+ // the end of the road: no action-reference lookup, no log probe, and never
3632
+ // the decision handler. Behind a relay, a button carrying a real action's
3633
+ // reference may sit under text this gate never wrote, and the approver's
3634
+ // genuine tap would otherwise pass the chat check and the sender mapping.
3635
+ if (delivery === undefined && !refusedCard && !this.staleCopy) {
3636
+ await this.refuseUnissued(parsed, callbackId, result, sender);
3637
+ return;
3638
+ }
3008
3639
  if (delivery === undefined && parsed.actionRef !== null && !refusedCard) {
3009
3640
  // The pre-restart copy. Its nonce died with the process that issued it,
3010
3641
  // but the request it names is one THIS process has since re-delivered, so
@@ -3046,6 +3677,9 @@ export class TelegramChannel {
3046
3677
  ...(delivery.batchDeliveryId === undefined
3047
3678
  ? {}
3048
3679
  : { batchDeliveryId: delivery.batchDeliveryId }),
3680
+ // APRV-489. The layout THIS card was drawn in, from this process's own
3681
+ // record of what it sent; nothing in the tap chooses it.
3682
+ ...(delivery.rendering === undefined ? {} : { rendering: delivery.rendering }),
3049
3683
  ...(parsed.decision === "reject"
3050
3684
  ? { note: `${TELEGRAM_REJECT_NOTE} (callback ${callbackId})` }
3051
3685
  : {}),
@@ -3172,12 +3806,14 @@ export class TelegramChannel {
3172
3806
  let landed = 0;
3173
3807
  const refusals = [];
3174
3808
  for (const member of open) {
3809
+ const allRendering = this.digestRendering(digest.stale !== null);
3175
3810
  const one = {
3176
3811
  action_key: member.actionKey,
3177
3812
  decision,
3178
3813
  deliveryId: digest.deliveryId,
3179
3814
  batchDeliveryId: digest.batchDeliveryId,
3180
3815
  ...(sender === undefined ? {} : { sender }),
3816
+ ...(allRendering === undefined ? {} : { rendering: allRendering }),
3181
3817
  ...(decision === "reject"
3182
3818
  ? { note: `${TELEGRAM_REJECT_NOTE} (callback ${callbackId}, all)` }
3183
3819
  : {}),
@@ -3347,31 +3983,88 @@ export class TelegramChannel {
3347
3983
  await this.ignore(result, callbackId, "unknown-callback", "a review tap arrived before the runtime registered a review handler", "The runtime is not ready to record reviews.");
3348
3984
  return;
3349
3985
  }
3986
+ // PR #614 refutation F3. An armed Deny and a held grade are one account's
3987
+ // unfinished review. A tap that would finish either (any verdict, or a
3988
+ // grade landing on an armed Deny) from a different account is refused on
3989
+ // the card, so a record never carries a grade or a denial its reviewer did
3990
+ // not tap. A grade tapped with nothing armed only replaces the held grade
3991
+ // and is not refused: it finishes nothing, and its new holder is the tapper.
3992
+ const tapper = sender === undefined ? null : sender.id;
3993
+ const finishes = tap.choice === "ok" || tap.choice === "deny" || state.denyArmed;
3994
+ const othersArm = state.denyArmed && state.armedBy !== tapper;
3995
+ const othersGrade = state.heldReaction !== null && state.heldBy !== tapper;
3996
+ if (finishes && (othersArm || (othersGrade && (tap.choice === "ok" || tap.choice === "deny")))) {
3997
+ state.notice = { headline: TELEGRAM_NOT_RECORDED, lines: [TELEGRAM_REVIEW_OTHER_SENDER] };
3998
+ await this.safeAnswer(callbackId, TELEGRAM_REVIEW_OTHER_SENDER);
3999
+ await this.redrawReview(state);
4000
+ return;
4001
+ }
3350
4002
  // The first Deny tap arms and writes nothing. Stated on the card, so the
3351
4003
  // approver reads the state rather than inferring it from a toast.
4004
+ //
4005
+ // PR #614 recheck NF-1: arming is earned the way a held grade is. The tap
4006
+ // goes to the runtime with NO verdict, and the card arms only when the
4007
+ // answer is `verdict-required`, which the runtime gives only after the
4008
+ // sender and the class's roster have passed. An unmapped or off-roster
4009
+ // account's Deny is refused on the card with its own code and arms
4010
+ // nothing, so it cannot leave a half-finished denial that blocks the
4011
+ // approvers' taps.
3352
4012
  if (tap.choice === "deny" && !state.denyArmed) {
3353
- state.denyArmed = true;
3354
- state.notice = null;
3355
- await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ARM_TOAST);
3356
- await this.redrawReview(state);
4013
+ const answered = await this.recordReview(state, {
4014
+ sampleSeq: state.card.sampleSeq,
4015
+ ...(sender === undefined ? {} : { sender }),
4016
+ }, result, { kind: "deny", by: tapper });
4017
+ await this.safeAnswer(callbackId, state.denyArmed ? TELEGRAM_REVIEW_ARM_TOAST : answered?.toast ?? TELEGRAM_NOT_RECORDED);
3357
4018
  return;
3358
4019
  }
3359
- const verdict = state.denyArmed ? "denied" : "ok";
3360
4020
  if (tap.choice === "ok" || tap.choice === "deny") {
3361
4021
  // `ok` with deny armed is a correction, and it disarms: a human who
3362
4022
  // reached for Deny and then chose OK meant OK, and nothing was written in
3363
4023
  // between for the change of mind to contradict.
3364
4024
  const chosen = tap.choice === "deny" ? "denied" : "ok";
3365
4025
  state.denyArmed = chosen === "denied";
4026
+ if (!state.denyArmed)
4027
+ state.armedBy = null;
4028
+ // APRV-482: a grade held from before the verdict rides with it, and a
4029
+ // grade that wants words asks for them now, exactly as a grade tapped
4030
+ // after an armed Deny always has. The pair core refuses outright is sent
4031
+ // straight through, so no note is collected for a record that will not
4032
+ // exist.
4033
+ const held = state.heldReaction;
4034
+ if (held !== null) {
4035
+ const conflicts = chosen === "denied" && (held === "liked" || held === "loved");
4036
+ if (!conflicts && (held === "loved" || held === "disliked")) {
4037
+ await this.safeAnswer(callbackId, TELEGRAM_REVIEW_NOTE_TOAST);
4038
+ await this.askForNote(state, chosen, held, sender);
4039
+ return;
4040
+ }
4041
+ }
3366
4042
  await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
3367
4043
  await this.recordReview(state, {
3368
4044
  sampleSeq: state.card.sampleSeq,
3369
4045
  verdict: chosen,
4046
+ ...(held === null ? {} : { reaction: held }),
3370
4047
  ...(sender === undefined ? {} : { sender }),
3371
4048
  }, result);
3372
4049
  return;
3373
4050
  }
3374
4051
  const reaction = tap.choice;
4052
+ if (!state.denyArmed) {
4053
+ // APRV-482. A grade with no verdict is not an approval. The card holds
4054
+ // the grade (process memory, stated in its heading) and the tap still
4055
+ // goes to the runtime with NO verdict, so the refusal on the card is the
4056
+ // core's own `verdict-required` and its words, and the operator's stderr
4057
+ // says the same. No note is asked for: words for a record that is about
4058
+ // to be refused would be attention spent on nothing.
4059
+ await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
4060
+ await this.recordReview(state, {
4061
+ sampleSeq: state.card.sampleSeq,
4062
+ reaction,
4063
+ ...(sender === undefined ? {} : { sender }),
4064
+ }, result, { kind: "grade", reaction, by: tapper });
4065
+ return;
4066
+ }
4067
+ const verdict = "denied";
3375
4068
  // The two grades that require the human's own words ask for them FIRST, and
3376
4069
  // nothing is appended until the reply arrives. The exception is the pair
3377
4070
  // `core/audit.ts` refuses outright: a denied review that says liked or
@@ -3403,26 +4096,75 @@ export class TelegramChannel {
3403
4096
  * stays open, and a fresh card is offered — and can never cost a record
3404
4097
  * nobody asked for.
3405
4098
  *
3406
- * A second prompt replaces the first: only one grade can be outstanding on
3407
- * one card, and the older prompt stops resolving so a late reply to it lands
3408
- * nowhere rather than recording a grade the human moved on from.
4099
+ * Only one grade can be outstanding on one card, so a second prompt that is
4100
+ * SENT replaces the first, and the older prompt stops resolving: a late reply
4101
+ * to it lands nowhere rather than recording a grade the human moved on from.
4102
+ *
4103
+ * APRV-492, and the relay licenses one prompt per GRADE tap, so which taps
4104
+ * get a prompt sent is not this method's choice alone. Three rules:
4105
+ *
4106
+ * - A tap that would ask the SAME question again (same verdict, same grade,
4107
+ * same account: a doubled OK or a doubled second Deny) sends nothing. The
4108
+ * prompt on screen already collects those words, and a second send would
4109
+ * be refused.
4110
+ * - A prompt for a DIFFERENT VERDICT retires the old one BEFORE the send. A
4111
+ * verdict change is not a grade tap, so the relay refuses that send, and an
4112
+ * old prompt left live would record the verdict the human just changed. A
4113
+ * note may be lost this way; a wrong verdict is never recorded.
4114
+ * - A prompt for the same verdict and a different grade follows a grade tap,
4115
+ * which the relay licenses, and retires the old one only once the new send
4116
+ * has succeeded. A send that fails anyway (the network, not the relay)
4117
+ * leaves the old prompt live, and a reply to it records the grade the old
4118
+ * prompt names in its own text, not the one the card now holds: an
4119
+ * accepted residual (PR #619 refutation S2).
4120
+ *
4121
+ * A send that fails says so on the card, because the toast already told the
4122
+ * human to reply to a prompt: the note was not asked for, nothing was
4123
+ * recorded, and a grade tap asks again. Then it throws, so the operator hears
4124
+ * of it. A later prompt that is sent clears that line.
3409
4125
  */
3410
4126
  async askForNote(state, verdict, reaction, sender) {
3411
- if (state.awaitingNote !== null)
3412
- this.reviewNotePrompts.delete(state.awaitingNote.promptId);
4127
+ const waiting = state.awaitingNote;
4128
+ if (waiting !== null &&
4129
+ waiting.verdict === verdict &&
4130
+ waiting.reaction === reaction &&
4131
+ waiting.sender?.id === sender?.id &&
4132
+ this.reviewNotePrompts.get(waiting.promptId) === state.deliveryId) {
4133
+ return;
4134
+ }
4135
+ if (waiting !== null && waiting.verdict !== verdict) {
4136
+ this.reviewNotePrompts.delete(waiting.promptId);
4137
+ state.awaitingNote = null;
4138
+ }
3413
4139
  const lines = reviewNotePromptLines(reaction, verdict, state.card.fields.action_key.value);
3414
- const sent = await this.call("sendMessage", {
3415
- chat_id: this.chatId,
3416
- text: lines
3417
- .map((entry, index) => (index === 0 ? `<b>${escapeHtml(entry)}</b>` : escapeHtml(entry)))
3418
- .join("\n"),
3419
- parse_mode: "HTML",
3420
- disable_web_page_preview: true,
3421
- reply_markup: { force_reply: true },
3422
- });
4140
+ let sent;
4141
+ try {
4142
+ sent = await this.call("sendMessage", {
4143
+ chat_id: this.chatId,
4144
+ text: lines
4145
+ .map((entry, index) => (index === 0 ? `<b>${escapeHtml(entry)}</b>` : escapeHtml(entry)))
4146
+ .join("\n"),
4147
+ parse_mode: "HTML",
4148
+ disable_web_page_preview: true,
4149
+ reply_markup: { force_reply: true },
4150
+ });
4151
+ }
4152
+ catch (cause) {
4153
+ state.notice = { headline: TELEGRAM_NOT_RECORDED, lines: [telegramReviewNoteUnasked(verdict)] };
4154
+ await this.redrawReview(state);
4155
+ throw cause;
4156
+ }
3423
4157
  const promptId = String(sent.message_id);
4158
+ if (state.awaitingNote !== null)
4159
+ this.reviewNotePrompts.delete(state.awaitingNote.promptId);
3424
4160
  state.awaitingNote = { promptId, verdict, reaction, ...(sender === undefined ? {} : { sender }) };
3425
4161
  this.reviewNotePrompts.set(promptId, state.deliveryId);
4162
+ const unasked = state.notice?.lines;
4163
+ if (unasked?.length === 1 &&
4164
+ (unasked[0] === telegramReviewNoteUnasked("ok") || unasked[0] === telegramReviewNoteUnasked("denied"))) {
4165
+ state.notice = null;
4166
+ await this.redrawReview(state);
4167
+ }
3426
4168
  }
3427
4169
  /**
3428
4170
  * A message replying to an outstanding note prompt (APRV-299).
@@ -3494,13 +4236,25 @@ export class TelegramChannel {
3494
4236
  * to say which half they meant, and `note-required` asks for words — so the
3495
4237
  * buttons stay, with the refusal rendered above them and the arming intact.
3496
4238
  */
3497
- async recordReview(state, tap, result) {
4239
+ async recordReview(state, tap, result,
4240
+ /**
4241
+ * A lone grade to hold, or a Deny to arm, if the runtime answers
4242
+ * `verdict-required` (PR #614 refutation N6/F3, recheck NF-1): only then
4243
+ * has the tapper passed the sender and roster checks, so only then is the
4244
+ * half-finished review theirs to finish.
4245
+ */
4246
+ hold) {
3498
4247
  const handler = this.reviewHandler;
3499
4248
  if (handler === null)
3500
- return;
4249
+ return null;
4250
+ // APRV-481: whether the bytes were on this card is the card's own fact,
4251
+ // decided by the same pure view that drew it, so the record's payload hash
4252
+ // can never name more than the reviewer was shown.
4253
+ const view = reviewPayloadView(state.card);
4254
+ const sent = view.kind === "bytes" ? { ...tap, payloadHash: view.hash } : tap;
3501
4255
  let response;
3502
4256
  try {
3503
- response = await handler(tap);
4257
+ response = await handler(sent);
3504
4258
  }
3505
4259
  catch (cause) {
3506
4260
  state.notice = { headline: TELEGRAM_NOT_RECORDED, lines: [TELEGRAM_HANDLER_FAILED] };
@@ -3508,11 +4262,14 @@ export class TelegramChannel {
3508
4262
  throw cause;
3509
4263
  }
3510
4264
  this.counters.reviews += 1;
3511
- result.reviews.push({ tap, ok: response.ok });
4265
+ result.reviews.push({ tap: sent, ok: response.ok });
3512
4266
  if (response.ok) {
3513
4267
  state.settled = { headline: response.headline, detail: response.detail };
3514
4268
  state.notice = null;
3515
4269
  state.denyArmed = false;
4270
+ state.heldReaction = null;
4271
+ state.armedBy = null;
4272
+ state.heldBy = null;
3516
4273
  this.reviewNonces.delete(state.nonce);
3517
4274
  if (state.awaitingNote !== null) {
3518
4275
  this.reviewNotePrompts.delete(state.awaitingNote.promptId);
@@ -3521,8 +4278,23 @@ export class TelegramChannel {
3521
4278
  }
3522
4279
  else {
3523
4280
  state.notice = { headline: response.headline, lines: response.detail };
4281
+ if (hold !== undefined && response.code === "verdict-required") {
4282
+ if (hold.kind === "grade") {
4283
+ state.heldReaction = hold.reaction;
4284
+ state.heldBy = hold.by;
4285
+ }
4286
+ else {
4287
+ // The armed state is the card's message; the refusal that earned it
4288
+ // is the runtime's word for "a verdict is still needed", which the
4289
+ // armed heading already says.
4290
+ state.denyArmed = true;
4291
+ state.armedBy = hold.by;
4292
+ state.notice = null;
4293
+ }
4294
+ }
3524
4295
  }
3525
4296
  await this.redrawReview(state);
4297
+ return response;
3526
4298
  }
3527
4299
  /** One `editMessageText` that replaces a review card's text and its keyboard. */
3528
4300
  async redrawReview(state) {
@@ -3547,6 +4319,81 @@ export class TelegramChannel {
3547
4319
  this.complain(`approval: telegram could not redraw the review card for sample seq ${String(state.card.sampleSeq)} (message ${state.deliveryId}): ${this.describe(cause)} — the log is what it is; only the message is stale`);
3548
4320
  }
3549
4321
  }
4322
+ /**
4323
+ * Refuse a decision tap whose nonce this process is not holding, with the
4324
+ * stale-copy fallback off (APRV-456).
4325
+ *
4326
+ * What it does, in order, and what it never does:
4327
+ *
4328
+ * - It never calls the decision handler, so the gate is never asked. That is
4329
+ * the property: a button this process did not put on the screen cannot
4330
+ * carry a human's tap to `decide()`, whatever reference it carries.
4331
+ * - It looks the reference up among the deliveries THIS process holds open,
4332
+ * and only to name the action on the audit record. A reference that names
4333
+ * nothing open here produces no record: there is no action key this
4334
+ * process could truthfully write, and inventing one from bytes that came
4335
+ * off the network is the self-report §11.1 invariant 4 forbids.
4336
+ * - It hands at most one record per nonce to {@link onRefusedDecision}: a
4337
+ * redelivered or replayed tap on the same button is refused with the same
4338
+ * toast and records nothing.
4339
+ * - It leaves the live card alone. The request stays pending, and the card
4340
+ * this process did send still decides it.
4341
+ *
4342
+ * "Not holding" covers a nonce this process never issued and one it issued
4343
+ * and has since forgotten (an annotated card, a swept delivery, a restart).
4344
+ * It does not try to tell them apart: a set of every nonce ever issued would
4345
+ * reopen the hole for anyone who has seen one genuine button's bytes, because
4346
+ * an old nonce with a new reference would pass it. The cost is that a tap on
4347
+ * an earlier copy after a restart is refused on this shape and the approver
4348
+ * taps the newest card, which the restart banner says.
4349
+ */
4350
+ async refuseUnissued(parsed, callbackId, result, sender) {
4351
+ this.counters.staleCopyRefusals += 1;
4352
+ const live = parsed.actionRef === null ? undefined : this.liveDeliveryFor(parsed.actionRef);
4353
+ const repeat = this.unissuedRefused.has(parsed.nonce);
4354
+ // Re-inserted so a nonce refused again counts as the newest, and capped:
4355
+ // memory, never a decision. A nonce that falls off the end is refused again
4356
+ // and may record again, which is volume and nothing else.
4357
+ this.unissuedRefused.delete(parsed.nonce);
4358
+ this.unissuedRefused.add(parsed.nonce);
4359
+ while (this.unissuedRefused.size > TELEGRAM_REFUSED_NONCE_CAP) {
4360
+ const oldest = this.unissuedRefused.values().next().value;
4361
+ if (oldest === undefined)
4362
+ break;
4363
+ this.unissuedRefused.delete(oldest);
4364
+ }
4365
+ // APRV-456 refutation S4: the nonce is NOT quoted here. This message becomes
4366
+ // `payload.message` on the audit record, and the nonce is bytes whoever
4367
+ // made the card chose; quoting it would let a forger write text of their
4368
+ // choosing into the hash-chained log, one tap at a time.
4369
+ const message = `${parsed.decision} tap refused: its button carries a nonce this listener is not holding, and this listener's stale-copy fallback is off (a relayed channel, or --no-stale-copy). It was not carried to the gate by its action reference, because behind a relay a button with a real request's reference can sit under text this gate never sent. Nothing was decided; the request, if pending, is still open on the newest card this listener sent.`;
4370
+ const refusal = { ok: false, code: "nonce-not-issued", message };
4371
+ result.refused.push({
4372
+ code: refusal.code,
4373
+ action_key: live?.actionKey ?? null,
4374
+ detail: message,
4375
+ });
4376
+ this.complain(`approval: telegram refused a callback (nonce-not-issued): nonce ${JSON.stringify(parsed.nonce)}${live === undefined ? "" : ` for ${live.actionKey}`}${repeat ? " (again; not recorded twice)" : ""}`);
4377
+ // The toast first: it is the only answer this query gets, and the record
4378
+ // below changes nothing about what the person is told.
4379
+ await this.safeAnswer(callbackId, refusedDecisionLine(refusal.code));
4380
+ if (live === undefined || repeat || this.refusedDecisionHandler === null)
4381
+ return;
4382
+ const decision = {
4383
+ action_key: live.actionKey,
4384
+ decision: parsed.decision,
4385
+ deliveryId: live.deliveryId,
4386
+ ...(sender === undefined ? {} : { sender }),
4387
+ };
4388
+ try {
4389
+ this.refusedDecisionHandler({ decision, refusal });
4390
+ }
4391
+ catch (cause) {
4392
+ // Best effort by contract: the refusal has happened and been told; only
4393
+ // the record is lost, and the operator hears that here.
4394
+ this.complain(`approval: telegram could not record a nonce-not-issued refusal for ${live.actionKey}: ${this.describe(cause)}`);
4395
+ }
4396
+ }
3550
4397
  async ignore(result, callbackId, kind, detail, reply) {
3551
4398
  this.counters.anomalies[kind] += 1;
3552
4399
  result.ignored.push({ kind, detail });
@@ -3649,26 +4496,30 @@ export class TelegramChannel {
3649
4496
  * replace the real reason with a worse one.
3650
4497
  */
3651
4498
  async describeFailure(response) {
4499
+ const nothing = { description: null, retryAfterSeconds: null };
3652
4500
  let body;
3653
4501
  try {
3654
4502
  body = await response.text();
3655
4503
  }
3656
4504
  catch {
3657
- return null;
4505
+ return nothing;
3658
4506
  }
3659
4507
  let parsed;
3660
4508
  try {
3661
4509
  parsed = JSON.parse(body);
3662
4510
  }
3663
4511
  catch {
3664
- return null;
4512
+ return nothing;
3665
4513
  }
3666
4514
  if (parsed === null || typeof parsed !== "object")
3667
- return null;
4515
+ return nothing;
3668
4516
  const description = parsed["description"];
3669
- if (typeof description !== "string" || description.length === 0)
3670
- return null;
3671
- return this.redact(description);
4517
+ return {
4518
+ description: typeof description !== "string" || description.length === 0 ? null : this.redact(description),
4519
+ // PR #614 recheck NF-2: a 429 says how long to wait, and the review
4520
+ // walkthrough honours it rather than spending its retry budget early.
4521
+ retryAfterSeconds: retryAfterFrom(parsed),
4522
+ };
3672
4523
  }
3673
4524
  /**
3674
4525
  * One Bot API call.
@@ -3698,10 +4549,10 @@ export class TelegramChannel {
3698
4549
  // was thrown out of were the same "HTTP 400" on the operator's
3699
4550
  // terminal. Read best effort — a status is still worth reporting when
3700
4551
  // the body is missing, truncated, or not JSON at all.
3701
- const description = await this.describeFailure(response);
4552
+ const { description, retryAfterSeconds } = await this.describeFailure(response);
3702
4553
  throw new TelegramApiError(description === null
3703
4554
  ? `${method}: HTTP ${response.status}`
3704
- : `${method}: HTTP ${response.status} (${description})`, method, response.status, description);
4555
+ : `${method}: HTTP ${response.status} (${description})`, method, response.status, description, retryAfterSeconds);
3705
4556
  }
3706
4557
  raw = await response.text();
3707
4558
  }
@@ -3735,7 +4586,7 @@ export class TelegramChannel {
3735
4586
  // No HTTP status: this envelope arrived on a 2xx. The description is
3736
4587
  // carried anyway so a caller reads the same field whichever shape the
3737
4588
  // failure took (APRV-277).
3738
- null, description);
4589
+ null, description, retryAfterFrom(envelope));
3739
4590
  }
3740
4591
  return envelope["result"];
3741
4592
  }