approval-md 0.2.0 → 0.4.0

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 (378) hide show
  1. package/README.md +85 -877
  2. package/SPEC.md +150 -19
  3. package/cli.js +66 -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 +53 -1
  8. package/dist/src/channels/contract.js +304 -7
  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.js +6 -1
  17. package/dist/src/channels/tagging.js.map +1 -1
  18. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  19. package/dist/src/channels/telegram-webhook.js +536 -0
  20. package/dist/src/channels/telegram-webhook.js.map +1 -0
  21. package/dist/src/channels/telegram.d.ts +394 -12
  22. package/dist/src/channels/telegram.js +621 -68
  23. package/dist/src/channels/telegram.js.map +1 -1
  24. package/dist/src/channels/web.d.ts +9 -0
  25. package/dist/src/channels/web.js +17 -0
  26. package/dist/src/channels/web.js.map +1 -1
  27. package/dist/src/cli/amend.js +564 -34
  28. package/dist/src/cli/amend.js.map +1 -1
  29. package/dist/src/cli/attest.d.ts +9 -0
  30. package/dist/src/cli/attest.js +177 -13
  31. package/dist/src/cli/attest.js.map +1 -1
  32. package/dist/src/cli/channel-relay.d.ts +108 -0
  33. package/dist/src/cli/channel-relay.js +334 -0
  34. package/dist/src/cli/channel-relay.js.map +1 -0
  35. package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
  36. package/dist/src/cli/channel-telegram-webhook.js +709 -0
  37. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  38. package/dist/src/cli/channel-telegram.d.ts +347 -28
  39. package/dist/src/cli/channel-telegram.js +763 -28
  40. package/dist/src/cli/channel-telegram.js.map +1 -1
  41. package/dist/src/cli/channel.d.ts +10 -1
  42. package/dist/src/cli/channel.js +14 -1
  43. package/dist/src/cli/channel.js.map +1 -1
  44. package/dist/src/cli/codex-bridge.d.ts +819 -0
  45. package/dist/src/cli/codex-bridge.js +1607 -0
  46. package/dist/src/cli/codex-bridge.js.map +1 -0
  47. package/dist/src/cli/codex.d.ts +1 -1
  48. package/dist/src/cli/codex.js +304 -7
  49. package/dist/src/cli/codex.js.map +1 -1
  50. package/dist/src/cli/daemon.js +41 -6
  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 +1322 -122
  54. package/dist/src/cli/doctor.js.map +1 -1
  55. package/dist/src/cli/execute.d.ts +17 -1
  56. package/dist/src/cli/execute.js +366 -22
  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 +14 -2
  65. package/dist/src/cli/help.js +395 -82
  66. package/dist/src/cli/help.js.map +1 -1
  67. package/dist/src/cli/hook-codex.d.ts +49 -1
  68. package/dist/src/cli/hook-codex.js +60 -1
  69. package/dist/src/cli/hook-codex.js.map +1 -1
  70. package/dist/src/cli/hook.d.ts +828 -4
  71. package/dist/src/cli/hook.js +2972 -142
  72. package/dist/src/cli/hook.js.map +1 -1
  73. package/dist/src/cli/import.js +1 -1
  74. package/dist/src/cli/import.js.map +1 -1
  75. package/dist/src/cli/init.js +16 -7
  76. package/dist/src/cli/init.js.map +1 -1
  77. package/dist/src/cli/instructions.js +24 -3
  78. package/dist/src/cli/instructions.js.map +1 -1
  79. package/dist/src/cli/log-anchor.d.ts +55 -0
  80. package/dist/src/cli/log-anchor.js +199 -60
  81. package/dist/src/cli/log-anchor.js.map +1 -1
  82. package/dist/src/cli/main.d.ts +0 -12
  83. package/dist/src/cli/main.js +56 -3
  84. package/dist/src/cli/main.js.map +1 -1
  85. package/dist/src/cli/muse.d.ts +2 -0
  86. package/dist/src/cli/muse.js +62 -0
  87. package/dist/src/cli/muse.js.map +1 -0
  88. package/dist/src/cli/payload.js +79 -1
  89. package/dist/src/cli/payload.js.map +1 -1
  90. package/dist/src/cli/policy-apply.d.ts +195 -0
  91. package/dist/src/cli/policy-apply.js +573 -0
  92. package/dist/src/cli/policy-apply.js.map +1 -0
  93. package/dist/src/cli/policy.js +14 -1
  94. package/dist/src/cli/policy.js.map +1 -1
  95. package/dist/src/cli/preflight.d.ts +151 -13
  96. package/dist/src/cli/preflight.js +398 -41
  97. package/dist/src/cli/preflight.js.map +1 -1
  98. package/dist/src/cli/sandbox.js +17 -1
  99. package/dist/src/cli/sandbox.js.map +1 -1
  100. package/dist/src/cli/scaffold.d.ts +1 -1
  101. package/dist/src/cli/scaffold.js +7 -1
  102. package/dist/src/cli/scaffold.js.map +1 -1
  103. package/dist/src/cli/serve.d.ts +53 -0
  104. package/dist/src/cli/serve.js +270 -0
  105. package/dist/src/cli/serve.js.map +1 -0
  106. package/dist/src/cli/setup-channel.d.ts +9 -0
  107. package/dist/src/cli/setup-channel.js +28 -1
  108. package/dist/src/cli/setup-channel.js.map +1 -1
  109. package/dist/src/cli/setup-common.d.ts +3 -1
  110. package/dist/src/cli/setup-common.js +3 -2
  111. package/dist/src/cli/setup-common.js.map +1 -1
  112. package/dist/src/cli/setup.d.ts +2 -0
  113. package/dist/src/cli/setup.js +94 -2
  114. package/dist/src/cli/setup.js.map +1 -1
  115. package/dist/src/cli/up.js +134 -51
  116. package/dist/src/cli/up.js.map +1 -1
  117. package/dist/src/cli/values.js +3 -4
  118. package/dist/src/cli/values.js.map +1 -1
  119. package/dist/src/cli/verb-registry.d.ts +2 -0
  120. package/dist/src/cli/verb-registry.js +510 -47
  121. package/dist/src/cli/verb-registry.js.map +1 -1
  122. package/dist/src/cli/wordmark.d.ts +2 -2
  123. package/dist/src/cli/wordmark.js +2 -2
  124. package/dist/src/codex/broker.d.ts +229 -0
  125. package/dist/src/codex/broker.js +548 -0
  126. package/dist/src/codex/broker.js.map +1 -0
  127. package/dist/src/codex/runner.d.ts +178 -0
  128. package/dist/src/codex/runner.js +231 -0
  129. package/dist/src/codex/runner.js.map +1 -0
  130. package/dist/src/codex/serve.d.ts +56 -0
  131. package/dist/src/codex/serve.js +98 -0
  132. package/dist/src/codex/serve.js.map +1 -0
  133. package/dist/src/codex/workspace-commit.d.ts +219 -0
  134. package/dist/src/codex/workspace-commit.js +549 -0
  135. package/dist/src/codex/workspace-commit.js.map +1 -0
  136. package/dist/src/core/advance-cycle.d.ts +51 -0
  137. package/dist/src/core/advance-cycle.js +66 -2
  138. package/dist/src/core/advance-cycle.js.map +1 -1
  139. package/dist/src/core/agents-md.d.ts +20 -18
  140. package/dist/src/core/agents-md.js +33 -31
  141. package/dist/src/core/agents-md.js.map +1 -1
  142. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  143. package/dist/src/core/attest-bootstrap.js +135 -0
  144. package/dist/src/core/attest-bootstrap.js.map +1 -0
  145. package/dist/src/core/attest.d.ts +234 -2
  146. package/dist/src/core/attest.js +342 -7
  147. package/dist/src/core/attest.js.map +1 -1
  148. package/dist/src/core/audit.d.ts +73 -3
  149. package/dist/src/core/audit.js +65 -5
  150. package/dist/src/core/audit.js.map +1 -1
  151. package/dist/src/core/channel-lease.d.ts +322 -0
  152. package/dist/src/core/channel-lease.js +663 -0
  153. package/dist/src/core/channel-lease.js.map +1 -0
  154. package/dist/src/core/channel-owner.d.ts +213 -0
  155. package/dist/src/core/channel-owner.js +358 -0
  156. package/dist/src/core/channel-owner.js.map +1 -0
  157. package/dist/src/core/command-class.d.ts +205 -0
  158. package/dist/src/core/command-class.js +1433 -28
  159. package/dist/src/core/command-class.js.map +1 -1
  160. package/dist/src/core/commit-guard.d.ts +272 -0
  161. package/dist/src/core/commit-guard.js +424 -0
  162. package/dist/src/core/commit-guard.js.map +1 -0
  163. package/dist/src/core/daemon-actor.d.ts +45 -0
  164. package/dist/src/core/daemon-actor.js +54 -0
  165. package/dist/src/core/daemon-actor.js.map +1 -0
  166. package/dist/src/core/daemon-host.d.ts +178 -0
  167. package/dist/src/core/daemon-host.js +228 -0
  168. package/dist/src/core/daemon-host.js.map +1 -0
  169. package/dist/src/core/daemon-identity.d.ts +238 -0
  170. package/dist/src/core/daemon-identity.js +252 -0
  171. package/dist/src/core/daemon-identity.js.map +1 -0
  172. package/dist/src/core/dark-session.d.ts +109 -8
  173. package/dist/src/core/dark-session.js +266 -82
  174. package/dist/src/core/dark-session.js.map +1 -1
  175. package/dist/src/core/decision-refusal.d.ts +23 -2
  176. package/dist/src/core/decision-refusal.js +24 -2
  177. package/dist/src/core/decision-refusal.js.map +1 -1
  178. package/dist/src/core/env-file.d.ts +5 -0
  179. package/dist/src/core/env-file.js +60 -1
  180. package/dist/src/core/env-file.js.map +1 -1
  181. package/dist/src/core/execute.d.ts +15 -2
  182. package/dist/src/core/execute.js +15 -2
  183. package/dist/src/core/execute.js.map +1 -1
  184. package/dist/src/core/gate.d.ts +392 -6
  185. package/dist/src/core/gate.js +852 -24
  186. package/dist/src/core/gate.js.map +1 -1
  187. package/dist/src/core/gesture-refusal.d.ts +166 -0
  188. package/dist/src/core/gesture-refusal.js +188 -0
  189. package/dist/src/core/gesture-refusal.js.map +1 -0
  190. package/dist/src/core/harness-version.d.ts +128 -4
  191. package/dist/src/core/harness-version.js +181 -12
  192. package/dist/src/core/harness-version.js.map +1 -1
  193. package/dist/src/core/harness-wait.d.ts +82 -3
  194. package/dist/src/core/harness-wait.js +89 -3
  195. package/dist/src/core/harness-wait.js.map +1 -1
  196. package/dist/src/core/instance.d.ts +59 -2
  197. package/dist/src/core/instance.js +113 -0
  198. package/dist/src/core/instance.js.map +1 -1
  199. package/dist/src/core/log-subscribe.d.ts +21 -0
  200. package/dist/src/core/log-subscribe.js +16 -6
  201. package/dist/src/core/log-subscribe.js.map +1 -1
  202. package/dist/src/core/log-write-layer.d.ts +55 -0
  203. package/dist/src/core/log-write-layer.js +91 -0
  204. package/dist/src/core/log-write-layer.js.map +1 -0
  205. package/dist/src/core/log.d.ts +89 -22
  206. package/dist/src/core/log.js +197 -19
  207. package/dist/src/core/log.js.map +1 -1
  208. package/dist/src/core/payload-census.d.ts +51 -0
  209. package/dist/src/core/payload-census.js +125 -2
  210. package/dist/src/core/payload-census.js.map +1 -1
  211. package/dist/src/core/payload-store.d.ts +31 -0
  212. package/dist/src/core/payload-store.js +113 -8
  213. package/dist/src/core/payload-store.js.map +1 -1
  214. package/dist/src/core/payload.d.ts +18 -3
  215. package/dist/src/core/payload.js +20 -4
  216. package/dist/src/core/payload.js.map +1 -1
  217. package/dist/src/core/policy-diff.d.ts +10 -0
  218. package/dist/src/core/policy-diff.js +10 -2
  219. package/dist/src/core/policy-diff.js.map +1 -1
  220. package/dist/src/core/policy-explain.d.ts +17 -1
  221. package/dist/src/core/policy-explain.js +48 -1
  222. package/dist/src/core/policy-explain.js.map +1 -1
  223. package/dist/src/core/policy-load.d.ts +65 -1
  224. package/dist/src/core/policy-load.js +21 -3
  225. package/dist/src/core/policy-load.js.map +1 -1
  226. package/dist/src/core/policy-match.d.ts +81 -0
  227. package/dist/src/core/policy-match.js +87 -0
  228. package/dist/src/core/policy-match.js.map +1 -1
  229. package/dist/src/core/policy-proposal.d.ts +75 -0
  230. package/dist/src/core/policy-proposal.js +110 -4
  231. package/dist/src/core/policy-proposal.js.map +1 -1
  232. package/dist/src/core/protected-path-guard.d.ts +117 -4
  233. package/dist/src/core/protected-path-guard.js +362 -48
  234. package/dist/src/core/protected-path-guard.js.map +1 -1
  235. package/dist/src/core/question-preempted.d.ts +141 -0
  236. package/dist/src/core/question-preempted.js +152 -0
  237. package/dist/src/core/question-preempted.js.map +1 -0
  238. package/dist/src/core/read-scope.d.ts +182 -0
  239. package/dist/src/core/read-scope.js +256 -0
  240. package/dist/src/core/read-scope.js.map +1 -0
  241. package/dist/src/core/run-payload.d.ts +160 -0
  242. package/dist/src/core/run-payload.js +304 -0
  243. package/dist/src/core/run-payload.js.map +1 -0
  244. package/dist/src/core/sandbox.d.ts +81 -0
  245. package/dist/src/core/sandbox.js +190 -1
  246. package/dist/src/core/sandbox.js.map +1 -1
  247. package/dist/src/core/sender-identity.d.ts +501 -0
  248. package/dist/src/core/sender-identity.js +609 -0
  249. package/dist/src/core/sender-identity.js.map +1 -0
  250. package/dist/src/core/shlex.d.ts +102 -0
  251. package/dist/src/core/shlex.js +159 -0
  252. package/dist/src/core/shlex.js.map +1 -0
  253. package/dist/src/core/state.d.ts +52 -1
  254. package/dist/src/core/state.js +52 -6
  255. package/dist/src/core/state.js.map +1 -1
  256. package/dist/src/core/telegram-config.d.ts +79 -0
  257. package/dist/src/core/telegram-config.js +108 -0
  258. package/dist/src/core/telegram-config.js.map +1 -1
  259. package/dist/src/core/values.d.ts +18 -8
  260. package/dist/src/core/values.js +36 -1
  261. package/dist/src/core/values.js.map +1 -1
  262. package/dist/src/core/verify.d.ts +23 -1
  263. package/dist/src/core/verify.js +20 -2
  264. package/dist/src/core/verify.js.map +1 -1
  265. package/dist/src/daemon/advance.d.ts +10 -0
  266. package/dist/src/daemon/advance.js +25 -4
  267. package/dist/src/daemon/advance.js.map +1 -1
  268. package/dist/src/daemon/audit.d.ts +80 -5
  269. package/dist/src/daemon/audit.js +126 -6
  270. package/dist/src/daemon/audit.js.map +1 -1
  271. package/dist/src/daemon/daemon.d.ts +174 -2
  272. package/dist/src/daemon/daemon.js +307 -7
  273. package/dist/src/daemon/daemon.js.map +1 -1
  274. package/dist/src/daemon/git-evidence.d.ts +2 -2
  275. package/dist/src/daemon/git-evidence.js +1 -1
  276. package/dist/src/daemon/projection.d.ts +13 -2
  277. package/dist/src/daemon/projection.js +39 -6
  278. package/dist/src/daemon/projection.js.map +1 -1
  279. package/dist/src/mcp/server.d.ts +39 -3
  280. package/dist/src/mcp/server.js +30 -3
  281. package/dist/src/mcp/server.js.map +1 -1
  282. package/dist/src/muse/server.d.ts +16 -0
  283. package/dist/src/muse/server.js +238 -0
  284. package/dist/src/muse/server.js.map +1 -0
  285. package/dist/src/serve/archive.d.ts +158 -0
  286. package/dist/src/serve/archive.js +354 -0
  287. package/dist/src/serve/archive.js.map +1 -0
  288. package/dist/src/serve/arguments.d.ts +93 -0
  289. package/dist/src/serve/arguments.js +228 -0
  290. package/dist/src/serve/arguments.js.map +1 -0
  291. package/dist/src/serve/credentials.d.ts +91 -0
  292. package/dist/src/serve/credentials.js +143 -0
  293. package/dist/src/serve/credentials.js.map +1 -0
  294. package/dist/src/serve/follow.d.ts +86 -0
  295. package/dist/src/serve/follow.js +101 -0
  296. package/dist/src/serve/follow.js.map +1 -0
  297. package/dist/src/serve/hook-thread.d.ts +134 -0
  298. package/dist/src/serve/hook-thread.js +333 -0
  299. package/dist/src/serve/hook-thread.js.map +1 -0
  300. package/dist/src/serve/hook-worker.d.ts +134 -0
  301. package/dist/src/serve/hook-worker.js +173 -0
  302. package/dist/src/serve/hook-worker.js.map +1 -0
  303. package/dist/src/serve/server.d.ts +412 -0
  304. package/dist/src/serve/server.js +1251 -0
  305. package/dist/src/serve/server.js.map +1 -0
  306. package/docs/README-extended.md +766 -0
  307. package/docs/cli-reference.md +2563 -73
  308. package/docs/codex-enforced-session.md +75 -2
  309. package/docs/codex-workspace-broker.md +118 -0
  310. package/package.json +6 -2
  311. package/schema/event.schema.json +595 -9
  312. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
  313. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
  314. package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
  315. package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
  316. package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
  317. package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
  318. package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
  319. package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
  320. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  321. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  322. package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
  323. package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
  324. package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
  325. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  326. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  327. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  328. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  329. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  330. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  331. package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
  332. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  333. package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
  334. package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
  335. package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
  336. package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
  337. package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
  338. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  339. package/schema/fixtures/event/valid/daemon-identity.json +15 -0
  340. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  341. package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
  342. package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
  343. package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
  344. package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
  345. package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
  346. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  347. package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
  348. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  349. package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
  350. package/schema/fixtures/policy/valid/canonical.json +1 -1
  351. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  352. package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
  353. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  354. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  355. package/schema/fixtures/policy-md/valid/canonical.md +1 -1
  356. package/schema/fixtures/policy-md/valid/with-values.md +5 -7
  357. package/schema/fixtures/values/invalid/class-shaped.json +1 -1
  358. package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
  359. package/schema/fixtures/values/invalid/non-string-item.json +1 -1
  360. package/schema/fixtures/values/invalid/over-cap.json +1 -1
  361. package/schema/fixtures/values/invalid/unknown-key.json +1 -1
  362. package/schema/fixtures/values/invalid/version-float.json +1 -0
  363. package/schema/fixtures/values/invalid/version-integer.json +1 -0
  364. package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
  365. package/schema/fixtures/values/valid/empty-lists.json +2 -3
  366. package/schema/fixtures/values/valid/full.json +5 -7
  367. package/schema/fixtures/values/valid/minimal.json +1 -1
  368. package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
  369. package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
  370. package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
  371. package/schema/fixtures/values-md/invalid/version-1.md +69 -0
  372. package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
  373. package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
  374. package/schema/fixtures/values-md/valid/absent.md +1 -1
  375. package/schema/fixtures/values-md/valid/with-values.md +5 -7
  376. package/schema/policy.schema.json +100 -4
  377. package/schema/values.schema.json +7 -11
  378. package/schema/fixtures/values/invalid/version-string.json +0 -1
@@ -92,6 +92,10 @@ import { watch } from "node:fs";
92
92
  import { readFileSync, readdirSync, statSync } from "node:fs";
93
93
  import { basename, dirname, isAbsolute, join, relative } from "node:path";
94
94
  import { writeQueue } from "../channels/render-queue.js";
95
+ // APRV-403. The classifier is APRV-381's, shared rather than copied: one closed
96
+ // list of transient append codes means the drift scan and the sampling sweep
97
+ // cannot come to disagree about which failures a retry fixes.
98
+ import { isTransientAppendError } from "../core/audit.js";
95
99
  import { tick as readClock } from "../core/clock.js";
96
100
  import { parseFrontmatter, readTaskFile } from "../core/frontmatter.js";
97
101
  import { expire } from "../core/gate.js";
@@ -104,6 +108,9 @@ import { processReadCache, readVerifiedRecords, } from "../core/state.js";
104
108
  import { validate } from "../core/validate.js";
105
109
  import { publishedState } from "../cli/log-advance.js";
106
110
  import { isAdvanceBookkeeping } from "../core/advance-cycle.js";
111
+ import { markDaemonProcess } from "../core/daemon-actor.js";
112
+ import { declareDaemonIdentityFor, refreshDaemonAllowlist, } from "../core/daemon-host.js";
113
+ import { daemonIdentity } from "../core/daemon-identity.js";
107
114
  import { repoRoot } from "../cli/git-scope.js";
108
115
  import { checkLogAnchor, resolveAnchor } from "../cli/log-anchor.js";
109
116
  import { checkLogCheckpoints, checkpointPolicyOf, } from "../core/checkpoint.js";
@@ -121,6 +128,61 @@ import { driftAlreadyLogged, lapsedRequests, latestRegistration, taskEnvelopeSta
121
128
  export const DAEMON_ACTOR = "system:daemon";
122
129
  /** Backlog.md's conventional task folder, relative to the working directory. */
123
130
  export { DEFAULT_TASKS_DIR } from "../core/registration.js";
131
+ // ---------------------------------------------------------------------------
132
+ // The drift scan's deferral memory (APRV-403)
133
+ // ---------------------------------------------------------------------------
134
+ /**
135
+ * `envelope.drift` records this PROCESS deferred and has not yet seen appended,
136
+ * keyed by log path, task and drift reason.
137
+ *
138
+ * Exactly the standing `daemon/audit.ts` gives its own deferral memory, and
139
+ * deliberately the same shape: process-lifetime, memory-only, and a
140
+ * de-duplicator for OUTPUT. Nothing here is consulted before an append, and
141
+ * nothing is gated or suppressed by it. The RETRY needs no memory to happen,
142
+ * because the drift scan re-derives the whole question from the verified log on
143
+ * every tick; what this buys is the word "retry" on one line, so an operator can
144
+ * pair the deferral they read with the record that resolved it.
145
+ *
146
+ * It is consulted in exactly one other place, and that one is not cosmetic: the
147
+ * write-back pass skips a file whose drift record this tick could not append, so
148
+ * the file is not repaired off the record (SPEC.md §6.3's fixed order). Losing
149
+ * the memory there costs a repair one tick, never a lost record.
150
+ *
151
+ * Keyed by log path as well as task, because one process can be pointed at more
152
+ * than one gate over its lifetime and a task id is only unique inside one.
153
+ * Bounded, so a long run cannot accumulate entries for a file that drifts, is
154
+ * deferred, and is then deleted.
155
+ */
156
+ const driftDeferrals = new Set();
157
+ /** How many deferred drift records one process remembers for the retry wording. */
158
+ const DRIFT_DEFERRAL_LIMIT = 256;
159
+ function driftDeferralKey(logPath, task, reason) {
160
+ return JSON.stringify([logPath, task, reason]);
161
+ }
162
+ function rememberDriftDeferral(logPath, task, reason) {
163
+ const key = driftDeferralKey(logPath, task, reason);
164
+ if (driftDeferrals.has(key))
165
+ return;
166
+ // A `Set` iterates in insertion order, so "the oldest" needs no timestamp.
167
+ if (driftDeferrals.size >= DRIFT_DEFERRAL_LIMIT) {
168
+ const oldest = driftDeferrals.values().next();
169
+ if (!oldest.done)
170
+ driftDeferrals.delete(oldest.value);
171
+ }
172
+ driftDeferrals.add(key);
173
+ }
174
+ /** Whether this process deferred this record, consuming the memory of it. */
175
+ function takeDriftDeferral(logPath, task, reason) {
176
+ return driftDeferrals.delete(driftDeferralKey(logPath, task, reason));
177
+ }
178
+ /**
179
+ * Forget every deferral. Exported for tests, used nowhere else: the memory is
180
+ * output bookkeeping, so a suite that shares a process needs to be able to start
181
+ * from nothing and to exercise what a restarted daemon does.
182
+ */
183
+ export function resetDriftDeferrals() {
184
+ driftDeferrals.clear();
185
+ }
124
186
  /** How often the daemon looks, absent any watcher event. */
125
187
  export const DEFAULT_INTERVAL_MS = 30_000;
126
188
  /** How long a burst of watcher events is allowed to settle before a tick. */
@@ -236,6 +298,19 @@ export const DAEMON_WARNING_CODES = [
236
298
  * repair, and the next tick asks again.
237
299
  */
238
300
  "anchor-behind",
301
+ /**
302
+ * The working log changed between this tick's opening read and the anchor
303
+ * check's own read of the file, so the check read it again (APRV-389).
304
+ *
305
+ * A line rather than a stop, and rather than a silence. `approval log sync`
306
+ * and a records pull both rewrite `events.jsonl` under a running daemon
307
+ * (baseline, fast-forward, restore), and this loop reads without the append
308
+ * lock, so a tick can hold a view the file has already moved past. That is a
309
+ * fact about reads, never a fork: the anchor comparison is made again from the
310
+ * file and the verdict beside this line is the second read's. The line is the
311
+ * one an operator acts on when the writer was not a sync they ran.
312
+ */
313
+ "anchor-reread",
239
314
  /**
240
315
  * `audit.checkpoint_every` says a human-signed checkpoint is due and the log
241
316
  * carries none that recent (APRV-220). A WARNING and never a stop, at every
@@ -252,6 +327,53 @@ export const DAEMON_WARNING_CODES = [
252
327
  * month of taps.
253
328
  */
254
329
  "draw-unavailable",
330
+ /**
331
+ * This daemon's own id is not one the attested policy's `daemons` list admits,
332
+ * or the id it was launched with is not a usable id at all (APRV-383).
333
+ *
334
+ * A warning rather than a stop, and the distinction matters: the refusal itself
335
+ * happens at the write boundary, one append at a time, so the loop keeps
336
+ * reading, keeps rendering the queue and keeps reporting, and writes nothing.
337
+ * The line exists because an operator should learn that from the first tick
338
+ * rather than from a day of `append-refused` messages. The repair is a line in
339
+ * a policy a human re-attests, or a launch environment fixed, and neither is
340
+ * something this process can do for itself.
341
+ */
342
+ "daemon-identity-refused",
343
+ /**
344
+ * An `audit.sampled` append met a held lock or a moved head, so the sample was
345
+ * postponed to the next tick (APRV-381). Appended to this union, so no existing
346
+ * entry changed meaning.
347
+ *
348
+ * Distinct from `append-refused` because the operator's next move is different:
349
+ * `append-refused` on a sample is a fact about the record or the file and wants
350
+ * a person, and this is contention between writers that the next sweep resolves
351
+ * by re-deriving the sample from the log. It is still a warning rather than a
352
+ * silence, because a tick that keeps deferring is a log under contention and
353
+ * that is a thing to know, and the `sampled` line that closes it carries
354
+ * `retry`.
355
+ */
356
+ "sample-deferred",
357
+ /**
358
+ * An `envelope.drift` append met a held lock or a moved head, so the record was
359
+ * postponed to the next tick (APRV-403). Appended to this union, so no existing
360
+ * entry changed meaning.
361
+ *
362
+ * The same split APRV-381 made for `audit.sampled`, for the same reason and
363
+ * from the same closed list of transient codes. Found while landing that task:
364
+ * the end-to-end test that holds the append lockfile for a whole tick showed
365
+ * the drift scan printing `append-refused … (lock-timeout)`, which is true and
366
+ * reads as a lost record, while the next tick re-derived the drift from the
367
+ * verified log and appended it. An operator's next move differs between the
368
+ * two: `append-refused` on a drift record is a fact about the record or the
369
+ * file and wants a person, and this is contention between writers that the
370
+ * scan resolves by itself.
371
+ *
372
+ * Still a warning rather than a silence: a scan that keeps deferring is a log
373
+ * under contention, which is a thing to know. The `drift` line that closes it
374
+ * carries `retry`.
375
+ */
376
+ "drift-deferred",
255
377
  ];
256
378
  function errorMessage(cause) {
257
379
  return cause instanceof Error ? cause.message : String(cause);
@@ -354,10 +476,37 @@ export class Daemon {
354
476
  previousSelfWrites = new Set();
355
477
  settle = null;
356
478
  finished = false;
479
+ /**
480
+ * What this process resolved as its own daemon id (APRV-383), kept so the
481
+ * `started` line can name it and the startup check can say what is wrong with
482
+ * it. The id in FORCE lives in `core/daemon-identity.ts`'s process state, which
483
+ * is what the write boundary reads; this field is the resolution that produced
484
+ * it, and nothing reads it to decide anything.
485
+ */
486
+ identity;
487
+ /** Has the unlisted-id warning already been said this run? */
488
+ reportedIdentityRefusal = false;
357
489
  /** Whether {@link DaemonOptions.draw} actually bound (APRV-208). */
358
490
  drawServing = false;
359
491
  constructor(options) {
360
492
  this.options = options;
493
+ // APRV-382. This process is the daemon, and the gated advance path asks
494
+ // `core/daemon-actor.ts` rather than being told: `log.advance.daemon` is
495
+ // autonomous in this repository's policy and `log.advance` is not, so which
496
+ // class a cycle asks under must not be a field any caller can set. Marked
497
+ // at CONSTRUCTION rather than in `run()` so that a shutdown flush, a
498
+ // `--once` tick and a test driving this object one tick at a time are all
499
+ // the same actor as the loop.
500
+ markDaemonProcess();
501
+ // APRV-383. WHICH daemon this process is, declared in the same breath and at
502
+ // the same moment for the same reason. The resolution is the launch
503
+ // environment's `APPROVAL_DAEMON_ID` or the id derived from this log's
504
+ // instance; an unusable declared id is declared as an identity with NO id,
505
+ // which refuses every append at the write boundary rather than leaving a
506
+ // daemon writing records nothing can attribute. The CLI verbs refuse to start
507
+ // on the same resolution, so this is the belt on that brace, and it is what
508
+ // covers a `Daemon` constructed directly by an embedder or a test.
509
+ this.identity = declareDaemonIdentityFor(this.options.logPath);
361
510
  }
362
511
  /** Run until stopped (or, with `once`, for exactly one tick). */
363
512
  run() {
@@ -409,7 +558,19 @@ export class Daemon {
409
558
  // would be a write nobody asked for yet. Seeded into `reportedDangling`
410
559
  // so the tick that immediately follows does not repeat it.
411
560
  dangling_advances: this.listDanglingAdvancesAtStartup(),
561
+ // APRV-383. Resolved in the constructor, printed here, and `null` for an
562
+ // id this process could not use at all.
563
+ daemon: this.identity.ok ? this.identity.id : null,
412
564
  });
565
+ // APRV-383. Said once, before the first tick, when this run cannot write:
566
+ // a bad launch id is knowable without reading anything, and the allowlist
567
+ // is knowable from the attested policy the first tick resolves. An operator
568
+ // who reads it here does not have to infer it from a day of
569
+ // `append-refused` lines.
570
+ if (!this.identity.ok) {
571
+ this.reportedIdentityRefusal = true;
572
+ this.warn("daemon-identity-refused", `${this.identity.message}. Every append this run makes will be refused \`daemon-id-invalid\` until that is fixed; nothing is written and nothing is retried`);
573
+ }
413
574
  const outcome = this.tick();
414
575
  if (outcome !== null) {
415
576
  this.finish(outcome);
@@ -720,6 +881,14 @@ export class Daemon {
720
881
  const opening = this.read();
721
882
  if (!opening.ok)
722
883
  return this.fatal(opening);
884
+ // WHICH daemons this log's ATTESTED policy admits (APRV-383), put in force
885
+ // before anything below appends. Resolved every tick, from this tick's own
886
+ // verified read, so a `daemons` list a human adds and attests takes effect
887
+ // on the next tick rather than at the next restart; a resolution that FAILS
888
+ // (the policy will not load, or is not attested) leaves the previous one
889
+ // standing, so a policy that becomes unreadable is not a way out of the list
890
+ // it carried a moment ago.
891
+ this.refreshIdentity(opening.records);
723
892
  // The anchor check (APRV-219), on the full re-proof cadence and on the
724
893
  // first tick, which is always a cold walk. Placed immediately after the
725
894
  // opening read and before any append this tick could make: a log that
@@ -731,6 +900,17 @@ export class Daemon {
731
900
  if (anchor.status === "diverged") {
732
901
  return { kind: "anchor-diverged", message: anchor.message };
733
902
  }
903
+ // The log was rewritten between this tick's opening read and the
904
+ // check's own read of the file (APRV-389), so the check read it again
905
+ // and this verdict is the second read's. ONE line, and no stop: a
906
+ // `log sync` or a records pull replacing `events.jsonl` under this
907
+ // process is housekeeping on the container, the append lock keeps it
908
+ // from interleaving with any append, and the daemon's every other step
909
+ // re-reads the log for itself anyway. The line is here because a
910
+ // writer nobody expected is a thing to know, and because this is the
911
+ // read that used to be reported as a fork.
912
+ if (anchor.reread !== undefined)
913
+ this.warn("anchor-reread", anchor.reread.detail);
734
914
  if (anchor.status === "behind") {
735
915
  this.warn("anchor-behind", `${anchor.detail}. Nothing was appended onto the shorter chain by this check; run \`approval log sync\` before this daemon writes anything`);
736
916
  }
@@ -789,14 +969,19 @@ export class Daemon {
789
969
  ...(this.options.schemaDir === undefined ? {} : { schemaDir: this.options.schemaDir }),
790
970
  ...(this.options.clock === undefined ? {} : { clock: this.options.clock }),
791
971
  warn: (message) => this.warn("append-refused", message),
972
+ // APRV-381. Contention with another writer gets its own code, so the
973
+ // window an operator reads distinguishes "this sample is coming" from
974
+ // "an audit record was not written".
975
+ defer: (message) => this.warn("sample-deferred", message),
792
976
  // One line per sample appended (APRV-57). The sweep names no event; it
793
977
  // hands back what it wrote and the loop says it in the loop's own words.
794
- sampled: (sample) => this.emit({
978
+ sampled: (sample, retry) => this.emit({
795
979
  event: "sampled",
796
980
  action_key: sample.candidate.actionKey,
797
981
  task: sample.candidate.task,
798
982
  seq: sample.record.seq,
799
983
  subject_seq: sample.candidate.seq,
984
+ ...(retry ? { retry: true } : {}),
800
985
  }),
801
986
  }));
802
987
  // The dark-session sweep (APRV-192), on its own cadence. Placed with the
@@ -1370,14 +1555,45 @@ export class Daemon {
1370
1555
  ...(this.options.readProof === undefined ? {} : { readProof: this.options.readProof }),
1371
1556
  });
1372
1557
  }
1373
- /** The TTL in force right now, re-read every pass: policy files change. */
1374
- ttlMs() {
1558
+ /** Where this daemon's policy is, in `loadPolicy`'s own vocabulary. */
1559
+ policyWhere() {
1375
1560
  const where = this.options.policy.file !== undefined
1376
1561
  ? { file: this.options.policy.file }
1377
1562
  : { dir: this.options.policy.dir ?? this.options.cwd };
1378
1563
  if (this.options.schemaDir !== undefined)
1379
1564
  where.schemaDir = this.options.schemaDir;
1380
- const load = loadPolicy(where);
1565
+ return where;
1566
+ }
1567
+ /**
1568
+ * Put the attested policy's `daemons` allowlist in force for this tick
1569
+ * (APRV-383).
1570
+ *
1571
+ * Called once per tick, immediately after the opening verified read and before
1572
+ * any sweep that appends. It never widens: a resolution that fails sets nothing,
1573
+ * so whatever was last resolved stays in force, and a run whose policy was never
1574
+ * attested is unrestricted exactly as every run before this key existed was.
1575
+ *
1576
+ * The warning is said ONCE per run. A line every thirty seconds forever stops
1577
+ * being read (the `reportedDangling` set above exists for the same reason), and
1578
+ * the write boundary is where the refusal actually happens: every refused append
1579
+ * carries its own `append-refused` line with the reason in it.
1580
+ */
1581
+ refreshIdentity(records) {
1582
+ refreshDaemonAllowlist(records, loadPolicy(this.policyWhere()));
1583
+ if (this.reportedIdentityRefusal)
1584
+ return;
1585
+ const state = daemonIdentity();
1586
+ if (state === null || state.id === null)
1587
+ return;
1588
+ const allowed = state.allowed;
1589
+ if (allowed === null || allowed.includes(state.id))
1590
+ return;
1591
+ this.reportedIdentityRefusal = true;
1592
+ this.warn("daemon-identity-refused", `this daemon's id is ${state.id} and the attested policy's \`daemons\` list admits ${allowed.length === 0 ? "no daemon at all" : allowed.join(", ")}: every append this run makes is refused \`daemon-not-allowed\` and nothing is written. Add ${state.id} to \`daemons\` and re-attest the policy, or run the daemon whose id the list already names`);
1593
+ }
1594
+ /** The TTL in force right now, re-read every pass: policy files change. */
1595
+ ttlMs() {
1596
+ const load = loadPolicy(this.policyWhere());
1381
1597
  // Fail closed exactly as the gate does: an unloadable policy declares no
1382
1598
  // TTL, so nothing lapses and nothing is expired on its behalf.
1383
1599
  return load.ok ? load.durations.approvalTtlMs : null;
@@ -1410,6 +1626,29 @@ export class Daemon {
1410
1626
  * malformed file is not a *contradiction* of the log, it is a file the runtime
1411
1627
  * cannot read a claim out of at all, and inventing a `declared_state` for it
1412
1628
  * would put a fact in the log that nobody wrote.
1629
+ *
1630
+ * ## A deferred drift record is not a lost one (APRV-403)
1631
+ *
1632
+ * An append here can fail two ways that call for different things. A held
1633
+ * lockfile or a head that moved under the compare-and-append means the log was
1634
+ * not written and nothing about the file changed; the next tick re-reads the
1635
+ * folder, re-derives the disagreement from the verified log, and appends it.
1636
+ * A validation refusal, a canonicalization failure, a corrupt tail or an I/O
1637
+ * error is a fact about the record or the file that no retry repairs.
1638
+ *
1639
+ * Both used to read as `append-refused … was not appended`, which for the
1640
+ * first is true and reads as a loss. So the transient half now goes out as
1641
+ * {@link deferDrift}'s own `drift-deferred` line, naming the task and saying
1642
+ * that the scan retries, and the `drift` record that closes it carries
1643
+ * `retry`. Everything else keeps the form it had.
1644
+ *
1645
+ * Nothing about the APPEND changed, which is the part that matters for
1646
+ * SPEC.md §11.1 invariant 5: the record is still placed by `appendEvent`
1647
+ * against the `expectedHead` of a read taken immediately before it, and the
1648
+ * whole decision is still remade against that read. A `head-moved` here IS
1649
+ * compare-and-append working — another writer landed a record between this
1650
+ * scan's read and its append — and deferring is the only answer that does not
1651
+ * either write against a head this scan never saw or lose the observation.
1413
1652
  */
1414
1653
  scanForDrift() {
1415
1654
  // ONE verified read for the whole scan (APRV-211). The scan asks the same
@@ -1437,6 +1676,24 @@ export class Daemon {
1437
1676
  }
1438
1677
  return { appended, stop: null };
1439
1678
  }
1679
+ /**
1680
+ * Say that an `envelope.drift` append did not happen YET, and remember it
1681
+ * (APRV-403).
1682
+ *
1683
+ * Two things this line has to do that `append-refused` did not. It names the
1684
+ * TASK KEY, so an operator can match the deferral to the record that closes
1685
+ * it, and it says in words that the next tick retries — because the sentence
1686
+ * it replaced ended at "was not appended", which is true and reads as a loss.
1687
+ *
1688
+ * The claim is safe to make because the scan carries nothing between ticks:
1689
+ * the next one re-reads the folder, re-derives the state from the verified
1690
+ * log, and finds the same disagreement, so the retry is a property of how the
1691
+ * scan is written rather than a promise this method is keeping.
1692
+ */
1693
+ deferDrift(task, reason, error) {
1694
+ rememberDriftDeferral(this.options.logPath, task, reason);
1695
+ this.warn("drift-deferred", `envelope.drift${reason === "state-mismatch" ? "" : ` (${reason})`} for ${task} was deferred (${error.code}): ${error.message} The record is NOT lost: the disagreement is still there in the log's own terms, and the scan re-derives it and retries on the next tick.`);
1696
+ }
1440
1697
  /** Every `*.md` under the task folder, sorted, non-recursive. */
1441
1698
  taskFiles() {
1442
1699
  let entries;
@@ -1557,10 +1814,22 @@ export class Daemon {
1557
1814
  expectedHead: records.head,
1558
1815
  });
1559
1816
  if (!result.ok) {
1560
- this.warn("append-refused", `envelope.drift for ${id} was not appended (${result.error.code}): ${result.error.message}`);
1817
+ // APRV-403. A held lock or a moved head says the log was not written and
1818
+ // nothing about this file changed, which is a different fact from a record
1819
+ // the write boundary will never take; the scan re-derives and retries the
1820
+ // first, and only a person repairs the second. `isTransientAppendError`
1821
+ // and its closed list are `core/audit.ts`'s, shared rather than copied, so
1822
+ // the two sweeps cannot disagree about what transient means.
1823
+ if (isTransientAppendError(result.error)) {
1824
+ this.deferDrift(id, "state-mismatch", result.error);
1825
+ }
1826
+ else {
1827
+ this.warn("append-refused", `envelope.drift for ${id} was not appended (${result.error.code}): ${result.error.message}`);
1828
+ }
1561
1829
  return { appended: false, stop: null };
1562
1830
  }
1563
1831
  this.drifts += 1;
1832
+ const retried = takeDriftDeferral(this.options.logPath, id, "state-mismatch");
1564
1833
  this.emit({
1565
1834
  event: "drift",
1566
1835
  task: id,
@@ -1568,6 +1837,7 @@ export class Daemon {
1568
1837
  declared_state: declaredState,
1569
1838
  derived_state: facts.derivedState,
1570
1839
  seq: result.record.seq,
1840
+ ...(retried ? { retry: true } : {}),
1571
1841
  });
1572
1842
  return { appended: true, stop: null };
1573
1843
  }
@@ -1638,10 +1908,20 @@ export class Daemon {
1638
1908
  expectedHead: records.head,
1639
1909
  });
1640
1910
  if (!result.ok) {
1641
- this.warn("append-refused", `envelope.drift (envelope-missing) for ${task} was not appended (${result.error.code}): ${result.error.message}`);
1911
+ // The same split as the mismatch path above (APRV-403). Both sites take
1912
+ // it, because contention does not care which reason the record carries and
1913
+ // an operator reading a deferral for one and a refusal for the other would
1914
+ // be reading two answers to one question.
1915
+ if (isTransientAppendError(result.error)) {
1916
+ this.deferDrift(task, "envelope-missing", result.error);
1917
+ }
1918
+ else {
1919
+ this.warn("append-refused", `envelope.drift (envelope-missing) for ${task} was not appended (${result.error.code}): ${result.error.message}`);
1920
+ }
1642
1921
  return { appended: false, stop: null };
1643
1922
  }
1644
1923
  this.drifts += 1;
1924
+ const retried = takeDriftDeferral(this.options.logPath, task, "envelope-missing");
1645
1925
  this.emit({
1646
1926
  event: "drift",
1647
1927
  task,
@@ -1650,6 +1930,7 @@ export class Daemon {
1650
1930
  derived_state: facts.derivedState,
1651
1931
  seq: result.record.seq,
1652
1932
  reason: "envelope-missing",
1933
+ ...(retried ? { retry: true } : {}),
1653
1934
  });
1654
1935
  return { appended: true, stop: null };
1655
1936
  }
@@ -1764,7 +2045,7 @@ export class Daemon {
1764
2045
  * log, and produces bytes only through `core/task-file.ts`. A file can no more
1765
2046
  * teach the log a state than a screenshot can teach a database a row.
1766
2047
  *
1767
- * Four rules, each of which is a way of not making things worse:
2048
+ * Five rules, each of which is a way of not making things worse:
1768
2049
  *
1769
2050
  * 1. **Only files that already have an envelope.** `set-state` refuses
1770
2051
  * `no-envelope`, and that refusal is honoured silently: a task with no
@@ -1786,6 +2067,11 @@ export class Daemon {
1786
2067
  * envelope: each was warned about a few milliseconds ago by
1787
2068
  * {@link scanForDrift} over the same folder. Repeating it here would double
1788
2069
  * every line an operator reads without adding a fact.
2070
+ * 5. **No repair for a drift record that was deferred** (APRV-403). See the
2071
+ * check inside the loop: a file whose `envelope.drift` this tick could not
2072
+ * append is left exactly as it is, because repairing it would correct the
2073
+ * disagreement off the record and erase what the retry is supposed to
2074
+ * re-derive.
1789
2075
  *
1790
2076
  * Loop safety comes from the comparison, not from a remembered flag: the next
1791
2077
  * tick derives the same state from the same log, finds the file already
@@ -1835,6 +2121,20 @@ export class Daemon {
1835
2121
  const derived = taskEnvelopeState(records.records, id, ts, ttlMs).state;
1836
2122
  if (declaredState === derived)
1837
2123
  continue;
2124
+ // APRV-403, and it is rule 5 of this list. The drift scan earlier in this
2125
+ // tick DEFERRED the record for this task, so the disagreement has not been
2126
+ // written down yet. Repairing the file now would correct it off the record
2127
+ // and, worse, remove the evidence: the next tick would find the file
2128
+ // agreeing with the log, the retry would have nothing to re-derive, and
2129
+ // the deferral an operator was told about would resolve into silence.
2130
+ //
2131
+ // SPEC.md §6.3 fixes the order — the event is appended first and the file
2132
+ // is updated second, never the reverse — and a transient append failure is
2133
+ // exactly the case where "never the reverse" has to be enforced rather
2134
+ // than assumed. So the file waits for the record, and the tick that lands
2135
+ // the record repairs the file after it.
2136
+ if (driftDeferrals.has(driftDeferralKey(this.options.logPath, id, "state-mismatch")))
2137
+ continue;
1838
2138
  const rewritten = rewriteTaskFile(text, { kind: "set-state", state: derived }, rewriteOptions);
1839
2139
  if (!rewritten.ok) {
1840
2140
  if (rewritten.code === "no-envelope")