@anchrd/intel 0.59.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 (364) hide show
  1. package/README.md +496 -0
  2. package/bin/intel.mjs +134 -0
  3. package/dist/adapters/cloudflare/cloudflare-flow-workflow.d.ts +42 -0
  4. package/dist/adapters/cloudflare/cloudflare-flow-workflow.js +89 -0
  5. package/dist/adapters/cloudflare/cloudflare.d.ts +29 -0
  6. package/dist/adapters/cloudflare/cloudflare.js +416 -0
  7. package/dist/adapters/cloudflare/cloudflare.types.d.ts +31 -0
  8. package/dist/adapters/cloudflare/cloudflare.types.js +1 -0
  9. package/dist/adapters/content/content.d.ts +3 -0
  10. package/dist/adapters/content/content.js +128 -0
  11. package/dist/adapters/content/content.types.d.ts +38 -0
  12. package/dist/adapters/content/content.types.js +1 -0
  13. package/dist/adapters/db/db-audit.d.ts +62 -0
  14. package/dist/adapters/db/db-audit.js +150 -0
  15. package/dist/adapters/db/db-boards.d.ts +6 -0
  16. package/dist/adapters/db/db-boards.js +324 -0
  17. package/dist/adapters/db/db-feed.d.ts +30 -0
  18. package/dist/adapters/db/db-feed.js +276 -0
  19. package/dist/adapters/db/db-flows.d.ts +42 -0
  20. package/dist/adapters/db/db-flows.js +1311 -0
  21. package/dist/adapters/db/db-grants.d.ts +133 -0
  22. package/dist/adapters/db/db-grants.js +269 -0
  23. package/dist/adapters/db/db-indexing.d.ts +3 -0
  24. package/dist/adapters/db/db-indexing.js +200 -0
  25. package/dist/adapters/db/db-oauth.d.ts +3 -0
  26. package/dist/adapters/db/db-oauth.js +26 -0
  27. package/dist/adapters/db/db-prompts.d.ts +46 -0
  28. package/dist/adapters/db/db-prompts.js +144 -0
  29. package/dist/adapters/db/db.d.ts +6 -0
  30. package/dist/adapters/db/db.js +1687 -0
  31. package/dist/adapters/db/db.types.d.ts +16 -0
  32. package/dist/adapters/db/db.types.js +1 -0
  33. package/dist/adapters/document-converter/document-converter.d.ts +2 -0
  34. package/dist/adapters/document-converter/document-converter.js +26 -0
  35. package/dist/adapters/document-converter/document-converter.types.d.ts +17 -0
  36. package/dist/adapters/document-converter/document-converter.types.js +1 -0
  37. package/dist/adapters/flow-runtime/flow-runtime.d.ts +3 -0
  38. package/dist/adapters/flow-runtime/flow-runtime.js +22 -0
  39. package/dist/adapters/flow-runtime/flow-runtime.types.d.ts +19 -0
  40. package/dist/adapters/flow-runtime/flow-runtime.types.js +1 -0
  41. package/dist/adapters/index-queue/index-queue.d.ts +10 -0
  42. package/dist/adapters/index-queue/index-queue.js +12 -0
  43. package/dist/adapters/index-queue/index-queue.types.d.ts +11 -0
  44. package/dist/adapters/index-queue/index-queue.types.js +1 -0
  45. package/dist/adapters/json-schema/json-schema.d.ts +2 -0
  46. package/dist/adapters/json-schema/json-schema.js +77 -0
  47. package/dist/adapters/openid/openid.d.ts +5 -0
  48. package/dist/adapters/openid/openid.js +301 -0
  49. package/dist/adapters/portal-tokens/portal-tokens.d.ts +8 -0
  50. package/dist/adapters/portal-tokens/portal-tokens.js +79 -0
  51. package/dist/adapters/remote-tools/remote-tools.d.ts +5 -0
  52. package/dist/adapters/remote-tools/remote-tools.js +129 -0
  53. package/dist/adapters/semantic-index/semantic-index.d.ts +2 -0
  54. package/dist/adapters/semantic-index/semantic-index.js +139 -0
  55. package/dist/adapters/semantic-index/semantic-index.types.d.ts +56 -0
  56. package/dist/adapters/semantic-index/semantic-index.types.js +1 -0
  57. package/dist/adapters/session-cookie/session-cookie.d.ts +5 -0
  58. package/dist/adapters/session-cookie/session-cookie.js +87 -0
  59. package/dist/adapters/tool-source-policy/tool-source-policy.d.ts +1 -0
  60. package/dist/adapters/tool-source-policy/tool-source-policy.js +22 -0
  61. package/dist/audit/audit.d.ts +2 -0
  62. package/dist/audit/audit.js +87 -0
  63. package/dist/audit/audit.types.d.ts +23 -0
  64. package/dist/audit/audit.types.js +1 -0
  65. package/dist/auth/auth.d.ts +2 -0
  66. package/dist/auth/auth.js +284 -0
  67. package/dist/auth/auth.types.d.ts +108 -0
  68. package/dist/auth/auth.types.js +1 -0
  69. package/dist/boards/boards.d.ts +44 -0
  70. package/dist/boards/boards.js +447 -0
  71. package/dist/boards/boards.types.d.ts +117 -0
  72. package/dist/boards/boards.types.js +1 -0
  73. package/dist/build/build.d.ts +2 -0
  74. package/dist/build/build.js +192 -0
  75. package/dist/build/build.types.d.ts +17 -0
  76. package/dist/build/build.types.js +1 -0
  77. package/dist/bundle/bundle.d.ts +4 -0
  78. package/dist/bundle/bundle.js +1194 -0
  79. package/dist/bundle/bundle.types.d.ts +33 -0
  80. package/dist/bundle/bundle.types.js +1 -0
  81. package/dist/cli/cli.d.ts +4 -0
  82. package/dist/cli/cli.js +262 -0
  83. package/dist/cli/cli.types.d.ts +14 -0
  84. package/dist/cli/cli.types.js +1 -0
  85. package/dist/contract/audit.d.ts +50 -0
  86. package/dist/contract/audit.js +72 -0
  87. package/dist/contract/board.d.ts +150 -0
  88. package/dist/contract/board.js +262 -0
  89. package/dist/contract/bundle.d.ts +147 -0
  90. package/dist/contract/bundle.js +139 -0
  91. package/dist/contract/contract.d.ts +112 -0
  92. package/dist/contract/contract.js +143 -0
  93. package/dist/contract/feed.d.ts +126 -0
  94. package/dist/contract/feed.js +149 -0
  95. package/dist/contract/flow-run.d.ts +346 -0
  96. package/dist/contract/flow-run.js +194 -0
  97. package/dist/contract/flow.d.ts +1057 -0
  98. package/dist/contract/flow.js +552 -0
  99. package/dist/contract/node.d.ts +551 -0
  100. package/dist/contract/node.js +456 -0
  101. package/dist/contract/share.d.ts +216 -0
  102. package/dist/contract/share.js +134 -0
  103. package/dist/contract/table.d.ts +171 -0
  104. package/dist/contract/table.js +117 -0
  105. package/dist/contract/tool.d.ts +122 -0
  106. package/dist/contract/tool.js +172 -0
  107. package/dist/feed/feed.d.ts +2 -0
  108. package/dist/feed/feed.js +77 -0
  109. package/dist/feed/feed.types.d.ts +23 -0
  110. package/dist/feed/feed.types.js +1 -0
  111. package/dist/flows/flows.d.ts +41 -0
  112. package/dist/flows/flows.js +2216 -0
  113. package/dist/flows/flows.types.d.ts +339 -0
  114. package/dist/flows/flows.types.js +1 -0
  115. package/dist/http/http.d.ts +9 -0
  116. package/dist/http/http.js +874 -0
  117. package/dist/http/http.types.d.ts +22 -0
  118. package/dist/http/http.types.js +1 -0
  119. package/dist/indexing/indexing.d.ts +6 -0
  120. package/dist/indexing/indexing.js +239 -0
  121. package/dist/indexing/indexing.types.d.ts +11 -0
  122. package/dist/indexing/indexing.types.js +1 -0
  123. package/dist/intel/intel.d.ts +3 -0
  124. package/dist/intel/intel.js +194 -0
  125. package/dist/intel/intel.types.d.ts +24 -0
  126. package/dist/intel/intel.types.js +1 -0
  127. package/dist/mcp/mcp.d.ts +2 -0
  128. package/dist/mcp/mcp.js +1292 -0
  129. package/dist/mcp/mcp.types.d.ts +25 -0
  130. package/dist/mcp/mcp.types.js +1 -0
  131. package/dist/nodes/document-links/document-links.d.ts +20 -0
  132. package/dist/nodes/document-links/document-links.js +57 -0
  133. package/dist/nodes/nodes.d.ts +2 -0
  134. package/dist/nodes/nodes.js +1667 -0
  135. package/dist/nodes/nodes.types.d.ts +511 -0
  136. package/dist/nodes/nodes.types.js +1 -0
  137. package/dist/prepare/prepare.d.ts +4 -0
  138. package/dist/prepare/prepare.js +16 -0
  139. package/dist/prepare/prepare.types.d.ts +9 -0
  140. package/dist/prepare/prepare.types.js +1 -0
  141. package/dist/prompts/prompts.d.ts +2 -0
  142. package/dist/prompts/prompts.js +65 -0
  143. package/dist/prompts/prompts.types.d.ts +71 -0
  144. package/dist/prompts/prompts.types.js +1 -0
  145. package/dist/shared/callers/callers.d.ts +28 -0
  146. package/dist/shared/callers/callers.js +30 -0
  147. package/dist/shared/csv/csv.d.ts +13 -0
  148. package/dist/shared/csv/csv.js +85 -0
  149. package/dist/shared/document-text/document-text.d.ts +21 -0
  150. package/dist/shared/document-text/document-text.js +31 -0
  151. package/dist/shared/gate-authorization/gate-authorization.d.ts +5 -0
  152. package/dist/shared/gate-authorization/gate-authorization.js +26 -0
  153. package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
  154. package/dist/shared/grant-expiry/grant-expiry.js +26 -0
  155. package/dist/shared/intel-error/intel-error.d.ts +22 -0
  156. package/dist/shared/intel-error/intel-error.js +27 -0
  157. package/dist/shared/plain-title/plain-title.d.ts +16 -0
  158. package/dist/shared/plain-title/plain-title.js +45 -0
  159. package/dist/shared/problem-details/problem-details.d.ts +30 -0
  160. package/dist/shared/problem-details/problem-details.js +18 -0
  161. package/dist/shared/report-unexpected-error/report-unexpected-error.d.ts +1 -0
  162. package/dist/shared/report-unexpected-error/report-unexpected-error.js +37 -0
  163. package/dist/shared/safe-return-path/safe-return-path.d.ts +2 -0
  164. package/dist/shared/safe-return-path/safe-return-path.js +11 -0
  165. package/dist/shared/sha256/sha256.d.ts +2 -0
  166. package/dist/shared/sha256/sha256.js +14 -0
  167. package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
  168. package/dist/tools/tool-servers/tool-servers.js +177 -0
  169. package/dist/tools/tools.d.ts +2 -0
  170. package/dist/tools/tools.js +207 -0
  171. package/dist/tools/tools.types.d.ts +56 -0
  172. package/dist/tools/tools.types.js +1 -0
  173. package/examples/branding/favicon.svg +5 -0
  174. package/examples/branding/logo.svg +5 -0
  175. package/examples/branding/theme.css +16 -0
  176. package/examples/dev.vars.example +7 -0
  177. package/examples/intel.json +9 -0
  178. package/migrations/0000_intel_foundation.sql +203 -0
  179. package/migrations/0001_portal_native_tools.sql +21 -0
  180. package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
  181. package/migrations/0003_folder_permissions.sql +211 -0
  182. package/migrations/0004_subflow_runs.sql +14 -0
  183. package/migrations/0005_flow_node_cleanup.sql +28 -0
  184. package/migrations/0005_tables_in_the_knowledge_tree.sql +68 -0
  185. package/migrations/0006_links_are_written_in_the_text.sql +20 -0
  186. package/migrations/0007_no_node_waits.sql +69 -0
  187. package/migrations/0008_three_layers.sql +130 -0
  188. package/migrations/0009_no_context_policy.sql +48 -0
  189. package/migrations/0010_no_run_waits_a_year.sql +27 -0
  190. package/migrations/0011_one_name_for_the_tree.sql +53 -0
  191. package/migrations/0012_table_snapshots.sql +29 -0
  192. package/migrations/0017_a_vector_per_card.sql +38 -0
  193. package/migrations/0018_no_context_policy_at_last.sql +97 -0
  194. package/migrations/0019_one_name_for_the_grants.sql +52 -0
  195. package/migrations/0020_cascade_purge_replay.sql +11 -0
  196. package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
  197. package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
  198. package/migrations/0023_a_board_and_its_tasks.sql +116 -0
  199. package/migrations/0024_the_archive_is_a_column.sql +33 -0
  200. package/migrations/0025_archiving_takes_the_subtree.sql +29 -0
  201. package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
  202. package/migrations/0027_the_runs_of_every_flow.sql +27 -0
  203. package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
  204. package/package.json +118 -0
  205. package/src/contract/CLAUDE.md +152 -0
  206. package/src/contract/README.md +102 -0
  207. package/src/contract/audit.ts +85 -0
  208. package/src/contract/board.ts +306 -0
  209. package/src/contract/bundle.ts +157 -0
  210. package/src/contract/contract.ts +161 -0
  211. package/src/contract/feed.ts +165 -0
  212. package/src/contract/flow-run.ts +241 -0
  213. package/src/contract/flow.ts +675 -0
  214. package/src/contract/node.ts +583 -0
  215. package/src/contract/share.ts +196 -0
  216. package/src/contract/table.ts +178 -0
  217. package/src/contract/tool.ts +202 -0
  218. package/ui/README.md +110 -0
  219. package/ui/components.json +21 -0
  220. package/ui/index.html +13 -0
  221. package/ui/package.json +11 -0
  222. package/ui/scripts/lint-tokens.mjs +187 -0
  223. package/ui/src/access-summary/access-summary.tsx +385 -0
  224. package/ui/src/app/action-slot/action-slot.tsx +27 -0
  225. package/ui/src/app/app-sidebar/app-sidebar.tsx +71 -0
  226. package/ui/src/app/app-tree/app-tree.tsx +1175 -0
  227. package/ui/src/app/app.tsx +87 -0
  228. package/ui/src/app/header-search/header-search.tsx +294 -0
  229. package/ui/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
  230. package/ui/src/app/settings-dialog/settings-dialog.tsx +135 -0
  231. package/ui/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
  232. package/ui/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
  233. package/ui/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
  234. package/ui/src/app/tree-expansion/tree-expansion.ts +107 -0
  235. package/ui/src/app/tree-expansion/tree-expansion.types.ts +31 -0
  236. package/ui/src/app/tree-move/tree-move.tsx +427 -0
  237. package/ui/src/app/tree-reveal/tree-reveal.ts +64 -0
  238. package/ui/src/app/tree-reveal/tree-reveal.types.ts +16 -0
  239. package/ui/src/app/user-footer/user-footer.tsx +188 -0
  240. package/ui/src/app/view-menu/view-menu.tsx +115 -0
  241. package/ui/src/app-root/app-root.tsx +109 -0
  242. package/ui/src/app-root/app-root.types.ts +27 -0
  243. package/ui/src/archive/archive.tsx +417 -0
  244. package/ui/src/attachment-detail/attachment-detail.ts +52 -0
  245. package/ui/src/attachment-viewer/attachment-viewer.tsx +166 -0
  246. package/ui/src/blocknote-view/blocknote-view.tsx +30 -0
  247. package/ui/src/board/board-assignee/board-assignee-picker.tsx +132 -0
  248. package/ui/src/board/board-assignee/board-assignee.ts +48 -0
  249. package/ui/src/board/board-assignee/board-assignee.tsx +75 -0
  250. package/ui/src/board/board-chip/board-chip.tsx +46 -0
  251. package/ui/src/board/board-crumbs/board-crumbs.ts +56 -0
  252. package/ui/src/board/board-crumbs/board-crumbs.tsx +111 -0
  253. package/ui/src/board/board-data/board-data.ts +195 -0
  254. package/ui/src/board/board-data/board-data.types.ts +38 -0
  255. package/ui/src/board/board-dates/board-dates.ts +40 -0
  256. package/ui/src/board/board-dates/board-dates.tsx +105 -0
  257. package/ui/src/board/board-kanban/board-kanban.ts +162 -0
  258. package/ui/src/board/board-kanban/board-kanban.tsx +680 -0
  259. package/ui/src/board/board-open-task/board-open-task.ts +32 -0
  260. package/ui/src/board/board-panel/board-panel.tsx +259 -0
  261. package/ui/src/board/board-settings/board-settings.tsx +226 -0
  262. package/ui/src/board/board-status/board-status.tsx +60 -0
  263. package/ui/src/board/board-stripes/board-stripes.ts +128 -0
  264. package/ui/src/board/board-table/board-table.ts +53 -0
  265. package/ui/src/board/board-table/board-table.tsx +610 -0
  266. package/ui/src/board/board-task/board-task.ts +108 -0
  267. package/ui/src/board/board-task/board-task.tsx +335 -0
  268. package/ui/src/board/board-title-row/board-title-row.tsx +66 -0
  269. package/ui/src/branding/branding.tsx +17 -0
  270. package/ui/src/branding/custom-logo.ts +1 -0
  271. package/ui/src/branding/favicon.default.svg +5 -0
  272. package/ui/src/branding/favicon.svg +5 -0
  273. package/ui/src/components/ui/avatar.tsx +39 -0
  274. package/ui/src/components/ui/button.tsx +64 -0
  275. package/ui/src/components/ui/collapsible.tsx +20 -0
  276. package/ui/src/components/ui/command.tsx +160 -0
  277. package/ui/src/components/ui/dialog.tsx +143 -0
  278. package/ui/src/components/ui/dropdown-menu.tsx +187 -0
  279. package/ui/src/components/ui/input.tsx +21 -0
  280. package/ui/src/components/ui/popover.tsx +41 -0
  281. package/ui/src/components/ui/select.tsx +163 -0
  282. package/ui/src/components/ui/separator.tsx +26 -0
  283. package/ui/src/components/ui/sheet.tsx +136 -0
  284. package/ui/src/components/ui/sidebar.tsx +693 -0
  285. package/ui/src/components/ui/skeleton.tsx +13 -0
  286. package/ui/src/components/ui/switch.tsx +25 -0
  287. package/ui/src/components/ui/table.tsx +82 -0
  288. package/ui/src/components/ui/tabs.tsx +52 -0
  289. package/ui/src/components/ui/tooltip.tsx +51 -0
  290. package/ui/src/data/intel-data-provider/intel-data-provider.ts +685 -0
  291. package/ui/src/data/intel-data-provider/intel-data-provider.types.ts +266 -0
  292. package/ui/src/data/query-client.ts +18 -0
  293. package/ui/src/data/request-refusal/refusal-notice.tsx +36 -0
  294. package/ui/src/data/request-refusal/request-refusal.ts +51 -0
  295. package/ui/src/data/sign-in/sign-in.ts +97 -0
  296. package/ui/src/data/sign-in/sign-in.types.ts +15 -0
  297. package/ui/src/document-link/document-link.tsx +114 -0
  298. package/ui/src/document-markdown/document-markdown.ts +195 -0
  299. package/ui/src/editor-schema/editor-schema.ts +23 -0
  300. package/ui/src/entry-picker/entry-picker.tsx +238 -0
  301. package/ui/src/feed/feed.tsx +344 -0
  302. package/ui/src/file-preview/file-preview-view.tsx +496 -0
  303. package/ui/src/file-preview/file-preview.tsx +28 -0
  304. package/ui/src/file-preview/pdf-file-preview.tsx +5 -0
  305. package/ui/src/file-preview/presentation-file-preview.tsx +21 -0
  306. package/ui/src/file-preview/spreadsheet-file-preview.tsx +5 -0
  307. package/ui/src/file-preview/word-file-preview.tsx +5 -0
  308. package/ui/src/flow-runs/flow-runs.tsx +238 -0
  309. package/ui/src/flows/flows.tsx +1646 -0
  310. package/ui/src/flows/node-icon/node-icon.ts +34 -0
  311. package/ui/src/flows/node-palette/node-palette.tsx +214 -0
  312. package/ui/src/flows/node-palette/node-palette.types.ts +15 -0
  313. package/ui/src/folder-contents/folder-contents.tsx +321 -0
  314. package/ui/src/folder-index/folder-index.ts +55 -0
  315. package/ui/src/frontmatter/frontmatter.tsx +193 -0
  316. package/ui/src/graph-pane/graph-pane.tsx +53 -0
  317. package/ui/src/hooks/use-mobile.ts +19 -0
  318. package/ui/src/i18n/custom.json +4 -0
  319. package/ui/src/i18n/de.json +609 -0
  320. package/ui/src/i18n/en.json +609 -0
  321. package/ui/src/i18n/es.json +609 -0
  322. package/ui/src/i18n/i18n-context.tsx +65 -0
  323. package/ui/src/i18n/i18n-languages/i18n-languages.ts +83 -0
  324. package/ui/src/i18n/i18n.ts +32 -0
  325. package/ui/src/i18n/i18n.types.ts +49 -0
  326. package/ui/src/kind-icon.ts +97 -0
  327. package/ui/src/lib/utils.ts +6 -0
  328. package/ui/src/main.tsx +68 -0
  329. package/ui/src/modal/modal.tsx +53 -0
  330. package/ui/src/node-details/node-details.tsx +545 -0
  331. package/ui/src/node-editor/node-editor.tsx +304 -0
  332. package/ui/src/node-graph/graph-notice.tsx +21 -0
  333. package/ui/src/node-graph/node-graph.ts +214 -0
  334. package/ui/src/node-graph/node-graph.tsx +295 -0
  335. package/ui/src/node-import/node-import.tsx +418 -0
  336. package/ui/src/node-save-error.ts +46 -0
  337. package/ui/src/node-source/node-source.tsx +128 -0
  338. package/ui/src/node-table/node-table.tsx +107 -0
  339. package/ui/src/nodes/nodes.tsx +240 -0
  340. package/ui/src/rename-resource/rename-resource.ts +80 -0
  341. package/ui/src/resolved-names/resolved-names.tsx +67 -0
  342. package/ui/src/resource-error.ts +33 -0
  343. package/ui/src/resource-menu/resource-menu.tsx +1156 -0
  344. package/ui/src/router/router-context.ts +6 -0
  345. package/ui/src/router/router.tsx +67 -0
  346. package/ui/src/router/router.types.ts +8 -0
  347. package/ui/src/router/selection-search.ts +88 -0
  348. package/ui/src/save-button/save-button.tsx +94 -0
  349. package/ui/src/section-hint/section-hint.tsx +40 -0
  350. package/ui/src/sign-in-refused/sign-in-refused.tsx +35 -0
  351. package/ui/src/styles.css +189 -0
  352. package/ui/src/table-columns/table-columns.tsx +379 -0
  353. package/ui/src/theme/custom.css +1 -0
  354. package/ui/src/theme/theme-context.tsx +89 -0
  355. package/ui/src/theme/theme.ts +70 -0
  356. package/ui/src/time/relative-time.tsx +41 -0
  357. package/ui/src/time/time-context.tsx +78 -0
  358. package/ui/src/time/time.ts +165 -0
  359. package/ui/src/title-row/title-row.tsx +185 -0
  360. package/ui/src/tools/tools.tsx +546 -0
  361. package/ui/src/user-name/user-name.ts +61 -0
  362. package/ui/src/verbatim/verbatim.tsx +68 -0
  363. package/ui/tsconfig.json +17 -0
  364. package/ui/vite.config.ts +129 -0
@@ -0,0 +1,1667 @@
1
+ import { TableMediaType } from "../contract/table.js";
2
+ import { callersPhrase, namedOrCounted } from "../shared/callers/callers.js";
3
+ import { encodeCsv, parseCsv } from "../shared/csv/csv.js";
4
+ import { requireFutureExpiry } from "../shared/grant-expiry/grant-expiry.js";
5
+ import { IntelError } from "../shared/intel-error/intel-error.js";
6
+ import { plainTitle } from "../shared/plain-title/plain-title.js";
7
+ import { documentLinkTargets } from "./document-links/document-links.js";
8
+ // ⚠️ The R2 key of a version written before #125 begins `knowledge/`, and it stays that way. A key
9
+ // is stored in `node_versions.content_key` and read back from there; nothing derives one from ids,
10
+ // and nothing lists the bucket by prefix. So the two prefixes cost nothing, while rewriting the old
11
+ // ones would mean copying every object in the bucket to change a string nobody reads.
12
+ function contentKeyFor(nodeId, versionId) {
13
+ return `nodes/${nodeId}/versions/${versionId}`;
14
+ }
15
+ // A node grant covers every flow beneath the node it sits on, so there is never a sub-flow it fails
16
+ // to reach and this half of the warning is empty here by construction (#530). Written out rather
17
+ // than left to the schema's default: nothing parses a service's answer on the way out, so a default
18
+ // would arrive at the UI as `undefined`.
19
+ const nothingWithheld = { titles: [], hidden: 0 };
20
+ // A verb that cannot apply to a node is neither offered on it nor accepted for it (ADR-0004 §2).
21
+ // The answer lives here rather than in the screen so HTTP, MCP and the UI cannot disagree about it.
22
+ //
23
+ // `execute` is meaningful where something can be run, and since #390 that is the folder alone —
24
+ // because only a folder can hold a flow. Reading something and being permitted to put it to work
25
+ // stay separate questions, which is the whole reason the verbs are granted independently rather
26
+ // than as a ladder (ADR-0004 §2).
27
+ function applicableVerbs(kind) {
28
+ return kind === "folder" ? ["read", "write", "execute", "share"] : ["read", "write", "share"];
29
+ }
30
+ /**
31
+ * Which kinds hold an editor body — the text somebody types and `node_get` reads back.
32
+ *
33
+ * ⚠️ **A card is one of them, and that is the whole of D66 on the write side** (#785). A task is a
34
+ * node like every other: its text is a node version, in the same table, through the same route. The
35
+ * panel reaches this call through `POST /nodes/:id/versions` exactly as MCP does, so a list that
36
+ * omitted `task` closed BOTH surfaces at once — an agent could name a card and not describe it, and
37
+ * a reader typing into the card got "could not be saved" over a body the model says it should hold.
38
+ *
39
+ * ⚠️ **A set, not one more `||`.** The other four kinds each carry their content some other way —
40
+ * a folder and a board hold children, an attachment holds bytes, a table holds CSV segments — and
41
+ * naming the ones that DO take an editor version is what keeps the next kind from being waved
42
+ * through by a condition that only ever grew. The annotation ties it to the enum: a kind renamed in
43
+ * the contract stops this file from compiling instead of silently dropping out of the set.
44
+ */
45
+ const EditorContentKinds = ["document", "task"];
46
+ // ⚠️ The refusal has to be actionable without becoming a directory of the tree. Whoever holds
47
+ // `share` on one folder must not learn the titles of flows they may not see, so the ones they may
48
+ // see are named and the rest are only counted (ADR-0004 §3, and #17's review) — which is the whole
49
+ // of what `callersPhrase` does, in the one place both refusals read it from.
50
+ //
51
+ // The sentence stays for whoever has nothing to formulate with — a model over MCP reads it as it
52
+ // stands — and `namedOrCounted` carries the same decision as data, so a screen can say it in its
53
+ // own language (#448). Both come out of one place: the split between named and counted is an
54
+ // authorization answer, and a surface that recomputed it would be deciding what it may not.
55
+ function callersDetail(callers) {
56
+ return `Flows outside this folder call into it: ${callersPhrase(callers)}. Change or unpublish them before narrowing the folder.`;
57
+ }
58
+ // The grantee as the ACL sees them, and as nothing else: an identity with no capability of its own,
59
+ // never `isAdmin`. It answers for Intel's resource ACLs only — whether Gate hands this person
60
+ // `intel/admin` is Gate's to know, so the warning below can be pessimistic and never permissive.
61
+ // A grant to an email address is judged as that address, which is what the grant will be attached
62
+ // to; a second grant the same person holds under their user ID is not folded in.
63
+ function asPrincipalActor(principal) {
64
+ if (principal.type === "user")
65
+ return { id: principal.id, email: "" };
66
+ if (principal.type === "email")
67
+ return { id: "", email: principal.email };
68
+ return { id: "", email: "" };
69
+ }
70
+ function decodeBase64(value) {
71
+ if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
72
+ throw new IntelError(400, "attachment_invalid", "Attachment content is not valid base64");
73
+ }
74
+ try {
75
+ return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
76
+ }
77
+ catch {
78
+ throw new IntelError(400, "attachment_invalid", "Attachment content is not valid base64");
79
+ }
80
+ }
81
+ /**
82
+ * Where a kind may be filed, and it is one rule read by two callers — `create` and the move inside
83
+ * `update` (#377).
84
+ *
85
+ * ⚠️ **Three cases, and the first version had only one of them.** It handled "wrong parent that
86
+ * exists" and left two doors open, both of which produced exactly the state the rule exists against:
87
+ *
88
+ * * **No parent at all.** `parentId: null` is the ROOT, and the root is not a board. The first
89
+ * version skipped the check entirely for `null`, so `node_create({ kind: "task", parentId:
90
+ * null })` made a task with no board — and `attachToBoard` did not run either, for the same
91
+ * reason. A task on no board, created through the front door.
92
+ * * **Right kind, wrong board.** Every `kind === "board"` was accepted as a destination, so a
93
+ * task could be moved to ANOTHER board while its `board_tasks.board_id` stayed behind. That
94
+ * divergence was reachable only by writing D1 by hand before this rule existed (the join in
95
+ * `db-boards.ts` guards against it); the first version of this rule opened a real code path to
96
+ * it. **A change that widens what is allowed has to be read for what it now permits, not only
97
+ * for what it now refuses.**
98
+ *
99
+ * ⚠️ The refusal names the KIND in the sentence rather than a bare "not a folder". Somebody
100
+ * dragging a card into a folder has to learn what went wrong, not that something did.
101
+ */
102
+ /**
103
+ * ⚠️ **Nothing may be filed under an ARCHIVED node**, and this is not tidiness — it is what keeps
104
+ * the purge honest.
105
+ *
106
+ * Archiving takes the whole subtree since D71 (#733), so an archived node holds nothing live at the
107
+ * moment it is archived. Without this rule it could GAIN one afterwards, and `purge` — which asks
108
+ * only whether the node itself is archived — walks the whole subtree and takes that child with it.
109
+ * It would disappear for good without ever being named in the confirmation.
110
+ *
111
+ * Found in review of #669, against my own sentence claiming purge needed no rule of its own. That
112
+ * sentence was true only as long as an archived node could never gain a live child, and until #679
113
+ * and D71 nothing made it true: the guard covered tasks alone, and archiving covered one row.
114
+ */
115
+ /**
116
+ * The board a node would be filed on: the board itself, or the board of the task it hangs under
117
+ * (#669).
118
+ *
119
+ * ⚠️ **One function, because there are three callers and they must not drift.** The create path,
120
+ * the move and the idempotent replay all ask the same question, and the replay was written when a
121
+ * task's parent was always its board — it passed `parentId` straight through as the board id. With
122
+ * nesting that files a subtask on a "board" that is another card, and `board_tasks.board_id` has no
123
+ * foreign key saying otherwise. Found in review of #669: the main path had been corrected and this
124
+ * one had not, which is the shape of `anchrd/intel#457` — two places answering one question, only
125
+ * one of them told about the change.
126
+ */
127
+ async function boardFor(deps, actor, parent) {
128
+ if (parent === null)
129
+ return null;
130
+ if (parent.kind === "board")
131
+ return parent.id;
132
+ // ⚠️ Only a TASK can have a board, and asking for anything else costs a D1 round trip on every
133
+ // write in the whole tree — a document filed in a folder would pay for a board feature. The
134
+ // question is not "do we know the board" but "can this parent have one at all".
135
+ if (parent.kind !== "task")
136
+ return null;
137
+ return (await deps.boardOfTask?.(actor, parent.id)) ?? null;
138
+ }
139
+ // How many of the nodes a purge would take are named in the preview. High enough that an ordinary
140
+ // folder is listed in full, low enough that a tree nobody can render is not built (#733).
141
+ const PURGE_PREVIEW_NAMES = 50;
142
+ function refuseArchivedParent(parent) {
143
+ // ⚠️ EVERY kind, not just tasks (#679). It was narrowed to tasks in #677 because widening it out
144
+ // of a board ticket would have changed the whole tree with no test outside `boards.int.ts`; the
145
+ // hole it left is older than that ticket and is what this one closes.
146
+ //
147
+ // ⚠️ **The DIRECT parent is enough since D71 (#733), and only since then.** Archiving cascades, so
148
+ // an archived node holds nothing live at the moment it is archived, and a restore under an
149
+ // archived parent is refused: there is no live node under an archived one for something to be
150
+ // filed beneath. Before that, this guard closed one level while `Aussen` archived over a live
151
+ // `Innen` still handed anything filed under `Innen` to the purge.
152
+ if (parent === null || parent.archivedAt === null)
153
+ return;
154
+ throw new IntelError(409, "parent_archived", `This ${parent.kind} is archived, so nothing new can be filed under it. Restore it first.`);
155
+ }
156
+ function refuseWrongParent(childKind, parent, boards) {
157
+ if (childKind === "task") {
158
+ if (parent === null) {
159
+ throw new IntelError(409, "task_belongs_to_a_board", "A task lives on a board. The top level is not one, so there is nowhere for it to sit there.");
160
+ }
161
+ // ⚠️ Since #669 a task may sit under a task — the hierarchy is the node tree. Everything else
162
+ // is still refused by name.
163
+ if (parent.kind !== "board" && parent.kind !== "task") {
164
+ throw new IntelError(409, "task_belongs_to_a_board", `A task lives on a board and cannot be filed under a ${parent.kind}. Move the board instead, or make a new task on the board you want it on.`);
165
+ }
166
+ /**
167
+ * ⚠️ **The same BOARD, not the same parent node — and that difference is what #669 changed.**
168
+ * Until then the two were interchangeable, because a task's parent always WAS its board; the
169
+ * check compared parent ids and was right by accident. With nesting, moving a card under a
170
+ * sibling changes its parent legitimately while the board stays the same, and comparing ids
171
+ * would refuse a move that is allowed.
172
+ *
173
+ * The rule it protects is unchanged: `board_tasks.board_id` does not travel with a move through
174
+ * the node path, so a card handed to another board would answer from one board's query while
175
+ * its row names the other — and the card would show up on neither, or on both.
176
+ */
177
+ if (boards?.destination === null || boards?.destination === undefined) {
178
+ throw new IntelError(409, "task_belongs_to_a_board", "This destination has no board, so a card cannot be filed under it.");
179
+ }
180
+ if (boards.current !== undefined &&
181
+ boards.current !== null &&
182
+ boards.destination !== boards.current) {
183
+ throw new IntelError(409, "task_cannot_change_board", "A card cannot be handed to another board this way. Make it on the board you want it on; its position, status and dependencies belong to the board it was filed on.");
184
+ }
185
+ return;
186
+ }
187
+ // The root takes everything else — that is what a top-level folder or document is.
188
+ if (parent === null)
189
+ return;
190
+ if (parent.kind === "board") {
191
+ throw new IntelError(409, "board_holds_only_tasks", `A board holds tasks and nothing else; a ${childKind} cannot be filed on one.`);
192
+ }
193
+ if (parent.kind !== "folder") {
194
+ throw new IntelError(409, "parent_not_folder", "A node's parent must be a folder");
195
+ }
196
+ }
197
+ export function createNodes(deps) {
198
+ function mergeSearchResults(lexical, semantic, semanticScores, limit) {
199
+ const merged = new Map();
200
+ for (const citation of lexical) {
201
+ merged.set(citation.nodeId, {
202
+ citation,
203
+ lexicalScore: citation.score,
204
+ semanticScore: undefined,
205
+ });
206
+ }
207
+ for (const citation of semantic) {
208
+ const current = merged.get(citation.nodeId);
209
+ merged.set(citation.nodeId, {
210
+ citation: current?.citation ?? citation,
211
+ lexicalScore: current?.lexicalScore,
212
+ semanticScore: semanticScores.get(citation.nodeId) ?? citation.score,
213
+ });
214
+ }
215
+ return [...merged.values()]
216
+ .map(({ citation, lexicalScore, semanticScore }) => {
217
+ const score = lexicalScore !== undefined && semanticScore !== undefined
218
+ ? lexicalScore * 0.45 + semanticScore * 0.55
219
+ : lexicalScore !== undefined
220
+ ? lexicalScore * 0.9
221
+ : (semanticScore ?? 0) * 0.85;
222
+ return {
223
+ ...citation,
224
+ score: Math.max(0, Math.min(1, score)),
225
+ match: lexicalScore !== undefined && semanticScore !== undefined
226
+ ? "hybrid"
227
+ : lexicalScore !== undefined
228
+ ? "lexical"
229
+ : "semantic",
230
+ };
231
+ })
232
+ .sort((left, right) => right.score - left.score || right.freshness.localeCompare(left.freshness))
233
+ .slice(0, limit);
234
+ }
235
+ /**
236
+ * A table's body: every version's R2 object joined in sequence order (#40).
237
+ *
238
+ * ⚠️ This is the price of appending without reading. Each `append` writes one immutable object
239
+ * holding only the rows it added, so the write is O(new rows) and two concurrent appends cannot
240
+ * overwrite one another — but a read of a table with k appends costs k R2 gets. That is the
241
+ * trade the ticket asks for by name: the write side is the hot path a flow uses on a schedule,
242
+ * the read side is a person opening a grid or one indexing pass. The gets are issued together
243
+ * rather than in a chain so the cost is k requests, not k round trips; compacting old segments
244
+ * into one object is a later ticket, and it can happen without changing anything a caller sees
245
+ * because the version rows stay the history either way.
246
+ */
247
+ async function tableContent(node) {
248
+ if (node.currentVersionId === null)
249
+ return "";
250
+ // ⚠️ One statement for every segment key, never one per segment, and only the keys rather than
251
+ // the whole version rows. The number of D1 round trips a table costs must not grow with the
252
+ // number of times it has been appended to (#30).
253
+ const keys = await deps.repository.listVersionContentKeys(node.id);
254
+ const segments = await Promise.all(keys.map(async (key) => await deps.content.get(key)));
255
+ if (segments.some((segment) => segment === null)) {
256
+ throw new IntelError(500, "content_missing", "Version content is missing");
257
+ }
258
+ return segments.join("");
259
+ }
260
+ /**
261
+ * The column names, read from the first version alone.
262
+ *
263
+ * ⚠️ Deliberately not `tableContent`, and deliberately not the version list either. Checking an
264
+ * append against the header runs on every append, so it must cost the same on a table of ten rows
265
+ * and on one of ten thousand: one statement that returns one key, and one small R2 read. Reading
266
+ * every version row to look at the first would be a cost that grows with the history — one query,
267
+ * but more of it every time (#30, #40).
268
+ */
269
+ async function tableHeader(node) {
270
+ if (node.currentVersionId === null)
271
+ return null;
272
+ const key = await deps.repository.tableHeaderContentKey(node.id);
273
+ if (key === null)
274
+ return null;
275
+ const body = await deps.content.get(key);
276
+ if (body === null)
277
+ throw new IntelError(500, "content_missing", "Version content is missing");
278
+ return parseCsv(body)[0] ?? null;
279
+ }
280
+ /**
281
+ * Whether the write this call lost was its OWN earlier attempt (#821).
282
+ *
283
+ * ⚠️ Both doors for an attachment ask this at their conflict, and the reason is a race the check
284
+ * at the top of each cannot see: two attempts with one idempotency key can both find nothing
285
+ * there, and then the second one's `appendVersion` refuses because the first one's version has
286
+ * become the current one in the meantime. Without this the caller who simply retried after a
287
+ * timeout is told somebody else wrote first — about a version that IS theirs. `save` has answered
288
+ * it this way since it was written; the attachment doors did not, and one of them is new.
289
+ */
290
+ async function replayedUpload(actor, nodeId, idempotencyKey) {
291
+ const replayed = await deps.repository.findIdempotentNode(actor.id, "node.save", idempotencyKey);
292
+ return replayed !== null && replayed !== undefined && replayed === nodeId;
293
+ }
294
+ async function getDocument(node) {
295
+ if (node.currentVersionId === null)
296
+ return { node, version: null, content: null };
297
+ const version = await deps.repository.getVersion(node.currentVersionId);
298
+ if (!version)
299
+ throw new IntelError(500, "version_missing", "Current version is missing");
300
+ if (node.kind === "attachment")
301
+ return { node, version, content: null };
302
+ // A table answers with the whole CSV, so downloading, citing and reading it over MCP all get
303
+ // the same bytes a person sees in the grid — the format is the export (#40).
304
+ if (node.kind === "table")
305
+ return { node, version, content: await tableContent(node) };
306
+ const content = await deps.content.get(version.contentKey);
307
+ if (content === null)
308
+ throw new IntelError(500, "content_missing", "Version content is missing");
309
+ return { node, version, content };
310
+ }
311
+ async function tableOf(node) {
312
+ const [header = [], ...rows] = parseCsv(await tableContent(node));
313
+ return { node, columns: header, rows, versionId: node.currentVersionId };
314
+ }
315
+ /**
316
+ * One immutable object holding only what this write adds, plus the version row that orders it.
317
+ *
318
+ * ⚠️ The R2 object is written before the version row and is deleted again if the row does not
319
+ * land, exactly as `save` does it. An orphaned object is invisible; a version row pointing at
320
+ * nothing is a table that cannot be read at all.
321
+ */
322
+ async function writeTableSegment(actor, node, body, segment, idempotencyKey) {
323
+ const versionId = deps.id();
324
+ const contentKey = contentKeyFor(node.id, versionId);
325
+ await deps.content.put(contentKey, body, TableMediaType);
326
+ try {
327
+ return await deps.repository.appendTableVersion({
328
+ version: {
329
+ id: versionId,
330
+ nodeId: node.id,
331
+ contentKey,
332
+ mediaType: TableMediaType,
333
+ contentHash: await deps.hash(body),
334
+ size: new TextEncoder().encode(body).byteLength,
335
+ segment,
336
+ createdBy: actor.id,
337
+ createdAt: deps.now().toISOString(),
338
+ },
339
+ actorId: actor.id,
340
+ idempotencyKey,
341
+ auditId: deps.id(),
342
+ });
343
+ }
344
+ catch (error) {
345
+ await deps.content.delete(contentKey).catch(() => undefined);
346
+ throw error;
347
+ }
348
+ }
349
+ /**
350
+ * One snapshot — the complete table after a mutation — written only against the state the caller
351
+ * read (#135).
352
+ *
353
+ * ⚠️ The base check lives in the repository's INSERT, not in a read here: between reading the
354
+ * table and writing its replacement another segment can always land, and only the statement that
355
+ * inserts the row can refuse at the moment it matters. On "conflict" the R2 object is removed
356
+ * again and the idempotency table is asked once more, exactly as `save` does it — the conflict
357
+ * may be this very mutation, already written by a racing retry with the same key.
358
+ */
359
+ async function writeTableSnapshot(input) {
360
+ const versionId = deps.id();
361
+ const contentKey = contentKeyFor(input.node.id, versionId);
362
+ const version = {
363
+ id: versionId,
364
+ nodeId: input.node.id,
365
+ sequence: await nextSequence(input.node),
366
+ contentKey,
367
+ mediaType: TableMediaType,
368
+ contentHash: await deps.hash(input.body),
369
+ size: new TextEncoder().encode(input.body).byteLength,
370
+ segment: "snapshot",
371
+ createdBy: input.actor.id,
372
+ createdAt: deps.now().toISOString(),
373
+ };
374
+ await deps.content.put(contentKey, input.body, TableMediaType);
375
+ let saved;
376
+ try {
377
+ saved = await deps.repository.appendSnapshotVersion({
378
+ version,
379
+ actorId: input.actor.id,
380
+ baseVersionId: input.baseVersionId,
381
+ operation: input.operation,
382
+ metadata: input.metadata,
383
+ idempotencyKey: input.idempotencyKey,
384
+ auditId: deps.id(),
385
+ });
386
+ }
387
+ catch (error) {
388
+ await deps.content.delete(contentKey).catch(() => undefined);
389
+ throw error;
390
+ }
391
+ if (saved === "conflict") {
392
+ await deps.content.delete(contentKey);
393
+ const replayedId = await deps.repository.findIdempotentNode(input.actor.id, input.operation, input.idempotencyKey);
394
+ if (replayedId) {
395
+ const replayed = await deps.repository.getVersion(replayedId);
396
+ if (replayed)
397
+ return replayed;
398
+ }
399
+ throw new IntelError(409, "version_conflict", "A newer version already exists");
400
+ }
401
+ await deps.indexing.enqueue(version.id);
402
+ return version;
403
+ }
404
+ /**
405
+ * The current state a mutation addresses: header and rows, read only after the base was checked.
406
+ *
407
+ * ⚠️ The refusal for a stale base comes before the content is read, so a caller with an outdated
408
+ * `baseVersionId` costs one D1 row and no R2 traffic — and the position validation that follows
409
+ * never runs against rows the caller was not looking at.
410
+ */
411
+ async function tableStateFor(node, baseVersionId) {
412
+ if (node.currentVersionId === null) {
413
+ throw new IntelError(409, "table_undefined", "This table has no header yet; define its columns before changing rows");
414
+ }
415
+ if (node.currentVersionId !== baseVersionId) {
416
+ throw new IntelError(409, "version_conflict", "A newer version already exists");
417
+ }
418
+ const [header, ...rows] = parseCsv(await tableContent(node));
419
+ if (!header)
420
+ throw new IntelError(500, "content_missing", "Table header is missing");
421
+ return { header, rows };
422
+ }
423
+ // The same refusal an append gives, for the same reason: a row that does not fit the header is a
424
+ // caller that believes the table has a different shape, and quietly filling or cutting the gap
425
+ // would store that misunderstanding as data (#40, unchanged by #135).
426
+ function requireRowShape(rows, header) {
427
+ const wrong = rows.findIndex((row) => row.length !== header.length);
428
+ if (wrong !== -1) {
429
+ throw new IntelError(400, "table_row_shape", `Row ${wrong + 1} has ${rows[wrong]?.length ?? 0} cells but the table has ${header.length} columns: ${header.join(", ")}`);
430
+ }
431
+ }
432
+ // A position that misses the table is the same kind of misunderstanding as a row that misses the
433
+ // header: refused whole, nothing written, and the message says what the table actually holds.
434
+ function requirePositions(positions, rowCount) {
435
+ const outside = positions.find((position) => position >= rowCount);
436
+ if (outside !== undefined) {
437
+ throw new IntelError(400, "table_row_position", `Row position ${outside} is out of range: the table has ${rowCount} row${rowCount === 1 ? "" : "s"}`);
438
+ }
439
+ }
440
+ async function requireTable(actor, nodeId) {
441
+ const node = await requireVisible(actor, nodeId);
442
+ if (node.kind !== "table") {
443
+ throw new IntelError(409, "not_a_table", "Only tables accept rows");
444
+ }
445
+ if (!(await deps.repository.can(actor, node.id, "write"))) {
446
+ throw new IntelError(403, "node_forbidden", "Table cannot be edited");
447
+ }
448
+ return node;
449
+ }
450
+ async function unreadableForPrincipal(actor, folderId, principal) {
451
+ const grantee = asPrincipalActor(principal);
452
+ const titles = [];
453
+ let hidden = 0;
454
+ // What a flow reaches for and the grant does not cover. Deduplicated, because a document two
455
+ // flows both name is one thing the new principal cannot read, not two.
456
+ const referenced = [...new Set(await deps.flowNodeReferences(actor, folderId))];
457
+ for (const resourceId of referenced) {
458
+ if (await deps.repository.can(grantee, resourceId, "read"))
459
+ continue;
460
+ const node = await deps.repository.getVisible(actor, resourceId);
461
+ if (node)
462
+ titles.push(node.title);
463
+ else
464
+ hidden += 1;
465
+ }
466
+ return { titles, hidden };
467
+ }
468
+ /**
469
+ * The graph, brought in line with what the saved document actually says (#41).
470
+ *
471
+ * A text link is the relationship — there is no second way to make one any more — so the links
472
+ * of a document are rewritten every time it is saved: what is no longer written is no longer
473
+ * there, and what was added is.
474
+ *
475
+ * ⚠️ Only targets this actor may see become links. The author can only insert what the picker
476
+ * offers them, but `node_version_create` takes any content over MCP, and an unfiltered write
477
+ * would turn the graph into a place where the existence of an unreachable document can be confirmed by
478
+ * anyone who guesses its ID. The filter is `resolveVisibleTitles`, the same lookup the reader's
479
+ * side goes through — one rule, not two.
480
+ *
481
+ * ⚠️ Rows made in the removed dialog are `manual` and are left alone. Saving a document must not
482
+ * silently delete a relationship somebody entered before there was another way to enter one.
483
+ */
484
+ async function reconcileTextLinks(actor, sourceNodeId, mediaType, content) {
485
+ const written = documentLinkTargets(mediaType, content).filter((id) => id !== sourceNodeId);
486
+ const visible = written.length === 0
487
+ ? []
488
+ : (await deps.repository.resolveVisibleTitles(actor, written)).map((entry) => entry.nodeId);
489
+ await deps.repository.replaceTextLinks({
490
+ sourceNodeId,
491
+ links: visible.map((targetNodeId) => ({ id: deps.id(), targetNodeId })),
492
+ actorId: actor.id,
493
+ auditId: deps.id(),
494
+ occurredAt: deps.now().toISOString(),
495
+ });
496
+ }
497
+ async function requireVisible(actor, nodeId) {
498
+ const node = await deps.repository.getVisible(actor, nodeId);
499
+ if (!node)
500
+ throw new IntelError(404, "node_not_found", "Node was not found");
501
+ return node;
502
+ }
503
+ async function nextSequence(node) {
504
+ if (!node.currentVersionId)
505
+ return 1;
506
+ const current = await deps.repository.getVersion(node.currentVersionId);
507
+ if (!current) {
508
+ throw new IntelError(500, "version_missing", "Current node version is missing");
509
+ }
510
+ return current.sequence + 1;
511
+ }
512
+ async function attachment(actor, nodeId) {
513
+ const node = await requireVisible(actor, nodeId);
514
+ if (node.kind !== "attachment" || !node.currentVersionId) {
515
+ throw new IntelError(404, "attachment_not_found", "Attachment was not found");
516
+ }
517
+ const version = await deps.repository.getVersion(node.currentVersionId);
518
+ if (!version)
519
+ throw new IntelError(500, "version_missing", "Current version is missing");
520
+ return {
521
+ node,
522
+ version,
523
+ resourceUri: `intel://nodes/${encodeURIComponent(node.id)}/attachment`,
524
+ };
525
+ }
526
+ return {
527
+ async list(actor, input) {
528
+ return await deps.repository.listVisible(actor, input);
529
+ },
530
+ // The same level under a bound, for the one caller that draws a bounded picture of it. It goes
531
+ // through the same predicate as `list`, so what is drawn is a prefix of what is listed and never
532
+ // a different selection (#30).
533
+ async childrenBounded(actor, input) {
534
+ return await deps.repository.listVisibleBounded(actor, input);
535
+ },
536
+ async get(actor, nodeId) {
537
+ return await getDocument(await requireVisible(actor, nodeId));
538
+ },
539
+ async purgePreview(actor, input) {
540
+ const node = await requireVisible(actor, input.nodeId);
541
+ if (!(await deps.repository.can(actor, input.nodeId, "write"))) {
542
+ throw new IntelError(403, "node_forbidden", "This node cannot be edited");
543
+ }
544
+ if (!node.archivedAt) {
545
+ throw new IntelError(409, "node_not_archived", "Only an archived node can be deleted for good. Archive it first.");
546
+ }
547
+ const tree = await deps.repository.inspectPurgeTree(node.id);
548
+ return {
549
+ inboundLinks: await deps.repository.countInboundLinks(node.id),
550
+ totalItems: tree.nodeIds.length + tree.flowIds.length,
551
+ /**
552
+ * ⚠️ **Named, not just counted** (D71, #733), and the cap is stated rather than silent:
553
+ * `totalItems` keeps counting past it, so a surface can always say "and 40 more". Whoever
554
+ * confirms a purge is deciding about things, and "82 items" is not a thing.
555
+ *
556
+ * The node itself is in here too. It is the one the caller named, so it is the one they can
557
+ * check the list against.
558
+ */
559
+ items: tree.named.slice(0, PURGE_PREVIEW_NAMES).map((entry) => ({
560
+ id: entry.id,
561
+ kind: entry.kind,
562
+ title: entry.title,
563
+ })),
564
+ };
565
+ },
566
+ /**
567
+ * The content of one pinned version (#147). Search citations pin the version they quoted, and
568
+ * without this read a citation could name text no surface can show any more.
569
+ *
570
+ * ⚠️ Authorization first, existence second: `requireVisible` answers 404 for a node this actor
571
+ * may not reach before the version table is asked anything, so probing version IDs proves
572
+ * nothing about nodes one cannot see. A version of ANOTHER node is the same 404 — a version ID
573
+ * is not an address of its own, only a position in the history of a node one may read.
574
+ *
575
+ * A table version answers with that segment's own rows (an append) or its snapshot state —
576
+ * deliberately not the assembled table of that moment, which is what `getTable` answers for
577
+ * the present. An attachment version answers with its metadata and `content: null`, the same
578
+ * shape `get` gives an attachment: old attachment bytes stay out of scope here (#147 allows
579
+ * that explicitly), because inlining them is the attachment resource's decision, not this one's.
580
+ */
581
+ async getVersion(actor, nodeId, versionId) {
582
+ const node = await requireVisible(actor, nodeId);
583
+ const version = await deps.repository.getVersion(versionId);
584
+ if (!version || version.nodeId !== node.id) {
585
+ throw new IntelError(404, "version_not_found", "Version was not found");
586
+ }
587
+ if (node.kind === "attachment")
588
+ return { node, version, content: null };
589
+ const content = await deps.content.get(version.contentKey);
590
+ if (content === null) {
591
+ throw new IntelError(500, "content_missing", "Version content is missing");
592
+ }
593
+ return { node, version, content };
594
+ },
595
+ // Nodes and Flows share one folder tree (ADR-0004), so Flows has to ask one question about
596
+ // it: may this actor file something in that folder. The answer stays here, with the tree and
597
+ // its ACLs, rather than being reimplemented on the flow side.
598
+ async folderAccess(actor, folderId) {
599
+ const folder = await deps.repository.getVisible(actor, folderId);
600
+ // ⚠️ An archived folder answers `missing` rather than a state of its own: filing into one is
601
+ // refused exactly like filing into one that is not there. The question "is the folder above
602
+ // archived" is asked by `parentArchivedAt` instead, because it must not run through the
603
+ // visibility predicate at all (#733).
604
+ if (!folder || folder.archivedAt)
605
+ return "missing";
606
+ if (folder.kind !== "folder")
607
+ return "not-a-folder";
608
+ return (await deps.repository.can(actor, folder.id, "write")) ? "ok" : "forbidden";
609
+ },
610
+ async create(actor, input) {
611
+ const existingId = await deps.repository.findIdempotentNode(actor.id, "node.create", input.idempotencyKey);
612
+ if (existingId) {
613
+ const existing = await requireVisible(actor, existingId);
614
+ /**
615
+ * ⚠️ **The filing runs on the REPLAY path too, and that is what makes a retry a repair.**
616
+ *
617
+ * `insertNode` commits the node, its idempotency key and its audit row in one batch;
618
+ * `attachToBoard` is a separate statement after it. If that one fails — a transient D1
619
+ * error is enough — the node exists and its board row does not, and `create` throws. The
620
+ * obvious response is the same call again with the same key, and until this line that call
621
+ * returned here **without filing anything**: a task that is a task by kind, sits under a
622
+ * board, and appears on no board, permanently, with nothing reporting it.
623
+ *
624
+ * `attach` is `ON CONFLICT DO NOTHING`, so running it on a node that is already filed costs
625
+ * one statement and changes nothing. That asymmetry is why the repair belongs here rather
626
+ * than in a sweeper somebody has to remember to write.
627
+ */
628
+ if (existing.kind === "task" && existing.parentId !== null) {
629
+ // ⚠️ The BOARD, resolved the same way the create path resolves it. Passing `parentId`
630
+ // straight through was right only while a task's parent was always its board.
631
+ const board = await boardFor(deps, actor, await requireVisible(actor, existing.parentId));
632
+ if (board !== null) {
633
+ await deps.attachToBoard?.(actor, existing.id, board, deps.now().toISOString());
634
+ }
635
+ }
636
+ return existing;
637
+ }
638
+ if (input.parentId !== null && !(await deps.repository.can(actor, input.parentId, "write"))) {
639
+ throw new IntelError(403, "node_forbidden", "Parent folder cannot be edited");
640
+ }
641
+ // ⚠️ The same rule on the way IN, and it runs for the ROOT as well. Guarding only a named
642
+ // parent leaves `parentId: null` open, and that is not a smaller hole: the task is created,
643
+ // `attachToBoard` skips it for the same reason, and the card belongs to no board at all.
644
+ const destinationParent = input.parentId === null ? null : await requireVisible(actor, input.parentId);
645
+ const destinationBoard = await boardFor(deps, actor, destinationParent);
646
+ // ⚠️ Kind first, archive second. A task under an archived FOLDER is refused for the deeper
647
+ // reason — a task lives on a board — and saying "restore the folder first" would send the
648
+ // reader to do something that changes nothing: restored, the folder still cannot hold a task.
649
+ refuseWrongParent(input.kind, destinationParent, { destination: destinationBoard });
650
+ refuseArchivedParent(destinationParent);
651
+ const timestamp = deps.now().toISOString();
652
+ const created = await deps.repository.insertNode({
653
+ node: {
654
+ // ⚠️ A node is never born offered (#775). Naming a slash command is a curation decision
655
+ // about a document that already exists, and `node_create` takes no name: a creation that
656
+ // could also take the last free name in the catalogue would fail for a reason that has
657
+ // nothing to do with creating anything.
658
+ promptName: null,
659
+ id: deps.id(),
660
+ parentId: input.parentId,
661
+ kind: input.kind,
662
+ title: plainTitle(input.title),
663
+ description: input.description,
664
+ ownerId: actor.id,
665
+ currentVersionId: null,
666
+ createdAt: timestamp,
667
+ updatedAt: timestamp,
668
+ archivedAt: null,
669
+ },
670
+ actorId: actor.id,
671
+ idempotencyKey: input.idempotencyKey,
672
+ auditId: deps.id(),
673
+ });
674
+ /**
675
+ * ⚠️ **A task created through the plain node path still gets its board row** (#648). Without
676
+ * this an agent calling `node_create` under a board makes a node that is a task by kind, has
677
+ * no row in `board_tasks`, and therefore appears on NO board — with nothing reporting an
678
+ * error. It is exactly the shape of failure this repository keeps writing rules about: not a
679
+ * refusal, an absence.
680
+ *
681
+ * ⚠️ It runs AFTER the node exists and is its own statement rather than part of the batch.
682
+ * The node is the truth; the board row is derived from it. A pass that dies in between leaves
683
+ * a task nobody filed — visible through `node_get`, repairable by filing it — while the other
684
+ * order would leave a board row pointing at a node that was never written.
685
+ *
686
+ * The port is optional because the node service must not require a board to exist: the CLI
687
+ * and the bundle importer build one without that half of the world.
688
+ */
689
+ // ⚠️ `destinationBoard`, not `input.parentId`. Since #669 the parent may be another task, and
690
+ // filing the card under its parent's ID would put a subtask on a "board" that is a card.
691
+ if (created.kind === "task" && destinationBoard !== null) {
692
+ await deps.attachToBoard?.(actor, created.id, destinationBoard, timestamp);
693
+ }
694
+ return created;
695
+ },
696
+ async save(actor, input) {
697
+ const existingId = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
698
+ if (existingId) {
699
+ const document = await getDocument(await requireVisible(actor, input.nodeId));
700
+ await deps.indexing.enqueue(existingId);
701
+ return document;
702
+ }
703
+ const node = await requireVisible(actor, input.nodeId);
704
+ if (!EditorContentKinds.includes(node.kind)) {
705
+ throw new IntelError(409, "document_content_required", "Only documents and cards accept editor content versions");
706
+ }
707
+ if (!(await deps.repository.can(actor, node.id, "write"))) {
708
+ throw new IntelError(403, "node_forbidden", "This node cannot be edited");
709
+ }
710
+ if (node.currentVersionId !== input.baseVersionId) {
711
+ throw new IntelError(409, "version_conflict", "A newer version already exists");
712
+ }
713
+ const versionId = deps.id();
714
+ const contentKey = contentKeyFor(node.id, versionId);
715
+ const createdAt = deps.now().toISOString();
716
+ const version = {
717
+ id: versionId,
718
+ nodeId: node.id,
719
+ sequence: await nextSequence(node),
720
+ contentKey,
721
+ mediaType: input.mediaType,
722
+ contentHash: await deps.hash(input.content),
723
+ size: new TextEncoder().encode(input.content).byteLength,
724
+ segment: null,
725
+ createdBy: actor.id,
726
+ createdAt,
727
+ };
728
+ await deps.content.put(contentKey, input.content, input.mediaType);
729
+ let saved;
730
+ try {
731
+ saved = await deps.repository.appendVersion({
732
+ version,
733
+ actorId: actor.id,
734
+ baseVersionId: input.baseVersionId,
735
+ idempotencyKey: input.idempotencyKey,
736
+ auditId: deps.id(),
737
+ });
738
+ }
739
+ catch (error) {
740
+ await deps.content.delete(contentKey).catch(() => undefined);
741
+ throw error;
742
+ }
743
+ if (saved === "conflict") {
744
+ await deps.content.delete(contentKey);
745
+ const replayed = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
746
+ if (replayed)
747
+ return await getDocument(await requireVisible(actor, node.id));
748
+ throw new IntelError(409, "version_conflict", "A newer version already exists");
749
+ }
750
+ const updated = await requireVisible(actor, node.id);
751
+ await reconcileTextLinks(actor, node.id, input.mediaType, input.content);
752
+ await deps.indexing.enqueue(version.id);
753
+ return { node: updated, version, content: input.content };
754
+ },
755
+ async saveAttachment(actor, input) {
756
+ const existingId = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
757
+ if (existingId) {
758
+ const document = await getDocument(await requireVisible(actor, input.nodeId));
759
+ await deps.indexing.enqueue(existingId);
760
+ return document;
761
+ }
762
+ const node = await requireVisible(actor, input.nodeId);
763
+ if (node.kind !== "attachment") {
764
+ throw new IntelError(409, "not_an_attachment", "Only attachment nodes accept file uploads");
765
+ }
766
+ if (!(await deps.repository.can(actor, node.id, "write"))) {
767
+ throw new IntelError(403, "node_forbidden", "Attachment cannot be edited");
768
+ }
769
+ if (node.currentVersionId !== input.baseVersionId) {
770
+ throw new IntelError(409, "version_conflict", "A newer version already exists");
771
+ }
772
+ const bytes = decodeBase64(input.contentBase64);
773
+ const versionId = deps.id();
774
+ const contentKey = contentKeyFor(node.id, versionId);
775
+ const createdAt = deps.now().toISOString();
776
+ const version = {
777
+ id: versionId,
778
+ nodeId: node.id,
779
+ sequence: await nextSequence(node),
780
+ contentKey,
781
+ mediaType: input.mediaType,
782
+ contentHash: await deps.hash(bytes),
783
+ size: bytes.byteLength,
784
+ segment: null,
785
+ createdBy: actor.id,
786
+ createdAt,
787
+ };
788
+ await deps.content.putBytes(contentKey, bytes.buffer, input.mediaType);
789
+ let saved;
790
+ try {
791
+ saved = await deps.repository.appendVersion({
792
+ version,
793
+ actorId: actor.id,
794
+ baseVersionId: input.baseVersionId,
795
+ idempotencyKey: input.idempotencyKey,
796
+ auditId: deps.id(),
797
+ });
798
+ }
799
+ catch (error) {
800
+ await deps.content.delete(contentKey).catch(() => undefined);
801
+ throw error;
802
+ }
803
+ if (saved === "conflict") {
804
+ await deps.content.delete(contentKey);
805
+ if (await replayedUpload(actor, node.id, input.idempotencyKey)) {
806
+ return await getDocument(await requireVisible(actor, node.id));
807
+ }
808
+ throw new IntelError(409, "version_conflict", "A newer version already exists");
809
+ }
810
+ await deps.indexing.enqueue(version.id);
811
+ return { node: await requireVisible(actor, node.id), version, content: null };
812
+ },
813
+ /**
814
+ * The same version, written from a stream (#821).
815
+ *
816
+ * ⚠️ It is deliberately NOT `saveAttachment` with a different argument. Everything the two share
817
+ * is the order of the writes, and that order is written out twice on purpose: the base64 door
818
+ * knows its bytes before it starts and can hash them where it stands, while this one learns the
819
+ * hash and the length only once the last chunk has gone by. Folding them together would mean
820
+ * buffering here — the one thing this door exists to avoid.
821
+ */
822
+ async saveAttachmentBytes(actor, input, body) {
823
+ const existingId = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
824
+ if (existingId) {
825
+ const document = await getDocument(await requireVisible(actor, input.nodeId));
826
+ await deps.indexing.enqueue(existingId);
827
+ return document;
828
+ }
829
+ const node = await requireVisible(actor, input.nodeId);
830
+ if (node.kind !== "attachment") {
831
+ throw new IntelError(409, "not_an_attachment", "Only attachment nodes accept file uploads");
832
+ }
833
+ if (!(await deps.repository.can(actor, node.id, "write"))) {
834
+ throw new IntelError(403, "node_forbidden", "Attachment cannot be edited");
835
+ }
836
+ if (node.currentVersionId !== input.baseVersionId) {
837
+ throw new IntelError(409, "version_conflict", "A newer version already exists");
838
+ }
839
+ const versionId = deps.id();
840
+ const contentKey = contentKeyFor(node.id, versionId);
841
+ const createdAt = deps.now().toISOString();
842
+ // ⚠️ A body that does not carry the announced number of bytes fails here, and the half-written
843
+ // object goes with it. Storing what arrived would file a truncated file under a name that says
844
+ // it is the whole one — and nothing later could tell the two apart.
845
+ let stored;
846
+ try {
847
+ stored = await deps.content.putStream(contentKey, body, input.mediaType, input.size);
848
+ }
849
+ catch (error) {
850
+ await deps.content.delete(contentKey).catch(() => undefined);
851
+ if (error instanceof IntelError)
852
+ throw error;
853
+ throw new IntelError(400, "upload_incomplete", "The upload ended before the announced number of bytes had arrived");
854
+ }
855
+ const version = {
856
+ id: versionId,
857
+ nodeId: node.id,
858
+ sequence: await nextSequence(node),
859
+ contentKey,
860
+ mediaType: input.mediaType,
861
+ contentHash: stored.hash,
862
+ size: stored.size,
863
+ segment: null,
864
+ createdBy: actor.id,
865
+ createdAt,
866
+ };
867
+ let saved;
868
+ try {
869
+ saved = await deps.repository.appendVersion({
870
+ version,
871
+ actorId: actor.id,
872
+ baseVersionId: input.baseVersionId,
873
+ idempotencyKey: input.idempotencyKey,
874
+ auditId: deps.id(),
875
+ });
876
+ }
877
+ catch (error) {
878
+ await deps.content.delete(contentKey).catch(() => undefined);
879
+ throw error;
880
+ }
881
+ if (saved === "conflict") {
882
+ await deps.content.delete(contentKey);
883
+ if (await replayedUpload(actor, node.id, input.idempotencyKey)) {
884
+ return await getDocument(await requireVisible(actor, node.id));
885
+ }
886
+ throw new IntelError(409, "version_conflict", "A newer version already exists");
887
+ }
888
+ await deps.indexing.enqueue(version.id);
889
+ return { node: await requireVisible(actor, node.id), version, content: null };
890
+ },
891
+ async getAttachment(actor, nodeId) {
892
+ return await attachment(actor, nodeId);
893
+ },
894
+ async readAttachment(actor, nodeId) {
895
+ const metadata = await attachment(actor, nodeId);
896
+ const body = await deps.content.getStream(metadata.version.contentKey);
897
+ if (body === null)
898
+ throw new IntelError(500, "content_missing", "Attachment is missing");
899
+ return { attachment: metadata, body };
900
+ },
901
+ /**
902
+ * One page of readable attachments for the MCP resource listing (#773).
903
+ *
904
+ * ⚠️ It asks the repository for one row MORE than it hands back. Without that, a page that
905
+ * happens to be exactly full is indistinguishable from a page with more behind it, and the
906
+ * choice is between always sending a cursor (one wasted round trip per listing) or never
907
+ * sending one (a silently truncated list, which reads as complete).
908
+ */
909
+ async listAttachments(actor, input) {
910
+ const rows = await deps.repository.listVisibleAttachments(actor, {
911
+ limit: input.limit + 1,
912
+ after: input.after,
913
+ });
914
+ const items = rows.slice(0, input.limit);
915
+ const last = items.at(-1);
916
+ return { items, nextCursor: rows.length > input.limit && last ? last.id : null };
917
+ },
918
+ async getTable(actor, nodeId) {
919
+ const node = await requireVisible(actor, nodeId);
920
+ if (node.kind !== "table") {
921
+ throw new IntelError(404, "table_not_found", "Table was not found");
922
+ }
923
+ return await tableOf(node);
924
+ },
925
+ /**
926
+ * Writes the header, once. The columns are the contract every later append is measured against
927
+ * (#40), so a second definition is refused rather than merged: a table whose header changed
928
+ * would reinterpret every row already appended under the old one, silently and irreversibly.
929
+ */
930
+ async defineTable(actor, input) {
931
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.append", input.idempotencyKey);
932
+ const node = await requireTable(actor, input.nodeId);
933
+ if (replayedId)
934
+ return await tableOf(node);
935
+ if (node.currentVersionId !== null) {
936
+ throw new IntelError(409, "table_already_defined", "This table already has a header");
937
+ }
938
+ // The definition is the first snapshot (#135): the complete state of a table with no rows
939
+ // yet, and the anchor "read from the newest snapshot" starts from.
940
+ const body = encodeCsv([input.columns]);
941
+ const version = await writeTableSegment(actor, node, body, "snapshot", input.idempotencyKey);
942
+ await deps.indexing.enqueue(version.id);
943
+ return await tableOf({ ...node, currentVersionId: version.id });
944
+ },
945
+ /**
946
+ * Rows at the end, and nothing else touched.
947
+ *
948
+ * ⚠️ No `baseVersionId` and no conflict: each append writes its own immutable object and its
949
+ * own version row, so two appends that arrive together both land and neither can overwrite the
950
+ * other. This is the whole difference to `save`, which replaces content and therefore has to
951
+ * know what it replaces.
952
+ */
953
+ async appendTableRows(actor, input) {
954
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.append", input.idempotencyKey);
955
+ const node = await requireTable(actor, input.nodeId);
956
+ if (replayedId) {
957
+ const replayed = await deps.repository.getVersion(replayedId);
958
+ if (replayed) {
959
+ return {
960
+ node: await requireVisible(actor, node.id),
961
+ version: replayed,
962
+ appended: input.rows.length,
963
+ };
964
+ }
965
+ }
966
+ const header = await tableHeader(node);
967
+ if (header === null) {
968
+ throw new IntelError(409, "table_undefined", "This table has no header yet; define its columns before appending");
969
+ }
970
+ // ⚠️ Refused, never padded and never truncated — see `requireRowShape`.
971
+ requireRowShape(input.rows, header);
972
+ const version = await writeTableSegment(actor, node, encodeCsv(input.rows), "append", input.idempotencyKey);
973
+ await deps.indexing.enqueue(version.id);
974
+ return {
975
+ node: await requireVisible(actor, node.id),
976
+ version,
977
+ appended: input.rows.length,
978
+ };
979
+ },
980
+ /**
981
+ * Rows replaced in place (#135). Position is the address — rows carry no identity on purpose —
982
+ * so the write is guarded the way `save` guards a document: against the version the caller
983
+ * read, and a table that moved on answers `version_conflict` rather than editing rows the
984
+ * positions no longer mean.
985
+ */
986
+ async updateTableRows(actor, input) {
987
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.table_update", input.idempotencyKey);
988
+ const node = await requireTable(actor, input.nodeId);
989
+ if (replayedId) {
990
+ const replayed = await deps.repository.getVersion(replayedId);
991
+ if (replayed) {
992
+ return {
993
+ node: await requireVisible(actor, node.id),
994
+ version: replayed,
995
+ updated: input.updates.length,
996
+ };
997
+ }
998
+ }
999
+ const { header, rows } = await tableStateFor(node, input.baseVersionId);
1000
+ requireRowShape(input.updates.map((update) => update.row), header);
1001
+ requirePositions(input.updates.map((update) => update.position), rows.length);
1002
+ for (const update of input.updates)
1003
+ rows[update.position] = [...update.row];
1004
+ const version = await writeTableSnapshot({
1005
+ actor,
1006
+ node,
1007
+ body: encodeCsv([header, ...rows]),
1008
+ baseVersionId: input.baseVersionId,
1009
+ operation: "node.table_update",
1010
+ metadata: {
1011
+ positions: input.updates.map((update) => update.position),
1012
+ updated: input.updates.length,
1013
+ },
1014
+ idempotencyKey: input.idempotencyKey,
1015
+ });
1016
+ return {
1017
+ node: await requireVisible(actor, node.id),
1018
+ version,
1019
+ updated: input.updates.length,
1020
+ };
1021
+ },
1022
+ async deleteTableRows(actor, input) {
1023
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.table_delete", input.idempotencyKey);
1024
+ const node = await requireTable(actor, input.nodeId);
1025
+ if (replayedId) {
1026
+ const replayed = await deps.repository.getVersion(replayedId);
1027
+ if (replayed) {
1028
+ return {
1029
+ node: await requireVisible(actor, node.id),
1030
+ version: replayed,
1031
+ deleted: input.positions.length,
1032
+ };
1033
+ }
1034
+ }
1035
+ const { header, rows } = await tableStateFor(node, input.baseVersionId);
1036
+ requirePositions(input.positions, rows.length);
1037
+ const removed = new Set(input.positions);
1038
+ const remaining = rows.filter((_row, position) => !removed.has(position));
1039
+ const version = await writeTableSnapshot({
1040
+ actor,
1041
+ node,
1042
+ body: encodeCsv([header, ...remaining]),
1043
+ baseVersionId: input.baseVersionId,
1044
+ operation: "node.table_delete",
1045
+ metadata: { positions: input.positions, deleted: input.positions.length },
1046
+ idempotencyKey: input.idempotencyKey,
1047
+ });
1048
+ return {
1049
+ node: await requireVisible(actor, node.id),
1050
+ version,
1051
+ deleted: input.positions.length,
1052
+ };
1053
+ },
1054
+ /**
1055
+ * A new header over the stored rows, through the explicit mapping and only through it (#135).
1056
+ * The blind second definition stays refused in `defineTable` — this is the deliberate opposite:
1057
+ * every new column names the current column that fills it, or names none and starts empty, and
1058
+ * a current column no entry names is removed together with its cells.
1059
+ */
1060
+ async redefineTable(actor, input) {
1061
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.table_redefine", input.idempotencyKey);
1062
+ const node = await requireTable(actor, input.nodeId);
1063
+ if (replayedId)
1064
+ return await tableOf(node);
1065
+ const { header, rows } = await tableStateFor(node, input.baseVersionId);
1066
+ // ⚠️ Sources are matched against the header as it is stored, exactly. A forgiving match would
1067
+ // make "which column did this take" depend on rules nobody can read off the table.
1068
+ const sourceIndex = new Map(header.map((column, index) => [column, index]));
1069
+ for (const column of input.columns) {
1070
+ if (column.source !== null && !sourceIndex.has(column.source)) {
1071
+ throw new IntelError(400, "table_column_unknown", `Column ${column.source} does not exist: the table has ${header.join(", ")}`);
1072
+ }
1073
+ }
1074
+ const remapped = rows.map((row) => input.columns.map((column) => {
1075
+ if (column.source === null)
1076
+ return "";
1077
+ const index = sourceIndex.get(column.source);
1078
+ return index === undefined ? "" : (row[index] ?? "");
1079
+ }));
1080
+ await writeTableSnapshot({
1081
+ actor,
1082
+ node,
1083
+ body: encodeCsv([input.columns.map((column) => column.name), ...remapped]),
1084
+ baseVersionId: input.baseVersionId,
1085
+ operation: "node.table_redefine",
1086
+ metadata: { columns: input.columns },
1087
+ idempotencyKey: input.idempotencyKey,
1088
+ });
1089
+ return await tableOf(await requireVisible(actor, node.id));
1090
+ },
1091
+ async listVersions(actor, nodeId) {
1092
+ await requireVisible(actor, nodeId);
1093
+ return { items: await deps.repository.listVersions(nodeId) };
1094
+ },
1095
+ async update(actor, input) {
1096
+ const current = await requireVisible(actor, input.nodeId);
1097
+ if (!(await deps.repository.can(actor, current.id, "write"))) {
1098
+ throw new IntelError(403, "node_forbidden", "This node cannot be edited");
1099
+ }
1100
+ /**
1101
+ * ⚠️ **Only a document can be offered as a slash command, and the refusal names the kind**
1102
+ * (#775). A prompt is an instruction; a folder, a table, an attachment, a board or a task is
1103
+ * material, and material is read through `node_get` rather than typed as a command.
1104
+ *
1105
+ * ⚠️ It stands BEFORE the idempotency replay on purpose: a call that is wrong about the kind
1106
+ * is wrong on every attempt, and answering the second one out of the replay table would tell
1107
+ * a caller their refused write had succeeded.
1108
+ *
1109
+ * ⚠️ Clearing it — `null` on a kind that could never have had one — is deliberately allowed.
1110
+ * It changes nothing, and refusing a no-op would make "take the command off everything in
1111
+ * this folder" a call that fails on the folder itself.
1112
+ */
1113
+ if (input.promptName != null && current.kind !== "document") {
1114
+ throw new IntelError(409, "prompt_not_a_document", `Only a document can be offered as a slash command, and this is a ${current.kind}.`);
1115
+ }
1116
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.update", input.idempotencyKey);
1117
+ if (replayedId)
1118
+ return await requireVisible(actor, replayedId);
1119
+ // ⚠️ `undefined` means "do not move" and is the only value that skips this. An explicit
1120
+ // `null` IS a move — to the root — and has to be checked like any other destination; the
1121
+ // first version of this treated the two the same and let a card be dragged to the top level.
1122
+ if (input.parentId !== undefined) {
1123
+ if (input.parentId === current.id) {
1124
+ throw new IntelError(409, "move_cycle", "A node cannot contain itself");
1125
+ }
1126
+ const parent = input.parentId === null ? null : await requireVisible(actor, input.parentId);
1127
+ /**
1128
+ * ⚠️ **The write check on the destination comes FIRST, before the board is resolved.**
1129
+ * `boardOfTask` asks with the verb `write`, so for a destination this actor may only read
1130
+ * it answers `null` — and the refusal below would then say "this destination has no board"
1131
+ * about a card that has one perfectly well. The honest answer is 403, and it is the order
1132
+ * `packages/intel/CLAUDE.md` prescribes anyway: capability, then resource, then action.
1133
+ */
1134
+ if (parent !== null && !(await deps.repository.can(actor, parent.id, "write"))) {
1135
+ throw new IntelError(403, "node_forbidden", "Destination folder cannot be edited");
1136
+ }
1137
+ const destinationBoard = await boardFor(deps, actor, parent);
1138
+ /**
1139
+ * ⚠️ **A board that cannot be resolved is refused, not skipped.** Without this line an
1140
+ * installation whose board port is absent — or one where the actor may not write the
1141
+ * board — would answer `undefined`, the comparison below would read that as "not a move
1142
+ * between boards", and a card could be handed to any board at all. The integration test
1143
+ * `refuses the three ways a card could leave its board` caught exactly that: the test
1144
+ * harness did not wire the port, and door 3 stood open again.
1145
+ */
1146
+ const currentBoard = current.kind === "task" ? ((await deps.boardOfTask?.(actor, current.id)) ?? null) : null;
1147
+ if (current.kind === "task" && currentBoard === null) {
1148
+ throw new IntelError(409, "task_cannot_change_board", "This card's board cannot be resolved, so it cannot be moved. A card belongs to the board it was filed on.");
1149
+ }
1150
+ // Kind first, archive second — see the same pair in `create`.
1151
+ refuseWrongParent(current.kind, parent, {
1152
+ destination: destinationBoard,
1153
+ current: currentBoard,
1154
+ });
1155
+ refuseArchivedParent(parent);
1156
+ }
1157
+ const updatedAt = deps.now().toISOString();
1158
+ const updated = await deps.repository.updateNode({
1159
+ node: {
1160
+ ...current,
1161
+ parentId: input.parentId === undefined ? current.parentId : input.parentId,
1162
+ title: input.title === undefined ? current.title : plainTitle(input.title),
1163
+ description: input.description === undefined ? current.description : input.description,
1164
+ promptName: input.promptName === undefined ? current.promptName : input.promptName,
1165
+ updatedAt,
1166
+ },
1167
+ baseUpdatedAt: input.baseUpdatedAt,
1168
+ actorId: actor.id,
1169
+ idempotencyKey: input.idempotencyKey,
1170
+ auditId: deps.id(),
1171
+ });
1172
+ if (updated === "cycle") {
1173
+ throw new IntelError(409, "move_cycle", "A node cannot be moved into its descendant");
1174
+ }
1175
+ if (updated === "prompt_name_taken") {
1176
+ /**
1177
+ * ⚠️ **The sentence names the holder only when this actor may see it.** That a name is
1178
+ * taken has to be said — otherwise the caller retries forever against a wall they cannot
1179
+ * see — but WHICH document holds it is a fact about the tree, and ADR-0004 §3 does not let
1180
+ * a refusal become a way of reading it. `promptNameHolder` answers `null` for the title in
1181
+ * that case, and the second sentence below is what the caller gets instead.
1182
+ */
1183
+ const holder = await deps.repository.promptNameHolder(actor, input.promptName);
1184
+ const held = holder === null
1185
+ ? "It is already taken."
1186
+ : holder.title === null
1187
+ ? `A ${holder.kind} you cannot see already uses it.`
1188
+ : `The ${holder.kind} "${holder.title}" already uses it.`;
1189
+ throw new IntelError(409, "prompt_name_taken", `The slash command "${input.promptName}" is not free. ${held} Pick another name — names are never changed for you, because the catalogue would then mean a different thing to every reader.`);
1190
+ }
1191
+ if (updated === "conflict") {
1192
+ throw new IntelError(409, "update_conflict", "This node was changed by another editor");
1193
+ }
1194
+ if (updated.currentVersionId)
1195
+ await deps.indexing.enqueue(updated.currentVersionId);
1196
+ return updated;
1197
+ },
1198
+ /**
1199
+ * Archiving and restoring.
1200
+ *
1201
+ * ⚠️ Nothing in this repository removes a `nodes` row — `archived_at` is what "delete" means
1202
+ * here, which is why a restore is the same call with `archived: false` and why both directions
1203
+ * go through the indexing queue below.
1204
+ *
1205
+ * ⚠️ Until #390 this also switched an agent's Gate Application off with the node, and the ORDER
1206
+ * of the two writes was the whole safety argument. With the agents parked there is no second
1207
+ * system to keep in step, so #418 took the caller's bearer back out — a parameter kept "in
1208
+ * case" is a parameter every surface has to find a value for, and both of them were passing a
1209
+ * token nothing read.
1210
+ */
1211
+ async archive(actor, input) {
1212
+ const current = await requireVisible(actor, input.nodeId);
1213
+ if (!(await deps.repository.can(actor, current.id, "write"))) {
1214
+ throw new IntelError(403, "node_forbidden", "This node cannot be edited");
1215
+ }
1216
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.archive", input.idempotencyKey);
1217
+ // A replay does nothing a second time and answers with what the first run made.
1218
+ if (replayedId)
1219
+ return await requireVisible(actor, replayedId);
1220
+ /**
1221
+ * ⚠️ **Archiving takes the whole subtree, flows included** (D71, #733), and the refusal that
1222
+ * used to stand here is gone with it. `task_has_subtasks` (#669) refused a card with live
1223
+ * subtasks, on the reasoning that a cascade turns one confirmed click into an unknown number
1224
+ * of disappearances.
1225
+ *
1226
+ * That reasoning holds for `purge` and not for this call. The two steps are not symmetric:
1227
+ * archiving is REVERSIBLE, so cascading it costs nothing that cannot be undone, while `purge`
1228
+ * has cascaded over the subtree since #492 and cannot be undone at all. With the cascade on
1229
+ * this side, nothing live sits under an archived node any more — which is precisely what
1230
+ * makes the purge honest, and what `refuseArchivedParent` below keeps true afterwards.
1231
+ *
1232
+ * ⚠️ The counterpart is the restore, and it is the part that can quietly do too much or too
1233
+ * little. It brings back what fell with THIS operation **and hangs under the node being
1234
+ * restored** — a child archived earlier keeps its own operation and stays archived, and
1235
+ * restoring a leaf does not drag its whole former tree back with it.
1236
+ */
1237
+ /**
1238
+ * ⚠️ **Archiving something that is already archived changes nothing, deliberately** (found in
1239
+ * review of #738). Without this it mints a NEW operation on the root while the cascade skips
1240
+ * every child (`archived_at IS NULL`), so the children keep the old one — and a later restore
1241
+ * hands back an empty folder while its contents stay archived, with nothing saying so.
1242
+ *
1243
+ * The likeliest way there is not misuse but a retry after a timeout with a fresh idempotency
1244
+ * key: the key does not catch it, and the operation is re-minted.
1245
+ */
1246
+ if (input.archived && current.archivedAt !== null)
1247
+ return current;
1248
+ /**
1249
+ * ⚠️ **A restore under an archived parent is refused, and this is the other half of
1250
+ * `refuseArchivedParent`** (found in review of #738). Without it the cascade closes nothing:
1251
+ * archive a child alone, archive the folder over it, restore the child, and a LIVE node hangs
1252
+ * under an archived one again — where `purge` reaches it, because it checks `archived_at` on
1253
+ * the root alone and walks the whole subtree.
1254
+ *
1255
+ * The refusal names the way out rather than the rule: whoever wants this child back wants the
1256
+ * folder back first, and then the child comes with it.
1257
+ */
1258
+ if (!input.archived && current.parentId !== null) {
1259
+ // ⚠️ Read WITHOUT the visibility predicate, see `parentArchivedAt`: asked through
1260
+ // `getVisible` this guard misses exactly the actor it exists for, the one holding a grant on
1261
+ // the child alone.
1262
+ const parent = await deps.repository.parentArchivedAt(current.parentId);
1263
+ if (parent?.archivedAt) {
1264
+ throw new IntelError(409, "parent_archived", `This ${parent.kind} is archived, so nothing under it can be restored on its own. Restore it first, and this comes back with it.`);
1265
+ }
1266
+ }
1267
+ const updatedAt = deps.now().toISOString();
1268
+ const updated = await deps.repository.archiveNode({
1269
+ nodeId: current.id,
1270
+ baseUpdatedAt: input.baseUpdatedAt,
1271
+ archivedAt: input.archived ? updatedAt : null,
1272
+ operationId: deps.id(),
1273
+ updatedAt,
1274
+ actorId: actor.id,
1275
+ idempotencyKey: input.idempotencyKey,
1276
+ auditId: deps.id(),
1277
+ });
1278
+ if (updated === "conflict") {
1279
+ throw new IntelError(409, "update_conflict", "This node was changed by another editor");
1280
+ }
1281
+ /**
1282
+ * ⚠️ Both directions, through the same queue every save goes through (anchrd/intel#348). The
1283
+ * pass reads the node's state and does the matching thing: an archived node has its vectors
1284
+ * taken out of the index, a restored one is embedded again from a record that was emptied when
1285
+ * it went. Two calls to one door rather than a purge written out here, because Vectorize is a
1286
+ * second system and a call into it can fail — the queue is the only thing in this repository
1287
+ * that comes back for it, and a deletion nobody retries is a deletion that quietly did not
1288
+ * happen.
1289
+ *
1290
+ * ⚠️ The full-text half is deliberately NOT emptied on the way in, and the asymmetry is the
1291
+ * point rather than an oversight: an FTS row costs storage and is already invisible (every
1292
+ * read joins `nodes` and drops what is archived), while a vector costs a place in a candidate
1293
+ * list Vectorize caps at 100 for the whole installation. An archived node pays with
1294
+ * somebody else's search results.
1295
+ *
1296
+ * A replayed archive never reaches this line — it returned above, on the idempotency key — so
1297
+ * repeating the same call does not ask the index to forget the same names twice.
1298
+ *
1299
+ * ⚠️ **The whole subtree, not the root** (D71, #733, found in review of #738). Since archiving
1300
+ * cascades, a folder with fifty documents takes fifty nodes out of sight — and every one of
1301
+ * them would keep its place in the candidate list if only the root were enqueued. The pass
1302
+ * reads each node's state, so a node that was already archived costs one no-op rather than a
1303
+ * wrong answer.
1304
+ *
1305
+ * ⚠️ It is one enqueue per versioned node in the subtree, sequentially, and each one is a
1306
+ * subrequest. A tree large enough runs into the Workers limit AFTER the batch has committed:
1307
+ * archived tree, error to the caller, index half followed. That is #754, and it needs
1308
+ * `sendBatch` or a sweeper rather than a cap here — a cap would be the silent kind.
1309
+ */
1310
+ for (const versionId of await deps.repository.versionIdsInSubtree(updated.id)) {
1311
+ await deps.indexing.enqueue(versionId);
1312
+ }
1313
+ return updated;
1314
+ },
1315
+ /**
1316
+ * ⚠️ The one call in Intel after which something is really gone (#457). Read the decisions on
1317
+ * that issue before changing anything here: each refusal below is one of them, and each was
1318
+ * chosen to the safer side.
1319
+ */
1320
+ async purge(actor, input) {
1321
+ const idempotencyKey = `${input.nodeId}:${input.idempotencyKey}`;
1322
+ const replay = await deps.repository.findPurgeReplay(actor.id, idempotencyKey);
1323
+ if (replay) {
1324
+ for (const key of replay.contentKeys)
1325
+ await deps.content.delete(key);
1326
+ await deps.repository.completePurgeReplay(actor.id, idempotencyKey, deps.now().toISOString());
1327
+ return { purged: true, title: replay.title };
1328
+ }
1329
+ const node = await requireVisible(actor, input.nodeId);
1330
+ if (!(await deps.repository.can(actor, input.nodeId, "write"))) {
1331
+ throw new IntelError(403, "node_forbidden", "This node cannot be edited");
1332
+ }
1333
+ // ⚠️ THE structural guard: only what is already archived may go. A stronger PERMISSION would
1334
+ // not have made this safer, only rarer — whoever may archive can already make the content
1335
+ // invisible. What makes it safe is that the destructive path has two steps and the first one
1336
+ // is reversible.
1337
+ if (!node.archivedAt) {
1338
+ throw new IntelError(409, "node_not_archived", "Only an archived node can be deleted for good. Archive it first.");
1339
+ }
1340
+ // #492 replaces the refusal with owned cleanup. Flows inside the tree disappear with it;
1341
+ // published flows outside it are depublished atomically before their dependency vanishes.
1342
+ const tree = await deps.repository.inspectPurgeTree(node.id);
1343
+ // Not a refusal — a fact for the record. A prose reference is a mention, not an operation;
1344
+ // refusing over one would make deleting a lottery, decided by whoever once linked to it.
1345
+ //
1346
+ // The same count is exposed by `purgePreview`, so the surface can name the consequence before
1347
+ // confirmation. It is counted again here because the audit records the state at deletion time.
1348
+ const inboundLinks = await deps.repository.countInboundLinks(node.id);
1349
+ // ⚠️ The vectors go FIRST, and the order is the correction of a wrong claim (found in review).
1350
+ // The comment here used to say Vectorize needed no call because archiving had already emptied
1351
+ // it. Archiving only ENQUEUES the purge, and that pass starts from `archivedNodeId(versionId)`
1352
+ // — a join over `nodes`. Once this batch has run, that answers `null` and removes nothing,
1353
+ // while `node_vectors`, the only record of what the vectors are CALLED, is gone with it. The
1354
+ // orphans would then be unnameable forever, and each one keeps a place in the 100-candidate
1355
+ // list Vectorize caps per installation.
1356
+ //
1357
+ // ⚠️ Before the batch rather than after, which inverts the order the content keys follow — and
1358
+ // the rule behind both is the same: DO THE IRRECOVERABLE THING LAST. A failed batch after the
1359
+ // vectors are gone leaves an ARCHIVED node whose index can be rebuilt (`reindex`); a failed
1360
+ // removal after the batch leaves orphans nothing can name.
1361
+ const vectorKeys = tree.vectorKeys;
1362
+ // The whole-node chunk carries the empty key and is never in the table — the pass writes it
1363
+ // and records the rest, so a purge that only sent the recorded ones would leave it behind.
1364
+ if (deps.semantic) {
1365
+ for (const vector of vectorKeys) {
1366
+ await deps.semantic.remove(vector.nodeId, ["", ...vector.keys]);
1367
+ }
1368
+ }
1369
+ const purged = await deps.repository.purgeNode({
1370
+ nodeId: node.id,
1371
+ actorId: actor.id,
1372
+ auditId: deps.id(),
1373
+ occurredAt: deps.now().toISOString(),
1374
+ // ⚠️ The title travels INTO the record from the row itself (the repository reads it there),
1375
+ // and this is what travels beside it. After the delete nothing can look either up.
1376
+ metadata: { inboundLinks, counts: tree.counts },
1377
+ idempotencyKey,
1378
+ });
1379
+ // The row was restored or already gone between the check and the statement. Nothing was
1380
+ // deleted, and saying so is better than reporting a success that did not happen.
1381
+ if (purged === "missing") {
1382
+ throw new IntelError(409, "update_conflict", "This node was changed by another editor");
1383
+ }
1384
+ // ⚠️ AFTER the database agreed, never before: it is the truth about what exists, and an object
1385
+ // deleted ahead of a batch that then fails would leave a node whose content is gone. This is
1386
+ // the same rule the vectors follow in the other direction — do the irrecoverable thing last —
1387
+ // and the directions differ because only one of the two can be rebuilt: an index can, an
1388
+ // object cannot.
1389
+ for (const key of purged.contentKeys)
1390
+ await deps.content.delete(key);
1391
+ await deps.repository.completePurgeReplay(actor.id, idempotencyKey, deps.now().toISOString());
1392
+ return { purged: true, title: node.title };
1393
+ },
1394
+ async listGrants(actor, resourceId) {
1395
+ const node = await requireVisible(actor, resourceId);
1396
+ if (!(await deps.repository.can(actor, resourceId, "share"))) {
1397
+ throw new IntelError(403, "node_forbidden", "Sharing of this node cannot be managed");
1398
+ }
1399
+ return {
1400
+ resourceId: node.id,
1401
+ applicableVerbs: applicableVerbs(node.kind),
1402
+ items: await deps.repository.listGrants(resourceId),
1403
+ };
1404
+ },
1405
+ async listEffectiveAccess(actor, resourceId) {
1406
+ const node = await requireVisible(actor, resourceId);
1407
+ if (!(await deps.repository.can(actor, resourceId, "share"))) {
1408
+ throw new IntelError(403, "node_forbidden", "Sharing of this node cannot be managed");
1409
+ }
1410
+ const effective = await deps.repository.listEffectiveAccess(resourceId);
1411
+ // ⚠️ The folders the inherited grants sit on, named through the SAME predicate every other
1412
+ // node read goes through (#803). Managing THIS node's sharing says nothing about whether the
1413
+ // reader may see the folder above it: a grant can reach here from an ancestor they cannot
1414
+ // open, and naming it would hand them an ancestor the tree never showed them.
1415
+ //
1416
+ // ⚠️ A folder that is absent from the answer is not reported as refused. The screen falls back
1417
+ // to saying the access is inherited without saying from where — the same silence
1418
+ // `resolveLinks` keeps, and for the same reason: "not yours to see" and "not there" must stay
1419
+ // indistinguishable (#41).
1420
+ const inheritedOn = [
1421
+ ...new Set(effective.items.map((grant) => grant.resourceId).filter((id) => id !== resourceId)),
1422
+ ];
1423
+ const named = inheritedOn.length > 0
1424
+ ? await deps.repository.resolveVisibleTitles(actor, inheritedOn)
1425
+ : [];
1426
+ return {
1427
+ resourceId: node.id,
1428
+ ...effective,
1429
+ sources: named.map((entry) => ({ resourceId: entry.nodeId, title: entry.title })),
1430
+ };
1431
+ },
1432
+ async listLinks(actor, nodeId) {
1433
+ await requireVisible(actor, nodeId);
1434
+ return { items: await deps.repository.listLinksVisible(actor, nodeId) };
1435
+ },
1436
+ /**
1437
+ * The titles of linked documents, for the reader who is looking at the text (#41).
1438
+ *
1439
+ * ⚠️ The one place a document link gets a name, and it answers with what this reader may see —
1440
+ * `getVisible`, the same predicate every other node read goes through — never with a
1441
+ * lookup written specially for a label. What is missing from the answer is missing for two
1442
+ * reasons that must stay indistinguishable: the target is gone, or it is not theirs to see. A
1443
+ * shape that told them apart would let a document confirm the existence of one they may not
1444
+ * reach, and that is what the link does not get to say.
1445
+ *
1446
+ * ⚠️ Archived targets are absent too. A link to a deleted document has to break visibly rather
1447
+ * than point quietly at nothing, and "absent" is what the reader's side draws as broken.
1448
+ */
1449
+ async resolveLinks(actor, input) {
1450
+ return { items: await deps.repository.resolveVisibleTitles(actor, input.nodeIds) };
1451
+ },
1452
+ async graph(actor, input) {
1453
+ return await deps.repository.graphVisible(actor, input);
1454
+ },
1455
+ // ⚠️ The one shape of this question that answers `null` instead of throwing, and the only one a
1456
+ // graph may be built from. `get` loads the body and turns a refusal into a 404 the caller has to
1457
+ // catch; here "you cannot reach it" is a value, so a drawing can leave a node out rather than
1458
+ // deciding what to do with an exception halfway through (#19).
1459
+ //
1460
+ // ⚠️ Visibility and nothing else — deliberately not "and not archived". A run authorizes its
1461
+ // tree links through this same answer (#20), and archiving a document must not start
1462
+ // refusing steps under a message that names the wrong reason. Callers that must not *draw* an
1463
+ // archived node say so where they draw, the way the callee side already does.
1464
+ async visibleNode(actor, nodeId) {
1465
+ return await deps.repository.getVisible(actor, nodeId);
1466
+ },
1467
+ async share(actor, input) {
1468
+ const node = await requireVisible(actor, input.resourceId);
1469
+ if (!(await deps.repository.can(actor, input.resourceId, "share"))) {
1470
+ throw new IntelError(403, "node_forbidden", "Sharing of this node cannot be managed");
1471
+ }
1472
+ if (!applicableVerbs(node.kind).includes(input.verb)) {
1473
+ throw new IntelError(409, "verb_not_applicable", `A ${node.kind} cannot be granted ${input.verb}`);
1474
+ }
1475
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.share", input.idempotencyKey);
1476
+ const principal = input.principal.type === "email"
1477
+ ? { type: "email", email: input.principal.email.toLowerCase() }
1478
+ : input.principal;
1479
+ if (replayedId) {
1480
+ const replayed = (await deps.repository.listGrants(input.resourceId)).find((grant) => grant.id === replayedId);
1481
+ // A replay describes the same access as the first attempt did, so the warning is asked
1482
+ // again rather than remembered: whether a document is readable can have changed since.
1483
+ if (replayed) {
1484
+ return {
1485
+ grant: replayed,
1486
+ unreadable: await unreadableForPrincipal(actor, node.id, replayed.principal),
1487
+ unrunnable: nothingWithheld,
1488
+ };
1489
+ }
1490
+ }
1491
+ /**
1492
+ * ⚠️ Behind the replay and in front of `setGrant` — both halves matter (#442).
1493
+ *
1494
+ * In front of `setGrant` is the rule itself: nothing that could never work gets written, so
1495
+ * no row, no idempotency key and no audit event. Every path that reaches the write passes
1496
+ * here, including a replay whose grant was revoked in between and falls through.
1497
+ *
1498
+ * Behind the replay because otherwise this refusal would break the promise `IdempotencyKey`
1499
+ * makes. A caller retries with the SAME key and the SAME body; if the retry arrives after the
1500
+ * expiry the first attempt named, the value is no longer in the future — and the second call
1501
+ * would be refused for a grant that is already written. The replay path writes nothing, so
1502
+ * standing behind it costs the rule nothing and keeps the retry answering with what happened.
1503
+ */
1504
+ requireFutureExpiry(input.expiresAt, deps.now());
1505
+ const timestamp = deps.now().toISOString();
1506
+ const grant = await deps.repository.setGrant({
1507
+ grant: {
1508
+ id: deps.id(),
1509
+ resourceId: input.resourceId,
1510
+ principal,
1511
+ verb: input.verb,
1512
+ expiresAt: input.expiresAt,
1513
+ createdBy: actor.id,
1514
+ createdAt: timestamp,
1515
+ },
1516
+ actorId: actor.id,
1517
+ idempotencyKey: input.idempotencyKey,
1518
+ auditId: deps.id(),
1519
+ });
1520
+ // ⚠️ After the grant is written, never before. The answer has to describe the access that is
1521
+ // now in force — sharing `read` on this folder is exactly what makes the documents inside it
1522
+ // readable, and a warning computed a moment earlier would name them all.
1523
+ return {
1524
+ grant,
1525
+ unreadable: await unreadableForPrincipal(actor, node.id, principal),
1526
+ unrunnable: nothingWithheld,
1527
+ };
1528
+ },
1529
+ async revokeGrant(actor, input) {
1530
+ await requireVisible(actor, input.resourceId);
1531
+ if (!(await deps.repository.can(actor, input.resourceId, "share"))) {
1532
+ throw new IntelError(403, "node_forbidden", "Sharing of this node cannot be managed");
1533
+ }
1534
+ const replayed = await deps.repository.findIdempotentRevocation(actor.id, input.idempotencyKey);
1535
+ if (replayed !== null)
1536
+ return { revoked: replayed };
1537
+ // ⚠️ What makes a folder a library is `execute` for the whole organization: that is the one
1538
+ // grant ADR-0004 §3 lets a flow call across a folder edge for. Taking it away while calls
1539
+ // reach in from outside would leave those flows published and unrunnable, so the attempt names
1540
+ // the callers instead of breaking them silently. A grant to one principal beside it narrows
1541
+ // nothing while the organization-wide one stands, so only that one is guarded — and only when
1542
+ // no second organization-wide `execute` above it keeps the reach alive, because then this
1543
+ // revocation narrows nothing either and there is nothing to refuse.
1544
+ const revoked = (await deps.repository.listGrants(input.resourceId)).find((grant) => grant.id === input.grantId);
1545
+ if (revoked?.verb === "execute" &&
1546
+ revoked.principal.type === "organization" &&
1547
+ !(await deps.repository.organizationExecuteReaches(input.resourceId, input.grantId))) {
1548
+ const callers = await deps.externalFlowCallers(actor, input.resourceId);
1549
+ if (callers.visible.length || callers.hidden) {
1550
+ throw new IntelError(409, "folder_execute_in_use", callersDetail(callers), namedOrCounted(callers));
1551
+ }
1552
+ }
1553
+ return {
1554
+ revoked: await deps.repository.revokeGrant({
1555
+ resourceId: input.resourceId,
1556
+ grantId: input.grantId,
1557
+ actorId: actor.id,
1558
+ idempotencyKey: input.idempotencyKey,
1559
+ auditId: deps.id(),
1560
+ occurredAt: deps.now().toISOString(),
1561
+ }),
1562
+ };
1563
+ },
1564
+ async search(actor, input) {
1565
+ // Authorization before access (ADR-0004 §2): a scope is resolved against the tree and its ACLs
1566
+ // before FTS, the vector index, or any content is touched. A folder the actor may not read and
1567
+ // one that does not exist give the same answer on purpose — `getVisible` cannot tell them
1568
+ // apart, so a scope cannot be used to probe what is filed elsewhere.
1569
+ if (input.scopeId !== undefined) {
1570
+ const scope = await deps.repository.getVisible(actor, input.scopeId);
1571
+ if (!scope || scope.archivedAt) {
1572
+ throw new IntelError(404, "scope_not_found", "Search scope was not found");
1573
+ }
1574
+ if (scope.kind !== "folder") {
1575
+ throw new IntelError(400, "scope_not_folder", "A search scope must be a folder");
1576
+ }
1577
+ }
1578
+ const lexical = await deps.repository.searchVisible(actor, {
1579
+ ...input,
1580
+ limit: Math.min(50, input.limit * 2),
1581
+ });
1582
+ if (!deps.semantic)
1583
+ return { items: lexical.slice(0, input.limit) };
1584
+ try {
1585
+ /**
1586
+ * Every candidate Vectorize will give for one query, scoped or not (anchrd/intel#348).
1587
+ *
1588
+ * ⚠️ A candidate is a CARD since anchrd/intel#301, not a node, so a busy board can take a
1589
+ * large share of these places and push other nodes out before this code ever sees them —
1590
+ * the fold below cannot repair that, it runs on what came back. `limit * 4` was written when
1591
+ * a board was one vector and forty candidates were forty nodes; against one vector per card
1592
+ * it is a list a single board fills on its own. Asking for the ceiling is the one widening
1593
+ * available. Vectorize charges the query and not the depth, so that side is free; the price
1594
+ * is on the D1 side, where `hydrateVisibleCitations` batches 40 pairs per statement and the
1595
+ * ordinary unscoped search therefore goes from one statement to as many as three. Nothing
1596
+ * about the ceiling itself moves: a `limit` of 25 and every scoped search reached 100 before.
1597
+ *
1598
+ * ⚠️ It is a widening and not a fix, and the reason it is not is written down in
1599
+ * `packages/intel/CLAUDE.md`: bounding the fan-out per NODE means filtering on a `nodeId`
1600
+ * metadata index, and Cloudflare only puts a vector into such an index when it is upserted
1601
+ * AFTER the index was created — so it would cost every installation a full re-embed of its
1602
+ * tree, plus an operational step no deployment has taken. That is anchrd/intel#356.
1603
+ *
1604
+ * ⚠️ 100 is the port's own clamp too, and it is Vectorize's documented ceiling for a query
1605
+ * that returns neither values nor metadata (50 for one that does). The scoped case has asked
1606
+ * for it since #126, because a scope cuts the candidates AFTERWARDS, in the D1 statement
1607
+ * that re-checks the ACL — a folder of a dozen documents inside a tree of thousands is not
1608
+ * reached by a narrow fan-out, and a starved scope looks like an empty folder.
1609
+ */
1610
+ const candidates = 100;
1611
+ const hits = await deps.semantic.search(input.query, candidates);
1612
+ /**
1613
+ * The best-scoring chunk of each node, and its score (anchrd/intel#301).
1614
+ *
1615
+ * ⚠️ The best, not the sum and not the first. A board answers once per card whose vector
1616
+ * matched, and a citation names a node — so a board of three hundred mediocre cards must not
1617
+ * out-rank one document that actually answers, and the passage the reader is shown has to be
1618
+ * the card that scored, not the one that happened to come back first.
1619
+ */
1620
+ const best = new Map();
1621
+ for (const hit of hits) {
1622
+ const current = best.get(hit.nodeId);
1623
+ if (current === undefined || hit.score > current.score) {
1624
+ best.set(hit.nodeId, { chunkKey: hit.chunkKey, score: hit.score });
1625
+ }
1626
+ }
1627
+ const semanticScores = new Map([...best].map(([nodeId, winner]) => [nodeId, winner.score]));
1628
+ const semantic = await deps.repository.hydrateVisibleCitations(actor, [...best].map(([nodeId, winner]) => ({ nodeId, chunkKey: winner.chunkKey })), input.scopeId);
1629
+ return {
1630
+ items: mergeSearchResults(lexical, semantic, semanticScores, input.limit),
1631
+ };
1632
+ }
1633
+ catch {
1634
+ return { items: lexical.slice(0, input.limit) };
1635
+ }
1636
+ },
1637
+ async reindex(actor) {
1638
+ if (actor.isAdmin !== true) {
1639
+ throw new IntelError(403, "reindex_forbidden", "Reindex permission is required");
1640
+ }
1641
+ /**
1642
+ * ⚠️ First, and before a single version is enqueued: the record of what the vector index
1643
+ * holds (anchrd/intel#301). Since #301 an indexing pass skips a chunk whose fingerprint has
1644
+ * not moved, which is what keeps a board of three hundred cards from costing three hundred
1645
+ * embeddings per save — and it would equally make `reindex` skip everything, so a vector
1646
+ * index that had been emptied would stay empty while every version was dutifully requeued.
1647
+ * That is the one failure this call exists to prevent, and it is a silent one: the answer
1648
+ * would be a search that finds less and a `queued` count that says all is well.
1649
+ *
1650
+ * The full-text half needs no equivalent because its rows are overwritten, never skipped.
1651
+ */
1652
+ await deps.repository.invalidateVectors();
1653
+ let queued = 0;
1654
+ let after = null;
1655
+ for (;;) {
1656
+ const versionIds = await deps.repository.listCurrentVersionIds({ after, limit: 100 });
1657
+ for (const versionId of versionIds)
1658
+ await deps.indexing.enqueue(versionId);
1659
+ queued += versionIds.length;
1660
+ if (versionIds.length < 100)
1661
+ break;
1662
+ after = versionIds.at(-1) ?? null;
1663
+ }
1664
+ return { queued };
1665
+ },
1666
+ };
1667
+ }