@openclaw/fs-safe 0.4.6 → 0.5.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 (246) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +110 -41
  3. package/dist/archive-entry.d.ts.map +1 -1
  4. package/dist/archive-entry.js +5 -4
  5. package/dist/archive-errors.d.ts +11 -0
  6. package/dist/archive-errors.d.ts.map +1 -0
  7. package/dist/archive-errors.js +16 -0
  8. package/dist/archive-input.d.ts +12 -0
  9. package/dist/archive-input.d.ts.map +1 -0
  10. package/dist/archive-input.js +72 -0
  11. package/dist/archive-kind.d.ts +1 -1
  12. package/dist/archive-kind.d.ts.map +1 -1
  13. package/dist/archive-kind.js +18 -0
  14. package/dist/archive-limits.d.ts +10 -0
  15. package/dist/archive-limits.d.ts.map +1 -1
  16. package/dist/archive-limits.js +18 -0
  17. package/dist/archive-native.d.ts +18 -0
  18. package/dist/archive-native.d.ts.map +1 -0
  19. package/dist/archive-native.js +115 -0
  20. package/dist/archive-options.d.ts +21 -0
  21. package/dist/archive-options.d.ts.map +1 -0
  22. package/dist/archive-options.js +1 -0
  23. package/dist/archive-policy.d.ts +20 -0
  24. package/dist/archive-policy.d.ts.map +1 -0
  25. package/dist/archive-policy.js +29 -0
  26. package/dist/archive-read.d.ts +6 -0
  27. package/dist/archive-read.d.ts.map +1 -0
  28. package/dist/archive-read.js +200 -0
  29. package/dist/archive-staging.d.ts +2 -5
  30. package/dist/archive-staging.d.ts.map +1 -1
  31. package/dist/archive-staging.js +19 -10
  32. package/dist/archive-tar-meta.d.ts +6 -0
  33. package/dist/archive-tar-meta.d.ts.map +1 -0
  34. package/dist/archive-tar-meta.js +143 -0
  35. package/dist/archive-tar-runtime.d.ts +43 -0
  36. package/dist/archive-tar-runtime.d.ts.map +1 -0
  37. package/dist/archive-tar-runtime.js +8 -0
  38. package/dist/archive-tar.d.ts +5 -1
  39. package/dist/archive-tar.d.ts.map +1 -1
  40. package/dist/archive-tar.js +24 -6
  41. package/dist/archive-zip-entry.d.ts +15 -0
  42. package/dist/archive-zip-entry.d.ts.map +1 -0
  43. package/dist/archive-zip-entry.js +17 -0
  44. package/dist/archive.d.ts +7 -17
  45. package/dist/archive.d.ts.map +1 -1
  46. package/dist/archive.js +111 -108
  47. package/dist/atomic.d.ts +1 -1
  48. package/dist/atomic.d.ts.map +1 -1
  49. package/dist/config.d.ts +1 -1
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/config.js +1 -1
  52. package/dist/containment.d.ts +2 -0
  53. package/dist/containment.d.ts.map +1 -0
  54. package/dist/containment.js +1 -0
  55. package/dist/directory-durability.d.ts +44 -0
  56. package/dist/directory-durability.d.ts.map +1 -0
  57. package/dist/directory-durability.js +299 -0
  58. package/dist/durability.d.ts +4 -0
  59. package/dist/durability.d.ts.map +1 -0
  60. package/dist/durability.js +3 -0
  61. package/dist/errors.d.ts +4 -1
  62. package/dist/errors.d.ts.map +1 -1
  63. package/dist/errors.js +2 -0
  64. package/dist/file-hash.d.ts +10 -0
  65. package/dist/file-hash.d.ts.map +1 -0
  66. package/dist/file-hash.js +73 -0
  67. package/dist/file-lock-sync.d.ts +35 -0
  68. package/dist/file-lock-sync.d.ts.map +1 -0
  69. package/dist/file-lock-sync.js +233 -0
  70. package/dist/file-lock.d.ts +2 -0
  71. package/dist/file-lock.d.ts.map +1 -1
  72. package/dist/file-lock.js +1 -0
  73. package/dist/filename.d.ts.map +1 -1
  74. package/dist/filename.js +4 -1
  75. package/dist/fsync.d.ts +1 -1
  76. package/dist/fsync.d.ts.map +1 -1
  77. package/dist/fsync.js +1 -21
  78. package/dist/guarded-mkdir.d.ts +7 -1
  79. package/dist/guarded-mkdir.d.ts.map +1 -1
  80. package/dist/guarded-mkdir.js +45 -3
  81. package/dist/index.d.ts +4 -3
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +1 -1
  84. package/dist/json-document-store.d.ts.map +1 -1
  85. package/dist/json-document-store.js +30 -15
  86. package/dist/move-path.d.ts.map +1 -1
  87. package/dist/move-path.js +101 -17
  88. package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
  89. package/dist/native/darwin-x64/fs-safe-native.node +0 -0
  90. package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
  91. package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
  92. package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
  93. package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
  94. package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
  95. package/dist/native-binding.d.ts +79 -0
  96. package/dist/native-binding.d.ts.map +1 -0
  97. package/dist/native-binding.js +1 -0
  98. package/dist/native-config.d.ts +17 -0
  99. package/dist/native-config.d.ts.map +1 -0
  100. package/dist/native-config.js +71 -0
  101. package/dist/native-operations.d.ts +21 -0
  102. package/dist/native-operations.d.ts.map +1 -0
  103. package/dist/native-operations.js +115 -0
  104. package/dist/native-pinned-write.d.ts +5 -0
  105. package/dist/native-pinned-write.d.ts.map +1 -0
  106. package/dist/native-pinned-write.js +115 -0
  107. package/dist/native.d.ts +14 -0
  108. package/dist/native.d.ts.map +1 -0
  109. package/dist/native.js +204 -0
  110. package/dist/output-sibling.d.ts +8 -0
  111. package/dist/output-sibling.d.ts.map +1 -0
  112. package/dist/output-sibling.js +122 -0
  113. package/dist/output.d.ts +2 -0
  114. package/dist/output.d.ts.map +1 -1
  115. package/dist/output.js +32 -5
  116. package/dist/owner-dacl.d.ts +31 -0
  117. package/dist/owner-dacl.d.ts.map +1 -0
  118. package/dist/owner-dacl.js +27 -0
  119. package/dist/path-policy.js +1 -1
  120. package/dist/permissions-public.d.ts +2 -0
  121. package/dist/permissions-public.d.ts.map +1 -1
  122. package/dist/permissions-public.js +2 -0
  123. package/dist/permissions.d.ts.map +1 -1
  124. package/dist/permissions.js +6 -0
  125. package/dist/pinned-operation.d.ts +2 -0
  126. package/dist/pinned-operation.d.ts.map +1 -0
  127. package/dist/pinned-operation.js +35 -0
  128. package/dist/pinned-write.d.ts +3 -16
  129. package/dist/pinned-write.d.ts.map +1 -1
  130. package/dist/pinned-write.js +20 -85
  131. package/dist/private-directory.d.ts +5 -0
  132. package/dist/private-directory.d.ts.map +1 -0
  133. package/dist/private-directory.js +13 -0
  134. package/dist/private-temp-workspace.d.ts +6 -2
  135. package/dist/private-temp-workspace.d.ts.map +1 -1
  136. package/dist/private-temp-workspace.js +40 -13
  137. package/dist/publish-file-failure.d.ts +27 -0
  138. package/dist/publish-file-failure.d.ts.map +1 -0
  139. package/dist/publish-file-failure.js +21 -0
  140. package/dist/publish-file.d.ts +21 -0
  141. package/dist/publish-file.d.ts.map +1 -0
  142. package/dist/publish-file.js +351 -0
  143. package/dist/read-opened-file.d.ts +3 -0
  144. package/dist/read-opened-file.d.ts.map +1 -1
  145. package/dist/read-opened-file.js +1 -0
  146. package/dist/replace-file-copy-fallback.d.ts +34 -0
  147. package/dist/replace-file-copy-fallback.d.ts.map +1 -0
  148. package/dist/replace-file-copy-fallback.js +339 -0
  149. package/dist/replace-file.d.ts +6 -2
  150. package/dist/replace-file.d.ts.map +1 -1
  151. package/dist/replace-file.js +39 -90
  152. package/dist/root-context.d.ts +4 -1
  153. package/dist/root-context.d.ts.map +1 -1
  154. package/dist/root-context.js +20 -1
  155. package/dist/root-impl.d.ts +5 -0
  156. package/dist/root-impl.d.ts.map +1 -1
  157. package/dist/root-impl.js +128 -225
  158. package/dist/root-path-existing.d.ts +3 -0
  159. package/dist/root-path-existing.d.ts.map +1 -0
  160. package/dist/root-path-existing.js +69 -0
  161. package/dist/root-path.d.ts +1 -2
  162. package/dist/root-path.d.ts.map +1 -1
  163. package/dist/root-path.js +67 -81
  164. package/dist/root-walk.d.ts +41 -0
  165. package/dist/root-walk.d.ts.map +1 -0
  166. package/dist/root-walk.js +114 -0
  167. package/dist/root.d.ts +2 -0
  168. package/dist/root.d.ts.map +1 -1
  169. package/dist/secret-file.d.ts +5 -2
  170. package/dist/secret-file.d.ts.map +1 -1
  171. package/dist/secret-file.js +20 -2
  172. package/dist/secret-read-async.d.ts +4 -0
  173. package/dist/secret-read-async.d.ts.map +1 -0
  174. package/dist/secret-read-async.js +95 -0
  175. package/dist/secret.d.ts +2 -1
  176. package/dist/secret.d.ts.map +1 -1
  177. package/dist/secret.js +2 -1
  178. package/dist/sibling-temp.d.ts.map +1 -1
  179. package/dist/sibling-temp.js +2 -13
  180. package/dist/sidecar-lock-handle.d.ts +20 -0
  181. package/dist/sidecar-lock-handle.d.ts.map +1 -0
  182. package/dist/sidecar-lock-handle.js +28 -0
  183. package/dist/sidecar-lock-policy.d.ts +10 -0
  184. package/dist/sidecar-lock-policy.d.ts.map +1 -0
  185. package/dist/sidecar-lock-policy.js +29 -0
  186. package/dist/sidecar-lock-reclaim.d.ts +22 -6
  187. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  188. package/dist/sidecar-lock-reclaim.js +106 -20
  189. package/dist/sidecar-lock-types.d.ts +56 -0
  190. package/dist/sidecar-lock-types.d.ts.map +1 -0
  191. package/dist/sidecar-lock-types.js +1 -0
  192. package/dist/sidecar-lock.d.ts +2 -45
  193. package/dist/sidecar-lock.d.ts.map +1 -1
  194. package/dist/sidecar-lock.js +94 -55
  195. package/dist/temp-cleanup.d.ts +7 -1
  196. package/dist/temp-cleanup.d.ts.map +1 -1
  197. package/dist/temp-cleanup.js +35 -5
  198. package/dist/temp.d.ts +2 -1
  199. package/dist/temp.d.ts.map +1 -1
  200. package/dist/test-hooks.d.ts +3 -0
  201. package/dist/test-hooks.d.ts.map +1 -1
  202. package/dist/windows-permissions-native.d.ts +9 -0
  203. package/dist/windows-permissions-native.d.ts.map +1 -0
  204. package/dist/windows-permissions-native.js +31 -0
  205. package/docs/advanced.md +1 -1
  206. package/docs/archive.md +131 -13
  207. package/docs/atomic.md +30 -1
  208. package/docs/config.md +51 -21
  209. package/docs/durability.md +299 -0
  210. package/docs/errors.md +36 -6
  211. package/docs/filename.md +0 -0
  212. package/docs/index.md +11 -7
  213. package/docs/install.md +27 -26
  214. package/docs/json-store.md +18 -3
  215. package/docs/migrating-to-0.5.md +196 -0
  216. package/docs/native-helper.md +85 -0
  217. package/docs/native.md +134 -0
  218. package/docs/output.md +37 -11
  219. package/docs/path.md +1 -1
  220. package/docs/permissions.md +84 -1
  221. package/docs/quickstart.md +5 -2
  222. package/docs/reading.md +4 -4
  223. package/docs/root.md +31 -15
  224. package/docs/secret-file.md +50 -1
  225. package/docs/security-model.md +42 -8
  226. package/docs/sidecar-lock.md +92 -7
  227. package/docs/temp.md +24 -1
  228. package/docs/test-hooks.md +22 -2
  229. package/docs/testing.md +18 -7
  230. package/docs/types.md +3 -1
  231. package/docs/walk.md +55 -0
  232. package/docs/writing.md +3 -3
  233. package/package.json +12 -2
  234. package/dist/pinned-helper.d.ts +0 -9
  235. package/dist/pinned-helper.d.ts.map +0 -1
  236. package/dist/pinned-helper.js +0 -19
  237. package/dist/pinned-path.d.ts +0 -7
  238. package/dist/pinned-path.d.ts.map +0 -1
  239. package/dist/pinned-path.js +0 -21
  240. package/dist/pinned-python-config.d.ts +0 -9
  241. package/dist/pinned-python-config.d.ts.map +0 -1
  242. package/dist/pinned-python-config.js +0 -38
  243. package/dist/pinned-python.d.ts +0 -12
  244. package/dist/pinned-python.d.ts.map +0 -1
  245. package/dist/pinned-python.js +0 -688
  246. package/docs/python-helper.md +0 -103
@@ -0,0 +1,299 @@
1
+ ---
2
+ title: Directory durability
3
+ description: "Pin directory identities, fsync publication metadata, and durably create nested directory paths."
4
+ ---
5
+
6
+ # Directory durability
7
+
8
+ `@openclaw/fs-safe/durability` provides the directory side of crash-safe file
9
+ publication. Flushing a file does not guarantee that its containing directory
10
+ entry reached storage; callers that promise durable create, link, rename, or
11
+ unlink operations must also synchronize the affected directory.
12
+
13
+ ```ts
14
+ import {
15
+ ensureDurableDirectory,
16
+ pinDirectory,
17
+ } from "@openclaw/fs-safe/durability";
18
+
19
+ const repository = await ensureDurableDirectory({
20
+ directoryPath: "/srv/backups/sqlite",
21
+ mode: 0o700,
22
+ });
23
+
24
+ const pinned = await pinDirectory(repository, { label: "backup repository" });
25
+ try {
26
+ await publishSnapshot();
27
+ const outcome = await pinned.sync();
28
+ if (outcome.status === "unsupported") {
29
+ // Decide at the product boundary whether this platform can weaken the promise.
30
+ }
31
+ } finally {
32
+ await pinned.close();
33
+ }
34
+ ```
35
+
36
+ ## Outcomes and failure semantics
37
+
38
+ `syncDirectory()` and `PinnedDirectory.sync()` return:
39
+
40
+ ```ts
41
+ type DirectorySyncOutcome =
42
+ | { status: "synced" }
43
+ | { status: "unsupported"; code?: string };
44
+ ```
45
+
46
+ POSIX synchronization failures propagate. Windows directory handles do not
47
+ portably support `FlushFileBuffers`; the known unsupported error family is
48
+ reported as `unsupported` after the pathname and pinned identity are checked
49
+ again. Directory-open access failures and other Windows I/O failures still
50
+ propagate.
51
+
52
+ `syncDirectoryBestEffort()` and `syncDirectoryBestEffortSync()` intentionally
53
+ discard both unsupported outcomes and failures. Use them only when the primary
54
+ write remains useful without a crash-durability promise.
55
+
56
+ ## Pinned directories
57
+
58
+ `pinDirectory()` rejects final symlinks and non-directories. On POSIX it opens
59
+ with `O_DIRECTORY`, `O_NOFOLLOW`, and `O_NONBLOCK`, then compares the open
60
+ descriptor, pathname identity, and canonical path. `assertCurrent()` repeats
61
+ those checks. This prevents a pathname replacement from turning a later sync
62
+ into proof for a different directory.
63
+
64
+ Call `close()` in `finally`. Closing is idempotent; using a closed pin fails.
65
+
66
+ ## Durable directory creation
67
+
68
+ `ensureDurableDirectory()` finds and pins the nearest existing ancestor,
69
+ creates the requested path, opens every new directory segment, and synchronizes
70
+ each new parent-to-child edge from the leaf upward. It returns the final
71
+ directory receipt plus the aggregate parent-sync outcome.
72
+
73
+ By default it uses fs-safe's guarded one-segment-at-a-time absolute-directory
74
+ creator. Advanced callers can pass `create` when directory creation needs
75
+ platform-specific ACLs or another product-owned policy. The callback owns the
76
+ safety of its mutations and must create exactly `directoryPath`; fs-safe
77
+ validates and pins every resulting segment before any synchronization is
78
+ accepted.
79
+
80
+ `expectedExistingIdentity` binds an existing target to an identity observed by
81
+ the caller before a separate permission or policy check. A missing or replaced
82
+ target fails with `FsSafeError("path-mismatch")`.
83
+
84
+ ## Exclusive file publication
85
+
86
+ `publishFileExclusive()` materializes one file without clobbering an existing
87
+ target. It pins the source with `O_NOFOLLOW`, optionally verifies
88
+ `expectedSourceIdentity`, tries a hardlink first, then synchronizes the target
89
+ parent directory.
90
+
91
+ For example, a backup archive is complete before publication. If directory
92
+ sync fails, keeping that complete file is more useful than conditionally
93
+ deleting it by pathname:
94
+
95
+ ```ts
96
+ import { FsSafeError } from "@openclaw/fs-safe/errors";
97
+ import { publishFileExclusive } from "@openclaw/fs-safe/durability";
98
+
99
+ try {
100
+ const result = await publishFileExclusive({
101
+ sourcePath: stagedArchive,
102
+ targetPath: finalArchive,
103
+ strategy: "link-or-copy",
104
+ onSyncFailure: "preserve",
105
+ parentReceipt: backupDirectory,
106
+ });
107
+ recordDurableBackup(result.identity, result.directorySync);
108
+ } catch (error) {
109
+ if (
110
+ error instanceof FsSafeError &&
111
+ error.details?.phase === "directory-sync" &&
112
+ error.details.cleanup === "preserved"
113
+ ) {
114
+ recordCompleteButPossiblyNonDurableBackup(finalArchive, error.details);
115
+ } else {
116
+ throw error;
117
+ }
118
+ }
119
+ ```
120
+
121
+ ### Strategies
122
+
123
+ | Strategy | Behavior | Native requirement |
124
+ |---|---|---|
125
+ | `link-required` | Create a same-filesystem hardlink or propagate the failure. | No; guarded JS `link` fallback remains. |
126
+ | `link-or-copy` | Try hardlink, then clone, Linux `copy_file_range`, then the JS byte loop for classified unsupported errors. | No; acceleration is optional. |
127
+ | `rename-noreplace` | Atomically move the source without replacing an existing target. Success consumes `sourcePath`. | Yes. |
128
+
129
+ `"link-required"` propagates an unsupported hardlink failure.
130
+ `"link-or-copy"` falls back only for `EPERM`, `EXDEV`, `ENOTSUP`,
131
+ `EOPNOTSUPP`, or `ENOSYS`; `isHardlinkFallbackError()` exposes that exact
132
+ classifier. The fallback copies from the pinned source into a `wx` target,
133
+ fsyncs it, and fences source and target identity and content before reporting
134
+ success. `parentReceipt`, when supplied, must name the target's direct parent.
135
+
136
+ With a native binding, the copy fallback first attempts a copy-on-write clone
137
+ (`fclonefileat` on macOS, `FICLONE` on Linux), then Linux
138
+ `copy_file_range`, and finally the existing JavaScript byte loop. Every route
139
+ creates the target exclusively, normalizes its mode to `0o600`, and goes
140
+ through the same post-copy identity and SHA-256 fencing. Hashing uses an async
141
+ native task when available, so large verification reads do not occupy the
142
+ JavaScript event loop.
143
+
144
+ On a clone-capable filesystem, publication of a large file becomes mostly a
145
+ metadata operation: data blocks are shared copy-on-write until either file is
146
+ modified. Clone support is filesystem- and mount-dependent, so callers must
147
+ not infer durability or physical independence from timing; an unsupported
148
+ clone or `copy_file_range` transparently continues down the fallback chain.
149
+
150
+ ## Recoverable atomic-replace fallback
151
+
152
+ `replaceFileAtomic()` normally publishes a synchronized sibling temp with an
153
+ atomic rename. Some Windows filesystems and file owners reject that rename with
154
+ `EPERM` or `EEXIST`; `copyFallbackOnPermissionError: true` permits a non-atomic
155
+ copy fallback.
156
+
157
+ Callers that cannot tolerate a torn in-place fallback can add:
158
+
159
+ ```ts
160
+ await replaceFileAtomic({
161
+ filePath: statePath,
162
+ content: nextState,
163
+ syncTempFile: true,
164
+ syncParentDir: true,
165
+ copyFallbackOnPermissionError: true,
166
+ copyFallbackRestore: "restore-original",
167
+ maxRestoreBytes: 4 * 1024 * 1024,
168
+ destinationHardlinks: "reject",
169
+ });
170
+ ```
171
+
172
+ The existing regular-file destination is pinned before its link count is
173
+ accepted. Its original bytes are read within `maxRestoreBytes`, then the new
174
+ bytes are written and synchronized through the same descriptor. If a write or
175
+ sync tears, fs-safe rewrites the snapshot and fsyncs it before throwing. Inspect
176
+ `details.cleanup`: `"restored"` means the original bytes were put back and
177
+ synchronized; `"restore-failed"` means the replacement and recovery both
178
+ failed, so the destination must be treated as indeterminate. This is recovery
179
+ from a live-process I/O failure, not a transaction or a substitute for an
180
+ application backup protocol.
181
+
182
+ ## Streaming SHA-256
183
+
184
+ `sha256File()` hashes either a pathname string or an already-open Node
185
+ `FileHandle`. A backup verifier can pin the file itself, compare its size, and
186
+ keep ownership of the handle:
187
+
188
+ ```ts
189
+ import { open } from "node:fs/promises";
190
+ import { sha256File } from "@openclaw/fs-safe/durability";
191
+
192
+ const snapshot = await open(stagedArchive, "r");
193
+ try {
194
+ const before = await snapshot.stat();
195
+ const hash = await sha256File(snapshot);
196
+ if (hash.bytes !== before.size || hash.digest !== manifest.sha256) {
197
+ throw new Error("staged backup does not match its manifest");
198
+ }
199
+ } finally {
200
+ await snapshot.close();
201
+ }
202
+ ```
203
+
204
+ The result is `{ bytes, digest }`, where `digest` is lowercase hexadecimal.
205
+ The handle overload never closes the caller's descriptor and uses positioned
206
+ reads, so it does not alter the descriptor's current offset. The path overload
207
+ rejects symbolic links and non-regular files, verifies the opened descriptor
208
+ still names the requested path, and closes its own handle. POSIX opens are
209
+ nonblocking, so a raced FIFO or device is rejected after descriptor inspection
210
+ rather than waiting for a writer.
211
+
212
+ When the optional binding is active, hashing runs as an async native task and
213
+ does not occupy the JavaScript event loop with digest updates. With native mode
214
+ `off`, or in `auto` when no binding loads, the fallback performs asynchronous
215
+ positioned reads in 64 KiB chunks but updates Node's `Hash` on the JavaScript
216
+ thread. Both paths stream constant-size buffers rather than loading the file
217
+ into memory. Native mode `require` keeps its usual fail-closed loader semantics.
218
+
219
+ If publication fails after this call created the target, it throws an
220
+ `FsSafeError` with a `details` receipt:
221
+
222
+ ```ts
223
+ type PublishFileExclusiveFailureDetails = {
224
+ phase:
225
+ | "hardlink-create" | "hardlink-verify"
226
+ | "copy-create" | "copy-verify"
227
+ | "rename-create" | "rename-verify"
228
+ | "directory-sync";
229
+ targetCreated: boolean;
230
+ targetIdentity?: { dev: number | bigint; ino: number | bigint };
231
+ cleanup: "removed" | "preserved" | "unknown";
232
+ directorySync?: { status: "failed"; code?: string };
233
+ };
234
+ ```
235
+
236
+ `"removed"` means the path still matched the identity created by this call and
237
+ was unlinked (or was already absent). `"preserved"` means it was deliberately
238
+ retained—for example after a successful no-replace rename—or the pathname had
239
+ been replaced and therefore was not safe to remove. `"unknown"` means cleanup
240
+ could not verify or remove the created identity. Callers that run a second
241
+ application-level guard, such as SQLite snapshot validation, should branch on
242
+ this receipt instead of inferring ownership from path existence. The original
243
+ failure remains available as `cause`. Failures before target creation retain
244
+ their existing error shape and do not claim a cleanup result.
245
+
246
+ ### Directory-sync failure policy
247
+
248
+ `onSyncFailure` applies only after target creation and content/identity fencing
249
+ have succeeded but synchronizing the containing directory throws:
250
+
251
+ ```ts
252
+ type PublishFileExclusiveSyncFailurePolicy = "rollback" | "preserve";
253
+ ```
254
+
255
+ A returned `{ status: "unsupported", code? }` is an explicit successful
256
+ publication outcome, not a thrown sync failure, so this option does not rewrite
257
+ or clean up that target.
258
+
259
+ - `rollback` is the default. fs-safe removes the target only if its current
260
+ identity still matches the file created by this call. A replacement is never
261
+ removed. The error reports `cleanup: "removed"`, `"preserved"`, or
262
+ `"unknown"` and `directorySync: { status: "failed", code? }`.
263
+ - `preserve` never attempts that unlink. The error reports
264
+ `targetCreated: true`, `cleanup: "preserved"`, the created identity, and the
265
+ failed directory-sync outcome. The file is complete and fenced, but its
266
+ directory entry is not proven crash-durable.
267
+
268
+ Choose `rollback` when the pathname must mean “durably committed” and a failed
269
+ commit should disappear from the live process view. Choose `preserve` when the
270
+ payload itself remains valuable—backup archives are the common case—and the
271
+ caller can record, retry, or independently validate durability. Neither choice
272
+ can make a failed directory sync succeed: rollback deletion is also not proven
273
+ durable, and a preserved name may disappear after a crash. Always use the
274
+ typed receipt rather than inferring ownership from `exists()`.
275
+
276
+ `rename-noreplace` always preserves its target after a successful rename,
277
+ because removing it would discard the source's only remaining name; its typed
278
+ failure receipt makes that explicit regardless of `onSyncFailure`.
279
+
280
+ `"rename-noreplace"` requires the native helper and atomically moves the
281
+ source to the target without replacement. A collision is reported as
282
+ `EEXIST`, both files remain unchanged, and a successful call returns
283
+ `method: "rename-noreplace"` after synchronizing the source and target parent
284
+ directories. Unlike the link/copy strategies, success consumes `sourcePath`.
285
+
286
+ ## Scope
287
+
288
+ These primitives establish path identity and filesystem synchronization. One
289
+ `publishFileExclusive()` call is one no-clobber file materialization, not a
290
+ retention policy, multi-file transaction, or application commit protocol. They
291
+ do not decide application commit protocols, marker formats, permission policy,
292
+ or whether an unsupported platform is acceptable. Keep those decisions at the
293
+ owning product boundary.
294
+
295
+ ## See also
296
+
297
+ - [Migrating to 0.5](migrating-to-0.5.md) — choosing a publication policy during upgrade.
298
+ - [Native architecture](native.md) — clone/copy/hash mechanisms and fallback guarantees.
299
+ - [Errors](errors.md) — typed operational failure handling.
package/docs/errors.md CHANGED
@@ -13,13 +13,41 @@ class FsSafeError extends Error {
13
13
  readonly name: "FsSafeError";
14
14
  readonly code: FsSafeErrorCode;
15
15
  readonly category: "policy" | "operational";
16
+ readonly details?: Readonly<Record<string, unknown>>;
16
17
 
17
- constructor(code: FsSafeErrorCode, message: string, options?: { cause?: unknown });
18
+ constructor(
19
+ code: FsSafeErrorCode,
20
+ message: string,
21
+ options?: { cause?: unknown; details?: Readonly<Record<string, unknown>> },
22
+ );
18
23
  }
19
24
  ```
20
25
 
21
26
  `cause` is available through the standard `Error` `cause` property when the failure was triggered by a `NodeJS.ErrnoException` (e.g. a wrapped `EACCES`). Inspect it for the original `code` / `errno` / `syscall` if you need finer-grained reporting.
22
27
 
28
+ `details` is an operation-specific receipt, not an alternate error code. For
29
+ example, `publishFileExclusive()` uses it to report the failing phase, created
30
+ target identity, cleanup decision, and failed directory-sync outcome. Narrow
31
+ by `code` and a documented details field before consuming it; do not assume all
32
+ `FsSafeError` instances carry the same keys.
33
+
34
+ `replaceFileAtomic({ copyFallbackRestore: "restore-original" })` reports a
35
+ failed copy fallback with the exported `ReplaceFileAtomicRestoreFailureDetails`
36
+ shape:
37
+
38
+ ```ts
39
+ type ReplaceFileAtomicRestoreFailureDetails = {
40
+ cleanup: "restored" | "restore-failed";
41
+ };
42
+ ```
43
+
44
+ Both outcomes use `code: "helper-failed"`. `"restored"` means the original
45
+ snapshot was written back and fsynced through the pinned destination handle.
46
+ `"restore-failed"` means both the replacement and recovery failed; `cause` is
47
+ an `AggregateError` containing both failures. A snapshot that exceeds
48
+ `maxRestoreBytes` fails earlier with `too-large` and does not overwrite the
49
+ destination.
50
+
23
51
  `category` separates caller-policy failures from operational failures:
24
52
 
25
53
  - `"policy"` — unsafe input or target state, such as `outside-workspace`, `symlink`, `hardlink`, or `too-large`.
@@ -46,6 +74,7 @@ type FsSafeErrorCode =
46
74
  | "path-alias"
47
75
  | "path-mismatch"
48
76
  | "permission-unverified"
77
+ | "secret-exists"
49
78
  | "symlink"
50
79
  | "timeout"
51
80
  | "too-large"
@@ -60,8 +89,8 @@ type FsSafeErrorCode =
60
89
  | `denied-path` | A root mutation matched `denyMutations.paths` or `denyMutations.prefixes`. | Caller configured application-sensitive paths that must not be written, removed, moved, or created. |
61
90
  | `device-path` | A read/open target is a known unsafe device or process-fd path. | `/dev/zero`, `/dev/random`, `/dev/stdin`, `/dev/fd/*`, `/proc/*/fd/*`, or a Windows reserved device name. |
62
91
  | `hardlink` | Read or copy with `hardlinks: "reject"` saw `nlink > 1`. | File is hardlinked — possibly an alias of an out-of-tree inode. |
63
- | `helper-failed` | Internal POSIX helper failed after startup. | Inspect `cause`; retrying may be unsafe if the operation may have partially completed. |
64
- | `helper-unavailable` | Persistent Python helper was disabled or could not be spawned. | `FS_SAFE_PYTHON_MODE=off`, Python missing in PATH, restricted sandbox. `auto` falls back where possible; `require` fails closed. |
92
+ | `helper-failed` | A native mechanism or multi-step operational helper failed. | Inspect `cause` and any operation-specific `details`; retrying may be unsafe if the operation partially completed. |
93
+ | `helper-unavailable` | Required native binding could not be loaded. | Unsupported platform, missing/incompatible bundled binary, or `FS_SAFE_NATIVE_MODE=require`. `auto` falls back where possible; `require` fails closed. |
65
94
  | `insecure-permissions` | A secure file or path permission check found a mode/ACL that allows broader access than requested. | File or directory is group/world writable/readable; Windows ACL grants broad read. |
66
95
  | `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable. | Caller didn't validate input; input was a network path on Windows. |
67
96
  | `not-empty` | `remove()` on a non-empty directory. | Use `replaceDirectoryAtomic` or remove children first. |
@@ -73,9 +102,10 @@ type FsSafeErrorCode =
73
102
  | `path-alias` | A path alias check failed (e.g. canonical-real-path moved out of the root). | Symlink resolution lands outside the root. |
74
103
  | `path-mismatch` | Post-open identity check failed: the opened fd does not match the resolved path. | TOCTOU — something else swapped the path between resolve and open. |
75
104
  | `permission-unverified` | A secure file check could not verify required permissions. | Windows ACL inspection failed; POSIX ownership/mode was unavailable. |
105
+ | `secret-exists` | `createSecretFileAtomic()` found an existing final path. | First-writer-wins secret creation lost a race or the credential was already initialized. |
76
106
  | `symlink` | Path component is a symlink, policy is `reject`. | Caller followed a symlink they shouldn't have, or `symlinks: "reject"` is set. |
77
107
  | `timeout` | An operation with a wall-clock budget overran. | Secure file read or timed operation exceeded `timeoutMs`. |
78
- | `too-large` | Read exceeded `maxBytes`. | Caller gave a too-permissive file or didn't size-cap correctly. |
108
+ | `too-large` | A read or bounded walk exceeded its configured budget. | Caller gave a too-permissive file or traversal limit. |
79
109
  | `unsupported-platform` | The requested operation is not supported on the current platform. | E.g. POSIX-only helper invoked on Windows. |
80
110
 
81
111
  ## Branching
@@ -139,8 +169,8 @@ A common pattern is to wrap your domain code in a single try/catch that maps bot
139
169
  A handful of helpers throw their own typed errors instead of `FsSafeError`:
140
170
 
141
171
  - `JsonFileReadError` — thrown by [`readJson`](json.md). Carries `cause` so you can distinguish missing (`ENOENT`) from invalid (`SyntaxError`).
142
- - `ArchiveLimitError` — thrown by [`extractArchive`](archive.md) when an archive size, entry count, or extracted-byte budget is exceeded. The `code` field uses `ARCHIVE_LIMIT_ERROR_CODE` constants (e.g. `"ARCHIVE_SIZE_EXCEEDS_LIMIT"`).
143
- - `ArchiveSecurityError` — thrown by extraction when an entry path violates safety rules (traversal, drive prefix, blocked link type). The `code` field uses `ArchiveSecurityErrorCode` values.
172
+ - `ArchiveLimitError` — thrown by [`extractArchive`](archive.md) when an archive size, entry count, path depth, metadata, or extracted-byte budget is exceeded. The `code` field uses the string values exposed by `ARCHIVE_LIMIT_ERROR_CODE` (for example `archive-entry-path-components-exceeds-limit`).
173
+ - `ArchiveSecurityError` — thrown by extraction when entry policy or destination safety fails. Entry codes are `entry-path`, `entry-link`, and `entry-filtered`; destination codes cover non-directory, symlink, and symlink-traversal failures.
144
174
 
145
175
  These are exported from their respective subpaths.
146
176
 
package/docs/filename.md CHANGED
Binary file
package/docs/index.md CHANGED
@@ -38,7 +38,8 @@ await fs.remove("notes/archive/today.txt");
38
38
  ## Pick your path
39
39
 
40
40
  - **First time?** [Install](install.md), then walk through the [Quickstart](quickstart.md). Five minutes from `pnpm add` to a working root.
41
- - **Designing a workspace feature.** Read the [Security model](security-model.md) before you trust the boundary, the [Python helper policy](python-helper.md) before you pick deployment defaults, and the [Errors](errors.md) reference so you know what to catch.
41
+ - **Upgrading from 0.4?** Follow [Migrating to 0.5](migrating-to-0.5.md) in order, including the archive clamp-default audit.
42
+ - **Designing a workspace feature.** Read the [Security model](security-model.md) before you trust the boundary, the [native helper policy](native-helper.md) before you pick deployment defaults, and the [Errors](errors.md) reference so you know what to catch.
42
43
  - **Replacing ad-hoc atomic writes.** Jump to [Atomic writes](atomic.md) or, for keyed JSON state, [JSON files](json.md).
43
44
  - **Extracting an upload.** Start at [Archive extraction](archive.md) — handles ZIP and TAR with traversal, link, count, and byte limits.
44
45
  - **Running an agent in a sandbox.** [Private temp workspaces](temp.md) plus [secret files](secret-file.md) cover the common scratch-and-credentials shape.
@@ -48,10 +49,12 @@ await fs.remove("notes/archive/today.txt");
48
49
 
49
50
  | Surface | Use it for |
50
51
  |---|---|
51
- | [`root()`](root.md) | One boundary for read/write/move/remove inside a trusted directory. |
52
- | [`@openclaw/fs-safe/config`](config.md) | Process-global Python helper and lock-option defaults. |
53
- | [Python helper policy](python-helper.md) | Choose `auto`, `off`, or `require` for POSIX fd-relative hardening. |
52
+ | [`root()`](root.md) | One boundary for read/write/move/remove and bounded recursive walking inside a trusted directory. |
53
+ | [`@openclaw/fs-safe/config`](config.md) | Process-global native helper and lock-option defaults. |
54
+ | [Native helper policy](native-helper.md) | Choose `auto`, `off`, or `require` for bundled native primitives. |
55
+ | [Native architecture](native.md) | Understand the thin syscall layer, beneath model, platform mechanisms, and fallback boundary. |
54
56
  | [`replaceFileAtomic`](atomic.md) | Sibling-temp + rename, fsync hooks, mode preservation, copy fallback. |
57
+ | [`@openclaw/fs-safe/durability`](durability.md) | Pinned directory identities, durable creation, exclusive publication, streaming SHA-256, provenance receipts, and sync-failure policy. |
55
58
  | [`writeExternalFileWithinRoot`](output.md) | Stage external-library file output in private temp storage, then finalize under a root. |
56
59
  | [`writeJson` / `readJson*`](json.md) | JSON state files with strict and lenient read variants. |
57
60
  | [`@openclaw/fs-safe/store`](store.md) | Overview of `fileStore`, `fileStoreSync`, and `jsonStore`. |
@@ -60,15 +63,16 @@ await fs.remove("notes/archive/today.txt");
60
63
  | [Private file-store mode](private-file-store.md) | `fileStore({ private: true })` for private JSON/text state at 0600 under 0700 dirs. |
61
64
  | [`tempWorkspace`](temp.md) | 0700 scratch dir with auto-cleanup. |
62
65
  | [`readSecureFile`](secure-file.md) | Absolute file reads with fd pinning, permissions, owner, size, and timeout checks. |
63
- | [`walkDirectory`](walk.md) | Budget-bounded recursive directory scan with symlink policy and filters. |
64
- | [`extractArchive`](archive.md) | ZIP/TAR extraction with size, count, link, and traversal limits. |
66
+ | [`walkDirectory` / `Root.walk`](walk.md) | Standalone inventories plus root-bounded pruning, budgets, and partial-error reporting. |
67
+ | [`extractArchive`](archive.md) | Policy-driven ZIP/TAR extraction with clamp/filter, metadata/path-depth, link, count, and byte limits. |
65
68
  | [Secret files](secret-file.md) | Mode-0600 credentials with size and TOCTOU defense. |
66
- | [Permissions](permissions.md) | POSIX mode and Windows ACL inspection/remediation helpers. |
69
+ | [Permissions](permissions.md) | POSIX mode helpers plus Windows ACL inspection, raw owner/ACE facts, remediation, and private-directory creation. |
67
70
  | [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry and fail-closed stale-lock handling. |
68
71
  | [`FsSafeError`](errors.md) | Closed code union (with `policy` / `operational` category) you can branch on. |
69
72
  | [`pathScope()`](path-scope.md) | Lower-level absolute-path boundary helper; lives behind `@openclaw/fs-safe/advanced`. |
70
73
  | [`@openclaw/fs-safe/advanced`](advanced.md) | Directory of lower-level composition helpers (path scopes, regular-file I/O, install paths, sibling-temp writes, …). |
71
74
  | [`@openclaw/fs-safe/test-hooks`](test-hooks.md) | Test-only injection hooks for reproducing open/lstat races. |
75
+ | [Migrating to 0.5](migrating-to-0.5.md) | End-to-end checklist for Python removal and 0.4 API behavior changes. |
72
76
 
73
77
  ## Status
74
78
 
package/docs/install.md CHANGED
@@ -62,20 +62,22 @@ Use the main entry for the common surface, or the focused subpaths when you want
62
62
 
63
63
  | Subpath | Contents |
64
64
  |---|---|
65
- | `@openclaw/fs-safe` | Small common surface: `root`, root types, and errors. |
66
- | `@openclaw/fs-safe/root` | `root()`, `Root`, `RootDefaults`, related types. |
67
- | `@openclaw/fs-safe/config` | Process-global Python helper configuration. |
65
+ | `@openclaw/fs-safe` | Common root, config, output, lock, native-mode, and error exports. |
66
+ | `@openclaw/fs-safe/root` | `root()`, `Root`, `RootDefaults`, and root-walk types. |
67
+ | `@openclaw/fs-safe/config` | Process-global native helper and lock defaults. |
68
68
  | `@openclaw/fs-safe/path` | `isPathInside`, `safeRealpathSync`, `isWithinDir`, error helpers. |
69
+ | `@openclaw/fs-safe/output` | Guarded staging/finalization for libraries that require an absolute output path. |
69
70
  | `@openclaw/fs-safe/json` | `tryReadJson`, `readJson`, `readJsonIfExists`, `writeJson`, sync variants. |
70
71
  | `@openclaw/fs-safe/store` | `fileStore()`, `fileStoreSync()`, and `jsonStore<T>()`. |
71
72
  | `@openclaw/fs-safe/secret` | Secret file read/write helpers. |
72
73
  | `@openclaw/fs-safe/atomic` | `replaceFileAtomic`, `writeTextAtomic`, `replaceDirectoryAtomic`, `movePathWithCopyFallback`. |
74
+ | `@openclaw/fs-safe/durability` | Pinned directories, strict sync, durable directory creation, exclusive publication, and streaming SHA-256. |
73
75
  | `@openclaw/fs-safe/temp` | `tempWorkspace`, `withTempWorkspace`, sync variants, `resolveSecureTempRoot`. |
74
76
  | `@openclaw/fs-safe/secure-file` | `readSecureFile` for pinned absolute file reads with permissions checks. |
75
77
  | `@openclaw/fs-safe/file-lock` | `acquireFileLock`, `withFileLock`, `createFileLockManager`, and related lock types. |
76
- | `@openclaw/fs-safe/permissions` | POSIX mode and Windows ACL inspection/remediation helpers. |
78
+ | `@openclaw/fs-safe/permissions` | POSIX mode helpers, Windows ACL inspection/remediation, raw owner/ACE facts, and private-directory creation. |
77
79
  | `@openclaw/fs-safe/walk` | `walkDirectory`, `walkDirectorySync`, related types. Budget-bounded, not root-bounded. |
78
- | `@openclaw/fs-safe/archive` | `extractArchive`, `resolveArchiveKind`, limits, preflight helpers. |
80
+ | `@openclaw/fs-safe/archive` | `extractArchive`, `readArchiveEntry`, kind resolution, policy types, limits, and preflight helpers. |
79
81
  | `@openclaw/fs-safe/advanced` | Lower-level composition helpers: path scopes, root-file open, install paths, local-root readers, temp-file targets, sibling-temp writes, regular-file helpers, `pathExists`, `withTimeout`, and related advanced types. This surface is less stable than the focused public subpaths. |
80
82
  | `@openclaw/fs-safe/errors` | `FsSafeError`, `FsSafeErrorCode`. |
81
83
  | `@openclaw/fs-safe/types` | Shared types: `DirEntry`, `PathStat`, `BasePathOptions`, … |
@@ -85,40 +87,39 @@ Use the main entry for the common surface, or the focused subpaths when you want
85
87
 
86
88
  `@openclaw/fs-safe` lists `jszip` and `tar` as optional dependencies for [archive extraction](archive.md). They are loaded lazily and only required when ZIP/TAR helpers run. Installs that omit optional dependencies can still import and use every non-archive subpath; archive calls fail with a clear missing-optional-dependency message.
87
89
 
88
- There are no peer dependencies and no native build step.
90
+ There are no peer dependencies. The single npm package bundles all seven native binaries, so consumers do not run a native build, download platform code, or execute a postinstall step. Shipping every target increases the tarball size compared with per-platform packages, intentionally trading bandwidth for deterministic installation.
89
91
 
90
- ## Python helper policy
92
+ Upgrading an existing consumer? Follow [Migrating to 0.5](migrating-to-0.5.md)
93
+ before choosing a native mode or accepting the new archive clamp default.
91
94
 
92
- On POSIX, `root()` uses one persistent Python helper process for the
93
- fd-relative operations Node does not expose cleanly. The default is `auto`: use
94
- the helper when it starts, fall back to Node-only behavior when it is disabled
95
- or unavailable.
95
+ ## Native helper policy
96
+
97
+ The bundled native binaries provide fd-relative open/link/mkdir primitives,
98
+ atomic no-replace rename, and file identity checks. The default is `auto`: use
99
+ the matching binary when it loads, otherwise silently keep the guarded
100
+ JavaScript path. Platforms without one of the seven bundled targets therefore
101
+ continue through the documented fallback in `auto` mode.
96
102
 
97
103
  ```ts
98
- import { configureFsSafePython } from "@openclaw/fs-safe/config";
104
+ import { configureFsSafeNative } from "@openclaw/fs-safe/config";
99
105
 
100
- configureFsSafePython({ mode: "auto" }); // default
101
- configureFsSafePython({ mode: "off" }); // never spawn Python
102
- configureFsSafePython({ mode: "require" }); // fail closed if unavailable
106
+ configureFsSafeNative({ mode: "auto" }); // default
107
+ configureFsSafeNative({ mode: "off" }); // guarded JavaScript only
108
+ configureFsSafeNative({ mode: "require" }); // fail closed if unavailable
103
109
  ```
104
110
 
105
111
  Environment variables are read at runtime:
106
112
 
107
113
  ```bash
108
- FS_SAFE_PYTHON_MODE=off # auto | off | require
109
- FS_SAFE_PYTHON=/usr/bin/python3
114
+ FS_SAFE_NATIVE_MODE=off # auto | off | require
110
115
  ```
111
116
 
112
- OpenClaw compatibility aliases are also accepted:
113
- `OPENCLAW_FS_SAFE_PYTHON_MODE`, `OPENCLAW_FS_SAFE_PYTHON`,
114
- `OPENCLAW_PINNED_PYTHON`, and `OPENCLAW_PINNED_WRITE_PYTHON`.
117
+ `OPENCLAW_FS_SAFE_NATIVE_MODE` is also accepted.
115
118
 
116
- Disabling Python keeps the public API working, but downgrades POSIX mutation
117
- hardening from fd-relative syscalls to Node path operations guarded by lexical
118
- and canonical checks plus identity verification. Use `require` for
119
- security-sensitive deployments where that downgrade should be a startup/runtime
120
- failure instead of a fallback. The full tradeoff is documented in
121
- [Python helper policy](python-helper.md).
119
+ Disabling native loading keeps the public API working through Node path
120
+ operations guarded by lexical and canonical checks plus identity verification.
121
+ Use `require` when native-backed operations must fail instead of falling back.
122
+ The exact boundary is documented in [native helper policy](native-helper.md).
122
123
 
123
124
  ## Verify the install
124
125
 
@@ -113,7 +113,17 @@ Read, transform, write — under the lock if locking is enabled. Returns the new
113
113
  const next = await store.update((prev) => ({ count: (prev?.count ?? 0) + 1 }));
114
114
  ```
115
115
 
116
- `run` is async-friendly. The whole `read → run → write` sequence runs inside one `withLock` call, so concurrent updaters from different processes serialize cleanly.
116
+ `run` is async-friendly. The whole `read → run → write` sequence is serialized
117
+ by canonical file path inside the process. With locking enabled, the sidecar
118
+ lock is acquired inside that queue, so concurrent updaters from different
119
+ processes serialize cleanly too.
120
+
121
+ Do not call `write()`, `update()`, or `updateOr()` for the same file from inside
122
+ an update callback. That nested mutation cannot run until the outer update
123
+ finishes, so `jsonStore` rejects it immediately with
124
+ `FsSafeError("store-reentrant-update")`. Return the complete next value from the
125
+ outer callback instead. The check follows Node async context, including promise
126
+ and `queueMicrotask` boundaries.
117
127
 
118
128
  Use `update(run)` when missing state is part of your model. Use `updateOr(fallback, run)` when the missing-file case should start from a concrete value and you want to merge into defaults:
119
129
 
@@ -137,13 +147,18 @@ const counter = jsonStore<{ count: number }>({
137
147
  });
138
148
  ```
139
149
 
140
- When `lock` is falsy, `read` / `write` / `update` are unlocked. The `update` shape is still useful it gives you a single function for the read-modify-write pattern — but it offers no concurrency guarantees if other processes also write to the file.
150
+ When `lock` is falsy, writes and updates still serialize inside this process by
151
+ canonical file path, including across separate `jsonStore` handles. They offer
152
+ no concurrency guarantees if another process also writes to the file.
141
153
 
142
154
  Process-wide lock defaults from `configureFsSafeLocks()` apply only after locking is explicitly enabled. They do not make JSON stores lock by default.
143
155
 
144
156
  JSON store locks fail closed on stale sidecars by default. Opt-in `staleRecovery: "remove-if-unchanged"` requires caller approval and uses the same exclusive reclaim guard as the low-level sidecar-lock API.
145
157
 
146
- The default `managerKey` namespaces the in-process `FileLockManager` per absolute file path, so two `jsonStore` calls on the same file share lock state automatically.
158
+ The default `managerKey` namespaces the `FileLockManager` per absolute file
159
+ path. The JSON-store queue is independent of the manager key, so separate
160
+ handles and custom lock-manager namespaces still cannot overlap mutations of
161
+ the same canonical file path inside one process.
147
162
 
148
163
  ## Common patterns
149
164