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.
- package/SPEC.md +3 -0
- package/cli.js +72 -4
- package/dist/src/channels/contract.d.ts +53 -0
- package/dist/src/channels/contract.js +56 -0
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/tagging.d.ts +25 -0
- package/dist/src/channels/tagging.js +57 -5
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-minimal.d.ts +251 -0
- package/dist/src/channels/telegram-minimal.js +618 -0
- package/dist/src/channels/telegram-minimal.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +454 -19
- package/dist/src/channels/telegram.js +931 -80
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +8 -1
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/audit-card.d.ts +1 -1
- package/dist/src/cli/audit-card.js +40 -1
- package/dist/src/cli/audit-card.js.map +1 -1
- package/dist/src/cli/audit.d.ts +7 -5
- package/dist/src/cli/audit.js +22 -10
- package/dist/src/cli/audit.js.map +1 -1
- package/dist/src/cli/channel-telegram-webhook.d.ts +2 -0
- package/dist/src/cli/channel-telegram-webhook.js +7 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -1
- package/dist/src/cli/channel-telegram.d.ts +75 -2
- package/dist/src/cli/channel-telegram.js +170 -15
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/execute.d.ts +12 -0
- package/dist/src/cli/execute.js +46 -1
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/help.d.ts +1 -0
- package/dist/src/cli/help.js +39 -18
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +114 -3
- package/dist/src/cli/hook.js +468 -50
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/log-unlock.d.ts +23 -0
- package/dist/src/cli/log-unlock.js +109 -0
- package/dist/src/cli/log-unlock.js.map +1 -0
- package/dist/src/cli/main.js +23 -6
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/up.js +5 -0
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/verb-registry.js +1 -0
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/core/audit.d.ts +98 -9
- package/dist/src/core/audit.js +363 -15
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/command-class.js +6 -0
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/gate.d.ts +58 -1
- package/dist/src/core/gate.js +30 -3
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/log-lock.d.ts +435 -0
- package/dist/src/core/log-lock.js +1093 -0
- package/dist/src/core/log-lock.js.map +1 -0
- package/dist/src/core/log.d.ts +82 -3
- package/dist/src/core/log.js +285 -40
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +17 -0
- package/dist/src/core/policy-proposal.js +29 -14
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/prompt-layout.d.ts +186 -1
- package/dist/src/core/prompt-layout.js +345 -3
- package/dist/src/core/prompt-layout.js.map +1 -1
- package/dist/src/core/sender-identity.d.ts +24 -1
- package/dist/src/core/sender-identity.js +31 -0
- package/dist/src/core/sender-identity.js.map +1 -1
- package/dist/src/core/validate.d.ts +20 -6
- package/dist/src/core/validate.js +0 -0
- package/dist/src/core/validate.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/serve/archive.d.ts +8 -0
- package/dist/src/serve/archive.js +11 -2
- package/dist/src/serve/archive.js.map +1 -1
- package/docs/README-extended.md +2 -0
- package/docs/cli-reference.md +507 -25
- package/package.json +2 -2
- package/schema/event.schema.json +354 -28
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
- package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
- package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
- package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
- package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
- package/schema/fixtures/event/valid/audit-sampled.json +2 -1
- package/schema/fixtures/event/valid/daemon-identity.json +2 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1600
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|