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
@@ -42,15 +42,53 @@
42
42
  * canonicalization minus the `hash` field. One scheme, two inputs — a
43
43
  * verifier strips `hash` and re-derives. Appended with a single `write(2)`
44
44
  * on a handle opened `O_APPEND`.
45
+ * 5b. **Durable before ok (APRV-440).** The write is followed by `fsync(2)` on
46
+ * the same descriptor, and only then does the append report ok. When this
47
+ * append created the file, the log directory is fsynced too (and the parent
48
+ * of every directory this call created), so the new name survives along with
49
+ * its bytes. Without this, an acknowledged record lived in the page cache
50
+ * only: a platform kill before writeback left the file extended over blocks
51
+ * that were never written, which reads back as a NUL-filled unterminated tail
52
+ * (observed on a hosted tenant on 2026-09-25: an acknowledged attestation
53
+ * lost, 456 NUL bytes in its place). Atomicity against concurrent writers
54
+ * was guarantee 5 all along; durability against the machine dying is this one.
55
+ *
56
+ * 6. **The writing daemon names itself, or is refused (APRV-383).** A record
57
+ * appended by a declaring daemon process carries `daemon`, the id of the
58
+ * instance that wrote it, so a tenant reading a log hosted by somebody else can
59
+ * tell which daemon acted on their behalf. The id comes from this process's own
60
+ * state (`core/daemon-identity.ts`) and never from the caller, and where the
61
+ * attested policy lists the ids that may write, an unlisted one is refused here
62
+ * with nothing written. Every earlier record, carrying no such field, validates
63
+ * and verifies unchanged.
64
+ *
65
+ * 7. **A lock whose holder is provably gone is taken back, and the log says so
66
+ * (APRV-479).** The lockfile carries its holder's record. A writer that has
67
+ * waited out its whole lock timeout judges the holder once, and only a holder
68
+ * provably gone (a dead pid in this process's own pid namespace and boot, or
69
+ * an empty lockfile ten minutes old) loses the lock: the writer claims it
70
+ * with one exclusive `link(2)`, takes it with one `rename(2)` of its own
71
+ * lockfile over the lock's path, and appends `audit.lock_reclaimed` as the
72
+ * first record under it. A live holder's lock, and any holder this process
73
+ * cannot check, is never taken; the refusal names the human verb
74
+ * `approval log unlock`. `core/log-lock.ts` holds the rules.
45
75
  *
46
76
  * Determinism: `ts` is supplied by the caller. This module never reads the
47
77
  * clock, because a hash-relevant field sourced from ambient state would make
48
- * the log irreproducible.
78
+ * the log irreproducible. The one exception is the record this module writes on
79
+ * its own behalf, the `audit.lock_reclaimed` of guarantee 7: no caller authors
80
+ * it, and its `ts` is assigned at the write boundary like any audit record's.
81
+ * `daemon` is ambient in the same sense and is not a counter-example: a log is reproducible per WRITER, the field says which writer,
82
+ * and a verifier re-derives the hash from the record's own bytes either way.
49
83
  */
50
84
  import { createHash } from "node:crypto";
51
- import { closeSync, fstatSync, mkdirSync, openSync, readSync, unlinkSync, writeSync, } from "node:fs";
52
- import { dirname } from "node:path";
85
+ import { closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readSync, } from "node:fs";
86
+ import { basename, dirname } from "node:path";
87
+ import { DAEMON_APPEND_REFUSAL_CODES, daemonStampForAppend, } from "./daemon-identity.js";
88
+ import { systemClock } from "./clock.js";
53
89
  import { canonicalize, JcsError } from "./jcs.js";
90
+ import { createLockFile, finishReclaim, guardTerminationWhileLocked, observeLock, reclaimStaleLock, releaseLockFile, settleTerminationGuards, takeLockForUnlock, } from "./log-lock.js";
91
+ import { appendWriteLayer } from "./log-write-layer.js";
54
92
  import { validate } from "./validate.js";
55
93
  /** SPEC.md §8: the hash scheme identifier stamped on every v0.1 record. */
56
94
  export const ALG = "sha256/jcs";
@@ -85,9 +123,24 @@ export const APPEND_ERROR_CODES = [
85
123
  * caller made is stale. Nothing was written.
86
124
  */
87
125
  "head-moved",
126
+ /**
127
+ * The two identity refusals of APRV-383, spread in from
128
+ * `core/daemon-identity.ts` where the logic that emits them lives: a daemon that
129
+ * declared an unusable id, and a daemon whose id the attested policy's `daemons`
130
+ * list does not admit. Both leave the file byte-identical, like every member
131
+ * above them, and both are additions to the closed union rather than renames of
132
+ * anything in it.
133
+ */
134
+ ...DAEMON_APPEND_REFUSAL_CODES,
88
135
  ];
89
- const DEFAULT_LOCK_TIMEOUT_MS = 2_000;
90
- const DEFAULT_LOCK_RETRY_MS = 20;
136
+ /**
137
+ * How long an append waits for `<log>.lock` before refusing `lock-timeout`, and
138
+ * how often it tries. Exported (APRV-478) so a caller that waits for the lock
139
+ * itself, on the event loop rather than in {@link acquireLock}'s synchronous
140
+ * retry, keeps the same bound and cadence as every other writer.
141
+ */
142
+ export const DEFAULT_LOCK_TIMEOUT_MS = 2_000;
143
+ export const DEFAULT_LOCK_RETRY_MS = 20;
91
144
  /**
92
145
  * Strip `hash` and canonicalize. Exported shape is documented on
93
146
  * {@link computeRecordHash}; kept separate so hashing and verification cannot
@@ -138,22 +191,124 @@ function sleepSync(ms) {
138
191
  const buffer = new Int32Array(new SharedArrayBuffer(4));
139
192
  Atomics.wait(buffer, 0, 0, ms);
140
193
  }
194
+ /** `O_APPEND` writer flags; `O_EXCL` added for the first try, so creation is known. */
195
+ const APPEND_FLAGS = fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT;
196
+ /** The mode Node's `"a"` opens with; the umask applies as it always did. */
197
+ const APPEND_MODE = 0o666;
198
+ /**
199
+ * Open `logPath` for append and say whether this call created it. `O_EXCL`
200
+ * first: under the append lock no other sanctioned writer can race the create, so
201
+ * `EEXIST` means the file was already there and a plain append open follows.
202
+ */
203
+ function openForAppend(logPath) {
204
+ const writeLayer = appendWriteLayer();
205
+ try {
206
+ return { fd: writeLayer.open(logPath, APPEND_FLAGS | fsConstants.O_EXCL, APPEND_MODE), created: true };
207
+ }
208
+ catch (cause) {
209
+ if (cause.code !== "EEXIST")
210
+ throw cause;
211
+ }
212
+ return { fd: writeLayer.open(logPath, APPEND_FLAGS, APPEND_MODE), created: false };
213
+ }
214
+ /**
215
+ * The errors a directory fsync returns on a filesystem that cannot sync a
216
+ * directory at all (some FUSE, SMB and drvfs mounts). PostgreSQL ignores the
217
+ * same set for the same reason: there, every first append would otherwise
218
+ * report io with record 1 already in the file, inviting a re-run and a
219
+ * duplicate. EIO and everything else still fail the append.
220
+ */
221
+ const DIRECTORY_FSYNC_UNSUPPORTED = new Set(["EINVAL", "EBADF", "ENOTSUP", "EOPNOTSUPP"]);
222
+ /**
223
+ * fsync a directory, so a name created in it survives a crash. Windows cannot
224
+ * open a directory for this and its filesystems journal names themselves, so it
225
+ * is skipped there, as is a filesystem that reports directory fsync as
226
+ * unsupported; everywhere else a failure is the caller's to report.
227
+ */
228
+ function fsyncDirectory(dir) {
229
+ if (process.platform === "win32")
230
+ return;
231
+ const writeLayer = appendWriteLayer();
232
+ const fd = writeLayer.open(dir, fsConstants.O_RDONLY, 0);
233
+ try {
234
+ writeLayer.fsync(fd);
235
+ }
236
+ catch (cause) {
237
+ if (!DIRECTORY_FSYNC_UNSUPPORTED.has(cause.code ?? ""))
238
+ throw cause;
239
+ }
240
+ finally {
241
+ try {
242
+ writeLayer.close(fd);
243
+ }
244
+ catch {
245
+ // The fsync above is what mattered; a failed close of a read-only
246
+ // directory handle changes nothing on disk.
247
+ }
248
+ }
249
+ }
250
+ /**
251
+ * The directories whose entries a first append made new: the log's own
252
+ * directory (which gained the file), and, when `withAppendLock` had to create
253
+ * directories, the parent of each one it created, up to and including the
254
+ * parent of the topmost. Ordered innermost first.
255
+ */
256
+ function directoriesToSync(logPath, firstCreatedDir) {
257
+ const dirs = [dirname(logPath)];
258
+ if (firstCreatedDir === undefined)
259
+ return dirs;
260
+ const stop = dirname(firstCreatedDir);
261
+ for (let dir = dirs[0]; dir !== stop;) {
262
+ const parent = dirname(dir);
263
+ if (parent === dir)
264
+ break; // the filesystem root: nothing above to sync
265
+ dirs.push(parent);
266
+ dir = parent;
267
+ }
268
+ return dirs;
269
+ }
270
+ /**
271
+ * How many times one wait may find, at its end, that the lock moved or that
272
+ * another writer is reclaiming it, and wait again, before it refuses.
273
+ */
274
+ const MAX_RECLAIM_ROUNDS = 3;
141
275
  /**
142
276
  * Acquire `<logPath>.lock` by `open(…, "wx")` — atomic create-or-fail, which
143
- * needs no dependency and works on every platform Node supports. Retries with
144
- * a fixed delay until `timeoutMs` elapses, then reports `lock-timeout`. A
145
- * stale lock is never stolen: silently breaking someone else's lock is how two
146
- * writers end up sharing a `seq`.
277
+ * needs no dependency and works on every platform Node supports — and write
278
+ * this process's holder record into it (APRV-479). Retries with a fixed delay
279
+ * until `timeoutMs` elapses.
280
+ *
281
+ * A lock is never stolen from a holder that may be alive: silently breaking
282
+ * someone else's lock is how two writers end up sharing a `seq`. Only once the
283
+ * whole wait has passed does the writer judge the lock, once
284
+ * ({@link reclaimStaleLock}): a holder that is provably gone loses the lock to
285
+ * one atomic claim and take, and this writer then holds it with the reclaim to
286
+ * record first; a lock that moved, or that another writer is reclaiming, sends
287
+ * it back to wait again (at most {@link MAX_RECLAIM_ROUNDS} times); anything
288
+ * else is `lock-timeout`, whose message names the holder, why its lock was
289
+ * kept, and the human command.
290
+ *
291
+ * The termination-signal guard ({@link guardTerminationWhileLocked}) is
292
+ * installed BEFORE each create, so a lockfile never exists in this process
293
+ * without it, and is released and settled at once when the create fails, so
294
+ * the wait runs without it (a signal there kills the process holding nothing).
147
295
  */
148
- function acquireLock(logPath, timeoutMs, retryMs) {
296
+ function acquireLock(logPath, timeoutMs, retryMs, op) {
149
297
  const path = `${logPath}.lock`;
150
- const deadline = Date.now() + timeoutMs;
298
+ let deadline = Date.now() + timeoutMs;
299
+ let rounds = 0;
300
+ let kept;
301
+ // The lockfile as first refused in this wait (APRV-479 R3-8): an empty one
302
+ // is taken only if it is still that file when the wait ends.
303
+ let watched;
151
304
  for (;;) {
305
+ const releaseGuard = guardTerminationWhileLocked();
152
306
  try {
153
- closeSync(openSync(path, "wx"));
154
- return { ok: true, path };
307
+ return { ok: true, own: createLockFile(path, op), releaseGuard, reclaimed: null };
155
308
  }
156
309
  catch (cause) {
310
+ releaseGuard();
311
+ settleTerminationGuards();
157
312
  const code = cause.code;
158
313
  if (code !== "EEXIST") {
159
314
  return {
@@ -161,26 +316,73 @@ function acquireLock(logPath, timeoutMs, retryMs) {
161
316
  error: { code: "io", message: `lockfile ${path} could not be created: ${errorMessage(cause)}` },
162
317
  };
163
318
  }
164
- if (Date.now() >= deadline) {
165
- return {
166
- ok: false,
167
- error: {
168
- code: "lock-timeout",
169
- message: `another writer holds ${path}; gave up after ${timeoutMs}ms`,
170
- },
171
- };
172
- }
319
+ if (watched === undefined)
320
+ watched = observeLock(logPath);
321
+ }
322
+ if (Date.now() < deadline) {
173
323
  sleepSync(retryMs);
324
+ continue;
325
+ }
326
+ if (rounds < MAX_RECLAIM_ROUNDS) {
327
+ rounds += 1;
328
+ const outcome = reclaimStaleLock(logPath, op, {
329
+ recordValid: reclaimRecordValid,
330
+ recordContext: () => reclaimRecordContext(logPath),
331
+ watched: watched ?? null,
332
+ });
333
+ if (outcome.kind === "taken") {
334
+ return { ok: true, own: outcome.own, releaseGuard: outcome.releaseGuard, reclaimed: outcome.note };
335
+ }
336
+ kept = outcome.why;
337
+ if (outcome.kind === "retry") {
338
+ deadline = Date.now() + timeoutMs;
339
+ watched = undefined;
340
+ continue;
341
+ }
174
342
  }
343
+ const holder = kept === undefined ? "" : ` (${kept})`;
344
+ return {
345
+ ok: false,
346
+ error: {
347
+ code: "lock-timeout",
348
+ // A zero timeout is a single try by request (APRV-478: a caller that
349
+ // waits for the lock on its own event loop), and "gave up after 0ms"
350
+ // would read as a writer that never tried.
351
+ message: timeoutMs <= 0
352
+ ? `another writer holds ${path}; this append tried the lock once, as its caller asked, and did not wait${holder}`
353
+ : `another writer holds ${path}; gave up after ${timeoutMs}ms${holder}`,
354
+ },
355
+ };
175
356
  }
176
357
  }
177
- function releaseLock(path) {
358
+ /**
359
+ * Is `<logPath>.lock` present right now (APRV-478)?
360
+ *
361
+ * ADVISORY, and nothing else. It takes no lock, writes nothing, and authorizes
362
+ * nothing: the answer can be stale the instant it is returned. It exists so a
363
+ * caller that must not block its event loop (a harness hook between a grant and
364
+ * its spend, which has to stay answerable to a signal) can wait out another
365
+ * writer by sleeping on a timer, and only then make its one synchronous attempt
366
+ * (`lockTimeoutMs: 0`). That attempt is still {@link acquireLock}'s atomic
367
+ * create-or-fail, and the head is still compared under the lock, so a probe
368
+ * that said "free" and was wrong costs a refused attempt and never a shared
369
+ * `seq`. A probe that cannot stat the path answers `false`, and the attempt
370
+ * then meets the real error.
371
+ */
372
+ export function appendLockHeld(logPath) {
373
+ return existsSync(`${logPath}.lock`);
374
+ }
375
+ /**
376
+ * Remove this process's own lockfile, and only it (APRV-479: a lockfile that is
377
+ * not the one this process wrote is somebody else's). Best effort: the lock is
378
+ * advisory and a failed unlink must not mask the append's own result.
379
+ */
380
+ function releaseLock(own) {
178
381
  try {
179
- unlinkSync(path);
382
+ releaseLockFile(own);
180
383
  }
181
384
  catch {
182
- // Best effort: the lock is advisory and a failed unlink must not mask the
183
- // append's own result.
385
+ // As above.
184
386
  }
185
387
  }
186
388
  /**
@@ -353,7 +555,8 @@ function readTail(logPath) {
353
555
  },
354
556
  };
355
557
  }
356
- return { ok: true, tail: { seq, hash } };
558
+ const ts = record["ts"];
559
+ return { ok: true, tail: typeof ts === "string" ? { seq, hash, ts } : { seq, hash } };
357
560
  }
358
561
  /**
359
562
  * Evaluate the compare-and-append precondition against the tail read under the
@@ -385,8 +588,13 @@ function headPrecondition(logPath, expected, tail) {
385
588
  * Build a complete record from caller content plus chain state. Property
386
589
  * insertion order is irrelevant to the digest (JCS sorts keys) but is kept
387
590
  * readable here for anyone eyeballing the code.
591
+ *
592
+ * `daemon` comes from the writing process and never from `input`, which is why it
593
+ * is a parameter here rather than a member of {@link EventInput}: this function
594
+ * composes a record field by field, so there is no spelling of a caller's input
595
+ * that can put a `daemon` on a record.
388
596
  */
389
- function buildRecord(input, seq, prev) {
597
+ function buildRecord(input, seq, prev, daemon) {
390
598
  const record = {
391
599
  seq,
392
600
  ts: input.ts,
@@ -396,6 +604,10 @@ function buildRecord(input, seq, prev) {
396
604
  prev,
397
605
  hash: "",
398
606
  };
607
+ // Beside `actor`, because it answers the same question about a different layer:
608
+ // who caused the event, and which process wrote it down.
609
+ if (daemon !== null)
610
+ record.daemon = daemon;
399
611
  if (input.task !== undefined)
400
612
  record.task = input.task;
401
613
  if (input.action_key !== undefined)
@@ -434,8 +646,17 @@ function buildRecord(input, seq, prev) {
434
646
  * guarantee that nobody else is appending while it works.
435
647
  */
436
648
  export function withAppendLock(logPath, run, options = {}) {
649
+ return lockedRun(logPath, () => run(), options, "hold");
650
+ }
651
+ /**
652
+ * {@link withAppendLock}, also telling `run` the first directory its `mkdir`
653
+ * created (`undefined` when the log's directory already existed), which the
654
+ * append needs to know which directory entries are new (APRV-440).
655
+ */
656
+ function lockedRun(logPath, run, options, op) {
657
+ let firstCreatedDir;
437
658
  try {
438
- mkdirSync(dirname(logPath), { recursive: true });
659
+ firstCreatedDir = mkdirSync(dirname(logPath), { recursive: true });
439
660
  }
440
661
  catch (cause) {
441
662
  return {
@@ -446,14 +667,147 @@ export function withAppendLock(logPath, run, options = {}) {
446
667
  },
447
668
  };
448
669
  }
449
- const lock = acquireLock(logPath, options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS, options.lockRetryMs ?? DEFAULT_LOCK_RETRY_MS);
670
+ const lock = acquireLock(logPath, options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS, options.lockRetryMs ?? DEFAULT_LOCK_RETRY_MS, op);
450
671
  if (!lock.ok)
451
672
  return { ok: false, error: lock.error };
673
+ let reclaimWrote = false;
452
674
  try {
453
- return { ok: true, value: run() };
675
+ try {
676
+ // A lock this writer took over from a dead holder is recorded first,
677
+ // before the caller reads the tail (APRV-479).
678
+ if (lock.reclaimed !== null) {
679
+ const recorded = recordReclaim(logPath, lock.reclaimed, "system:log", firstCreatedDir);
680
+ reclaimWrote = recorded.wrote;
681
+ if (!recorded.result.ok)
682
+ return { ok: false, error: recorded.result.error };
683
+ }
684
+ return { ok: true, value: run(firstCreatedDir) };
685
+ }
686
+ finally {
687
+ releaseLock(lock.own);
688
+ }
689
+ }
690
+ finally {
691
+ lock.releaseGuard();
692
+ if (reclaimWrote)
693
+ noteAppend(logPath);
694
+ }
695
+ }
696
+ /** The `audit.lock_reclaimed` payload: the lockfile's base name, the reason, the age, and the strictly parsed holder. */
697
+ function reclaimPayload(note) {
698
+ const payload = { lockfile: note.lockfile, reason: note.reason };
699
+ if (note.age_ms !== undefined)
700
+ payload["age_ms"] = note.age_ms;
701
+ if (note.holder !== undefined)
702
+ payload["holder"] = { pid: note.holder.pid, op: note.holder.op, created: note.holder.created };
703
+ return payload;
704
+ }
705
+ /**
706
+ * The write boundary's own verdict on the record a reclaim would append,
707
+ * computed before the reclaim is made: a reclaim whose record would be refused
708
+ * is not made.
709
+ */
710
+ function reclaimRecordValid(note) {
711
+ try {
712
+ const record = buildRecord({ ts: systemClock(), event: "audit.lock_reclaimed", actor: "system:log", payload: reclaimPayload(note) }, 1, GENESIS_PREV, null);
713
+ return validate("event", record).ok;
714
+ }
715
+ catch {
716
+ return false;
717
+ }
718
+ }
719
+ /**
720
+ * Whether this writer could append the reclaim record now (APRV-479 R3-3): the
721
+ * two refusals {@link recordReclaim} would meet that the schema check cannot
722
+ * see, its daemon stamp and the log's tail, both read without the lock. A
723
+ * reclaim whose record would be refused is not made: the lock stays for a
724
+ * writer that can record it, and nothing is claimed or appended. When it
725
+ * could, the last record's `ts`, which bounds the holder `created` the record
726
+ * may carry (R3-7).
727
+ */
728
+ function reclaimRecordContext(logPath) {
729
+ const stamp = daemonStampForAppend();
730
+ if (stamp.kind === "refuse")
731
+ return { ok: false, why: `its records are refused: ${stamp.code}` };
732
+ const tail = readTail(logPath);
733
+ if (!tail.ok) {
734
+ return { ok: false, why: `the log's tail refuses an append (${tail.error.code}), which a human repairs first; \`approval log verify\` shows where` };
735
+ }
736
+ const lastTs = tail.tail.ts === undefined ? Number.NaN : Date.parse(tail.tail.ts);
737
+ return { ok: true, lastTs: Number.isFinite(lastTs) ? lastTs : null };
738
+ }
739
+ /**
740
+ * The record of a lock taken over from a holder that is gone (APRV-479),
741
+ * appended as the first write under the lock that was taken: before the
742
+ * caller's own read of the tail, so the caller's compare-and-append sees it as
743
+ * the moved head it is. Written through the same path as every other record,
744
+ * with no head precondition (it decides nothing from the log) and the same
745
+ * daemon stamp. Only once it is in the log is the stale name removed; when it
746
+ * cannot be appended, nothing else is, and the stale name stays beside the lock
747
+ * as the evidence.
748
+ */
749
+ function recordReclaim(logPath, note, actor, firstCreatedDir) {
750
+ const stamp = daemonStampForAppend();
751
+ if (stamp.kind === "refuse")
752
+ return { result: fail(stamp.code, stamp.message), wrote: false };
753
+ const daemon = stamp.kind === "stamp" ? stamp.id : null;
754
+ const recorded = appendUnderLock(logPath, { ts: systemClock(), event: "audit.lock_reclaimed", actor, payload: reclaimPayload(note) }, {}, firstCreatedDir, daemon);
755
+ if (recorded.result.ok) {
756
+ finishReclaim(note);
757
+ return recorded;
758
+ }
759
+ return {
760
+ result: {
761
+ ok: false,
762
+ error: {
763
+ ...recorded.result.error,
764
+ message: `${recorded.result.error.message} (${note.lockfile} was taken over from a holder that is gone, and the record of that could not be appended, so nothing else was; ${basename(note.stale)} is left beside the lock as the evidence)`,
765
+ },
766
+ },
767
+ wrote: recorded.wrote,
768
+ };
769
+ }
770
+ /**
771
+ * `approval log unlock --pid <n|none>` (APRV-479): a human takes over a lock no
772
+ * writer reclaims (another container, another boot, a holder that cannot be
773
+ * checked), with the same claim and take a writer's reclaim uses, and records
774
+ * it as `audit.lock_reclaimed` (reason `operator-cleared`) under their own
775
+ * `human:` actor as the first
776
+ * record under the lock taken. `pid` must be the pid the lockfile names, or
777
+ * `null` for one that names no holder. Human-only: the CLI resolves the actor,
778
+ * the schema refuses an `agent:` one, and the harness hook classifies the verb
779
+ * `policy.core`.
780
+ */
781
+ export function unlockAppendLock(logPath, pid, actor) {
782
+ if (!existsSync(logPath)) {
783
+ return { kind: "refused", message: `${logPath} does not exist, so there is no log a record of the unlock could follow; remove ${basename(logPath)}.lock by hand` };
784
+ }
785
+ const context = reclaimRecordContext(logPath);
786
+ if (!context.ok) {
787
+ return { kind: "refused", message: `the record of the unlock could not be appended (${context.why}), so nothing was touched` };
788
+ }
789
+ const taken = takeLockForUnlock(logPath, pid, context.lastTs);
790
+ if (taken.kind === "none")
791
+ return { kind: "none" };
792
+ if (taken.kind === "refused")
793
+ return { kind: "refused", message: taken.why };
794
+ let wrote = false;
795
+ try {
796
+ try {
797
+ const recorded = recordReclaim(logPath, taken.note, actor, undefined);
798
+ wrote = recorded.wrote;
799
+ if (!recorded.result.ok)
800
+ return { kind: "failed", error: recorded.result.error };
801
+ return { kind: "unlocked", record: recorded.result.record, note: taken.note };
802
+ }
803
+ finally {
804
+ releaseLock(taken.own);
805
+ }
454
806
  }
455
807
  finally {
456
- releaseLock(lock.path);
808
+ taken.releaseGuard();
809
+ if (wrote)
810
+ noteAppend(logPath);
457
811
  }
458
812
  }
459
813
  /**
@@ -467,7 +821,11 @@ export function withAppendLock(logPath, run, options = {}) {
467
821
  * not turn a written record into a failed one, so throws are swallowed.
468
822
  */
469
823
  const appendListeners = [];
470
- /** Subscribe to successful appends. Process-wide, memory-only, additive. */
824
+ /**
825
+ * Subscribe to appends that put bytes in the file: every successful one, and
826
+ * since APRV-440 one that wrote and then failed to confirm them durable (a
827
+ * short write, a failed fsync). Process-wide, memory-only, additive.
828
+ */
471
829
  export function onLogAppended(listener) {
472
830
  appendListeners.push(listener);
473
831
  }
@@ -482,7 +840,44 @@ function noteAppend(logPath) {
482
840
  }
483
841
  }
484
842
  export function appendEvent(logPath, input, options = {}) {
485
- const held = withAppendLock(logPath, () => {
843
+ // Which daemon is writing, and whether it may (APRV-383). Asked of this
844
+ // process's own state rather than of the caller, and asked BEFORE the lock
845
+ // because it reads no log state: taking a lock only to refuse would make every
846
+ // other writer wait for an answer that was never going to touch the file. It is
847
+ // the write boundary in the sense that matters — one code path, nothing written
848
+ // on the refused branch, and the record's own schema pins the field's shape so a
849
+ // malformed id could not land even if this check were absent.
850
+ const stamp = daemonStampForAppend();
851
+ if (stamp.kind === "refuse")
852
+ return fail(stamp.code, stamp.message);
853
+ const daemon = stamp.kind === "stamp" ? stamp.id : null;
854
+ // Set once bytes may reach the file, success or not: the read cache
855
+ // must re-prove a log whose bytes changed even when the append then failed to
856
+ // confirm them durable.
857
+ let wrote = false;
858
+ const held = lockedRun(logPath, (firstCreatedDir) => {
859
+ const appended = appendUnderLock(logPath, input, options, firstCreatedDir, daemon);
860
+ wrote = appended.wrote;
861
+ return appended.result;
862
+ }, options, "append");
863
+ const result = held.ok ? held.value : { ok: false, error: held.error };
864
+ // After the lock is released, for every append that put bytes in the file:
865
+ // the reader cache must not serve a prefix proof anchored before this write.
866
+ if (wrote)
867
+ noteAppend(logPath);
868
+ return result;
869
+ }
870
+ /**
871
+ * One append, with `<logPath>.lock` already held by this process: read the
872
+ * tail, compare the head, build, validate, write, fsync. The body of
873
+ * {@link appendEvent}, shared with the reclaim record (APRV-479) so that record
874
+ * goes through every check any other record does. `wrote` says whether bytes
875
+ * may have reached the file, success or not.
876
+ */
877
+ function appendUnderLock(logPath, input, options, firstCreatedDir, daemon) {
878
+ let wrote = false;
879
+ const done = (result) => ({ result, wrote });
880
+ const run = () => {
486
881
  const tail = readTail(logPath);
487
882
  if (!tail.ok)
488
883
  return { ok: false, error: tail.error };
@@ -497,7 +892,7 @@ export function appendEvent(logPath, input, options = {}) {
497
892
  let record;
498
893
  let line;
499
894
  try {
500
- record = buildRecord(input, tail.tail.seq + 1, tail.tail.hash);
895
+ record = buildRecord(input, tail.tail.seq + 1, tail.tail.hash, daemon);
501
896
  // Stored line = JCS of the complete record; digest input excluded `hash`.
502
897
  line = serializeRecord(record);
503
898
  }
@@ -512,35 +907,63 @@ export function appendEvent(logPath, input, options = {}) {
512
907
  if (!validation.ok) {
513
908
  return fail("validation", `event failed schema validation at the write boundary; log left unchanged`, validation.errors);
514
909
  }
515
- let fd;
910
+ let opened;
516
911
  try {
517
- fd = openSync(logPath, "a");
912
+ opened = openForAppend(logPath);
518
913
  }
519
914
  catch (cause) {
520
915
  return fail("io", `log ${logPath} could not be opened for append: ${errorMessage(cause)}`);
521
916
  }
917
+ const { fd, created } = opened;
918
+ const writeLayer = appendWriteLayer();
919
+ const bytes = Buffer.from(`${line}\n`, "utf8");
522
920
  try {
523
921
  // Single write syscall on an O_APPEND handle: the line lands whole or not at all.
524
- writeSync(fd, `${line}\n`);
525
- }
526
- catch (cause) {
527
- return fail("io", `log ${logPath} could not be written: ${errorMessage(cause)}`);
922
+ // Set BEFORE the write: one that throws may still have put bytes in the
923
+ // file, and a needless re-proof by the read cache costs only time.
924
+ wrote = true;
925
+ let written;
926
+ try {
927
+ written = writeLayer.write(fd, bytes);
928
+ }
929
+ catch (cause) {
930
+ return fail("io", `log ${logPath} could not be written: ${errorMessage(cause)}`);
931
+ }
932
+ if (written !== bytes.length) {
933
+ return fail("io", `log ${logPath} took ${String(written)} of ${String(bytes.length)} bytes of seq ${String(record.seq)}: the write was short, so the file now ends with a torn line and the record was not appended`);
934
+ }
935
+ // Durable before ok (guarantee 5b). Until this returns, the record exists
936
+ // in the page cache only, and a kill before writeback leaves the file
937
+ // extended over unwritten blocks: a NUL-filled tail where the record was.
938
+ try {
939
+ writeLayer.fsync(fd);
940
+ }
941
+ catch (cause) {
942
+ return fail("io", `log ${logPath}: seq ${String(record.seq)} was written but fsync failed (${errorMessage(cause)}), so it is not known to be durable: it may be in the file now and gone after a crash. Re-read the log before deciding anything from it.`);
943
+ }
528
944
  }
529
945
  finally {
530
946
  try {
531
- closeSync(fd);
947
+ writeLayer.close(fd);
532
948
  }
533
949
  catch {
534
- // Nothing actionable; the data is already handed to the kernel.
950
+ // Nothing actionable: the bytes are written and, on the ok path, synced.
951
+ }
952
+ }
953
+ // A new file is a new directory entry, and fsync of the file does not make
954
+ // its NAME durable: without this a crash can keep the bytes and lose the file.
955
+ if (created) {
956
+ for (const dir of directoriesToSync(logPath, firstCreatedDir)) {
957
+ try {
958
+ fsyncDirectory(dir);
959
+ }
960
+ catch (cause) {
961
+ return fail("io", `log ${logPath}: seq ${String(record.seq)} was written and synced but its directory ${dir} could not be (${errorMessage(cause)}), so the newly created file is not known to survive a crash. Re-read the log before deciding anything from it.`);
962
+ }
535
963
  }
536
964
  }
537
965
  return { ok: true, record, line };
538
- }, options);
539
- const result = held.ok ? held.value : { ok: false, error: held.error };
540
- // After the lock is released and only for a record that actually landed: the
541
- // reader cache must not serve a prefix proof anchored before this write.
542
- if (result.ok)
543
- noteAppend(logPath);
544
- return result;
966
+ };
967
+ return done(run());
545
968
  }
546
969
  //# sourceMappingURL=log.js.map