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