@plurnk/plurnk-service 3.0.0 → 3.2.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 (280) hide show
  1. package/.env.defaults +38 -21
  2. package/INSTALL.md +38 -1
  3. package/README.md +3 -0
  4. package/SPEC.md +681 -268
  5. package/dist/build-info.json +1 -1
  6. package/dist/completions/_plurnk-service +13 -0
  7. package/dist/completions/plurnk-service.bash +19 -0
  8. package/dist/completions/plurnk-service.fish +94 -0
  9. package/dist/content/path-mimetype.d.ts.map +1 -1
  10. package/dist/content/path-mimetype.js +2 -8
  11. package/dist/content/path-mimetype.js.map +1 -1
  12. package/dist/core/AdmittedTurnExecutor.d.ts +2 -2
  13. package/dist/core/AdmittedTurnExecutor.d.ts.map +1 -1
  14. package/dist/core/AdmittedTurnExecutor.js +36 -32
  15. package/dist/core/AdmittedTurnExecutor.js.map +1 -1
  16. package/dist/core/BudgetReadout.d.ts +7 -2
  17. package/dist/core/BudgetReadout.d.ts.map +1 -1
  18. package/dist/core/BudgetReadout.js +36 -5
  19. package/dist/core/BudgetReadout.js.map +1 -1
  20. package/dist/{schemes/_derivation-use.d.ts → core/ContentUse.d.ts} +3 -3
  21. package/dist/core/ContentUse.d.ts.map +1 -0
  22. package/dist/{schemes/_derivation-use.js → core/ContentUse.js} +8 -7
  23. package/dist/core/ContentUse.js.map +1 -0
  24. package/dist/core/DataStatementRunner.d.ts.map +1 -1
  25. package/dist/core/DataStatementRunner.js +8 -20
  26. package/dist/core/DataStatementRunner.js.map +1 -1
  27. package/dist/core/Dispatcher.d.ts +11 -3
  28. package/dist/core/Dispatcher.d.ts.map +1 -1
  29. package/dist/core/Dispatcher.js +186 -109
  30. package/dist/core/Dispatcher.js.map +1 -1
  31. package/dist/core/EditMutations.js +1 -1
  32. package/dist/core/EditMutations.js.map +1 -1
  33. package/dist/core/Engine.d.ts +1 -1
  34. package/dist/core/Engine.d.ts.map +1 -1
  35. package/dist/core/Engine.js +4 -7
  36. package/dist/core/Engine.js.map +1 -1
  37. package/dist/core/EnvFlags.js +1 -1
  38. package/dist/core/EnvFlags.js.map +1 -1
  39. package/dist/core/FabricatedLog.d.ts +1 -0
  40. package/dist/core/FabricatedLog.d.ts.map +1 -1
  41. package/dist/core/FabricatedLog.js +8 -0
  42. package/dist/core/FabricatedLog.js.map +1 -1
  43. package/dist/core/FileAddress.d.ts +12 -0
  44. package/dist/core/FileAddress.d.ts.map +1 -0
  45. package/dist/core/FileAddress.js +73 -0
  46. package/dist/core/FileAddress.js.map +1 -0
  47. package/dist/core/FilePublication.d.ts +15 -0
  48. package/dist/core/FilePublication.d.ts.map +1 -0
  49. package/dist/core/FilePublication.js +143 -0
  50. package/dist/core/FilePublication.js.map +1 -0
  51. package/dist/core/FileSnapshot.d.ts +26 -0
  52. package/dist/core/FileSnapshot.d.ts.map +1 -0
  53. package/dist/core/FileSnapshot.js +179 -0
  54. package/dist/core/FileSnapshot.js.map +1 -0
  55. package/dist/core/KillHandler.d.ts.map +1 -1
  56. package/dist/core/KillHandler.js +4 -1
  57. package/dist/core/KillHandler.js.map +1 -1
  58. package/dist/core/LiveSubscription.d.ts.map +1 -1
  59. package/dist/core/LiveSubscription.js +14 -2
  60. package/dist/core/LiveSubscription.js.map +1 -1
  61. package/dist/core/LiveSubscriptions.d.ts +3 -1
  62. package/dist/core/LiveSubscriptions.d.ts.map +1 -1
  63. package/dist/core/LiveSubscriptions.js +16 -1
  64. package/dist/core/LiveSubscriptions.js.map +1 -1
  65. package/dist/core/LogBody.d.ts.map +1 -1
  66. package/dist/core/LogBody.js +3 -1
  67. package/dist/core/LogBody.js.map +1 -1
  68. package/dist/core/LoopDriver.d.ts.map +1 -1
  69. package/dist/core/LoopDriver.js +3 -0
  70. package/dist/core/LoopDriver.js.map +1 -1
  71. package/dist/core/LoopLifecycle.sql +1 -0
  72. package/dist/core/MembershipMaterialization.d.ts.map +1 -1
  73. package/dist/core/MembershipMaterialization.js +87 -143
  74. package/dist/core/MembershipMaterialization.js.map +1 -1
  75. package/dist/core/MessageResources.sql +31 -0
  76. package/dist/core/MutationEffects.d.ts +2 -1
  77. package/dist/core/MutationEffects.d.ts.map +1 -1
  78. package/dist/core/MutationEffects.js +14 -1
  79. package/dist/core/MutationEffects.js.map +1 -1
  80. package/dist/core/PacketBuilder.d.ts +2 -2
  81. package/dist/core/PacketBuilder.d.ts.map +1 -1
  82. package/dist/core/PacketBuilder.js +123 -51
  83. package/dist/core/PacketBuilder.js.map +1 -1
  84. package/dist/core/PacketBuilder.sql +21 -14
  85. package/dist/core/PathGlob.d.ts +6 -0
  86. package/dist/core/PathGlob.d.ts.map +1 -0
  87. package/dist/core/PathGlob.js +24 -0
  88. package/dist/core/PathGlob.js.map +1 -0
  89. package/dist/core/ProposalLifecycle.d.ts +2 -1
  90. package/dist/core/ProposalLifecycle.d.ts.map +1 -1
  91. package/dist/core/ProposalLifecycle.js +45 -33
  92. package/dist/core/ProposalLifecycle.js.map +1 -1
  93. package/dist/core/ProposalLifecycle.sql +4 -0
  94. package/dist/core/ReplyMetadata.d.ts +10 -0
  95. package/dist/core/ReplyMetadata.d.ts.map +1 -0
  96. package/dist/core/ReplyMetadata.js +20 -0
  97. package/dist/core/ReplyMetadata.js.map +1 -0
  98. package/dist/core/ResourceBindings.d.ts.map +1 -1
  99. package/dist/core/ResourceBindings.js +4 -3
  100. package/dist/core/ResourceBindings.js.map +1 -1
  101. package/dist/core/ResourceSelector.d.ts +1 -2
  102. package/dist/core/ResourceSelector.d.ts.map +1 -1
  103. package/dist/core/ResourceSelector.js +30 -9
  104. package/dist/core/ResourceSelector.js.map +1 -1
  105. package/dist/core/ResourceTransfers.d.ts +1 -1
  106. package/dist/core/ResourceTransfers.d.ts.map +1 -1
  107. package/dist/core/ResourceTransfers.js +26 -11
  108. package/dist/core/ResourceTransfers.js.map +1 -1
  109. package/dist/core/ServiceHelp.d.ts +6 -0
  110. package/dist/core/ServiceHelp.d.ts.map +1 -0
  111. package/dist/core/ServiceHelp.js +35 -0
  112. package/dist/core/ServiceHelp.js.map +1 -0
  113. package/dist/core/StoredPacket.d.ts +1 -0
  114. package/dist/core/StoredPacket.d.ts.map +1 -1
  115. package/dist/core/StoredPacket.js +4 -1
  116. package/dist/core/StoredPacket.js.map +1 -1
  117. package/dist/core/StrikeRail.d.ts.map +1 -1
  118. package/dist/core/StrikeRail.js +2 -2
  119. package/dist/core/StrikeRail.js.map +1 -1
  120. package/dist/core/TokenCalibration.d.ts +2 -2
  121. package/dist/core/TokenCalibration.d.ts.map +1 -1
  122. package/dist/core/TokenCalibration.js +12 -16
  123. package/dist/core/TokenCalibration.js.map +1 -1
  124. package/dist/core/TokenCalibration.sql +29 -6
  125. package/dist/core/TurnDispositionHandler.d.ts +1 -2
  126. package/dist/core/TurnDispositionHandler.d.ts.map +1 -1
  127. package/dist/core/TurnDispositionHandler.js +30 -26
  128. package/dist/core/TurnDispositionHandler.js.map +1 -1
  129. package/dist/core/TurnMaterialization.d.ts.map +1 -1
  130. package/dist/core/TurnMaterialization.js +11 -1
  131. package/dist/core/TurnMaterialization.js.map +1 -1
  132. package/dist/core/TurnOps.d.ts +5 -1
  133. package/dist/core/TurnOps.d.ts.map +1 -1
  134. package/dist/core/TurnOps.js +29 -2
  135. package/dist/core/TurnOps.js.map +1 -1
  136. package/dist/core/TurnRunner.d.ts.map +1 -1
  137. package/dist/core/TurnRunner.js +122 -56
  138. package/dist/core/TurnRunner.js.map +1 -1
  139. package/dist/core/TurnSources.sql +2 -2
  140. package/dist/core/caps/SchemeCtxImpl.d.ts +1 -0
  141. package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
  142. package/dist/core/caps/SchemeCtxImpl.js +5 -0
  143. package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
  144. package/dist/core/git-membership.d.ts +1 -0
  145. package/dist/core/git-membership.d.ts.map +1 -1
  146. package/dist/core/git-membership.js +9 -11
  147. package/dist/core/git-membership.js.map +1 -1
  148. package/dist/core/glob_match.js +3 -3
  149. package/dist/core/glob_match.js.map +1 -1
  150. package/dist/core/mutation-types.d.ts +3 -0
  151. package/dist/core/mutation-types.d.ts.map +1 -1
  152. package/dist/core/namespace.d.ts +4 -1
  153. package/dist/core/namespace.d.ts.map +1 -1
  154. package/dist/core/namespace.js +14 -3
  155. package/dist/core/namespace.js.map +1 -1
  156. package/dist/core/packet-wire.d.ts +7 -1
  157. package/dist/core/packet-wire.d.ts.map +1 -1
  158. package/dist/core/packet-wire.js +98 -36
  159. package/dist/core/packet-wire.js.map +1 -1
  160. package/dist/core/plurnk-uri.d.ts +3 -2
  161. package/dist/core/plurnk-uri.d.ts.map +1 -1
  162. package/dist/core/plurnk-uri.js +23 -11
  163. package/dist/core/plurnk-uri.js.map +1 -1
  164. package/dist/core/recap-lines.d.ts +4 -0
  165. package/dist/core/recap-lines.d.ts.map +1 -0
  166. package/dist/core/recap-lines.js +11 -0
  167. package/dist/core/recap-lines.js.map +1 -0
  168. package/dist/core/scheme-types.d.ts +1 -0
  169. package/dist/core/scheme-types.d.ts.map +1 -1
  170. package/dist/core/sql-functions.d.ts +2 -0
  171. package/dist/core/sql-functions.d.ts.map +1 -0
  172. package/dist/core/sql-functions.js +5 -0
  173. package/dist/core/sql-functions.js.map +1 -0
  174. package/dist/core/sqlite-synchronous.d.ts +2 -0
  175. package/dist/core/sqlite-synchronous.d.ts.map +1 -0
  176. package/dist/core/sqlite-synchronous.js +4 -0
  177. package/dist/core/sqlite-synchronous.js.map +1 -0
  178. package/dist/core/turn-scheduler.d.ts.map +1 -1
  179. package/dist/core/turn-scheduler.js +1 -1
  180. package/dist/core/turn-scheduler.js.map +1 -1
  181. package/dist/evidence/EvidenceReader.d.ts.map +1 -1
  182. package/dist/evidence/EvidenceReader.js +23 -3
  183. package/dist/evidence/EvidenceReader.js.map +1 -1
  184. package/dist/evidence/digest.sql +7 -1
  185. package/dist/man/plurnk-service.1 +499 -0
  186. package/dist/schemes/Env.d.ts +26 -0
  187. package/dist/schemes/Env.d.ts.map +1 -0
  188. package/dist/schemes/Env.js +133 -0
  189. package/dist/schemes/Env.js.map +1 -0
  190. package/dist/schemes/Exec.d.ts.map +1 -1
  191. package/dist/schemes/Exec.js +15 -15
  192. package/dist/schemes/Exec.js.map +1 -1
  193. package/dist/schemes/ExecOutputScheme.d.ts +3 -2
  194. package/dist/schemes/ExecOutputScheme.d.ts.map +1 -1
  195. package/dist/schemes/ExecOutputScheme.js +9 -0
  196. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  197. package/dist/schemes/File.d.ts +1 -0
  198. package/dist/schemes/File.d.ts.map +1 -1
  199. package/dist/schemes/File.js +233 -268
  200. package/dist/schemes/File.js.map +1 -1
  201. package/dist/schemes/Log.d.ts.map +1 -1
  202. package/dist/schemes/Log.js +97 -17
  203. package/dist/schemes/Log.js.map +1 -1
  204. package/dist/schemes/Log.sql +9 -0
  205. package/dist/schemes/Worker.js +1 -1
  206. package/dist/schemes/Worker.js.map +1 -1
  207. package/dist/schemes/_entry-crud.d.ts +2 -0
  208. package/dist/schemes/_entry-crud.d.ts.map +1 -1
  209. package/dist/schemes/_entry-crud.js +8 -5
  210. package/dist/schemes/_entry-crud.js.map +1 -1
  211. package/dist/schemes/_entry-crud.sql +15 -15
  212. package/dist/schemes/_entry-find.d.ts +2 -1
  213. package/dist/schemes/_entry-find.d.ts.map +1 -1
  214. package/dist/schemes/_entry-find.js +31 -19
  215. package/dist/schemes/_entry-find.js.map +1 -1
  216. package/dist/schemes/_entry-manifest.d.ts +2 -2
  217. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  218. package/dist/schemes/_entry-manifest.js +10 -8
  219. package/dist/schemes/_entry-manifest.js.map +1 -1
  220. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  221. package/dist/schemes/_entry-ops.js +9 -7
  222. package/dist/schemes/_entry-ops.js.map +1 -1
  223. package/dist/schemes/_path-scope.d.ts.map +1 -1
  224. package/dist/schemes/_path-scope.js +12 -4
  225. package/dist/schemes/_path-scope.js.map +1 -1
  226. package/dist/schemes/_search-index.js +3 -3
  227. package/dist/schemes/_search-index.js.map +1 -1
  228. package/dist/schemes/exec-abort.js +2 -2
  229. package/dist/schemes/exec-abort.js.map +1 -1
  230. package/dist/schemes/exec-env.d.ts.map +1 -1
  231. package/dist/schemes/exec-env.js +7 -22
  232. package/dist/schemes/exec-env.js.map +1 -1
  233. package/dist/server/Daemon.d.ts +1 -0
  234. package/dist/server/Daemon.d.ts.map +1 -1
  235. package/dist/server/Daemon.js +45 -68
  236. package/dist/server/Daemon.js.map +1 -1
  237. package/dist/server/DrainSupervisor.d.ts +2 -3
  238. package/dist/server/DrainSupervisor.d.ts.map +1 -1
  239. package/dist/server/DrainSupervisor.js +34 -6
  240. package/dist/server/DrainSupervisor.js.map +1 -1
  241. package/dist/server/EnvFunctionality.d.ts +4 -2
  242. package/dist/server/EnvFunctionality.d.ts.map +1 -1
  243. package/dist/server/EnvFunctionality.js +23 -12
  244. package/dist/server/EnvFunctionality.js.map +1 -1
  245. package/dist/server/Functionality.d.ts.map +1 -1
  246. package/dist/server/Functionality.js +15 -8
  247. package/dist/server/Functionality.js.map +1 -1
  248. package/dist/server/FunctionalityManager.d.ts +1 -1
  249. package/dist/server/FunctionalityManager.d.ts.map +1 -1
  250. package/dist/server/FunctionalityManager.js +5 -1
  251. package/dist/server/FunctionalityManager.js.map +1 -1
  252. package/dist/server/MembersFunctionality.d.ts.map +1 -1
  253. package/dist/server/MembersFunctionality.js +12 -12
  254. package/dist/server/MembersFunctionality.js.map +1 -1
  255. package/dist/server/Retention.d.ts.map +1 -1
  256. package/dist/server/Retention.js +5 -5
  257. package/dist/server/Retention.js.map +1 -1
  258. package/dist/server/Retention.sql +49 -23
  259. package/dist/server/client-input.d.ts.map +1 -1
  260. package/dist/server/client-input.js +12 -3
  261. package/dist/server/client-input.js.map +1 -1
  262. package/dist/server/drain.sql +10 -9
  263. package/dist/server/lifecycle-recovery.sql +4 -1
  264. package/dist/service.d.ts.map +1 -1
  265. package/dist/service.js +9 -31
  266. package/dist/service.js.map +1 -1
  267. package/docs/copy-move.md +3 -0
  268. package/docs/env.md +32 -4
  269. package/docs/members.md +16 -1
  270. package/migrations/018_worker_owners.sql +1 -0
  271. package/migrations/019_interactive_owners.sql +1 -0
  272. package/migrations/020_log_projection.sql +1 -0
  273. package/migrations/021_packet_roles.sql +86 -0
  274. package/migrations/022_message_completion.sql +24 -0
  275. package/migrations/023_file_revisions.sql +54 -0
  276. package/migrations/024_proposal_decisions.sql +34 -0
  277. package/migrations/025_response_retention.sql +30 -0
  278. package/package.json +38 -34
  279. package/dist/schemes/_derivation-use.d.ts.map +0 -1
  280. package/dist/schemes/_derivation-use.js.map +0 -1
@@ -1,14 +1,15 @@
1
1
  var _a;
2
- import { lstat, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
2
+ import { lstat, realpath, rm, stat } from "node:fs/promises";
3
3
  import Namespace from "../core/namespace.js";
4
- import { fileMissRecovery, missDetail, missExtensions } from "../core/plurnk-uri.js";
5
- import { basename, dirname, relative, isAbsolute, join } from "node:path";
4
+ import FileAddress from "../core/FileAddress.js";
5
+ import { entryAddress, fileMissRecovery, missDetail, missExtensions } from "../core/plurnk-uri.js";
6
+ import { relative, isAbsolute, join } from "node:path";
6
7
  import { createPatch } from "diff";
7
8
  import { PlurnkParser } from "@plurnk/plurnk-parser";
8
- import { PathSyntax } from "@plurnk/plurnk-contracts";
9
9
  import EntryManifest from "./_entry-manifest.js";
10
10
  import GitMembership from "../core/git-membership.js";
11
- import EntryFind from "./_entry-find.js";
11
+ import EntryFind, { emptyFindFields } from "./_entry-find.js";
12
+ import { pathScope } from "./_path-scope.js";
12
13
  import EntryCrud from "./_entry-crud.js";
13
14
  import { CoreSchemeAdapterBase } from "../core/CoreSchemeServices.js";
14
15
  import ErrorDetail from "../core/ErrorDetail.js";
@@ -18,34 +19,8 @@ import { FileByteSource, InvalidOperationResultError, } from "@plurnk/plurnk-sch
18
19
  import { LineMarkerOps, LineAnchors, EditCollision, EDIT_NOOP_DETAIL, MimetypeBinary, editReceipt, projectEditReceipt, reviewerReplacementReceipt, withEditReceiptParseIssues, } from "../content/index.js";
19
20
  import DbProjectionCaps from "../core/caps/DbProjectionCaps.js";
20
21
  import FileMaterialization from "../core/file-materialization.js";
21
- // Workspace root for file ops is sourced from `workspaces.project_root`,
22
- // supplied at {§methods-workspace-create}. Under {§fs-namespace}, core never
23
- // guesses a root: headless workspaces fail file operations at 400.
24
- const loadWorkspaceRoot = async (db, workspaceId) => {
25
- const row = await db.envelope_get_workspace.get({ id: workspaceId });
26
- return row?.project_root ?? null;
27
- };
28
- // Resolve the nearest existing ancestor, then append the still-absent tail. This
29
- // gives a proposed create the same physical containment check as an existing path:
30
- // an in-root symlinked parent cannot smuggle a root-scoped write outside the namespace.
31
- const prospectiveRealpath = async (requested) => {
32
- const tail = [basename(requested)];
33
- let cursor = dirname(requested);
34
- while (true) {
35
- try {
36
- return join(await realpath(cursor), ...tail);
37
- }
38
- catch (cause) {
39
- if (cause.code !== "ENOENT")
40
- throw cause;
41
- }
42
- const parent = dirname(cursor);
43
- if (parent === cursor)
44
- throw new Error(`No existing ancestor contains '${requested}'.`);
45
- tail.unshift(basename(cursor));
46
- cursor = parent;
47
- }
48
- };
22
+ import FileSnapshot from "../core/FileSnapshot.js";
23
+ import FilePublication from "../core/FilePublication.js";
49
24
  // Detect mimetype from a file's path through the configured registry. The
50
25
  // `MimetypeBinary.normalizeAutoTextMimetype` wrapper ensures text/plain returned by the
51
26
  // service is normalized to text/markdown — plurnk-service never auto-
@@ -62,10 +37,9 @@ const detectFileMimetype = async (canonical, ctx) => {
62
37
  class File extends CoreSchemeAdapterBase {
63
38
  static manifest = {
64
39
  name: "file",
65
- storedScheme: "file", // {§entry-identity-no-null} — durable identity; file addresses still render as bare paths
66
- // body's real mimetype is detected per file path; the declared type is only the
67
- // extension-less fallback. readable: the derived projection ({§readable-channel}).
68
- channels: { body: "text/markdown", readable: "text/markdown" },
40
+ storedScheme: "file", // {§entry-identity-no-null} — durable identity, independent of displayed spelling
41
+ // The body is typed per resource; readable is its derived projection ({§readable-channel}).
42
+ channels: { readable: "text/markdown" },
69
43
  defaultChannel: "body",
70
44
  category: "data",
71
45
  writableBy: ["model", "client", "_plurnk"],
@@ -80,37 +54,21 @@ class File extends CoreSchemeAdapterBase {
80
54
  "A scoped EDIT replaces the addressed lines; put the replacement text in its body.",
81
55
  ].join("\n\n"),
82
56
  };
83
- // {§fs-namei} Resolve FIND's address before selecting member candidates.
84
- static async #canonTarget(statement, root) {
85
- const t = statement.target;
86
- if (t === null)
87
- return statement;
88
- if (t.kind === "url") {
89
- const key = await _a.#canonSpelling(t.pathname, root);
90
- if (key === null)
91
- return null;
92
- return key === t.pathname ? statement : { ...statement, target: { ...t, pathname: key } };
93
- }
94
- if (t.kind === "local") {
95
- const key = await _a.#canonSpelling(t.raw, root);
96
- if (key === null)
97
- return null;
98
- return key === t.raw ? statement : { ...statement, target: { ...t, raw: key } };
99
- }
100
- return statement; // regex — not a path
101
- }
102
57
  // {§file-find-directory}: a directory's canonical collection address does not depend on
103
58
  // whether its authored spelling included the slash. Membership still owns its contents.
104
- static async #canonSpelling(raw, root) {
59
+ static async #canonSpelling(raw, root, pattern = false) {
105
60
  const key = Namespace.canonicalizeSpelling(raw, root);
106
61
  if (root === null || key === null || key.length === 0
107
- || key.endsWith("/") || key.startsWith("../") || PathSyntax.hasGlob(key))
62
+ || key.endsWith("/") || key.startsWith("../") || pattern)
108
63
  return key;
109
- return (await _a.#occupant(root, PathSyntax.decodeParens(key)))?.isDirectory() === true ? `${key}/` : key;
64
+ return (await _a.#occupant(root, key))?.isDirectory() === true ? `${key}/` : key;
110
65
  }
111
66
  async resolveEntryAddress(target, ctx, access = "read") {
112
67
  const core = this.coreContext(ctx);
113
- const pathname = await _a.#canonSpelling(target.kind === "url" ? target.pathname : target.raw, await loadWorkspaceRoot(core.db, core.workspaceId));
68
+ const spelling = FileAddress.pathname(target);
69
+ if (typeof spelling !== "string")
70
+ return spelling;
71
+ const pathname = await _a.#canonSpelling(spelling, await FileAddress.projectRoot(core), FileAddress.isPattern(target));
114
72
  if (pathname === null)
115
73
  return null;
116
74
  if (access === "write") {
@@ -135,9 +93,28 @@ class File extends CoreSchemeAdapterBase {
135
93
  async find(statement, ctx) {
136
94
  const core = this.coreContext(ctx);
137
95
  // {§fs-namei} Exact paths, folders and globs share the filesystem base.
138
- const root = await loadWorkspaceRoot(core.db, core.workspaceId);
139
- const canon = await _a.#canonTarget(statement, root);
140
- return EntryFind.findWorkspaceEntries(canon ?? statement, core, _a.manifest, {
96
+ const root = await FileAddress.projectRoot(core);
97
+ const spelling = statement.target === null ? "" : FileAddress.selector(statement.target, root);
98
+ if (typeof spelling !== "string")
99
+ return { ...emptyFindFields(), ...spelling };
100
+ const pattern = FileAddress.isPattern(statement.target);
101
+ const key = await _a.#canonSpelling(spelling, root, pattern);
102
+ let scope = key === null ? undefined : !pattern && key !== "" && !key.endsWith("/")
103
+ ? { kind: "exact", pathname: key, candidatePrefix: key }
104
+ : pathScope(key, true);
105
+ if (scope?.kind === "glob" && statement.target?.kind === "url") {
106
+ const authored = pathScope(statement.target.pathname, true);
107
+ // Structural catalogs use a literal prefix, even when its URL needs quoting.
108
+ if (authored.kind === "glob" && (authored.shallowPrefix !== null || authored.recursivePrefix !== null)) {
109
+ const literal = Namespace.canonicalizeSpelling(FileAddress.pathname(statement.target), root);
110
+ const prefix = literal.slice(0, authored.shallowPrefix !== null ? -1 : -2);
111
+ scope = { ...scope, candidatePrefix: prefix,
112
+ shallowPrefix: authored.shallowPrefix === null ? null : prefix,
113
+ recursivePrefix: authored.recursivePrefix === null ? null : prefix };
114
+ }
115
+ }
116
+ return EntryFind.findWorkspaceEntries(statement, core, _a.manifest, {
117
+ ...(scope === undefined ? {} : { scope }),
141
118
  ...(root === null ? {} : { pathBase: root }),
142
119
  bytes: (pathname) => this.byteSource({ authority: "", pathname }, core),
143
120
  });
@@ -146,7 +123,7 @@ class File extends CoreSchemeAdapterBase {
146
123
  async readEntry(pathname, ctx) {
147
124
  const core = this.coreContext(ctx);
148
125
  // {§scheme-address} {§fs-namei} All read paths resolve the same member key.
149
- const root = await loadWorkspaceRoot(core.db, core.workspaceId);
126
+ const root = await FileAddress.projectRoot(core);
150
127
  const key = root === null ? pathname : Namespace.canonicalize(pathname, root);
151
128
  const canonical = key ?? pathname;
152
129
  const result = "entries" in ctx
@@ -161,7 +138,7 @@ class File extends CoreSchemeAdapterBase {
161
138
  byteSource({ pathname }, ctx) {
162
139
  const core = this.coreContext(ctx);
163
140
  const locate = async () => {
164
- const root = await loadWorkspaceRoot(core.db, core.workspaceId);
141
+ const root = await FileAddress.projectRoot(core);
165
142
  if (root === null)
166
143
  return null;
167
144
  const key = Namespace.canonicalize(pathname, root);
@@ -174,7 +151,7 @@ class File extends CoreSchemeAdapterBase {
174
151
  // and an occupied path says so. Beyond it the disk stays dark: nothing is looked at, and the
175
152
  // sentence is true whether or not a file is there — it neither claims absence nor hints at presence.
176
153
  async missRefusal(pathname, core, fields) {
177
- const root = await loadWorkspaceRoot(core.db, core.workspaceId);
154
+ const root = await FileAddress.projectRoot(core);
178
155
  if (root === null)
179
156
  return null;
180
157
  const key = Namespace.canonicalize(pathname, root);
@@ -182,7 +159,7 @@ class File extends CoreSchemeAdapterBase {
182
159
  return null;
183
160
  const occupant = key.startsWith("../") ? null : await _a.#occupant(root, key);
184
161
  if (occupant?.isDirectory() === true) {
185
- const { code, detail, extensions } = _a.#directoryFacts(key, "READ");
162
+ const { code, detail, extensions } = _a.#directoryFacts(key, "READ", root);
186
163
  return Results.failure("scheme:file", code, 404, detail, fields, extensions);
187
164
  }
188
165
  const occupied = occupant !== null;
@@ -192,7 +169,7 @@ class File extends CoreSchemeAdapterBase {
192
169
  return Results.failure("scheme:file", occupied ? "entry-not-member" : "entry-not-found", 404, occupied
193
170
  ? `'${key}' exists on disk but is not a member of this workspace.`
194
171
  : missDetail("file", key), fields, {
195
- target: key,
172
+ target: entryAddress("file", "", key, root),
196
173
  recovery: occupied
197
174
  ? ignored
198
175
  ? "The repository ignores it: a client or operator members definition can include it, a model definition cannot."
@@ -214,14 +191,15 @@ class File extends CoreSchemeAdapterBase {
214
191
  }
215
192
  // {§file-directory-target} — a directory inside the root is named as one, never as a missing or non-member
216
193
  // file; a READ or KILL of it carries the listing that shows its files ({§diagnostic-observation}).
217
- static #directoryFacts(key, op) {
218
- const listing = `\`FIND (${key}/)\` lists its files.`;
194
+ static #directoryFacts(key, op, root) {
195
+ const target = entryAddress("file", "", key, root);
196
+ const listing = `\`FIND (${target}/)\` lists its files.`;
219
197
  const { detail, recovery } = {
220
198
  READ: { detail: `'${key}' is a directory, not a file; READ reads one file.`, recovery: listing },
221
199
  EDIT: { detail: `'${key}' is a directory, not a file; EDIT writes one file.`, recovery: undefined },
222
200
  KILL: { detail: `'${key}' is a directory, not a file; KILL removes one file.`, recovery: listing },
223
201
  }[op];
224
- return { code: "path-is-directory", detail, extensions: { target: key, ...(recovery === undefined ? {} : { recovery }), retryable: false } };
202
+ return { code: "path-is-directory", detail, extensions: { target, ...(recovery === undefined ? {} : { recovery }), retryable: false } };
225
203
  }
226
204
  // {§membership} disk-write gate, shared by edit() and writeEntry() (the COPY/MOVE
227
205
  // dest). Resolves the canonical path; enforces containment (traversal → 403),
@@ -231,7 +209,7 @@ class File extends CoreSchemeAdapterBase {
231
209
  // and a copy into file:/// are the same disk write under the same review.
232
210
  // {§membership-edit-membership-gate} — membership/containment/read-only/binary gate before any disk write
233
211
  async #resolveWriteTarget(pathname, ctx, bytesMode = false) {
234
- const root = await loadWorkspaceRoot(ctx.db, ctx.workspaceId);
212
+ const root = await FileAddress.projectRoot(ctx);
235
213
  if (root === null) {
236
214
  return {
237
215
  ok: false,
@@ -290,7 +268,7 @@ class File extends CoreSchemeAdapterBase {
290
268
  if (occupancyCause.code !== "ENOENT")
291
269
  throw occupancyCause;
292
270
  }
293
- canonical = await prospectiveRealpath(requested);
271
+ canonical = await FileSnapshot.prospectivePath(requested);
294
272
  fileExists = false;
295
273
  }
296
274
  if (!isMount) {
@@ -315,13 +293,14 @@ class File extends CoreSchemeAdapterBase {
315
293
  const rel = key; // the bare canonical member key ({§fs-canonical-name}) — storage ≡ wire
316
294
  let original = "";
317
295
  let baseSig = null; // the snapshot signature the proposal is computed against; null = create (assumed-absent)
296
+ let sourceMimetype = null;
318
297
  let creationAdmission = null;
319
298
  if (fileExists) {
320
- const member = await ctx.db.crud_get_member_sig.get({ workspace_id: ctx.workspaceId, scheme: "file", authority: "", pathname: rel });
299
+ const member = await ctx.db.crud_read_file_snapshot.get({ workspace_id: ctx.workspaceId, pathname: rel });
321
300
  // {§fs-errno} — the occupancy fact (POSIX O_EXCL precedent): something invisible
322
301
  // occupies the path; existence leaks, content stays dark. The model picks another name.
323
302
  if (member === undefined && !isMount && (await lstat(requested)).isDirectory()) {
324
- return { ok: false, status: 403, ..._a.#directoryFacts(rel, "EDIT") };
303
+ return { ok: false, status: 403, ..._a.#directoryFacts(rel, "EDIT", root) };
325
304
  }
326
305
  if (member === undefined) {
327
306
  return {
@@ -343,10 +322,15 @@ class File extends CoreSchemeAdapterBase {
343
322
  if (currentMaterialization.disposition === "input-limit") {
344
323
  return { ok: false, ...FileMaterialization.rejection(rel, currentMaterialization) };
345
324
  }
346
- // {§membership-edit-write-cas}: diff against the READ snapshot; CAS protects the disk landing.
347
- const snapshot = await ctx.db.ops_read_channel.get({ workspace_id: ctx.workspaceId, scheme: "file", authority: "", pathname: rel, channel: "body" });
348
- original = snapshot?.content ?? "";
325
+ // {§membership-edit-write-revision}: retain the materialized baseline through approval.
326
+ const revision = FileSnapshot.decode(member.synced_sig);
327
+ if (revision === null || revision.hash === null || member.content === null || member.source_mimetype === null) {
328
+ const conflict = EditCollision.result(rel);
329
+ return { ok: false, code: "edit-collision", status: conflict.status, detail: conflict.problem.detail };
330
+ }
331
+ original = member.content;
349
332
  baseSig = member.synced_sig;
333
+ sourceMimetype = member.source_mimetype;
350
334
  }
351
335
  else {
352
336
  const admission = await GitMembership.planCreation(ctx.db, ctx.workspaceId, rel, ctx.signal);
@@ -354,7 +338,7 @@ class File extends CoreSchemeAdapterBase {
354
338
  return admission;
355
339
  creationAdmission = admission;
356
340
  }
357
- const mimetype = await detectFileMimetype(canonical, ctx);
341
+ const mimetype = sourceMimetype ?? await detectFileMimetype(canonical, ctx);
358
342
  // {§binary-parity} — a text EDIT still cannot author bytes into a binary file (a text body is
359
343
  // not bytes). A whole-resource or byte-range TRANSFER passes bytesMode: it writes the source's
360
344
  // bytes verbatim, so the binary mimetype is the point, not an error.
@@ -372,7 +356,7 @@ class File extends CoreSchemeAdapterBase {
372
356
  }
373
357
  return { ok: true, canonical, rel, fileExists, original, mimetype, baseSig, creationAdmission };
374
358
  }
375
- // {§membership-edit-write-cas}, {§proposal-202-pauses}: return status=202
359
+ // {§membership-edit-write-revision}, {§proposal-202-pauses}: return status=202
376
360
  // with a udiff body for client review and attrs carrying the full patched
377
361
  // content. Engine pauses dispatch and calls applyResolution() after accept.
378
362
  async editBatch(statements, ctx) {
@@ -392,11 +376,16 @@ class File extends CoreSchemeAdapterBase {
392
376
  retryable: false,
393
377
  });
394
378
  }
395
- const pathname = PathSyntax.decodeParens(statement.target.kind === "url" ? statement.target.pathname : statement.target.raw); // {§path-parentheses}
396
- const target = await this.#resolveWriteTarget(pathname, core);
379
+ const pathname = FileAddress.pathname(statement.target);
380
+ if (typeof pathname !== "string")
381
+ return pathname;
382
+ const target = await this.#resolveWriteTarget(Namespace.expandHome(pathname), core);
397
383
  if (!target.ok)
398
384
  return failure(target.code, target.status, target.detail, target.extensions);
399
385
  const { canonical, rel, fileExists, original, mimetype, baseSig } = target;
386
+ const selectedRevision = SchemeCtxImpl.fileRevisionOf(ctx);
387
+ if (selectedRevision !== undefined && selectedRevision !== baseSig)
388
+ return EditCollision.result(rel);
400
389
  for (const candidate of statements.slice(1)) {
401
390
  if (candidate.target === null) {
402
391
  return failure("edit-batch-mismatch", 400, "The EDIT batch spans multiple resources.", {
@@ -404,8 +393,10 @@ class File extends CoreSchemeAdapterBase {
404
393
  retryable: false,
405
394
  });
406
395
  }
407
- const candidatePathname = PathSyntax.decodeParens(candidate.target.kind === "url" ? candidate.target.pathname : candidate.target.raw);
408
- const candidateTarget = await this.#resolveWriteTarget(candidatePathname, core);
396
+ const candidatePathname = FileAddress.pathname(candidate.target);
397
+ if (typeof candidatePathname !== "string")
398
+ return candidatePathname;
399
+ const candidateTarget = await this.#resolveWriteTarget(Namespace.expandHome(candidatePathname), core);
409
400
  if (!candidateTarget.ok || candidateTarget.rel !== rel) {
410
401
  return failure("edit-batch-mismatch", 400, "The EDIT batch spans multiple resources.", {
411
402
  recovery: "Submit a separate EDIT batch for each resource.",
@@ -458,14 +449,14 @@ class File extends CoreSchemeAdapterBase {
458
449
  const receiptEdits = fileExists
459
450
  ? (appliedEdits ?? statements.map((candidate) => ({ marker: candidate.lineMarker, body: candidate.body ?? "" })))
460
451
  : [{ marker: { marks: [1, -1] }, body: patched }];
461
- const batchReceipt = editReceipt(original, patched, receiptEdits, undefined, EntryManifest.toPath("file", "", rel));
452
+ const batchReceipt = editReceipt(original, patched, receiptEdits, undefined, EntryManifest.toPath("file", "", rel, await FileAddress.projectRoot(core)));
462
453
  return {
463
454
  status: 202,
464
455
  body: patch,
465
456
  editReceipt: batchReceipt,
466
457
  ...(scopeNormalizations === undefined ? {} : { scopeNormalizations }),
467
458
  ...(merges === undefined ? {} : { merges }),
468
- attrs: { path: rel, canonical, patch, patched, mimetype, editReceipt: batchReceipt, baseSig, existed: fileExists },
459
+ attrs: { path: rel, canonical, original, patch, patched, mimetype, editReceipt: batchReceipt, baseSig, existed: fileExists },
469
460
  };
470
461
  }
471
462
  async edit(statement, ctx) {
@@ -491,15 +482,22 @@ class File extends CoreSchemeAdapterBase {
491
482
  if (!target.ok) {
492
483
  return Results.failure("scheme:file", target.code, target.status, target.detail, { created: false, entryId: null }, target.extensions);
493
484
  }
494
- const { canonical, rel, fileExists, original, mimetype, baseSig } = target;
485
+ const { canonical, rel, fileExists, original, baseSig } = target;
486
+ // {§copy-destination-mimetype}: retain the admitted per-call type on creation.
487
+ const mimetype = fileExists ? target.mimetype : bodyChannel.mimetype;
488
+ const selectedRevision = SchemeCtxImpl.fileRevisionOf(ctx);
489
+ if (selectedRevision !== undefined && selectedRevision !== baseSig)
490
+ return {
491
+ ...EditCollision.result(rel), created: false, entryId: null,
492
+ };
495
493
  if (bytes !== undefined) {
496
494
  // {§binary-parity} — a byte write carries no text diff; the receipt is the byte count and
497
495
  // the bytes ride attrs.patchedBytes to applyResolution, which writes them verbatim to disk.
498
- return { status: 202, created: !fileExists, entryId: null, body: `${bytes.length} bytes`, attrs: { path: rel, canonical, patchedBytes: bytes, patched: "", mimetype, baseSig, existed: fileExists } };
496
+ return { status: 202, created: !fileExists, entryId: null, body: `${bytes.length} bytes`, attrs: { path: rel, canonical, original, patchedBytes: bytes, patched: "", mimetype, baseSig, existed: fileExists } };
499
497
  }
500
498
  const patched = bodyChannel.content;
501
499
  const patch = createPatch(rel, original, patched, "current", "proposed");
502
- return { status: 202, created: !fileExists, entryId: null, body: patch, attrs: { path: rel, canonical, patch, patched, mimetype, baseSig, existed: fileExists } };
500
+ return { status: 202, created: !fileExists, entryId: null, body: patch, attrs: { path: rel, canonical, original, patch, patched, mimetype, baseSig, existed: fileExists } };
503
501
  }
504
502
  // applyResolution — called by Engine.dispatch after a proposed log
505
503
  // entry resolves with decision=accept. Two responsibilities:
@@ -511,37 +509,9 @@ class File extends CoreSchemeAdapterBase {
511
509
  async applyResolution(args, ctx) {
512
510
  const core = this.coreContext(ctx);
513
511
  const { attrs, body } = args;
514
- // Delete-apply (deferred behind review — the KILL proposal, or the MOVE source-delete gated by
515
- // its dest proposal): unlink the host file + deregister the entry on accept. A real unlink
516
- // failure surfaces as 500, never a silent noop; ENOENT ⇒ file already gone, still deregister.
517
- if (typeof attrs.deletePath === "string") {
518
- const root = await loadWorkspaceRoot(core.db, core.workspaceId);
519
- if (root === null) {
520
- throw new InvalidOperationResultError("An accepted file deletion has no workspace project root.");
521
- }
522
- try {
523
- await rm(join(root, attrs.deletePath));
524
- }
525
- catch (err) {
526
- if (err.code !== "ENOENT") {
527
- console.error(`File deletion failed for '${attrs.deletePath}':`, err);
528
- return Results.failure("scheme:file", "delete-failed", 500, `The file could not be deleted: ${ErrorDetail.preview(err)}`, {
529
- outcome: "delete_failed",
530
- }, {
531
- path: attrs.deletePath,
532
- stage: "filesystem-delete",
533
- });
534
- }
535
- }
536
- if ("entries" in ctx)
537
- await ctx.entries.delete(attrs.deletePath);
538
- else {
539
- await EntryCrud.deleteEntry({ authority: "", pathname: attrs.deletePath }, core, "file");
540
- }
541
- await GitMembership.removeCreationRecord(core.db, core.workspaceId, attrs.deletePath);
542
- return { status: 200 };
543
- }
544
- let canonical = attrs.canonical;
512
+ if (typeof attrs.deletePath === "string")
513
+ return this.#applyDelete(attrs.deletePath, attrs.baseSig ?? null, core);
514
+ const canonical = attrs.canonical;
545
515
  const relPath = attrs.path;
546
516
  const patched = body ?? attrs.patched;
547
517
  // {§binary-parity} — a byte write (COPY/MOVE of a binary) carries its bytes here; the disk
@@ -560,51 +530,12 @@ class File extends CoreSchemeAdapterBase {
560
530
  if (typeof mimetype !== "string" || mimetype.length === 0) {
561
531
  throw new InvalidOperationResultError("The accepted file proposal is missing attrs.mimetype.");
562
532
  }
563
- const existed = args.attrs.existed === true; // proposal targeted an existing member (edit) vs a fresh create
564
- let creationAdmission = null;
565
- if (!existed) {
566
- // {§file-create-transaction}: approval is a fresh admission boundary. Re-resolve
567
- // the physical target so a parent symlink swapped while review was pending cannot
568
- // redirect an accepted root-scoped create outside the workspace.
569
- const acceptedTarget = await this.#resolveWriteTarget(relPath, core, patchedBytes !== undefined);
570
- if (!acceptedTarget.ok) {
571
- return Results.failure("scheme:file", acceptedTarget.code, acceptedTarget.status, acceptedTarget.detail, { outcome: "create_refused" }, acceptedTarget.extensions);
572
- }
573
- if (acceptedTarget.fileExists) {
574
- return EditCollision.result(relPath, { outcome: "edit_collision" });
575
- }
576
- canonical = acceptedTarget.canonical;
577
- creationAdmission = acceptedTarget.creationAdmission;
578
- if (creationAdmission === null) {
579
- throw new InvalidOperationResultError("A revalidated file creation is missing its admission decision.");
580
- }
581
- }
582
- // CAS — the write-side twin of #materializeMember's read-gate (synced_sig === sig). The
583
- // proposal was computed against the snapshot (body + baseSig); if disk drifted out-of-band
584
- // since propose, the full-blob write would clobber it. Refuse and surface the same neutral
585
- // edit collision as an entry compare-and-swap —
586
- // the next reconcile narrates disk truth via FsDivergence; the model re-reads + re-proposes.
587
- // No clever re-diff, no clobber. {§membership-edit-write-cas}
588
- const baseSig = (args.attrs.baseSig ?? null);
589
- let currentSig = null;
590
- try {
591
- const st = await stat(canonical);
592
- currentSig = `${st.mtimeMs}:${st.size}`;
593
- }
594
- catch (err) {
595
- if (err.code !== "ENOENT")
596
- throw err;
597
- }
598
- // Edit → CAS on the snapshot signature (a null baseSig is an un-materialized member with no
599
- // recorded snapshot — no baseline to guard, so the write proceeds). Create → the proposal
600
- // assumed an absent path, so any file present now is the conflict.
601
- const conflict = existed ? (baseSig !== null && currentSig !== baseSig) : (currentSig !== null);
602
- if (conflict) {
533
+ const existed = args.attrs.existed === true;
534
+ const baseline = FileSnapshot.decode(attrs.baseSig ?? null);
535
+ if (existed && (baseline === null || baseline.hash === null))
603
536
  return EditCollision.result(relPath, { outcome: "edit_collision" });
604
- }
605
537
  let receipt = attrs.editReceipt;
606
- const needsOriginal = receipt !== undefined || (body !== undefined && body !== attrs.patched);
607
- const original = needsOriginal ? (existed ? await readFile(canonical, "utf8") : "") : null;
538
+ const original = attrs.original ?? (existed ? null : "");
608
539
  if (body !== undefined && body !== attrs.patched) {
609
540
  if (original === null) {
610
541
  throw new InvalidOperationResultError("A reviewer-modified file proposal is missing its original content.");
@@ -613,126 +544,149 @@ class File extends CoreSchemeAdapterBase {
613
544
  if (typeof attrs.patched !== "string") {
614
545
  throw new InvalidOperationResultError("The reviewer-modified file proposal is missing its proposed content.");
615
546
  }
616
- receipt = editReceipt(original, attrs.patched, [{ marker: { marks: [1, -1] }, body: attrs.patched }], undefined, EntryManifest.toPath("file", "", relPath));
547
+ receipt = editReceipt(original, attrs.patched, [{ marker: { marks: [1, -1] }, body: attrs.patched }], undefined, EntryManifest.toPath("file", "", relPath, await FileAddress.projectRoot(core)));
617
548
  }
618
- receipt = reviewerReplacementReceipt(original, patched, receipt, undefined, EntryManifest.toPath("file", "", relPath));
549
+ receipt = reviewerReplacementReceipt(original, patched, receipt, undefined, EntryManifest.toPath("file", "", relPath, await FileAddress.projectRoot(core)));
619
550
  }
620
551
  if (receipt !== undefined) {
621
552
  const parseIssues = await new DbProjectionCaps(core).parseIssueTransition(existed ? original : null, patched, mimetype);
622
553
  receipt = withEditReceiptParseIssues(receipt, parseIssues);
623
554
  }
624
- try {
625
- // A write into a not-yet-existing subtree creates it — an accepted proposal must not
626
- // die on a missing parent dir (the fan-out digest: tasks/… write_failed on ENOENT).
627
- await mkdir(dirname(canonical), { recursive: true });
628
- if (patchedBytes !== undefined) {
629
- await writeFile(canonical, Buffer.from(patchedBytes), { flag: existed ? "w" : "wx" });
630
- }
631
- else {
632
- await writeFile(canonical, patched, { encoding: "utf8", flag: existed ? "w" : "wx" });
633
- }
634
- }
635
- catch (err) {
636
- if (!existed && err.code === "EEXIST") {
555
+ return FileSnapshot.serialize(canonical, async () => {
556
+ const acceptedTarget = await this.#resolveWriteTarget(relPath, core, patchedBytes !== undefined);
557
+ if (!acceptedTarget.ok)
558
+ return Results.failure("scheme:file", acceptedTarget.code, acceptedTarget.status, acceptedTarget.detail, {}, acceptedTarget.extensions);
559
+ if (acceptedTarget.canonical !== canonical || acceptedTarget.fileExists !== existed)
637
560
  return EditCollision.result(relPath, { outcome: "edit_collision" });
561
+ const creationAdmission = acceptedTarget.creationAdmission;
562
+ const root = await FileAddress.projectRoot(core);
563
+ if (root === null)
564
+ throw new InvalidOperationResultError("An accepted file write has no workspace project root.");
565
+ const publication = await FilePublication.write(join(root, relPath), canonical, baseline, patchedBytes ?? Buffer.from(patched));
566
+ if (publication.status === "conflict")
567
+ return EditCollision.result(relPath, { outcome: "edit_collision" });
568
+ if (publication.status === "failed") {
569
+ console.error(`File publication failed for '${relPath}':`, publication.cause);
570
+ return Results.failure("scheme:file", "write-failed", 500, `${publication.published ? "The file was published, but settlement failed" : "The file was not published"}: ${ErrorDetail.preview(publication.cause)}`, { outcome: "write_failed", ...(publication.published ? { effects: [{ target: relPath, action: existed ? "update" : "create" }] } : {}) }, {
571
+ path: relPath,
572
+ stage: "filesystem-publication",
573
+ published: publication.published,
574
+ });
638
575
  }
639
- console.error(`File write failed for '${relPath}':`, err);
640
- return Results.failure("scheme:file", "write-failed", 500, `The file could not be written: ${ErrorDetail.preview(err)}`, { outcome: "write_failed" }, {
641
- path: relPath,
642
- stage: "filesystem-write",
643
- });
644
- }
645
- // Register the exact mimetype resolved at proposal time, then complete the
646
- // membership-owned incorporation transaction. No fallible receipt work follows.
647
- try {
648
- // {§entry-identity-no-null} — file members persist under the reserved
649
- // `file` identity; bare-path rendering is a projection of that row.
650
- // {§binary-parity} — a binary member registers with empty text content: its bytes live on
651
- // disk and READ serves them through byteSource; the next membership reconcile materializes
652
- // the facts line and source projection the way a git-tracked binary member gets them.
653
- const registerContent = patchedBytes !== undefined ? "" : patched;
654
- const write = "entries" in ctx
655
- ? await ctx.entries.write(relPath, {
656
- channels: { body: { content: registerContent, mimetype } },
657
- })
658
- : await EntryCrud.writeEntry({ authority: "", pathname: relPath }, {
576
+ // Register the exact mimetype resolved at proposal time, then complete the
577
+ // membership-owned incorporation transaction. No fallible receipt work follows.
578
+ try {
579
+ // {§entry-identity-no-null} — file members persist under the reserved
580
+ // `file` identity; bare-path rendering is a projection of that row.
581
+ // {§binary-parity} — a binary member registers with empty text content: its bytes live on
582
+ // disk and READ serves them through byteSource; the next membership reconcile materializes
583
+ // the facts line and source projection the way a git-tracked binary member gets them.
584
+ const registerContent = patchedBytes !== undefined ? "" : patched;
585
+ const write = await EntryCrud.writeEntry({ authority: "", pathname: relPath }, {
659
586
  channels: { body: { content: registerContent, mimetype } },
660
- }, core, "file");
661
- const { entryId } = write;
662
- // Restamp synced_sig to the landed write so the next reconcile recognizes our own
663
- // write as the synced state — not an FsDivergence narrated back at the model.
664
- if (entryId !== null) {
665
- const landed = await stat(canonical);
666
- await core.db.crud_set_synced_sig.run({ entry_id: entryId, synced_sig: `${landed.mtimeMs}:${landed.size}` });
667
- }
668
- if (!existed) {
669
- if (entryId === null || creationAdmission === null || !creationAdmission.ok) {
670
- throw new InvalidOperationResultError("A created file is missing its entry or admission decision.");
671
- }
672
- const root = await loadWorkspaceRoot(core.db, core.workspaceId);
673
- if (root === null) {
674
- throw new InvalidOperationResultError("A created file lost its workspace project root.");
587
+ attributes: patchedBytes === undefined
588
+ ? FileMaterialization.attributes(FileMaterialization.classify(Buffer.byteLength(patched)))
589
+ : {},
590
+ }, core, "file", { syncedSig: JSON.stringify(publication.revision) });
591
+ const { entryId } = write;
592
+ if (!existed) {
593
+ if (entryId === null || creationAdmission === null || !creationAdmission.ok) {
594
+ throw new InvalidOperationResultError("A created file is missing its entry or admission decision.");
595
+ }
596
+ await GitMembership.incorporateCreation(core.db, core.workspaceId, entryId, relPath, creationAdmission);
675
597
  }
676
- await GitMembership.incorporateCreation(core.db, core.workspaceId, entryId, relPath, creationAdmission);
677
598
  }
678
- }
679
- catch (err) {
680
- if (!existed) {
681
- const rollbackErrors = [];
682
- try {
683
- const row = await core.db.crud_find_workspace_entry.get({
684
- workspace_id: core.workspaceId,
685
- scheme: "file",
686
- authority: "",
687
- pathname: relPath,
688
- });
689
- if (row !== undefined)
690
- await core.db.crud_delete_entry.run({ entry_id: row.id });
691
- }
692
- catch (rollbackCause) {
693
- rollbackErrors.push(rollbackCause);
694
- }
695
- try {
696
- await rm(canonical);
697
- }
698
- catch (rollbackCause) {
699
- if (rollbackCause.code !== "ENOENT") {
599
+ catch (err) {
600
+ if (!existed) {
601
+ const rollbackErrors = [];
602
+ try {
603
+ const row = await core.db.crud_find_workspace_entry.get({
604
+ workspace_id: core.workspaceId,
605
+ scheme: "file",
606
+ authority: "",
607
+ pathname: relPath,
608
+ });
609
+ if (row !== undefined)
610
+ await core.db.crud_delete_entry.run({ entry_id: row.id });
611
+ }
612
+ catch (rollbackCause) {
700
613
  rollbackErrors.push(rollbackCause);
701
614
  }
702
- }
703
- if (rollbackErrors.length > 0) {
704
- const rollback = new AggregateError(rollbackErrors, `Creation rollback failed for '${relPath}'.`);
705
- console.error(`File creation failed and rollback was incomplete for '${relPath}':`, err, rollback);
706
- return Results.failure("scheme:file", "creation-rollback-failed", 500, `The file could not be incorporated and rollback was incomplete: ${ErrorDetail.preview(rollback)}`, { outcome: "creation_rollback_failed" }, {
615
+ try {
616
+ const current = await FileSnapshot.acquire(publication.revision.canonical, null);
617
+ if (current === null && await FileSnapshot.observe(publication.revision.canonical) !== null) {
618
+ throw new Error("The created path changed during rollback validation; it was preserved.");
619
+ }
620
+ if (current !== null && !FileSnapshot.matches(publication.revision, current.revision)) {
621
+ throw new Error("The created path changed; its replacement was preserved.");
622
+ }
623
+ if (current !== null)
624
+ await rm(publication.revision.canonical);
625
+ }
626
+ catch (rollbackCause) {
627
+ if (rollbackCause.code !== "ENOENT") {
628
+ rollbackErrors.push(rollbackCause);
629
+ }
630
+ }
631
+ if (rollbackErrors.length > 0) {
632
+ const rollback = new AggregateError(rollbackErrors, `Creation rollback failed for '${relPath}'.`);
633
+ console.error(`File creation failed and rollback was incomplete for '${relPath}':`, err, rollback);
634
+ return Results.failure("scheme:file", "creation-rollback-failed", 500, `The file could not be incorporated and rollback was incomplete: ${ErrorDetail.preview(rollback)}`, { outcome: "creation_rollback_failed", effects: [{ target: relPath, action: "create" }] }, {
635
+ path: relPath,
636
+ stage: "creation-rollback",
637
+ cause: ErrorDetail.preview(err),
638
+ retryable: false,
639
+ });
640
+ }
641
+ console.error(`File creation incorporation failed for '${relPath}':`, err);
642
+ return Results.failure("scheme:file", "creation-incorporation-failed", 500, `The file could not be incorporated, so its exclusive creation was rolled back: ${ErrorDetail.preview(err)}`, { outcome: "creation_rolled_back" }, {
707
643
  path: relPath,
708
- stage: "creation-rollback",
709
- cause: ErrorDetail.preview(err),
644
+ stage: "membership-incorporation",
710
645
  retryable: false,
711
646
  });
712
647
  }
713
- console.error(`File creation incorporation failed for '${relPath}':`, err);
714
- return Results.failure("scheme:file", "creation-incorporation-failed", 500, `The file could not be incorporated, so its exclusive creation was rolled back: ${ErrorDetail.preview(err)}`, { outcome: "creation_rolled_back" }, {
648
+ // An existing-file write has already changed disk. Surface the partial
649
+ // failure rather than pretending the addressable entry followed it.
650
+ console.error(`File registration failed for '${relPath}':`, err);
651
+ return Results.failure("scheme:file", "materialization-failed", 500, `The file was written to disk but could not be registered: ${ErrorDetail.preview(err)}`, { outcome: "materialize_failed", effects: [{ target: relPath, action: "update" }] }, {
715
652
  path: relPath,
716
- stage: "membership-incorporation",
653
+ stage: "entry-registration",
717
654
  retryable: false,
718
655
  });
719
656
  }
720
- // An existing-file write has already changed disk. Surface the partial
721
- // failure rather than pretending the addressable entry followed it.
722
- console.error(`File registration failed for '${relPath}':`, err);
723
- return Results.failure("scheme:file", "materialization-failed", 500, `The file was written to disk but could not be registered: ${ErrorDetail.preview(err)}`, { outcome: "materialize_failed" }, {
724
- path: relPath,
725
- stage: "entry-registration",
726
- retryable: false,
727
- });
728
- }
729
- if (receipt === undefined)
730
- return { status: 200 };
731
- return {
732
- status: 200,
733
- editReceipt: receipt,
734
- result: { receipt: projectEditReceipt(receipt, 0) },
735
- };
657
+ if (receipt === undefined)
658
+ return { status: 200 };
659
+ return {
660
+ status: 200,
661
+ editReceipt: receipt,
662
+ result: { receipt: projectEditReceipt(receipt, 0) },
663
+ };
664
+ });
665
+ }
666
+ async #applyDelete(pathname, baseSig, core) {
667
+ const baseline = FileSnapshot.decode(baseSig);
668
+ if (baseline === null || baseline.hash === null)
669
+ return EditCollision.result(pathname);
670
+ const root = await FileAddress.projectRoot(core);
671
+ if (root === null)
672
+ throw new InvalidOperationResultError("An accepted file deletion has no workspace project root.");
673
+ const requested = join(root, pathname);
674
+ return FileSnapshot.serialize(baseline.canonical, async () => {
675
+ const current = await FileSnapshot.acquire(requested, null);
676
+ if (current === null || !FileSnapshot.matches(baseline, current.revision))
677
+ return EditCollision.result(pathname);
678
+ let deleted = false;
679
+ try {
680
+ await rm(requested);
681
+ deleted = true;
682
+ await EntryCrud.deleteEntry({ authority: "", pathname }, core, "file");
683
+ await GitMembership.removeCreationRecord(core.db, core.workspaceId, pathname);
684
+ return { status: 200 };
685
+ }
686
+ catch (cause) {
687
+ return Results.failure("scheme:file", "delete-failed", 500, `${deleted ? "The file was deleted, but projection settlement failed" : "The file could not be deleted"}: ${ErrorDetail.preview(cause)}`, { outcome: "delete_failed", ...(deleted ? { effects: [{ target: pathname, action: "delete" }] } : {}) }, { path: pathname, stage: deleted ? "entry-deletion" : "filesystem-delete", deleted });
688
+ }
689
+ });
736
690
  }
737
691
  // deleteEntry — the KILL / MOVE-source counterpart of writeEntry. Deleting a host file is
738
692
  // DESTRUCTIVE, so it PROPOSES for review (202), exactly as edit() does — never an ungated unlink.
@@ -740,21 +694,32 @@ class File extends CoreSchemeAdapterBase {
740
694
  // disk is never probed or touched. applyResolution unlinks + deregisters on accept.
741
695
  async deleteEntry(pathname, ctx) {
742
696
  const core = this.coreContext(ctx);
743
- const root = await loadWorkspaceRoot(core.db, core.workspaceId);
697
+ const root = await FileAddress.projectRoot(core);
744
698
  if (root === null) {
745
699
  return Results.failure("scheme:file", "project-root-required", 400, "File deletion requires a workspace project root.", {}, { retryable: false });
746
700
  }
747
701
  const rel = Namespace.canonicalize(pathname, root);
748
702
  if (rel === null)
749
- return Results.failure("scheme:file", "entry-not-found", 404, missDetail("file", pathname), {}, missExtensions("file", pathname));
750
- const member = await core.db.crud_find_workspace_entry.get({ workspace_id: core.workspaceId, scheme: "file", authority: "", pathname: rel });
703
+ return Results.failure("scheme:file", "entry-not-found", 404, missDetail("file", pathname), {}, missExtensions("file", entryAddress("file", "", pathname, root)));
704
+ const member = await core.db.crud_get_member_sig.get({ workspace_id: core.workspaceId, scheme: "file", authority: "", pathname: rel });
751
705
  if (member === undefined && !rel.startsWith("../") && (await _a.#occupant(root, rel))?.isDirectory() === true) {
752
- const { code, detail, extensions } = _a.#directoryFacts(rel, "KILL");
706
+ const { code, detail, extensions } = _a.#directoryFacts(rel, "KILL", root);
753
707
  return Results.failure("scheme:file", code, 404, detail, {}, extensions);
754
708
  }
755
709
  if (member === undefined)
756
- return Results.failure("scheme:file", "entry-not-found", 404, missDetail("file", rel), {}, missExtensions("file", rel));
757
- return { status: 202, attrs: { deletePath: rel } };
710
+ return Results.failure("scheme:file", "entry-not-found", 404, missDetail("file", rel), {}, missExtensions("file", entryAddress("file", "", rel, root)));
711
+ const selectedRevision = SchemeCtxImpl.fileRevisionOf(ctx);
712
+ if (selectedRevision !== undefined && selectedRevision !== member.synced_sig)
713
+ return EditCollision.result(rel);
714
+ const baseline = FileSnapshot.decode(member.synced_sig);
715
+ if (baseline === null)
716
+ return EditCollision.result(rel);
717
+ // {§file-delete-revision}: an oversized marker has no body; deletion still captures exact bytes.
718
+ const acquired = baseline.hash === null ? await FileSnapshot.acquire(join(root, rel), null) : null;
719
+ if (baseline.hash === null && (acquired === null || !FileSnapshot.matches(baseline, acquired.revision))) {
720
+ return EditCollision.result(rel);
721
+ }
722
+ return { status: 202, attrs: { deletePath: rel, baseSig: acquired === null ? member.synced_sig : JSON.stringify(acquired.revision) } };
758
723
  }
759
724
  async deleteChannel(pathname, channel, ctx) {
760
725
  if (channel !== _a.manifest.defaultChannel) {