approval-md 0.4.0 → 0.4.1

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