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
@@ -311,6 +311,64 @@ that is due to a refusal of anything: it is a warning on `log verify`, a
311
311
  doctor`'s `checkpoint` row. A gate that held up an action for want of a tap is a
312
312
  gate whose operator turns the check off.
313
313
 
314
+ ## log unlock
315
+
316
+ Takes over `events.jsonl.lock` from a writer that died holding it, when no
317
+ writer could do that itself, and records it.
318
+
319
+ ```
320
+ approval log unlock --pid <n|none> --as human:<id> [--log <path>] [--json]
321
+ ```
322
+
323
+ **Why a person.** Every writer that finds the lock held waits out its whole
324
+ lock timeout (two seconds) and then judges the holder once. It takes the lock
325
+ back by itself only when it can prove the holder gone: the holder ran in this
326
+ writer's own pid namespace and boot, and `kill(pid, 0)` answers ESRCH (on Linux
327
+ also with `/proc/<pid>` absent or a zombie, or the pid now names a process that
328
+ started later), or the lockfile names no holder, is ten minutes old, and sat
329
+ unchanged through the writer's whole wait (a try-once caller never takes it). Any
330
+ other holder (another container, another boot, a pid answering EPERM, a `/proc`
331
+ it cannot read) is never taken automatically, because a wrong reclaim forks the
332
+ chain. The writer's `lock-timeout` refusal then names the pid and this command.
333
+
334
+ **What it does.** It reads the lockfile strictly, refuses a `--pid` that is not
335
+ the one the record names (`none` for a lockfile with no holder record), refuses
336
+ a holder it sees running (Linux: the same start time in this namespace;
337
+ elsewhere: the pid on this host in this boot), and refuses beside a `log sync`
338
+ snapshot. It then takes the lock exactly as a writer's reclaim does (an
339
+ exclusive `link(2)` to `<lock>.stale.<pid>.<created ms>`, then a `rename(2)` of
340
+ its own lockfile over the lock's path) and appends `audit.lock_reclaimed` as the
341
+ first record under it, with your `human:` actor, reason `operator-cleared` (your
342
+ word, which the log never presents as a writer's proof), the strictly parsed
343
+ holder and the lock's age. A holder `created` the log cannot vouch for (more
344
+ than a day before the log's last record, or more than five minutes ahead of
345
+ this clock) is recorded as `null`, with no age. A stale name a dead reclaimer or
346
+ anyone else left is cleared first, and a dead reclaimer's own
347
+ `<lock>.take.<pid>.*` file with it. Anything at the lock's path that is not a
348
+ lockfile (a FIFO, a link, a malformed file) is not taken: the refusal says to
349
+ `rm -v` it.
350
+
351
+ It also refuses, touching nothing:
352
+
353
+ - while a reclaim is in flight: a writer writes its own
354
+ `<lock>.take.<pid>.<nonce>` before it claims, and a running one is named, so
355
+ a reclaimer that is stopped (SIGSTOP, a debugger) is never unlocked around;
356
+ - when the record could not be appended: a daemon whose id the attested
357
+ policy refuses, or a torn tail (repair the tail first);
358
+ - when the claim is refused EPERM or EACCES (Linux `fs.protected_hardlinks`:
359
+ the lockfile is another user's): the refusal names the owner's uid; run it as
360
+ that user or root, or `rm -v` the lockfile once no writer runs;
361
+ - when a directory sits at the stale name: the refusal names `rm -r` for it.
362
+
363
+ **Run it only when no writer is running.** The verb asserts, on your word, that
364
+ the holder is gone. Human-only: the actor comes from `--as` or `APPROVAL_HUMAN`,
365
+ the schema refuses an `agent:` actor on the record, and `core/command-class.ts`
366
+ classifies `approval log unlock` (and `node <cli> log unlock`) `policy.core`, so the harness hook denies an agent
367
+ that tries it that way. Other spellings (`npx approval log unlock`, `npm exec`,
368
+ a wrapper script) classify as workspace writes, and `--as` is the caller's to
369
+ set, so the classification is a speed bump rather than the boundary (APRV-491).
370
+ Exit 0 when it unlocked or there was no lock; 4 when it refused.
371
+
314
372
  ## log follow
315
373
 
316
374
  `approval log follow --from <seq> --json` is the channel-independent decision
@@ -672,7 +730,8 @@ channel; policy-authorized execution is never represented as a human grant.
672
730
  "provenance":"rule"|"default"|"inherited"|"fail-closed"|"floor",
673
731
  "manualBecause":null|"matched-rule"|"irreversibility-floor"|"load-failure",
674
732
  "loadFailure":null|{"code":"file-missing"|"no-block"|"multiple-blocks"|
675
- "yaml-error"|"schema-invalid"|"protected-route-floor",
733
+ "yaml-error"|"schema-invalid"|"protected-route-floor"|
734
+ "sender-ambiguous"|"delegation-not-supported",
676
735
  "message":"..."},
677
736
  "matched":null|{"pattern":"vcs.push.main","rule":{"autonomy":"supervised"}},
678
737
  "overridden":null|{"pattern":"read.web"|null,"autonomy":"autonomous"},
@@ -716,6 +775,62 @@ protected surface without removing a path from any list. The policy does not
716
775
  load, so every class answers `manual` with `manualBecause: "load-failure"`, and
717
776
  the message names the offending entry.
718
777
 
778
+ ### Delegation block, reserved in 0.4.2; no behaviour (APRV-500)
779
+
780
+ A policy may carry one top-level `delegation` block. It is where the judge
781
+ will live: a pinned model, `model:<name>@<major>.<minor>.<patch>`, that may
782
+ later review supervised samples, advise on manual cards, and decide manual
783
+ cards in named classes within a daily cap. None of that exists in this core.
784
+ The block is parsed, validated, and loaded only in its off form:
785
+
786
+ ```yaml
787
+ delegation:
788
+ model: null # no judge; every other key is inert
789
+ classes: [] # exact keys of classes, never a wildcard
790
+ max_autonomy: manual # manual | supervised-live | supervised-retro
791
+ daily_cap: 0 # 0 to 1000 judge grants per rolling day
792
+ escalate_on: [] # deny, low_confidence, irreversible, unknown_class
793
+ advice: false
794
+ reviewers: [] # human:<approver id> or model:<name>@<version>
795
+ ```
796
+
797
+ An empty mapping (`delegation: {}`) and any subset of these keys at these
798
+ values is the same off form. It changes no resolution, no request and no
799
+ record, and `policy check` adds one line to the trace saying the block is
800
+ declared and off.
801
+
802
+ Any other value fails the load with `loadFailure.code`
803
+ `delegation-not-supported`, and the message names every key that is not off.
804
+ The policy then answers `manual` for every class, like any policy that does
805
+ not load. Accepting `daily_cap: 10` and doing nothing would leave the author
806
+ believing a setting is in force that nobody enforces, so this core refuses it.
807
+
808
+ The block's relationships to the rest of the file are checked first, and a
809
+ fault there is `schema-invalid` with its own keyword in `errors`:
810
+
811
+ | keyword | rule |
812
+ | --- | --- |
813
+ | `delegation-class-undeclared` | each `classes` entry is an exact key of `classes`; a class reached only through a `<prefix>.*` family is refused |
814
+ | `delegation-class-level` | no delegated class declares `human-only` or `autonomous` |
815
+ | `delegation-max-autonomy-pin` | every delegated class declares an autonomy at least as strict as `max_autonomy`. It is a pin: loosening a delegated row without rewriting this block fails the load |
816
+ | `delegation-escalation-floor` | `daily_cap` above 0 requires `escalate_on` to contain `irreversible` and `unknown_class` |
817
+ | `delegation-reviewer-model` | a `model:` reviewer equals `model` exactly |
818
+ | `delegation-reviewer-unknown` | a `human:` reviewer names a key of `approvers` |
819
+ | `delegation-model-required` | `advice: true`, a cap above 0 or a `model:` reviewer needs `model` |
820
+
821
+ The `model:` identity is reserved with the block. `delegation.reviewers` is
822
+ the only place it parses. Grant, reject, revoke, attest, `audit review` and
823
+ every channel decision still require `human:` and refuse a `model:` actor
824
+ with `actor-not-human`. The event schema admits no `model:` actor and no
825
+ `verdict_source: model`, which is registered as reserved and never written.
826
+
827
+ `policy diff` (and the `policy amend` ceremony) shows the block's keys as
828
+ `delegation.<key>` paths and, for an edit that turns one on, the
829
+ `delegation-not-supported` failure of the new side. Older cores (0.4.1 and
830
+ earlier) do not know the key and refuse even the off form as
831
+ `schema-invalid`, so a template carries the block only once every daemon that
832
+ reads it runs 0.4.2 or later.
833
+
719
834
  Human output: the `decisionPath` lines, then a final line `-> <autonomy>`
720
835
  carrying "(fail-closed: `<code>`)" or "(floor applied over `<pattern>`:
721
836
  `<autonomy>`)" when either applies. stderr stays empty on a successful answer.
@@ -2010,7 +2125,9 @@ to decide whether to fix itself, stop retrying, or ask a human.
2010
2125
  attestation (detail: `not-attested` | `hash-mismatch` | `unreadable`). Run
2011
2126
  `approval policy attest`.
2012
2127
  - `envelope-invalid` — the envelope failed `envelope.schema.json`, or the task
2013
- file has no frontmatter or no `approval:` key.
2128
+ file has no frontmatter or no `approval:` key. Also: an envelope, a proposal
2129
+ or a token-minting `request` naming the class `harness.tool.unmapped`, which
2130
+ is reserved to the harness hook (APRV-499).
2014
2131
  - `task-file-unreadable` — the task file could not be read (exit 4).
2015
2132
  - `task-already-registered` — this task id already has a `task.registered` record.
2016
2133
  - `envelope-missing` — the file carries no `approval:` envelope AND the log holds
@@ -2179,7 +2296,9 @@ Frozen public API in the same sense the gate's codes are.
2179
2296
  is wrong and nothing is recoverable: the grant is complete, and it authorized
2180
2297
  a process that runs the command itself rather than through `approval run`.
2181
2298
  Distinct from `token-mismatch`, which would send an agent hunting for a token
2182
- that deliberately never existed.
2299
+ that deliberately never existed. Also returned by `run` and `consume` for any
2300
+ grant on the class `harness.tool.unmapped`, which only the harness hook may
2301
+ spend (APRV-499).
2183
2302
  - `class-human-only` — the class the grant authorizes resolves to `human-only`,
2184
2303
  so the token may not be spent and `approval token` reports it unspendable
2185
2304
  rather than live. The gate refuses the request that would mint such a token,
@@ -3538,20 +3657,113 @@ carries in the open.
3538
3657
 
3539
3658
  ## audit review
3540
3659
 
3660
+ **The reviewer must be on the class's roster (APRV-483).** Where the policy rule
3661
+ that resolves the sampled action's class names `approvers`, the reviewer must be
3662
+ one of them, compared exactly as a grant's approver is (`namesApprover` in
3663
+ `core/gate.ts`): a bare id on the roster matches `human:<id>`. Anyone else is
3664
+ refused `actor-not-approver` (exit 1) and nothing is appended. This binds every
3665
+ surface, because the check is in `reviewSample` itself: `--as human:<id>` at a
3666
+ terminal and a sender-mapped tap on a Telegram card are held to the same list.
3667
+ A rule that names no `approvers` restricts nobody, exactly as for grants. The
3668
+ class comes from the action's registration (a sample naming no class is refused
3669
+ `actor-not-approver`, never read as "no roster"), and the roster is read from
3670
+ the policy file a grant would read, only when its bytes are the latest
3671
+ attestation's: an unattested, edited, unreadable or `--policy`-substituted file
3672
+ is refused `policy-not-attested` (exit 1), because a roster read from a file
3673
+ nobody attested is one the reviewer could have chosen. Attested bytes that do not
3674
+ load (a schema-invalid rule, a YAML error) are refused `policy-invalid` (exit 1):
3675
+ `policy attest` hashes bytes without parsing them, and the fail-closed reading
3676
+ of a broken policy names no roster, which for a review would mean anyone. Nothing
3677
+ resolves from a broken file, so an on-roster reviewer is refused too; the repair
3678
+ is a corrected policy, attested.
3679
+
3680
+ **The roster is the one in force when the action ran (PR #614 refutation F2,
3681
+ recheck NF-3).** Every `audit.sampled` the runtime writes pins the attested
3682
+ policy the sampled execution ran under, as `payload.policy_sha256`: the
3683
+ `sha256` named by the latest `policy.updated` attestation before the
3684
+ `execution.started` record, which is the policy the gate checked when it
3685
+ recorded the start (a harness start carries the same hash as its own
3686
+ `policy_sha256`). A policy attested between the run and the sweep therefore
3687
+ changes neither who may review the sample nor whether anyone can. Only an
3688
+ execution older than every attestation in the log pins the latest attestation
3689
+ at sampling instead. A review of that sample reads the
3690
+ roster only from policy bytes that hash to the pin, and only when an
3691
+ attestation before the sample names that hash. The bytes come from the payload
3692
+ store beside the log, where every attestation since APRV-356 keeps the text it
3693
+ attested (PR #614 fix round 3), so editing and re-attesting the policy, a
3694
+ settings save from the app included, leaves older open samples reviewable. The
3695
+ policy file on disk is the fallback, used only when its bytes hash to the pin
3696
+ (a chain last attested before APRV-356 stored none). Bytes in neither place are
3697
+ refused `policy-not-attested`, and the message names the hash it needs. A later
3698
+ re-attestation that renames or drops the class's rule therefore cannot leave
3699
+ the sample with no roster, and one that names a different roster cannot open
3700
+ it to a reviewer the action's policy did not name. A class that matches no rule in the pinned policy
3701
+ (it reached supervised through `defaults.autonomy`) is refused
3702
+ `actor-not-approver` with that reason, because the defaults carry no roster
3703
+ and for a review "no roster" would mean anyone. A sample written before samples
3704
+ pinned a policy has no `policy_sha256` and keeps the latest-attestation reading
3705
+ above.
3706
+
3707
+ Exactly one of `--ok` and `--deny` is required (APRV-482). A bare review, or one
3708
+ carrying only `--reaction`, is refused `verdict-required` (exit 1) and writes
3709
+ nothing: under supervised-retro the review counts as the approval, and an
3710
+ approval is something the reviewer says, never a default. Both flags together
3711
+ is a usage error (exit 2). The verdict is judged after the roster (PR #614
3712
+ refutation N6): a reviewer off the roster is told `actor-not-approver` whether
3713
+ or not they gave a verdict, and a grade with no verdict is not judged for its
3714
+ note until the verdict comes.
3715
+
3541
3716
  `--note` is optional — unlike `execution resolve`, this event records only that a
3542
3717
  person looked, and the runtime is not relying on the note for a fact it does not
3543
3718
  otherwise have. Human-only: a runtime that could mark its own samples reviewed
3544
3719
  would be a supervision backlog that empties itself.
3545
3720
 
3546
- No attestation is required, for the reason `execution resolve` states: review
3547
- records an observation, exercises no policy authority, authorizes nothing, and
3548
- spends no budget. A review blocked because a policy file was edited afterwards
3549
- would be a supervision backlog held open by an unrelated fact.
3550
-
3551
- What it appends is `audit.reviewed`, naming the sample's action key and task, with
3552
- payload `{"subject_seq":<seq of the audit.sampled>,"reviewed":true,"note"?:"...",
3553
- "reaction"?:"disliked"|"indifferent"|"liked"|"loved"}`.
3554
- An action key with several open samples refuses `ambiguous-subject`.
3721
+ Since APRV-483 a review needs the attested policy, because its roster check
3722
+ reads the class's `approvers` from it (below). Before that no attestation was
3723
+ required, for the reason `execution resolve` states: review recorded an
3724
+ observation and exercised no policy authority. Under supervised-retro the
3725
+ review is the approval, and the trade is stated plainly: a policy edited and not
3726
+ yet re-attested now holds the review backlog open until a human re-attests.
3727
+
3728
+ What it appends is `audit.reviewed`, naming the sample's action key and task; its
3729
+ payload is listed field by field under [the review record](#the-review-record),
3730
+ which is the one place those names are defined. An action key with several open
3731
+ samples refuses `ambiguous-subject`.
3732
+
3733
+ ### The review record
3734
+
3735
+ The field names a follower reads (the data pipeline's follower and the dbt
3736
+ models built on it read reviews as approvals under supervised-retro). This
3737
+ table is the single definition; other docs link here rather than restating it.
3738
+ Every `audit.reviewed` written since APRV-481 carries the three required fields,
3739
+ and since PR #614's F5 fix a fourth, `verdict_source`; the write boundary refuses
3740
+ one that does not. The verdict alone cannot say whether the reviewer gave it:
3741
+ before APRV-482 the runtime wrote `ok` for a grade-only tap or a bare
3742
+ `audit review`, and such a record may carry all three APRV-481 fields, so
3743
+ `verdict_source` is the field that separates the two. A review written before then
3744
+ may lack them and still verifies (the read boundary is the schema's
3745
+ `audit_reviewed_record_historical`), so a follower treats their absence on an
3746
+ old record as "not recorded", never as a default.
3747
+
3748
+ | Field | Where | Required | Meaning |
3749
+ | --- | --- | --- | --- |
3750
+ | `event` | record | yes | `audit.reviewed`. |
3751
+ | `seq`, `ts`, `hash` | record | yes | The review's own chain position and runtime-assigned time. |
3752
+ | `actor` | record | yes | `human:<id>`, the reviewer. Since APRV-483 a person on the class's `approvers` roster wherever the rule names one. |
3753
+ | `action_key`, `task` | record | when the sample named them | The reviewed action and its task. |
3754
+ | `channel` | record | no | The surface, when the writer recorded one. |
3755
+ | `payload.subject_seq` | payload | yes (APRV-481) | The `seq` of the `audit.sampled` record this review answers. Not the execution's seq. |
3756
+ | `payload.sampled_subject_hash` | payload | yes (APRV-481) | The `hash` of the `execution.started` record the sample named: the join key from a review to the execution it judged. |
3757
+ | `payload.verdict` | payload | yes (APRV-481) | `ok` or `denied`. The enforcement field. Explicit only on records that carry `verdict_source`. |
3758
+ | `payload.verdict_source` | payload | yes (PR #614 F5) | Always `explicit`: the reviewer said the verdict (`--ok`/`--deny`, a card's OK or second Deny). The discriminator for a follower: a review WITHOUT it predates APRV-482 or was written by an older build, and its `ok` may have been defaulted for a grade-only tap or a bare terminal review. Count only reviews carrying it as explicit approvals. |
3759
+ | `payload.payload_hash` | payload | no | The SHA-256/JCS binding of the bytes the execution ran, present ONLY when the surface showed those bytes whole before the verdict (a Telegram card's `bytes` view). Absent when the reviewer saw a hash, nothing, or a terminal. Checked against the execution's binding before it is written. |
3760
+ | `payload.reaction` | payload | no | `disliked`, `indifferent`, `liked` or `loved`. Guidance, never enforcement; absent is absent. |
3761
+ | `payload.note` | payload | with `loved`/`disliked` | The reviewer's words, verbatim. |
3762
+ | `payload.sender`, `payload.sender_source` | payload | no | The authenticated account the review arrived from, when a transport authenticated one. |
3763
+ | `payload.subject_event`, `payload.reviewed` | payload | no | Constants (`audit.sampled`, `true`) kept for older readers. |
3764
+
3765
+ A denial is followed by a runtime-authored `reconciliation.required` whose
3766
+ `payload.review_seq` is the review's `seq`.
3555
3767
 
3556
3768
  **`--json`** (one object on stdout):
3557
3769
 
@@ -3920,6 +4132,224 @@ turn on was already on the `ChannelRequest`, and `--json`, `approval queue` and
3920
4132
  the web page carried it all along; rendering stays a pure function of
3921
4133
  (request, layout).
3922
4134
 
4135
+ ### The minimal card: `channels.telegram.prompt.style` (APRV-489)
4136
+
4137
+ The technical card is three messages written for an engineer. A resident
4138
+ approving on a phone needs to know what their agent wants to do, the exact
4139
+ words it will act on, how long they have, and where the buttons are.
4140
+ `style: minimal` sends that as ONE message, with the whole technical card
4141
+ collapsed inside it:
4142
+
4143
+ ```yaml
4144
+ channels:
4145
+ telegram:
4146
+ prompt:
4147
+ always: [ttl_remaining_ms]
4148
+ style: minimal # technical (the default) | minimal
4149
+ say:
4150
+ digest.share:
4151
+ does: "share a note about you with other people"
4152
+ quote: { scope: "Shared with", expires_at: "Until", text: "Note", digest_id: "Reference" }
4153
+ note: none
4154
+ ```
4155
+
4156
+ **For the approver, in plain words.** The bold first line says what your agent
4157
+ wants to do. The box under it holds the agent's own words, copied exactly from
4158
+ what will be posted, shared or run; approval.md writes nothing inside the box
4159
+ except the labels. If a line under the box starts with ⚠ or says "Not shown
4160
+ here", the box is not the whole story: open "Full details" before deciding. A
4161
+ line marked "not checked" is a description (by an AI model or by your agent)
4162
+ that nobody verified. The next line says how long you have; if you do nothing,
4163
+ nothing happens. Tap "Full details" to see everything the runtime checked,
4164
+ including the exact text this approval is bound to. Approve says yes; Deny says
4165
+ no. For anything that deletes, sends files or money, or talks to outside
4166
+ services, open "Full details" every time.
4167
+
4168
+ **The exact layout** (HTML; `[ ]` are the inline buttons):
4169
+
4170
+ ```
4171
+ <b>Your agent wants to <phrase></b>
4172
+ <blockquote><b>Label:</b> quoted value one payload value per line, verbatim
4173
+ …</blockquote>
4174
+ ⚠ This runs at least 3 commands. Only the beginning is shown above: … a command, only when cut
4175
+ ⚠ There is more than fits here: open Full details before deciding. when any other value was cut
4176
+ Not shown here: <key>, <key>. Open Full details before deciding. when the say entry leaves a field off (~)
4177
+ <i>AI summary (not checked):</i> … only when a model sentence is attached
4178
+ <i>Your agent says (not checked):</i> … only when the class's say entry has note: summary
4179
+ <i>Your agent estimates the cost (not checked):</i> $0.00 only when the estimate is above zero
4180
+ Open for about 3 days. If you don't answer, your agent will not do this.
4181
+ <blockquote expandable><b>Full details (tap to open)</b>
4182
+ … the technical card's three messages, in order, character for character …</blockquote>
4183
+ [✅ Approve] [✋ Deny]
4184
+ ```
4185
+
4186
+ A tool call the agent is blocked in says "Your agent is waiting: about 4
4187
+ minutes left. If you don't answer, it will not do this." instead. The time is
4188
+ the gate's own window for the request (the policy's TTL, narrowed by a
4189
+ harness hook's cap), from the verified log, as it stood when the card was
4190
+ drawn; the line is not refreshed afterwards. A request with no time left says
4191
+ "Time is up: this request has closed, and an answer now will not count."
4192
+
4193
+ **What is computed, what is quoted, what is described.**
4194
+
4195
+ - *Computed by the runtime:* the first line, chosen by the exact class the LOG
4196
+ records: core's own phrase for a class core emits (`network.call`,
4197
+ `read.web`, `browser.exec`, `cron.manage`, `process.write`, `skill.manage`,
4198
+ `agent.delegate`, `message.send`, `files.delete.scratch`); else, for a
4199
+ payload the runtime reads itself, its structural kind ("run a command",
4200
+ "change a file", "send an email", followed by `(type: <class>)`), whatever
4201
+ the class's `say` entry says; else, for an opaque payload only, the attested
4202
+ `say.<class>.does`. The notices under the box; the deadline line; everything
4203
+ in "Full details"; the buttons.
4204
+ - *Quoted from the bound payload:* the box, and only payload values. Each line
4205
+ is one payload value, verbatim (marked as below, and visibly cut when long),
4206
+ under a label (the operator's attested label, or the runtime's fixed label
4207
+ for a field of a shape it knows), or alone when it is the only quotation.
4208
+ Nothing computed, abbreviated, joined or paraphrased is ever inside the box;
4209
+ a list of addresses is one line per address. Each value sits inside the
4210
+ runtime's own Unicode isolate (U+2068 … U+2069), so right-to-left letters in
4211
+ it cannot reorder its label or the lines around it. The cut mark is drawn
4212
+ inside the value's isolate, so a cut right-to-left value shows it at its left
4213
+ edge, still visible; the notice under the box says the same thing outside
4214
+ any isolate. The operator's `does` phrase in the headline is not isolated.
4215
+ The bytes were hash-checked against the request's
4216
+ `payload_hash` before the channel saw them, so they are what will be acted
4217
+ on; their content was written by the agent. A command is shown up to 160
4218
+ characters (under `Command:` when its `cwd` is quoted as `In folder:`); a
4219
+ longer one is shown from its start and cut, and only then does a computed
4220
+ line under the box say so ("⚠ This runs at least 3 commands. Only the
4221
+ beginning is shown above", the classifier's count as a lower bound, since it
4222
+ does not see commands started inside other commands such as a
4223
+ here-document piped into `sh`, `$( … )`, `bash -c` or `eval`; "⚠ Only the
4224
+ beginning of this command is shown above" when it counts one; "⚠ More than
4225
+ one command may be here, and only the beginning is shown above" when it
4226
+ cannot count). A command shown whole gets no line: the
4227
+ card makes no claim about how many commands it is. The line never names or
4228
+ paraphrases a command the box does not show; the
4229
+ classifier's outline is in "Full details" only, because it leaves out flags
4230
+ and their values, which is where a deletion target or an uploaded file
4231
+ lives. A file change shows the file, the change and every other field under
4232
+ its own key name (`tool:`, `replace_all: true`). An email shows From, To, Cc,
4233
+ Bcc, Subject, Format and Message. An opaque payload shows the fields its
4234
+ `say.<class>.quote` map labels.
4235
+ - *Never a clean-looking partial:* when a command is cut, its computed count
4236
+ line says so; when any other value is cut, "⚠ There is more than fits here:
4237
+ open Full details before deciding." follows the box; when the declaration leaves fields off (`~`),
4238
+ "Not shown here: <their key names>. Open Full details before deciding." does.
4239
+ Both are outside the box, so quoted text can neither produce nor suppress
4240
+ them.
4241
+ - *Described, and labelled "not checked":* the AI summary and the agent's own
4242
+ summary and estimate. They never stand alone and never come first: they sit
4243
+ below the box.
4244
+
4245
+ **Quoted text is hostile input.** Every quoted value is one line (a line break
4246
+ is drawn ` ⏎ `). Control, format, bidirectional and every default-ignorable
4247
+ character (variation selectors, joiners, tag characters, U+034F, the Khmer and
4248
+ Mongolian invisibles), the Hangul fillers and the braille blank, and combining
4249
+ marks beyond two on one character are drawn as `«U+202E»`; so are `«` and `⏎`
4250
+ in the value, which makes the marking injective. One exception keeps emoji
4251
+ readable: a grapheme cluster that is WHOLLY a recognised emoji sequence
4252
+ (Unicode's RGI set, `\p{RGI_Emoji}` with the regular-expression `v` flag: ❤️,
4253
+ 👨‍👩‍👧, 1️⃣, 👍🏽) is drawn as itself. Every other presentation selector or joiner
4254
+ is marked, so a U+FE0F after a face that is already an emoji, or a U+200D
4255
+ between two pictographs that form no emoji, cannot carry hidden bits. On a
4256
+ runtime without that property nothing is exempt. Node 20, this package's
4257
+ floor, ships V8 11.3, which has it. A value longer than 280 characters is
4258
+ cut between grapheme clusters with `…(cut; see full details)`, and everything is
4259
+ HTML-escaped. A value cannot start a line of its own, close the box, open or
4260
+ close the collapsed block, or be bold.
4261
+
4262
+ Injective is not "never looks the same". Look-alike letters from other scripts
4263
+ (a Cyrillic а for a Latin a), a lone emoji with and without its presentation
4264
+ selector, and runs of ordinary spaces are drawn as themselves, so two different
4265
+ values can still look alike. Telegram clients also turn URLs, `@names`,
4266
+ `#tags` and `/commands` in the box and in "Full details" into links; a link's
4267
+ text is always its own target (markup in a value is escaped, so a value cannot
4268
+ make link text say something else), but a look-alike domain stays look-alike.
4269
+
4270
+ **When the technical card is sent instead.** The minimal card is never guessed
4271
+ at or drawn partially. These requests get today's technical card, the decision
4272
+ record says why (`payload.rendering.fallback`, below), and the listener says
4273
+ so on stderr:
4274
+
4275
+ - an attestation prompt, a `policy.*` or `log.*` class, or a protected path;
4276
+ - a truncated payload, or none;
4277
+ - any abnormal health fact (a budget over its ceiling, a policy not attested,
4278
+ an autonomy that is not `manual`), even when the row layout hides its row;
4279
+ - an opaque payload whose class has no `say` entry, carrying a key the entry's
4280
+ `quote` map does not name, or that is not an object;
4281
+ - a request delivered as part of a batch: a digest, its members, the collapsed
4282
+ stale-request summary, and a batch whose digest did not fit or has one member;
4283
+ - a card over 3800 characters (Telegram's limit is 4096, and a relay's
4284
+ `Agent: <name>` line needs room): the canonical rendering is never cut to fit;
4285
+ - a policy file that does not match its attestation (`policy-unattested`);
4286
+ - a minimal card the Bot API or a relay refuses (any 4xx but 429): the
4287
+ technical card is sent once instead (`send-refused`); a 429, a 5xx or a
4288
+ network failure is retried by the next cycle as before.
4289
+
4290
+ When the edit that settles a minimal card is refused (it replaces the only
4291
+ message holding the request), a short settle text with the outcome, the
4292
+ request's key and who decided when is sent instead, so a decided card never
4293
+ looks undecided.
4294
+
4295
+ Review cards ("REVIEW — THIS ALREADY RAN"), their note prompt and checkpoint
4296
+ prompts are the same under both styles in this release.
4297
+
4298
+ **`say`.** Keyed by EXACT class name: a pattern such as `files.*` is refused at
4299
+ load (`prompt-say-wildcard`), because one friendly phrase must never stand for
4300
+ several classes. For a class core phrases itself (the list above) core's phrase
4301
+ always wins: a `say` entry may set `quote` and `note` for it but not `does`
4302
+ (`prompt-say-builtin`). The same holds for a class the command classifier
4303
+ emits (`files.delete.out_of_scope`, `vcs.push.main`, `harness.launch.*`, …),
4304
+ whose payload is a command or a file change the runtime phrases itself
4305
+ (`prompt-say-kind`). For any other class `does` is a phrase for OPAQUE
4306
+ payloads only: when a request of that class carries a command, a file change
4307
+ or an email, the runtime's kind phrase is drawn instead and the record notes
4308
+ `say_does_ignored` (below). For those classes `does` is required and is a verb
4309
+ phrase of at most 120 characters that completes "Your agent wants to …": it
4310
+ starts with a letter and has no line break, `.` `!` `?` `…` `:` `;`, markup or
4311
+ `⚠` (`prompt-say-does`), so operator text cannot read as a second sentence or
4312
+ as one of the runtime's notices. `quote` maps every top-level key an opaque
4313
+ payload may carry to the label it is quoted under (letters, digits and spaces,
4314
+ at most 24; `""` only when it is the only key shown) or to `~` for a key
4315
+ deliberately left off, and must show at least one key (`prompt-say-label`,
4316
+ `prompt-say-shape`); `note` is `summary` (the default) or `none`. A `say` entry is read only under `style: minimal`. A class
4317
+ core phrases itself still needs a `say` entry with a `quote` map when its
4318
+ payload is opaque (a Hermes tool payload, `{tool, input}`, is). A `say` entry
4319
+ for a class no rule names loads: it applies if that class is ever requested.
4320
+
4321
+ **Other channels.** `style` and `say` are validated wherever they appear and an
4322
+ unknown value fails the policy closed (`prompt-style-unknown`,
4323
+ `prompt-say-shape`, `prompt-say-wildcard` on an untyped channel; the schema's
4324
+ own `enum`, `pattern` and shape errors on `telegram`, `web` and `cli`). Only
4325
+ Telegram draws a minimal card; the CLI and web channels IGNORE the keys and keep
4326
+ their full prompt, which shows more than was asked for rather than less.
4327
+
4328
+ **What the record says.** A decision taken on a card drawn under a minimal
4329
+ policy carries `payload.rendering` on its `approval.granted` or
4330
+ `approval.rejected`: `{"style": "minimal"}`, or `{"style": "technical",
4331
+ "fallback": "<reason>"}` when the technical card was sent instead. A minimal
4332
+ card whose class's `say` entry set a `does` phrase the runtime did not use
4333
+ (the payload was a command, a file change or an email, so the runtime drew its
4334
+ own phrase) records `{"style": "minimal", "say_does_ignored": true}`. Under a
4335
+ technical policy the key is absent and the record is unchanged. Nothing reads
4336
+ it back; it is a statement about the screen the approver answered on. It is an
4337
+ OPEN field: `schema/event.schema.json` does not constrain it in this release,
4338
+ so a reader with a closed schema for `approval.granted` must admit it.
4339
+
4340
+ **Changing it.** It is a policy key, so changing it is an attestation, and a
4341
+ decision on a request routed under the old policy is refused `policy-drift`, as
4342
+ for any policy change. The Telegram listener resolves `style` and `say` for EACH
4343
+ card it draws, and only from attested bytes: it reads the policy file once,
4344
+ requires those bytes to hash to the latest attestation in the verified log
4345
+ (the digest the gate decides under), and takes the settings from the same
4346
+ bytes. A re-attested setting therefore reaches the next card without a
4347
+ restart, and an edit to the file that nobody attested changes nothing: the card
4348
+ is technical, and when the attested policy asks for minimal the record says
4349
+ `fallback: "policy-unattested"`. An older core refuses the keys at the schema, and the
4350
+ whole policy then fails closed to all-`manual`: roll the core out before the
4351
+ policy that uses them.
4352
+
3923
4353
  ## channel cli
3924
4354
 
3925
4355
  **The rendering convention (SPEC.md §9).** Every displayed field carries a
@@ -4236,6 +4666,34 @@ place in the walkthrough and a duplicate message, never a pending request nobody
4236
4666
  is shown; an approval that depended on a channel's memory would not be an
4237
4667
  acceptable trade.
4238
4668
 
4669
+ **Earlier copies, and `--no-stale-copy`** (APRV-196, APRV-456). Every single-request decision
4670
+ button carries `<verb>:<nonce>:<action ref>` (a digest's "all" buttons carry no reference and never had a fallback), where the nonce is issued by this
4671
+ process and the reference is the first 16 hex of the action key's SHA-256. On
4672
+ the direct-bot shape (no `--api-base`, or one naming `https://api.telegram.org`)
4673
+ a tap whose nonce this process is not holding, such as a button on a copy sent
4674
+ before a restart, falls back to its reference: when this process holds that
4675
+ action open on a newer card, the tap decides it there, and the gate judges it
4676
+ exactly as it judges any other tap. That fallback is safe because only the bot
4677
+ token can put a button in front of the approver.
4678
+
4679
+ The fallback is **off** when `--api-base` names anything other than the Bot API
4680
+ (a relay, or a self-hosted Bot API server, which this rule treats as a relay)
4681
+ and when `--no-stale-copy` is passed. Then a decision tap whose nonce this
4682
+ process is not holding is refused `nonce-not-issued` (a member of the channel
4683
+ decision refusal union, beside `sender-unmapped`): it never reaches the gate,
4684
+ the approver's toast says this listener did not send that button, and the live
4685
+ card stays armed. When the tap's reference names a request this process holds
4686
+ open, one `audit.decision_refused` records the attempt under that request's key,
4687
+ naming the sender the policy resolves; a reference that names nothing open here,
4688
+ or a replay of the same button within this process, records nothing (the memory of refused buttons is per process and bounded, so a redelivery after a restart can record once more). The restart banner says that
4689
+ earlier copies no longer decide. The reason is the relay: anyone holding its
4690
+ token can send the approver a card with forged text and a real pending action's
4691
+ reference, and on the fallback the approver's genuine tap would pass the chat
4692
+ check and the sender mapping. The flag is launch configuration only: no policy
4693
+ key and nothing in an update can set it or clear it. `approval up` and
4694
+ [`channel telegram webhook`](#channel-telegram-webhook) take the same flag and
4695
+ apply the same rule.
4696
+
4239
4697
  **The execution token is printed on this terminal's stdout and is never sent to
4240
4698
  Telegram.** A chat transcript is stored on someone else's servers, backed up to
4241
4699
  phones, and readable by anyone later added to the chat — it is not a credential
@@ -4279,16 +4737,61 @@ Each `audit.sampled` with no later `audit.reviewed` now arrives as a **review
4279
4737
  card**.
4280
4738
 
4281
4739
  A card is not a prompt and says so. Its headline is `REVIEW — THIS ALREADY RAN`,
4282
- it carries no payload region and no approve button, and it accepts no token: the
4283
- action has happened, and a card offering an approve would present a settled fact
4284
- as a live authorization. What it carries is the same rows a prompt would show
4285
- for the same action — class, the command breakdown, task, the agent's claimed
4286
- summary, the model gloss where one is attached — plus two the card adds: `ran
4287
- at` (the `execution.started` the sample named) and `verdict` (that the runtime
4288
- allowed this without asking, which autonomy said so, the rate it was drawn at,
4289
- and how the log says it ended). Everything computed is derived from the verified
4290
- log, the payload store and the classifier; the claimed rows sit under the same
4291
- "NOT verified by the runtime" heading a prompt gives them.
4740
+ it carries no approve button, and it accepts no token: the action has happened,
4741
+ and a card offering an approve would present a settled fact as a live
4742
+ authorization. What it carries is the same rows a prompt would show for the same
4743
+ action — class, the command breakdown, task, the agent's claimed summary, the
4744
+ model gloss where one is attached — plus two the card adds: `ran at` (the
4745
+ `execution.started` the sample named) and `verdict` (that the runtime allowed
4746
+ this without asking, which autonomy said so, the rate it was drawn at, and how
4747
+ the log says it ended). Everything computed is derived from the verified log,
4748
+ the payload store and the classifier; the claimed rows sit under the same "NOT
4749
+ verified by the runtime" heading a prompt gives them.
4750
+
4751
+ **The card shows the payload that ran (APRV-480).** Under supervised-retro the
4752
+ review is the individual approval nobody gave before the action executed, so the
4753
+ reviewer reads the published bytes rather than a key and the agent's own summary
4754
+ of what it did. Between the computed and the claimed rows the card carries one
4755
+ of three payload regions, and its heading says which:
4756
+
4757
+ | Heading | What the reviewer sees | When |
4758
+ | --- | --- | --- |
4759
+ | `PAYLOAD — the bytes that ran, shown whole; this review covers them` | the canonical rendering of the bytes, whole, in a `<pre>` block | the runtime holds bytes that hash to the execution's binding and they fit one card |
4760
+ | `PAYLOAD — NOT SHOWN, hash only; this review does not cover the bytes` | the binding's `sha256`, and why the bytes are absent | nobody holds the bytes, the bytes held do not hash to the binding, or they are longer than this card's payload budget (at most 2000 escaped characters, less when the card's rows leave less room) |
4761
+ | `PAYLOAD — none recorded; the execution bound to no payload hash` | nothing to show and nothing to name | the execution and its registration recorded no `payload_hash` |
4762
+
4763
+ The bytes are shown whole or not at all: a card is one message edited in place,
4764
+ so it cannot spill a payload over several messages the way a request prompt
4765
+ does, and a review over half the bytes would claim more than the reviewer read.
4766
+ Only the first case lets the review record carry a `payload_hash` (see
4767
+ [the review record](#the-review-record)). A terminal review (`approval audit
4768
+ review`) shows no payload and so never records one.
4769
+
4770
+ **The whole card fits one message (PR #614 refutation F4).** The agent's summary
4771
+ is cut at 400 characters and every other row at 300, each with a marker saying
4772
+ how many characters are not shown (the registration record keeps the text
4773
+ whole); a card whose rows are pathologically long cuts them further, in steps,
4774
+ until it fits. A notice shows at most 600 characters, the rest on the
4775
+ listener's stderr. The payload budget is what the card's own rows leave under
4776
+ Telegram's 4096-character limit after the longest heading and the largest
4777
+ notice a tap can add, capped at 2000, and it is computed from the card alone,
4778
+ so the card a reviewer saw and the view a tap records cannot disagree.
4779
+
4780
+ **A card that fails to send (PR #614 recheck NF-2).** When Telegram refuses the
4781
+ card itself (HTTP 400 `message is too long`, `can't parse entities`,
4782
+ `message text is empty` or `text must be non-empty`, `reply markup is too long`,
4783
+ `BUTTON_DATA_INVALID`, `ENTITIES_TOO_LONG`), resending it can only fail again:
4784
+ the listener prints a coded `approval: telegram review-offer-failed:` line
4785
+ naming `approval audit review <seq> --ok` (or `--deny`), leaves that sample for
4786
+ a terminal review, and offers the next one. Every other failure (a 429, a
4787
+ timeout, a 5xx, a network error) prints `approval: telegram review-offer-retry:`
4788
+ and pauses review cards for Telegram's `retry_after` or the backoff, whichever
4789
+ is longer (one minute, doubling per attempt; never more than an hour, whatever
4790
+ `retry_after` says), then offers the same sample again.
4791
+ After five failed attempts the sample is left for a terminal review with the
4792
+ `review-offer-failed` line. Either way the sample stays open in
4793
+ `approval audit list` and QUEUE.md, the queue behind it no longer waits, and a
4794
+ restarted listener offers it afresh.
4292
4795
 
4293
4796
  Six buttons, bare emoji and no words (APRV-302), in two rows: the verdict on the
4294
4797
  first (✅ OK, 🛑 Deny) and the grade on the second, worst to best (👎 disliked,
@@ -4296,12 +4799,39 @@ first (✅ OK, 🛑 Deny) and the grade on the second, worst to best (👎 disli
4296
4799
 
4297
4800
  | Tap | What is recorded |
4298
4801
  | --- | --- |
4299
- | ✅ | `audit.reviewed` with verdict `ok` and no reaction. |
4300
- | a reaction | verdict `ok` and that grade — a reaction alone implies OK. |
4301
- | 🛑 once | **Nothing.** It arms the card, which says `DENY ARMED` on itself. |
4302
- | 🛑 twice | verdict `denied`, and the reconciliation obligation it opens is named on the reply. |
4802
+ | ✅ | `audit.reviewed` with verdict `ok`, and the held grade if there is one. |
4803
+ | a reaction, nothing armed | **Nothing.** Refused `verdict-required` (APRV-482): a grade is not a verdict. The card holds the grade and says `GRADE … HELD` in its heading. |
4804
+ | ✅ after a reaction | verdict `ok` with the held grade (a `loved` or `disliked` asks for words first). |
4805
+ | 🛑 once | **Nothing.** The tap reaches the runtime with no verdict, and the card arms (it says `DENY ARMED` on itself) only when the answer is `verdict-required`, which the runtime gives only to a mapped sender on the class's roster. Any other refusal is shown on the card and arms nothing. |
4806
+ | 🛑 twice | verdict `denied` with the held grade if any, and the reconciliation obligation it opens is named on the reply. |
4303
4807
  | a reaction with deny armed | verdict `denied` with that grade. |
4304
4808
 
4809
+ **A held grade and an armed Deny belong to the account that tapped them (PR #614
4810
+ refutation F3).** The card remembers the transport's sender id with each. A tap
4811
+ that would finish another account's half-finished review records nothing and is
4812
+ refused on the card with one line: any ✅ or 🛑 while another account holds the
4813
+ grade or armed Deny, and any reaction while another account armed Deny. A
4814
+ reaction tapped with nothing armed only replaces the held grade, and its tapper
4815
+ then holds it. A record therefore never carries a grade or a denial its reviewer
4816
+ did not tap. Only an account that may review can leave either behind (PR #614
4817
+ recheck NF-1): a grade is held, and a Deny arms, only after the runtime has
4818
+ passed the sender and the roster and answered `verdict-required`, so an
4819
+ unmapped or off-roster account in the approver chat cannot block the
4820
+ approvers' taps.
4821
+
4822
+ **A verdict is an explicit act (APRV-482).** Under supervised-retro a review
4823
+ counts as the individual approval nobody gave before the action ran, so nothing
4824
+ records `ok` on a reviewer's behalf. The explicit forms per surface:
4825
+
4826
+ | Surface | `ok` | `denied` | Refused `verdict-required` |
4827
+ | --- | --- | --- | --- |
4828
+ | Telegram card | ✅ (alone, or after a grade) | 🛑 twice, or 🛑 then a grade | a grade tapped with nothing armed |
4829
+ | `approval audit review` | `--ok` | `--deny` | neither flag, with or without `--reaction`; both flags is a usage error (exit 2) |
4830
+
4831
+ The refusal on the card is the runtime's own: the tap reaches `reviewSample`
4832
+ with no verdict, and the code and its message come back exactly as a terminal
4833
+ sees them.
4834
+
4305
4835
  The card does not print this table under itself. It used to, and the paragraph
4306
4836
  of rules pushed the rows a review is actually about off the first screen for a
4307
4837
  reader who had read them on the card before. The two things a tap could get
@@ -4334,7 +4864,10 @@ reviewer has to be able to say which half they meant.
4334
4864
 
4335
4865
  Every append goes through the same `reviewSample` that `approval audit review`
4336
4866
  calls, recorded against the human identity this listener was configured with
4337
- (`--as` / `APPROVAL_HUMAN`), never anything the callback carried. So `approval
4867
+ (`--as` / `APPROVAL_HUMAN`), never anything the callback carried. That identity,
4868
+ or the person a sender mapping resolves a tap to, must be on the class's
4869
+ `approvers` roster where the rule names one, or the card shows
4870
+ `actor-not-approver` and keeps its buttons (APRV-483). So `approval
4338
4871
  feedback` shows a reaction given on a card exactly as one given at a terminal:
4339
4872
  same record, same `human:<id>`, same everything.
4340
4873
 
@@ -4459,7 +4992,10 @@ a gate.
4459
4992
 
4460
4993
  **Only the arrival changes, and that is the whole claim.** The pending queue is
4461
4994
  re-derived from the verified log every cycle by the same `dispatchPending`. The
4462
- handlers are the same four function objects `listen` registers. A tap becomes a
4995
+ handlers are the same function objects `listen` registers (since APRV-456 that
4996
+ includes the recorder for a `nonce-not-issued` refusal, and `--no-stale-copy`
4997
+ or a relayed `--api-base` turns the earlier-copy fallback off here exactly as it
4998
+ does there). A tap becomes a
4463
4999
  decision through the same `handleUpdate`, the same `callback_query.from.id`
4464
5000
  reading, the same `approvers.<id>.senders` resolution, the same
4465
5001
  `recordChannelDecision` and the same annotate-after-decision edit. There is no
@@ -6163,6 +6699,13 @@ A 409 that still happens at runtime — another machine, or a poller started
6163
6699
  outside this runtime — is reported once, with the instances the registry knows
6164
6700
  about, and its repeats are counted rather than reprinted.
6165
6701
 
6702
+ **A relayed Telegram channel refuses buttons it is not holding** (APRV-456). An
6703
+ `--api-base` that is not `https://api.telegram.org`, or `--no-stale-copy`, turns
6704
+ off the earlier-copy fallback, and a decision tap whose nonce this process is
6705
+ not holding is refused `nonce-not-issued` instead of reaching the gate. The rule
6706
+ and its reason are under [`channel telegram listen`](#channel-telegram-listen)
6707
+ ("Earlier copies, and `--no-stale-copy`").
6708
+
6166
6709
  **The ambient runtime: the daemon loop and every configured channel in one
6167
6710
  supervised foreground process.** `approval daemon run --with-channels` is the
6168
6711
  same verb spelled from the other side, and it reaches the same function before