approval-md 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/README.md +83 -914
  2. package/SPEC.md +101 -13
  3. package/cli.js +134 -7
  4. package/dist/src/adapters/smtp.d.ts +18 -0
  5. package/dist/src/adapters/smtp.js +24 -3
  6. package/dist/src/adapters/smtp.js.map +1 -1
  7. package/dist/src/channels/contract.d.ts +72 -0
  8. package/dist/src/channels/contract.js +161 -1
  9. package/dist/src/channels/contract.js.map +1 -1
  10. package/dist/src/channels/relay-server.d.ts +90 -0
  11. package/dist/src/channels/relay-server.js +295 -0
  12. package/dist/src/channels/relay-server.js.map +1 -0
  13. package/dist/src/channels/relay.d.ts +273 -0
  14. package/dist/src/channels/relay.js +564 -0
  15. package/dist/src/channels/relay.js.map +1 -0
  16. package/dist/src/channels/tagging.d.ts +25 -0
  17. package/dist/src/channels/tagging.js +63 -6
  18. package/dist/src/channels/tagging.js.map +1 -1
  19. package/dist/src/channels/telegram-minimal.d.ts +251 -0
  20. package/dist/src/channels/telegram-minimal.js +618 -0
  21. package/dist/src/channels/telegram-minimal.js.map +1 -0
  22. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  23. package/dist/src/channels/telegram-webhook.js +536 -0
  24. package/dist/src/channels/telegram-webhook.js.map +1 -0
  25. package/dist/src/channels/telegram.d.ts +725 -20
  26. package/dist/src/channels/telegram.js +1332 -123
  27. package/dist/src/channels/telegram.js.map +1 -1
  28. package/dist/src/cli/amend.js +358 -5
  29. package/dist/src/cli/amend.js.map +1 -1
  30. package/dist/src/cli/attest.js +43 -6
  31. package/dist/src/cli/attest.js.map +1 -1
  32. package/dist/src/cli/audit-card.d.ts +1 -1
  33. package/dist/src/cli/audit-card.js +40 -1
  34. package/dist/src/cli/audit-card.js.map +1 -1
  35. package/dist/src/cli/audit.d.ts +7 -5
  36. package/dist/src/cli/audit.js +22 -10
  37. package/dist/src/cli/audit.js.map +1 -1
  38. package/dist/src/cli/channel-relay.d.ts +108 -0
  39. package/dist/src/cli/channel-relay.js +334 -0
  40. package/dist/src/cli/channel-relay.js.map +1 -0
  41. package/dist/src/cli/channel-telegram-webhook.d.ts +275 -0
  42. package/dist/src/cli/channel-telegram-webhook.js +716 -0
  43. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  44. package/dist/src/cli/channel-telegram.d.ts +347 -28
  45. package/dist/src/cli/channel-telegram.js +648 -56
  46. package/dist/src/cli/channel-telegram.js.map +1 -1
  47. package/dist/src/cli/channel.d.ts +1 -1
  48. package/dist/src/cli/channel.js +5 -1
  49. package/dist/src/cli/channel.js.map +1 -1
  50. package/dist/src/cli/daemon.js +37 -5
  51. package/dist/src/cli/daemon.js.map +1 -1
  52. package/dist/src/cli/doctor.d.ts +63 -0
  53. package/dist/src/cli/doctor.js +856 -111
  54. package/dist/src/cli/doctor.js.map +1 -1
  55. package/dist/src/cli/execute.d.ts +29 -1
  56. package/dist/src/cli/execute.js +387 -21
  57. package/dist/src/cli/execute.js.map +1 -1
  58. package/dist/src/cli/exit-codes.d.ts +12 -0
  59. package/dist/src/cli/exit-codes.js +13 -0
  60. package/dist/src/cli/exit-codes.js.map +1 -1
  61. package/dist/src/cli/gate.d.ts +2 -0
  62. package/dist/src/cli/gate.js +233 -2
  63. package/dist/src/cli/gate.js.map +1 -1
  64. package/dist/src/cli/help.d.ts +10 -1
  65. package/dist/src/cli/help.js +283 -54
  66. package/dist/src/cli/help.js.map +1 -1
  67. package/dist/src/cli/hook.d.ts +485 -6
  68. package/dist/src/cli/hook.js +2370 -70
  69. package/dist/src/cli/hook.js.map +1 -1
  70. package/dist/src/cli/init.js +16 -7
  71. package/dist/src/cli/init.js.map +1 -1
  72. package/dist/src/cli/instructions.js +24 -3
  73. package/dist/src/cli/instructions.js.map +1 -1
  74. package/dist/src/cli/log-anchor.d.ts +55 -0
  75. package/dist/src/cli/log-anchor.js +199 -60
  76. package/dist/src/cli/log-anchor.js.map +1 -1
  77. package/dist/src/cli/log-unlock.d.ts +23 -0
  78. package/dist/src/cli/log-unlock.js +109 -0
  79. package/dist/src/cli/log-unlock.js.map +1 -0
  80. package/dist/src/cli/main.d.ts +0 -12
  81. package/dist/src/cli/main.js +72 -4
  82. package/dist/src/cli/main.js.map +1 -1
  83. package/dist/src/cli/muse.d.ts +2 -0
  84. package/dist/src/cli/muse.js +62 -0
  85. package/dist/src/cli/muse.js.map +1 -0
  86. package/dist/src/cli/payload.js +79 -1
  87. package/dist/src/cli/payload.js.map +1 -1
  88. package/dist/src/cli/scaffold.js +6 -0
  89. package/dist/src/cli/scaffold.js.map +1 -1
  90. package/dist/src/cli/serve.d.ts +53 -0
  91. package/dist/src/cli/serve.js +270 -0
  92. package/dist/src/cli/serve.js.map +1 -0
  93. package/dist/src/cli/up.js +26 -2
  94. package/dist/src/cli/up.js.map +1 -1
  95. package/dist/src/cli/verb-registry.d.ts +2 -0
  96. package/dist/src/cli/verb-registry.js +338 -39
  97. package/dist/src/cli/verb-registry.js.map +1 -1
  98. package/dist/src/cli/wordmark.d.ts +2 -2
  99. package/dist/src/cli/wordmark.js +2 -2
  100. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  101. package/dist/src/core/attest-bootstrap.js +135 -0
  102. package/dist/src/core/attest-bootstrap.js.map +1 -0
  103. package/dist/src/core/attest.d.ts +20 -3
  104. package/dist/src/core/attest.js +25 -0
  105. package/dist/src/core/attest.js.map +1 -1
  106. package/dist/src/core/audit.d.ts +153 -12
  107. package/dist/src/core/audit.js +415 -20
  108. package/dist/src/core/audit.js.map +1 -1
  109. package/dist/src/core/channel-lease.d.ts +322 -0
  110. package/dist/src/core/channel-lease.js +663 -0
  111. package/dist/src/core/channel-lease.js.map +1 -0
  112. package/dist/src/core/command-class.d.ts +51 -0
  113. package/dist/src/core/command-class.js +766 -8
  114. package/dist/src/core/command-class.js.map +1 -1
  115. package/dist/src/core/daemon-host.d.ts +178 -0
  116. package/dist/src/core/daemon-host.js +228 -0
  117. package/dist/src/core/daemon-host.js.map +1 -0
  118. package/dist/src/core/daemon-identity.d.ts +238 -0
  119. package/dist/src/core/daemon-identity.js +252 -0
  120. package/dist/src/core/daemon-identity.js.map +1 -0
  121. package/dist/src/core/gate.d.ts +364 -6
  122. package/dist/src/core/gate.js +802 -27
  123. package/dist/src/core/gate.js.map +1 -1
  124. package/dist/src/core/harness-version.d.ts +128 -4
  125. package/dist/src/core/harness-version.js +179 -12
  126. package/dist/src/core/harness-version.js.map +1 -1
  127. package/dist/src/core/harness-wait.d.ts +82 -3
  128. package/dist/src/core/harness-wait.js +89 -3
  129. package/dist/src/core/harness-wait.js.map +1 -1
  130. package/dist/src/core/log-lock.d.ts +435 -0
  131. package/dist/src/core/log-lock.js +1093 -0
  132. package/dist/src/core/log-lock.js.map +1 -0
  133. package/dist/src/core/log-subscribe.d.ts +21 -0
  134. package/dist/src/core/log-subscribe.js +16 -6
  135. package/dist/src/core/log-subscribe.js.map +1 -1
  136. package/dist/src/core/log-write-layer.d.ts +55 -0
  137. package/dist/src/core/log-write-layer.js +91 -0
  138. package/dist/src/core/log-write-layer.js.map +1 -0
  139. package/dist/src/core/log.d.ts +130 -22
  140. package/dist/src/core/log.js +473 -50
  141. package/dist/src/core/log.js.map +1 -1
  142. package/dist/src/core/payload-census.d.ts +51 -0
  143. package/dist/src/core/payload-census.js +125 -2
  144. package/dist/src/core/payload-census.js.map +1 -1
  145. package/dist/src/core/payload-store.d.ts +31 -0
  146. package/dist/src/core/payload-store.js +113 -8
  147. package/dist/src/core/payload-store.js.map +1 -1
  148. package/dist/src/core/payload.d.ts +18 -3
  149. package/dist/src/core/payload.js +20 -4
  150. package/dist/src/core/payload.js.map +1 -1
  151. package/dist/src/core/policy-diff.d.ts +10 -0
  152. package/dist/src/core/policy-diff.js +10 -2
  153. package/dist/src/core/policy-diff.js.map +1 -1
  154. package/dist/src/core/policy-explain.d.ts +7 -1
  155. package/dist/src/core/policy-explain.js +16 -1
  156. package/dist/src/core/policy-explain.js.map +1 -1
  157. package/dist/src/core/policy-load.d.ts +24 -0
  158. package/dist/src/core/policy-load.js.map +1 -1
  159. package/dist/src/core/policy-match.d.ts +38 -0
  160. package/dist/src/core/policy-match.js +35 -0
  161. package/dist/src/core/policy-match.js.map +1 -1
  162. package/dist/src/core/policy-proposal.d.ts +40 -0
  163. package/dist/src/core/policy-proposal.js +37 -16
  164. package/dist/src/core/policy-proposal.js.map +1 -1
  165. package/dist/src/core/prompt-layout.d.ts +186 -1
  166. package/dist/src/core/prompt-layout.js +345 -3
  167. package/dist/src/core/prompt-layout.js.map +1 -1
  168. package/dist/src/core/read-scope.d.ts +10 -0
  169. package/dist/src/core/read-scope.js +4 -0
  170. package/dist/src/core/read-scope.js.map +1 -1
  171. package/dist/src/core/run-payload.d.ts +160 -0
  172. package/dist/src/core/run-payload.js +304 -0
  173. package/dist/src/core/run-payload.js.map +1 -0
  174. package/dist/src/core/sender-identity.d.ts +59 -11
  175. package/dist/src/core/sender-identity.js +79 -11
  176. package/dist/src/core/sender-identity.js.map +1 -1
  177. package/dist/src/core/state.d.ts +52 -1
  178. package/dist/src/core/state.js +52 -6
  179. package/dist/src/core/state.js.map +1 -1
  180. package/dist/src/core/telegram-config.d.ts +79 -0
  181. package/dist/src/core/telegram-config.js +108 -0
  182. package/dist/src/core/telegram-config.js.map +1 -1
  183. package/dist/src/core/validate.d.ts +20 -6
  184. package/dist/src/core/validate.js +0 -0
  185. package/dist/src/core/validate.js.map +1 -1
  186. package/dist/src/core/verify.d.ts +23 -1
  187. package/dist/src/core/verify.js +20 -2
  188. package/dist/src/core/verify.js.map +1 -1
  189. package/dist/src/daemon/audit.d.ts +80 -5
  190. package/dist/src/daemon/audit.js +126 -6
  191. package/dist/src/daemon/audit.js.map +1 -1
  192. package/dist/src/daemon/daemon.d.ts +174 -2
  193. package/dist/src/daemon/daemon.js +298 -7
  194. package/dist/src/daemon/daemon.js.map +1 -1
  195. package/dist/src/daemon/git-evidence.d.ts +2 -2
  196. package/dist/src/daemon/git-evidence.js +1 -1
  197. package/dist/src/daemon/projection.d.ts +13 -2
  198. package/dist/src/daemon/projection.js +39 -6
  199. package/dist/src/daemon/projection.js.map +1 -1
  200. package/dist/src/mcp/server.d.ts +39 -3
  201. package/dist/src/mcp/server.js +22 -3
  202. package/dist/src/mcp/server.js.map +1 -1
  203. package/dist/src/muse/server.d.ts +16 -0
  204. package/dist/src/muse/server.js +238 -0
  205. package/dist/src/muse/server.js.map +1 -0
  206. package/dist/src/serve/archive.d.ts +166 -0
  207. package/dist/src/serve/archive.js +363 -0
  208. package/dist/src/serve/archive.js.map +1 -0
  209. package/dist/src/serve/arguments.d.ts +93 -0
  210. package/dist/src/serve/arguments.js +228 -0
  211. package/dist/src/serve/arguments.js.map +1 -0
  212. package/dist/src/serve/credentials.d.ts +91 -0
  213. package/dist/src/serve/credentials.js +143 -0
  214. package/dist/src/serve/credentials.js.map +1 -0
  215. package/dist/src/serve/follow.d.ts +86 -0
  216. package/dist/src/serve/follow.js +101 -0
  217. package/dist/src/serve/follow.js.map +1 -0
  218. package/dist/src/serve/hook-thread.d.ts +134 -0
  219. package/dist/src/serve/hook-thread.js +333 -0
  220. package/dist/src/serve/hook-thread.js.map +1 -0
  221. package/dist/src/serve/hook-worker.d.ts +134 -0
  222. package/dist/src/serve/hook-worker.js +173 -0
  223. package/dist/src/serve/hook-worker.js.map +1 -0
  224. package/dist/src/serve/server.d.ts +412 -0
  225. package/dist/src/serve/server.js +1251 -0
  226. package/dist/src/serve/server.js.map +1 -0
  227. package/docs/README-extended.md +768 -0
  228. package/docs/cli-reference.md +2139 -67
  229. package/package.json +4 -2
  230. package/schema/event.schema.json +413 -30
  231. package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
  232. package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
  233. package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
  234. package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
  235. package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
  236. package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
  237. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
  238. package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
  239. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
  240. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
  241. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
  242. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
  243. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
  244. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
  245. package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
  246. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  247. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  248. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  249. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  250. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  251. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  252. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  253. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  254. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  255. package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
  256. package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
  257. package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
  258. package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
  259. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
  260. package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
  261. package/schema/fixtures/event/valid/audit-sampled.json +2 -1
  262. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  263. package/schema/fixtures/event/valid/daemon-identity.json +16 -0
  264. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  265. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  266. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  267. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  268. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  269. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  270. package/schema/policy.schema.json +102 -6
@@ -52,7 +52,7 @@ import { spawnSync } from "node:child_process";
52
52
  import { readdirSync, rmSync } from "node:fs";
53
53
  import { constants as osConstants } from "node:os";
54
54
  import { dirname, isAbsolute, resolve as resolvePathSegments } from "node:path";
55
- import { HUMAN_ACTOR_ENV, checkAttestation, resolveHumanActor } from "../core/attest.js";
55
+ import { HUMAN_ACTOR_ENV, attesterAt, checkAttestation, resolveHumanActor, } from "../core/attest.js";
56
56
  import { RESOLVE_DANGLING_COMMAND, proveDanglingAdvances, } from "../core/advance-cycle.js";
57
57
  import { openObligations } from "../core/audit.js";
58
58
  import { evaluateBudgets } from "../core/budgets.js";
@@ -62,20 +62,24 @@ import { coverageReport } from "../core/coverage.js";
62
62
  import { credentialPathsFor, detectSandbox, resolveExecutable, sandboxPosture, sandboxRequired, wrapForSandbox, } from "../core/sandbox.js";
63
63
  import { DEFAULT_TRUNK_REF, defaultRange, observeGit, } from "../core/coverage-sources/git.js";
64
64
  import { danglingExecutions, findDeclaration, finishExecution, indexDeclarations, indeterminateExecutions, isReconcileResolution, loopEscalation, reconcileExecution, resolveExecution, startExecution, } from "../core/execute.js";
65
+ import { settleTerminationGuards, yieldToTerminationSignals } from "../core/log-lock.js";
65
66
  import { harnessLoopEscalation, harnessOutcomeCoverage, loopClearance } from "../core/loop.js";
66
67
  import { isPayloadHash, runPayloadHash } from "../core/payload.js";
67
68
  import { payloadStoreCensus } from "../core/payload-census.js";
68
69
  import { payloadStoreDirFor } from "../core/payload-store.js";
69
- import { withdraw } from "../core/gate.js";
70
+ import { boundScript, describeBoundScript } from "../core/run-payload.js";
71
+ import { attestedPolicySha256, requestStanding, taskRegistration, withdraw, } from "../core/gate.js";
70
72
  import { openGateWindow } from "../core/gate-window.js";
71
73
  import { keyStoreDirFor } from "../core/seal.js";
72
74
  import { readVerifiedRecords, requestState } from "../core/state.js";
73
75
  import { deliveredToken } from "../core/token.js";
74
76
  import { passphraseEnvFor } from "../core/vault.js";
77
+ import { resolveDaemonAllowlist, resolveDaemonId } from "../core/daemon-host.js";
78
+ import { DAEMON_ID_ENV } from "../core/daemon-identity.js";
75
79
  import { loadPolicy, parseDuration, POLICY_FILENAMES } from "../core/policy-load.js";
76
80
  import { verify } from "../core/verify.js";
77
81
  import { boolFlag, parseFlags, stringFlag } from "./args.js";
78
- import { EXIT_INTEGRITY, EXIT_IO, EXIT_NO_TOKEN, EXIT_OK, EXIT_TIMEOUT, EXIT_TORN_TAIL, EXIT_USAGE, } from "./exit-codes.js";
82
+ import { EXIT_INTEGRITY, EXIT_IO, EXIT_NO_TOKEN, EXIT_OK, EXIT_TIMEOUT, EXIT_VOID, EXIT_TORN_TAIL, EXIT_USAGE, } from "./exit-codes.js";
79
83
  import { repoRoot } from "./git-scope.js";
80
84
  import { resolveReadRoots } from "./hook.js";
81
85
  import { publishedState } from "./log-advance.js";
@@ -170,6 +174,10 @@ function emitRefusal(streams, json, refusal) {
170
174
  error["seq"] = refusal.seq;
171
175
  if (refusal.record !== undefined)
172
176
  error["event_seq"] = refusal.record.seq;
177
+ // APRV-448: the write-boundary code under `append-failed`, as `approval
178
+ // gate` and the gate verbs report it.
179
+ if (refusal.append !== undefined)
180
+ error["append"] = refusal.append.code;
173
181
  streams.err(`${JSON.stringify({ ok: false, error })}\n`);
174
182
  }
175
183
  else {
@@ -308,7 +316,40 @@ function childExitCode(status, signal) {
308
316
  return status ?? EXIT_COMMAND_NOT_RUN;
309
317
  }
310
318
  const INHERIT_CHILD_IO = { stdio: "inherit" };
319
+ /**
320
+ * `approval run`, for a caller that can await (the CLI): between appending
321
+ * `execution.started` and spawning the child it turns the event loop, so a
322
+ * SIGTERM, SIGINT or SIGHUP that landed during that append kills this process,
323
+ * with the signal's own status, before the command exists (APRV-479 round 2,
324
+ * RS1). Node shows a held signal to JavaScript only on that turn.
325
+ */
326
+ export async function commandRunYielding(argv, streams, cwd, childIo = INHERIT_CHILD_IO) {
327
+ const steps = runSteps(argv, streams, cwd, childIo);
328
+ for (let next = steps.next();; next = steps.next()) {
329
+ if (next.done === true)
330
+ return next.value;
331
+ await yieldToTerminationSignals();
332
+ settleTerminationGuards();
333
+ }
334
+ }
335
+ /**
336
+ * `approval run`, synchronously, for in-process callers (the MCP server, which
337
+ * owns its own signal listeners, so no lock guard is ever installed there).
338
+ */
311
339
  export function commandRun(argv, streams, cwd, childIo = INHERIT_CHILD_IO) {
340
+ const steps = runSteps(argv, streams, cwd, childIo);
341
+ for (let next = steps.next();; next = steps.next()) {
342
+ if (next.done === true)
343
+ return next.value;
344
+ settleTerminationGuards();
345
+ }
346
+ }
347
+ /**
348
+ * The body of `approval run`. It yields once, after `execution.started` is
349
+ * appended and before the child is spawned, so its driver can let a termination
350
+ * signal that arrived during the append end the process first.
351
+ */
352
+ function* runSteps(argv, streams, cwd, childIo) {
312
353
  // `--` separates our flags from the child's argv, and the child's argv may
313
354
  // legitimately contain anything at all — including flags this CLI knows. So
314
355
  // the split happens on the RAW argv, before any parsing, and everything to the
@@ -371,12 +412,23 @@ export function commandRun(argv, streams, cwd, childIo = INHERIT_CHILD_IO) {
371
412
  if (hashFlag !== null && !isPayloadHash(hashFlag)) {
372
413
  return usageError(streams, json, `--payload-hash expects 64 lowercase hex characters (SHA-256 over the RFC 8785 canonical serialization of the payload), got ${JSON.stringify(hashFlag)}`, RUN_HELP);
373
414
  }
415
+ // APRV-401 put the SCRIPT inside that computation: an interpreter followed by
416
+ // a path operand, or a path at argv[0], carries that file's digest, so a
417
+ // script rewritten between the declaration and this moment hashes differently
418
+ // and is refused here rather than executed under a grant nobody gave it. The
419
+ // refusal names what was bound, because "the hash differs" leaves an agent to
420
+ // guess whether the argv moved or the file under it did, and those have
421
+ // different repairs.
374
422
  const payloadHash = runPayloadHash(childArgv, cwd);
375
423
  if (hashFlag !== null && hashFlag !== payloadHash) {
424
+ const script = boundScript(childArgv, cwd);
425
+ const binding = script === null
426
+ ? `The argv names no readable script, so the binding is the argv and the cwd alone (amended SPEC.md §6.2, APRV-401).`
427
+ : `The binding covers the script's bytes as well (amended SPEC.md §6.2, APRV-401): ${describeBoundScript(script)}, so a script edited since the declaration changes this value.`;
376
428
  return emitRefusal(streams, json, {
377
429
  ok: false,
378
430
  code: "payload-mismatch",
379
- message: `--payload-hash ${hashFlag} is not the hash of the command this would spawn: ${JSON.stringify(childArgv[0])} and ${childArgv.length - 1} argument(s) in ${cwd} hash to ${payloadHash}. \`approval run\` recomputes the binding from the argv and cwd it is about to spawn and never accepts a caller's substitute for it (amended SPEC.md §10.4, APRV-140); the flag states what you believe you are running, and this is a refusal to run something else. Nothing was appended.`,
431
+ message: `--payload-hash ${hashFlag} is not the hash of the command this would spawn: ${JSON.stringify(childArgv[0])} and ${childArgv.length - 1} argument(s) in ${cwd} hash to ${payloadHash}. ${binding} \`approval run\` recomputes the binding from the argv, the cwd and those bytes and never accepts a caller's substitute for it (amended SPEC.md §10.4, APRV-140); the flag states what you believe you are running, and this is a refusal to run something else. Nothing was appended.`,
380
432
  });
381
433
  }
382
434
  // APRV-205. The child's environment is built BEFORE `execution.started`,
@@ -442,6 +494,12 @@ export function commandRun(argv, streams, cwd, childIo = INHERIT_CHILD_IO) {
442
494
  const wrapped = posture.kind === "apply"
443
495
  ? wrapExecutable(posture.mechanism, command, childArgv.slice(1), childEnv.env, logPath, readJailFor(flags, cwd))
444
496
  : null;
497
+ // `execution.started` was just appended. The driver turns the event loop here
498
+ // (the CLI) so a stop request that landed during the append ends the process
499
+ // before the child exists, then settles the guard, so a signal during the
500
+ // child's run reaches this process as it did before the guard existed
501
+ // (APRV-479, S2 and round 2 RS1).
502
+ yield "before-spawn";
445
503
  const child = spawnSync(wrapped?.command ?? command, wrapped?.args ?? childArgv.slice(1), {
446
504
  cwd,
447
505
  stdio: childIo.stdio,
@@ -486,8 +544,15 @@ export function commandRun(argv, streams, cwd, childIo = INHERIT_CHILD_IO) {
486
544
  // ===========================================================================
487
545
  // approval wait
488
546
  // ===========================================================================
489
- /** Synchronous sleep with no dependency and no busy-spin. */
547
+ /** `wait --timeout 0` (and `0s`, `0ms`, …): read once, never sleep (APRV-445). */
548
+ const ZERO_DURATION = /^0(?:ms|s|m|h|d|w)?$/u;
549
+ /**
550
+ * Synchronous sleep with no dependency and no busy-spin. The log lock's signal
551
+ * guard is settled first, so a wait that follows an append is never run with
552
+ * termination signals held (APRV-479, S2).
553
+ */
490
554
  function sleepSync(ms) {
555
+ settleTerminationGuards();
491
556
  if (ms <= 0)
492
557
  return;
493
558
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
@@ -499,10 +564,28 @@ function sleepSync(ms) {
499
564
  function stateRole(state) {
500
565
  if (state === "granted")
501
566
  return "ok";
502
- if (state === "expired" || state === "requested" || state === "withdrawn")
567
+ if (state === "expired" ||
568
+ state === "requested" ||
569
+ state === "withdrawn" ||
570
+ state === NOTHING_TO_WAIT_FOR) {
503
571
  return "warn";
572
+ }
504
573
  return "fail";
505
574
  }
575
+ /**
576
+ * `approval wait`'s status for a REGISTERED task that holds nothing a wait could
577
+ * resolve (APRV-428): no `approval.requested` at all (no action was declared,
578
+ * none reached the manual path, or none has been requested yet), or every
579
+ * granted action has already started executing, so its grant is spent.
580
+ *
581
+ * Exit 0, because the wait contract has always returned at once and at 0 for a
582
+ * task with no requests, and the supervised path tells an agent its
583
+ * `proceed: true` needs no wait at all. What changes is the WORD: before
584
+ * APRV-428 this was `granted`, and a caller that branched on `status` saw a
585
+ * grant nobody made. Nothing is minted either way, since `approval run` still
586
+ * needs a token on the manual path.
587
+ */
588
+ const NOTHING_TO_WAIT_FOR = "nothing-to-wait-for";
506
589
  /**
507
590
  * `approval wait`'s human answer: the verdict, then one aligned row per action.
508
591
  *
@@ -510,7 +593,11 @@ function stateRole(state) {
510
593
  * (`plainLeft`) and the state beside it carries the colour instead.
511
594
  */
512
595
  export function renderWaitHuman(task, status, actions, st = style()) {
513
- const glyph = status === "granted" ? "ok" : status === "expired" || status === "withdrawn" ? "skip" : "fail";
596
+ const glyph = status === "granted"
597
+ ? "ok"
598
+ : status === "expired" || status === "withdrawn" || status === NOTHING_TO_WAIT_FOR
599
+ ? "skip"
600
+ : "fail";
514
601
  const head = `${st.glyph(glyph)} ${st.key(task)} ${st.paint(stateRole(status), status)}`;
515
602
  if (actions.length === 0)
516
603
  return `${head}\n`;
@@ -521,6 +608,25 @@ export function renderWaitHuman(task, status, actions, st = style()) {
521
608
  }));
522
609
  return `${head}\n${st.table(rows, { indent: 2 })}\n`;
523
610
  }
611
+ /**
612
+ * `approval wait`'s refusal of a task the log has never registered (APRV-428).
613
+ *
614
+ * The frozen `{ok:false,error:{code,message}}` shape on stderr, like every other
615
+ * refusal this verb prints, and exit 1: the same code `approval request` exits
616
+ * for the same `not-registered`, and the meaning wait's exit 1 already carries
617
+ * (not authorized, and waiting again will not change that). A separate writer
618
+ * because the verb's other refusals are the execute union's log-read codes,
619
+ * and `not-registered` is a member of the gate union only.
620
+ */
621
+ function emitWaitRefusal(streams, json, refusal) {
622
+ if (json) {
623
+ streams.err(`${JSON.stringify({ ok: false, error: { code: refusal.code, message: refusal.message } })}\n`);
624
+ }
625
+ else {
626
+ streams.err(`${renderRefusal(style({ json }), refusal.code, refusal.message)}\n`);
627
+ }
628
+ return EXIT_INTEGRITY;
629
+ }
524
630
  /** Every action key of `task` that ever carried an `approval.requested`. */
525
631
  function requestedKeysOf(records, task) {
526
632
  const keys = [];
@@ -560,9 +666,18 @@ export function commandWait(argv, streams, cwd) {
560
666
  if (timeoutText === null) {
561
667
  return usageError(streams, json, "missing --timeout <duration>", WAIT_HELP);
562
668
  }
563
- const timeoutMs = parseDuration(timeoutText);
669
+ // APRV-445. `--timeout 0` is a STATUS READ: one verified read of the log, the
670
+ // current state of every request of the task, and no sleep at all. The
671
+ // duration grammar has no zero (a zero TTL or window in a policy would be a
672
+ // control that never runs), so the spelling is admitted here and only here.
673
+ // It is what a poller on `approval serve` uses: that server runs every verb
674
+ // and every hook call through one queue, and a `wait` that sleeps holds the
675
+ // whole tenant's hook traffic for as long as it sleeps. The result set and
676
+ // the exit codes are unchanged; an undecided request answers `timeout`
677
+ // (exit 6) at once, as a wait that ran out would.
678
+ const timeoutMs = ZERO_DURATION.test(timeoutText) ? 0 : parseDuration(timeoutText);
564
679
  if (timeoutMs === null) {
565
- return usageError(streams, json, `--timeout expects a duration like 30s, 10m, 6h, got ${JSON.stringify(timeoutText)}`, WAIT_HELP);
680
+ return usageError(streams, json, `--timeout expects a duration like 30s, 10m, 6h, or 0 to read the current state without waiting, got ${JSON.stringify(timeoutText)}`, WAIT_HELP);
566
681
  }
567
682
  const intervalText = stringFlag(flags, "--interval");
568
683
  const intervalMs = intervalText === null ? DEFAULT_WAIT_INTERVAL_MS : parseDuration(intervalText);
@@ -576,6 +691,12 @@ export function commandWait(argv, streams, cwd) {
576
691
  // after a nine-minute wait, and failing then, would leave exactly the stale
577
692
  // request the flag exists to prevent.
578
693
  const withdrawOnTimeout = boolFlag(flags, "--withdraw-on-timeout");
694
+ // APRV-445 (L1). A zero wait is a status read, and a status read that
695
+ // withdraws whatever is still pending would retract every question a poller
696
+ // looked at, on the first look.
697
+ if (withdrawOnTimeout && timeoutMs === 0) {
698
+ return usageError(streams, json, "--withdraw-on-timeout cannot be combined with --timeout 0: a zero wait reads the current state, and withdrawing on it would retract every pending request the moment it is polled", WAIT_HELP);
699
+ }
579
700
  const asFlag = stringFlag(flags, "--as");
580
701
  const withdrawActor = asFlag ?? resolveHumanActor();
581
702
  if (withdrawOnTimeout && (withdrawActor === null || !PRINCIPAL_ACTOR.test(withdrawActor))) {
@@ -592,33 +713,72 @@ export function commandWait(argv, streams, cwd) {
592
713
  message: read.message,
593
714
  });
594
715
  }
716
+ // APRV-428. A task the log has never registered is refused, not answered.
717
+ // Before this check the loop below found no requests and reported the task
718
+ // `granted` with no actions: a typo read as a grant, and a shim polling
719
+ // until granted would have proceeded on a request it never opened. Checked
720
+ // on every pass rather than once, because it costs one walk of records
721
+ // already in hand and a registration cannot disappear from an append-only
722
+ // log, so the answer only ever moves from refusal to not.
723
+ const registered = taskRegistration(read.records, task);
724
+ if (!registered.ok)
725
+ return emitWaitRefusal(streams, json, registered);
595
726
  const ts = now();
596
727
  const actions = [];
597
728
  let pending = false;
729
+ // APRV-445 refutation (B1): the state a waiter acts on is what the record
730
+ // still AUTHORIZES. A grant whose window lapsed reads `expired`, and a
731
+ // pending request, or a harness grant (every proposal), pinned to a
732
+ // superseded policy reads `void` (a token grant `run` still spends stays
733
+ // `granted`), so
734
+ // a poller asks again instead of being told `granted` about an answer the
735
+ // spend will refuse.
736
+ const attestedSha = attestedPolicySha256(read.records, { policy: policyLocation(flags, cwd) });
737
+ // A granted action whose grant nothing has spent yet: the one thing a
738
+ // `granted` status can honestly promise (APRV-428). Judged on the
739
+ // standing, so a lapsed or voided grant never counts as one.
740
+ let unspentGrant = false;
598
741
  for (const key of requestedKeysOf(read.records, task)) {
599
742
  const derivation = requestState(read.records, key, ts, ttlMs);
743
+ const standing = requestStanding(read.records, key, ts, ttlMs, attestedSha);
744
+ // An executed key reads as its recorded decision when a grant authorized
745
+ // it (the long-standing `granted`), and as `executed` when it ran on the
746
+ // policy's own authority after its request ended (APRV-445 recheck L-a):
747
+ // a policy-path `start` after a withdrawal is not a `withdrawn` action.
748
+ const state = standing === "executed"
749
+ ? derivation.state === "granted"
750
+ ? "granted"
751
+ : "executed"
752
+ : standing;
600
753
  // APRV-105. The token, when this machine can open it: the grant sealed it
601
754
  // to the ephemeral public key this action's request published, and the
602
755
  // private half is in the key store beside the log. Attached only to a
603
756
  // GRANTED action, and only in `--json` below — a `null` on every other
604
757
  // state would be a field consumers have to ignore, and a token on a
605
758
  // rejected action would be a value with nothing behind it.
606
- const token = json && derivation.state === "granted"
759
+ const token = json && state === "granted"
607
760
  ? deliveredToken(read.records, key, keyStoreDirFor(logPath))
608
761
  : null;
609
762
  actions.push({
610
763
  action_key: key,
611
- state: derivation.state,
764
+ state,
612
765
  seq: derivation.decisionSeq ?? derivation.requestSeq,
613
766
  ...(token === null ? {} : { token }),
614
767
  });
615
- if (derivation.state === "requested")
768
+ if (state === "requested")
616
769
  pending = true;
770
+ if (state === "granted" &&
771
+ derivation.state === "granted" &&
772
+ derivation.execution.started === null) {
773
+ unspentGrant = true;
774
+ }
617
775
  }
618
776
  if (!pending) {
619
777
  // Precedence, documented in --help: a human's "no" outranks a lapse, and
620
- // both outrank "everything was granted". A task with no requests at all
621
- // has nothing to wait for and is granted vacuously.
778
+ // both outrank "everything was granted". A registered task with no
779
+ // requests at all, or whose every grant has already been spent by an
780
+ // execution, has nothing to wait for and says so (APRV-428); it was
781
+ // "granted vacuously" before, which is a grant nobody made.
622
782
  //
623
783
  // APRV-106 puts `withdrawn` between the two, and REUSES exit 1 rather
624
784
  // than adding a code. The table in `cli/exit-codes.ts` is frozen public
@@ -630,15 +790,31 @@ export function commandWait(argv, streams, cwd) {
630
790
  // the JSON and printed beside the action in the human render.
631
791
  const rejected = actions.some((action) => action.state === "rejected" || action.state === "revoked");
632
792
  const withdrawn = actions.some((action) => action.state === "withdrawn");
793
+ const voided = actions.some((action) => action.state === "void");
633
794
  const expired = actions.some((action) => action.state === "expired");
795
+ // APRV-445: `void` sits below a human's no and a requester's withdrawal
796
+ // (both final) and above a lapse, because its repair is the most
797
+ // specific: the same question, asked again under the policy in force.
634
798
  const status = rejected
635
799
  ? "rejected"
636
800
  : withdrawn
637
801
  ? "withdrawn"
802
+ : voided
803
+ ? "void"
804
+ : expired
805
+ ? "expired"
806
+ : unspentGrant
807
+ ? "granted"
808
+ : actions.length > 0 && actions.every((action) => action.state === "executed")
809
+ ? "executed"
810
+ : NOTHING_TO_WAIT_FOR;
811
+ const code = rejected || withdrawn
812
+ ? EXIT_INTEGRITY
813
+ : voided
814
+ ? EXIT_VOID
638
815
  : expired
639
- ? "expired"
640
- : "granted";
641
- const code = rejected || withdrawn ? EXIT_INTEGRITY : expired ? EXIT_TORN_TAIL : EXIT_OK;
816
+ ? EXIT_TORN_TAIL
817
+ : EXIT_OK;
642
818
  if (json)
643
819
  emitJson(streams, { ok: true, task, status, actions });
644
820
  else
@@ -707,11 +883,18 @@ function pendingRequests(records, ts, ttlMs) {
707
883
  const derivation = requestState(records, key, ts, ttlMs);
708
884
  if (derivation.state !== "requested")
709
885
  continue;
886
+ // APRV-423 (second review pass): the window is the derivation's, never a
887
+ // second copy of the arithmetic here. `requestState` already took the
888
+ // minimum of the policy's TTL and the declared harness cap less its margin;
889
+ // reading `ttlMs` again would show a capped request the policy's hour while
890
+ // the gate refuses it after four minutes, and would show a capped request
891
+ // under a policy with no TTL as "no TTL" when it has one.
892
+ const windowMs = derivation.effectiveTtlMs;
710
893
  const requestedAt = Date.parse(derivation.requestTs ?? "");
711
894
  const asked = Date.parse(ts);
712
- const remaining = ttlMs === null || Number.isNaN(requestedAt) || Number.isNaN(asked)
895
+ const remaining = windowMs === null || Number.isNaN(requestedAt) || Number.isNaN(asked)
713
896
  ? null
714
- : requestedAt + ttlMs - asked;
897
+ : requestedAt + windowMs - asked;
715
898
  entries.push({
716
899
  action_key: key,
717
900
  task: derivation.task,
@@ -720,6 +903,7 @@ function pendingRequests(records, ts, ttlMs) {
720
903
  requested_ts: derivation.requestTs,
721
904
  seq: derivation.requestSeq,
722
905
  ttl_remaining_ms: remaining,
906
+ ttl_ms: windowMs,
723
907
  });
724
908
  }
725
909
  return entries.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
@@ -773,7 +957,13 @@ export function renderQueueHuman(pending, ttlMs, st = style()) {
773
957
  entry.class ?? "-",
774
958
  `$${String(entry.est_cost_usd ?? 0)}`,
775
959
  entry.requested_ts ?? "-",
776
- { text: ttlText(entry.ttl_remaining_ms), role: ttlRole(entry.ttl_remaining_ms, ttlMs) },
960
+ // The fraction is of the entry's OWN window (APRV-423): a capped request's
961
+ // four minutes, or the policy's TTL for one nothing narrowed. The `ttlMs`
962
+ // parameter stays as the fallback for an entry that carries no window.
963
+ {
964
+ text: ttlText(entry.ttl_remaining_ms),
965
+ role: ttlRole(entry.ttl_remaining_ms, entry.ttl_ms ?? ttlMs),
966
+ },
777
967
  ]);
778
968
  return `${table(st, rows, {
779
969
  header: ["action", "task", "class", "cost", "requested", "ttl"],
@@ -950,6 +1140,101 @@ function gitCoverageSummary(records, flags, cwd) {
950
1140
  covered: report.covered,
951
1141
  };
952
1142
  }
1143
+ /**
1144
+ * The two refusal records `status` counts, and the key each is reported under
1145
+ * (APRV-376).
1146
+ *
1147
+ * The event NAMES are matched here rather than imported from
1148
+ * `core/decision-refusal.ts` and `core/gesture-refusal.ts`, and that is
1149
+ * deliberate: both modules are writers on the append path, and the whole safety
1150
+ * argument for those records is that nothing reads them from anywhere that
1151
+ * decides. A report that filters on a string pulls in no writer, and the two
1152
+ * suites that pin the module graph stay true without an exemption for this file.
1153
+ */
1154
+ const REFUSAL_FAMILIES = {
1155
+ decision: "audit.decision_refused",
1156
+ gesture: "audit.gesture_refused",
1157
+ };
1158
+ /**
1159
+ * How many of the newest refusals each family lists.
1160
+ *
1161
+ * Small and fixed on purpose. The COUNT answers "is this happening", and the
1162
+ * point of the listing beside it is to hand an operator a seq to paste into
1163
+ * `approval log tail` and an account to recognize. A row that grew with the log
1164
+ * would push the rest of the report off a terminal for a fact that is
1165
+ * informational, and the whole history is `approval log export` away.
1166
+ */
1167
+ const RECENT_REFUSALS = 5;
1168
+ /** One record, read the way `status` reports it: verbatim, and nothing derived. */
1169
+ function refusalEntry(record) {
1170
+ const payload = record.payload ?? {};
1171
+ // The event schema requires `code` on both types, so the fallback is for a
1172
+ // reader of a log written by something else: a report that threw on a record
1173
+ // it could not read would take the whole health report down with it.
1174
+ const code = typeof payload["code"] === "string" ? payload["code"] : "(no code)";
1175
+ const sender = payload["sender"];
1176
+ if (sender === null || typeof sender !== "object")
1177
+ return { seq: record.seq, code };
1178
+ const observed = sender;
1179
+ const channel = observed["channel"];
1180
+ const id = observed["id"];
1181
+ if (typeof channel !== "string" || typeof id !== "string")
1182
+ return { seq: record.seq, code };
1183
+ return {
1184
+ seq: record.seq,
1185
+ code,
1186
+ sender: {
1187
+ channel,
1188
+ id,
1189
+ // APRV-370's marker, carried only when the record carries it: a keyed
1190
+ // digest and a raw account id are different things to go looking for.
1191
+ ...(observed["hashed"] === true ? { hashed: true } : {}),
1192
+ },
1193
+ };
1194
+ }
1195
+ /**
1196
+ * The refused decisions and refused gestures in this log (APRV-376).
1197
+ *
1198
+ * Both families were visible only through `approval log tail` and `approval log
1199
+ * export` until this row existed. The fact an operator needs is small and the
1200
+ * log is the wrong place to read it from: told that taps from an account they
1201
+ * did not map are being refused, they want to know how many and from which
1202
+ * account, and the chain is a poor answer to a question that size.
1203
+ *
1204
+ * INFORMATIONAL, on the terms `harness outcomes` and `git coverage` set: it
1205
+ * moves neither `healthy` nor the exit code. A refusal is the gate having
1206
+ * worked, and a report that went red because the gate refused a stranger would
1207
+ * teach an operator to stop reading it. It reads only verified records (SPEC.md
1208
+ * §11.1 invariant 1) because its caller passes only those, and it reads them
1209
+ * only to report: nothing here authorizes, settles, charges or samples
1210
+ * anything.
1211
+ *
1212
+ * A family with no records is ABSENT rather than zero, and the object is empty
1213
+ * when both are, so a log with no refusals emits the object it always emitted.
1214
+ * A zero here would be a row an operator scrolls past on every healthy
1215
+ * repository, which is how the rows that matter stop being read.
1216
+ */
1217
+ function refusalSummaries(records) {
1218
+ const summaries = {};
1219
+ for (const family of Object.keys(REFUSAL_FAMILIES)) {
1220
+ const seen = records.filter((record) => record.event === REFUSAL_FAMILIES[family]);
1221
+ if (seen.length === 0)
1222
+ continue;
1223
+ summaries[family] = {
1224
+ count: seen.length,
1225
+ // Newest first: the reason to read this row at all is what just happened.
1226
+ recent: seen.slice(-RECENT_REFUSALS).reverse().map(refusalEntry),
1227
+ };
1228
+ }
1229
+ return summaries;
1230
+ }
1231
+ /** How the human rendering spells one entry's account. */
1232
+ function refusalSenderText(entry) {
1233
+ if (entry.sender === undefined)
1234
+ return "";
1235
+ const keyed = entry.sender.hashed === true ? " (keyed)" : "";
1236
+ return ` ${entry.sender.channel}:${entry.sender.id}${keyed}`;
1237
+ }
953
1238
  export function commandStatus(argv, streams, cwd) {
954
1239
  const outcome = front(argv, { ...COMMON_FLAGS, ...POLICY_FLAGS, "--verbose": "boolean" }, STATUS_HELP, streams, cwd);
955
1240
  if (outcome.kind === "handled")
@@ -977,6 +1262,13 @@ export function commandStatus(argv, streams, cwd) {
977
1262
  // anything from one.
978
1263
  const records = read.ok ? read.records : [];
979
1264
  const attestation = checkAttestation(records, policyPathFor(flags, cwd));
1265
+ // APRV-449: who made the attestation the state names, read from the verified
1266
+ // record at that seq, never from the file. On `hash-mismatch` it is the
1267
+ // attester of the bytes the log last vouched for, which is the policy a
1268
+ // re-attestation would replace.
1269
+ const attestedBy = attestation.status === "attested" || attestation.status === "hash-mismatch"
1270
+ ? attesterAt(records, attestation.seq)
1271
+ : null;
980
1272
  // APRV-120. `dangling` is now the `open` custody state only: a harness
981
1273
  // execution is terminal by design and never gains an outcome, so listing it
982
1274
  // as debris trained operators to ignore this list (the reference repository's
@@ -1070,6 +1362,28 @@ export function commandStatus(argv, streams, cwd) {
1070
1362
  status: verification.status,
1071
1363
  records: verification.status === "corrupt" ? null : verification.records,
1072
1364
  };
1365
+ // APRV-383. The daemon identity this instance resolves, and the allowlist in
1366
+ // force for it. `status` is not the daemon, so what it reports is the id a
1367
+ // daemon started HERE would write: that is the fact an operator wiring up a
1368
+ // hosted deployment needs, and the one a tenant needs to look up a `daemon`
1369
+ // field they have just read in a record.
1370
+ //
1371
+ // The allowlist comes from `resolveDaemonAllowlist`, so it is reported only
1372
+ // when the policy loads AND is attested — the same bytes the write boundary
1373
+ // would refuse on. An unattested policy reports `null`, no restriction, which
1374
+ // is exactly what the write boundary would do with it.
1375
+ const daemonId = resolveDaemonId(logPath);
1376
+ const daemonAllowlist = resolveDaemonAllowlist(records, loadPolicy(policyLocation(flags, cwd)));
1377
+ const daemonReport = {
1378
+ id: daemonId.ok ? daemonId.id : null,
1379
+ source: daemonId.ok ? daemonId.source : null,
1380
+ allowed: daemonAllowlist.ok ? daemonAllowlist.allowed : null,
1381
+ };
1382
+ // APRV-376, and informational for the reason stated where it is computed: the
1383
+ // two refusal families, counted, with the newest few of each. Neither the
1384
+ // health line below nor the exit code reads it.
1385
+ const refusals = refusalSummaries(records);
1386
+ const refusalFamilies = Object.keys(refusals);
1073
1387
  // APRV-40. Timestamp anomalies (SPEC.md §8) are informational and deliberately
1074
1388
  // outside `healthy`: they are a judgment, not an integrity verdict. `verify`
1075
1389
  // already declined to refuse on them, and `status` does not get to overrule it
@@ -1106,6 +1420,9 @@ export function commandStatus(argv, streams, cwd) {
1106
1420
  seq: attestation.status === "attested" || attestation.status === "hash-mismatch"
1107
1421
  ? attestation.seq
1108
1422
  : null,
1423
+ // APRV-449, additive and always present: the identity the attesting
1424
+ // record declared, `null` when there is no attestation to name.
1425
+ attested_by: attestedBy,
1109
1426
  },
1110
1427
  verification: verificationSummary,
1111
1428
  dangling,
@@ -1127,7 +1444,16 @@ export function commandStatus(argv, streams, cwd) {
1127
1444
  },
1128
1445
  reconciliation: obligations,
1129
1446
  payload_store: payloadStore,
1447
+ // APRV-383. Always present, for the reason `coverage` is: three keys every
1448
+ // consumer sees on every log, whether or not a daemon has ever run here and
1449
+ // whether or not the policy restricts one. `allowed: null` is no restriction
1450
+ // and is not the same fact as an empty list, which admits nobody.
1451
+ daemon: daemonReport,
1130
1452
  ...(anomalies.length === 0 ? {} : { anomalies }),
1453
+ // APRV-376. Present only when the log carries a refusal of either family,
1454
+ // and each family present only when that family has one, so a repository
1455
+ // where nothing has been refused emits the object it always emitted.
1456
+ ...(refusalFamilies.length === 0 ? {} : { refusals }),
1131
1457
  // Present only while a window stands, exactly as `anomalies` and
1132
1458
  // `indeterminate` are: a repository with no window emits the object it
1133
1459
  // has always emitted, byte for byte.
@@ -1159,7 +1485,7 @@ export function commandStatus(argv, streams, cwd) {
1159
1485
  // a bug report, so APRV-102 took the `muted` dressing off it. The state
1160
1486
  // beside it still carries the colour.
1161
1487
  right: `${st.paint(attestation.status === "attested" ? "ok" : "warn", attestation.status)}${attestation.status === "attested" || attestation.status === "hash-mismatch"
1162
- ? ` (seq ${attestation.seq})`
1488
+ ? ` (seq ${attestation.seq}${attestedBy === null ? "" : `, by ${attestedBy}`})`
1163
1489
  : ""}`,
1164
1490
  },
1165
1491
  {
@@ -1254,6 +1580,26 @@ export function commandStatus(argv, streams, cwd) {
1254
1580
  ? `${String(coverage.covered)} of ${String(coverage.observed)} effects carry evidence`
1255
1581
  : (coverage.reason ?? "unavailable")),
1256
1582
  },
1583
+ // APRV-376. Its own INFORMATIONAL row, and printed only when the log
1584
+ // carries a refusal of either family: a repository where nothing has been
1585
+ // refused reads exactly as it always did, and a row saying "none" on every
1586
+ // healthy repository is a row an operator learns to skip. The counts say
1587
+ // whether this is happening; the lines under them hand over a seq to paste
1588
+ // into `approval log tail` and an account to recognize.
1589
+ ...(refusalFamilies.length === 0
1590
+ ? []
1591
+ : [
1592
+ {
1593
+ left: "refusals",
1594
+ right: st.muted(`${refusalFamilies
1595
+ .map((family) => `${String(refusals[family]?.count ?? 0)} ${family}`)
1596
+ .join(", ")} (reported; health unaffected)`),
1597
+ under: refusalFamilies.flatMap((family) => (refusals[family]?.recent ?? []).map((entry) =>
1598
+ // The family is padded to the longer of the two names, so the
1599
+ // seqs line up under each other when both are listed.
1600
+ `${family.padEnd("decision".length)} seq ${String(entry.seq)} ${entry.code}${refusalSenderText(entry)}`)),
1601
+ },
1602
+ ]),
1257
1603
  {
1258
1604
  // APRV-214. Its own row and not a footnote: while this says OPEN, the
1259
1605
  // policy is deciding nothing for the harness, and the person reading
@@ -1282,6 +1628,26 @@ export function commandStatus(argv, streams, cwd) {
1282
1628
  under: obligations.map((item) => `seq ${item.seq} ${item.action_key} ${item.class} ${item.obligation} — close with \`approval audit reconcile ${item.seq}\``),
1283
1629
  }),
1284
1630
  },
1631
+ {
1632
+ // APRV-383. The id a daemon started against THIS log writes onto every
1633
+ // record it appends, and whether the attested policy admits it. Printed
1634
+ // by a verb that is not the daemon on purpose: an operator setting up a
1635
+ // hosted deployment needs the id before anything is running, and a tenant
1636
+ // reading a record's `daemon` field needs somewhere to look it up.
1637
+ // Informational: it moves neither `healthy` nor the exit code, because
1638
+ // nothing here is a fact about the log's integrity.
1639
+ left: "daemon identity",
1640
+ right: daemonReport.id === null
1641
+ ? st.fail(`UNUSABLE (${DAEMON_ID_ENV} is set to something that is not an id)`)
1642
+ : `${daemonReport.id} ${st.muted(`(${daemonReport.source})`)}`,
1643
+ under: daemonReport.allowed === null
1644
+ ? [st.muted("the policy lists no allowed daemon ids, so any daemon may write")]
1645
+ : [
1646
+ daemonReport.id !== null && daemonReport.allowed.includes(daemonReport.id)
1647
+ ? st.muted(`allowed by the policy's daemons list (${daemonReport.allowed.join(", ")})`)
1648
+ : st.warn(`NOT in the policy's daemons list (${daemonReport.allowed.length === 0 ? "empty" : daemonReport.allowed.join(", ")}) — a daemon started here would be refused every append`),
1649
+ ],
1650
+ },
1285
1651
  { left: "log", right: relPath(logPath, cwd) },
1286
1652
  ];
1287
1653
  streams.out(`${st.table(rows)}\n`);