@bevel-software/platform-core-backend 0.23.0 → 0.25.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 (238) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +17 -3
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +3 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +25 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/core/lifecycle.d.ts +12 -0
  9. package/dist/core/lifecycle.d.ts.map +1 -1
  10. package/dist/core/lifecycle.js +10 -0
  11. package/dist/core/lifecycle.js.map +1 -1
  12. package/dist/core-config.d.ts +16 -0
  13. package/dist/core-config.d.ts.map +1 -1
  14. package/dist/core-config.js +17 -0
  15. package/dist/core-config.js.map +1 -1
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +13 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/modules/access/access.routes.d.ts.map +1 -1
  21. package/dist/modules/access/access.routes.js +4 -1
  22. package/dist/modules/access/access.routes.js.map +1 -1
  23. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
  24. package/dist/modules/access/directory-sync-bot.js +7 -3
  25. package/dist/modules/access/directory-sync-bot.js.map +1 -1
  26. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
  27. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  28. package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
  29. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  30. package/dist/modules/agent-instructions/compose.d.ts +38 -6
  31. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  32. package/dist/modules/agent-instructions/compose.js +39 -6
  33. package/dist/modules/agent-instructions/compose.js.map +1 -1
  34. package/dist/modules/agent-instructions/index.d.ts +2 -1
  35. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  36. package/dist/modules/agent-instructions/index.js +2 -1
  37. package/dist/modules/agent-instructions/index.js.map +1 -1
  38. package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
  39. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
  40. package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
  41. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
  42. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  43. package/dist/modules/audit/agent-audit.service.js +4 -2
  44. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  45. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
  46. package/dist/modules/auth/account-erasure.service.js +57 -16
  47. package/dist/modules/auth/account-erasure.service.js.map +1 -1
  48. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  49. package/dist/modules/auth/auth.service.js +25 -15
  50. package/dist/modules/auth/auth.service.js.map +1 -1
  51. package/dist/modules/code-mode/code-mode.tool.d.ts +20 -2
  52. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  53. package/dist/modules/code-mode/code-mode.tool.js +66 -35
  54. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  55. package/dist/modules/database/connection.d.ts +16 -0
  56. package/dist/modules/database/connection.d.ts.map +1 -1
  57. package/dist/modules/database/connection.js +117 -0
  58. package/dist/modules/database/connection.js.map +1 -1
  59. package/dist/modules/database/core-schema.d.ts +296 -96
  60. package/dist/modules/database/core-schema.d.ts.map +1 -1
  61. package/dist/modules/database/core-schema.js +81 -31
  62. package/dist/modules/database/core-schema.js.map +1 -1
  63. package/dist/modules/database/migrate.d.ts +95 -1
  64. package/dist/modules/database/migrate.d.ts.map +1 -1
  65. package/dist/modules/database/migrate.js +390 -2
  66. package/dist/modules/database/migrate.js.map +1 -1
  67. package/dist/modules/kb-fs/locking-filesystem.d.ts +17 -0
  68. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  69. package/dist/modules/kb-fs/locking-filesystem.js +29 -0
  70. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  71. package/dist/modules/mcp/mcp.service.d.ts +8 -0
  72. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  73. package/dist/modules/mcp/mcp.service.js +38 -8
  74. package/dist/modules/mcp/mcp.service.js.map +1 -1
  75. package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
  76. package/dist/modules/plugins/join-request-records.store.js +7 -4
  77. package/dist/modules/plugins/join-request-records.store.js.map +1 -1
  78. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  79. package/dist/modules/tool-auth/external-api-key.service.js +8 -2
  80. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  81. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  82. package/dist/modules/tool-manuals/tool-manuals.service.js +30 -0
  83. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  84. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  85. package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
  86. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  87. package/dist/modules/tool-registry/description-length.d.ts +80 -0
  88. package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
  89. package/dist/modules/tool-registry/description-length.js +108 -0
  90. package/dist/modules/tool-registry/description-length.js.map +1 -0
  91. package/dist/modules/workflow/git/git.service.d.ts +25 -0
  92. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  93. package/dist/modules/workflow/git/git.service.js +40 -1
  94. package/dist/modules/workflow/git/git.service.js.map +1 -1
  95. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  96. package/dist/modules/workflow/pending-commits.service.js +5 -1
  97. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  98. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
  99. package/dist/modules/workflow/recovery-bot.js +7 -3
  100. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  101. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  102. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  103. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  104. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  105. package/dist/modules/workflow/workflow.service.js +4 -1
  106. package/dist/modules/workflow/workflow.service.js.map +1 -1
  107. package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
  108. package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
  109. package/dist/modules/workspace/agent-upload.routes.js +210 -0
  110. package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
  111. package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
  112. package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
  113. package/dist/modules/workspace/agent-upload.store.js +553 -0
  114. package/dist/modules/workspace/agent-upload.store.js.map +1 -0
  115. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  116. package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
  117. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  118. package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
  119. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  120. package/dist/modules/workspace/startup/steps/template-source.js +46 -4
  121. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  122. package/dist/modules/workspace/upload-limits.d.ts +13 -0
  123. package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
  124. package/dist/modules/workspace/upload-limits.js +13 -0
  125. package/dist/modules/workspace/upload-limits.js.map +1 -0
  126. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  127. package/dist/modules/workspace/workspace.routes.js +1 -1
  128. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  129. package/dist/modules/workspace/workspace.service.d.ts +13 -0
  130. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  131. package/dist/modules/workspace/workspace.service.js +61 -33
  132. package/dist/modules/workspace/workspace.service.js.map +1 -1
  133. package/dist/modules/workspace/workspace.tools.d.ts +11 -9
  134. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  135. package/dist/modules/workspace/workspace.tools.js +570 -134
  136. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  137. package/dist/modules/workspace/write-denial.d.ts +0 -6
  138. package/dist/modules/workspace/write-denial.d.ts.map +1 -1
  139. package/dist/modules/workspace/write-denial.js +0 -6
  140. package/dist/modules/workspace/write-denial.js.map +1 -1
  141. package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
  142. package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
  143. package/dist/modules/workspace/zip-entry-rules.js +154 -0
  144. package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
  145. package/dist/shared/column-crypto.d.ts +194 -0
  146. package/dist/shared/column-crypto.d.ts.map +1 -0
  147. package/dist/shared/column-crypto.js +144 -0
  148. package/dist/shared/column-crypto.js.map +1 -0
  149. package/dist/shared/token-crypto.d.ts.map +1 -1
  150. package/dist/shared/token-crypto.js +25 -1
  151. package/dist/shared/token-crypto.js.map +1 -1
  152. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  153. package/dist/tenancy/static-tenant-source.js +1 -0
  154. package/dist/tenancy/static-tenant-source.js.map +1 -1
  155. package/dist/tenancy/tenant-secrets.d.ts +5 -1
  156. package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
  157. package/dist/tenancy/tenant-secrets.js +4 -0
  158. package/dist/tenancy/tenant-secrets.js.map +1 -1
  159. package/kb-template/AGENTS.md +42 -0
  160. package/migrations/0016_pii_encryption.sql +20 -0
  161. package/migrations/meta/0016_snapshot.json +2327 -0
  162. package/migrations/meta/_journal.json +7 -0
  163. package/package.json +3 -3
  164. package/src/core/__tests__/lifecycle.test.ts +71 -4
  165. package/src/core/create-core-server.ts +21 -3
  166. package/src/core/create-core-services.ts +27 -1
  167. package/src/core/lifecycle.ts +19 -0
  168. package/src/core-config.ts +18 -0
  169. package/src/index.ts +25 -0
  170. package/src/modules/access/__tests__/users-db-double.ts +21 -12
  171. package/src/modules/access/access.routes.ts +4 -1
  172. package/src/modules/access/directory-sync-bot.ts +7 -3
  173. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +8 -4
  174. package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
  175. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
  176. package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
  177. package/src/modules/agent-instructions/compose.ts +50 -7
  178. package/src/modules/agent-instructions/index.ts +12 -0
  179. package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
  180. package/src/modules/audit/agent-audit.service.ts +4 -2
  181. package/src/modules/auth/__tests__/account-deactivation.test.ts +2 -1
  182. package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
  183. package/src/modules/auth/__tests__/account.routes.test.ts +6 -3
  184. package/src/modules/auth/__tests__/auth.service.test.ts +3 -1
  185. package/src/modules/auth/account-erasure.service.ts +69 -18
  186. package/src/modules/auth/auth.service.ts +33 -23
  187. package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
  188. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
  189. package/src/modules/code-mode/code-mode.tool.ts +80 -34
  190. package/src/modules/database/__tests__/connection.test.ts +12 -0
  191. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +780 -0
  192. package/src/modules/database/connection.ts +117 -0
  193. package/src/modules/database/core-schema.ts +81 -31
  194. package/src/modules/database/migrate.ts +540 -2
  195. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +93 -0
  196. package/src/modules/kb-fs/locking-filesystem.ts +37 -0
  197. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
  198. package/src/modules/mcp/__tests__/mcp.service.test.ts +47 -7
  199. package/src/modules/mcp/mcp.service.ts +46 -7
  200. package/src/modules/plugins/join-request-records.store.ts +7 -4
  201. package/src/modules/tool-auth/external-api-key.service.ts +8 -2
  202. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +151 -0
  203. package/src/modules/tool-manuals/tool-manuals.service.ts +37 -0
  204. package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
  205. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
  206. package/src/modules/tool-registry/description-length.ts +111 -0
  207. package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
  208. package/src/modules/workflow/git/git.service.ts +47 -1
  209. package/src/modules/workflow/pending-commits.service.ts +5 -1
  210. package/src/modules/workflow/recovery-bot.ts +7 -3
  211. package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
  212. package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
  213. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  214. package/src/modules/workflow/workflow.service.ts +4 -1
  215. package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
  216. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +24 -14
  217. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
  218. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +77 -1
  219. package/src/modules/workspace/__tests__/workspace.service.test.ts +57 -0
  220. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +39 -36
  221. package/src/modules/workspace/__tests__/workspace.tools.test.ts +196 -46
  222. package/src/modules/workspace/agent-upload.routes.ts +214 -0
  223. package/src/modules/workspace/agent-upload.store.ts +668 -0
  224. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
  225. package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
  226. package/src/modules/workspace/startup/steps/template-source.ts +53 -5
  227. package/src/modules/workspace/upload-limits.ts +12 -0
  228. package/src/modules/workspace/workspace.routes.ts +1 -2
  229. package/src/modules/workspace/workspace.service.ts +63 -37
  230. package/src/modules/workspace/workspace.tools.ts +647 -148
  231. package/src/modules/workspace/write-denial.ts +0 -8
  232. package/src/modules/workspace/zip-entry-rules.ts +173 -0
  233. package/src/shared/__tests__/column-crypto.test.ts +217 -0
  234. package/src/shared/column-crypto.ts +218 -0
  235. package/src/shared/token-crypto.ts +28 -1
  236. package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -0
  237. package/src/tenancy/static-tenant-source.ts +1 -0
  238. package/src/tenancy/tenant-secrets.ts +5 -1
@@ -0,0 +1,314 @@
1
+ /**
2
+ * The rules that apply to MORE THAN ONE file tool, written ONCE.
3
+ *
4
+ * They used to be appended, in full, to every tool description that they
5
+ * covered: the content rule rode on all twelve file tools, the agent-guide
6
+ * reminder on all of them plus the shell, the write-mode and escape-sequence
7
+ * paragraphs on two or three apiece. A description then ran to two or three
8
+ * thousand characters, most of it text the agent had already read on the tool
9
+ * above — and clients cut long descriptions from the END, which is where the
10
+ * text specific to the tool sits. Agents saw `file_stat`, `read_file`,
11
+ * `write_file` and `write_files` arrive ending in "[truncated]".
12
+ *
13
+ * So the shared rules are stated in TWO places and in neither description:
14
+ *
15
+ * - the MCP `instructions` of the initialize handshake (see `compose.ts`),
16
+ * which Claude Code, Claude Desktop and Cursor put in the system prompt;
17
+ * - the platform-managed agent guide at the repository root (`AGENTS.md` by
18
+ * default), rendered from `{{sharedFileRules}}` in the template — claude.ai
19
+ * on the web, the Agent SDK and Cline drop `instructions`, and a guide the
20
+ * agent is told to read before its first action is always available.
21
+ *
22
+ * Both places get the SAME string, from {@link sharedFileRulesSection} — not
23
+ * two hand-mirrored copies. A rule written twice is a rule that drifts, and a
24
+ * drifted rule is worse than a repeated one, because the agent cannot tell
25
+ * which copy is current. Each description ends instead with
26
+ * {@link sharedRulesPointer}, one sentence naming the section and the file.
27
+ *
28
+ * Pure text, a function of the layout only: the guide's file name is a
29
+ * deployment setting, so nothing here may snapshot `AGENTS.md`.
30
+ */
31
+
32
+ import {
33
+ DEFAULT_KB_LAYOUT,
34
+ agentsFileOf,
35
+ LEGACY_AGENTS_FILE,
36
+ platformFilesByDepth,
37
+ type KbLayout,
38
+ } from '@bevel-software/platform-shared';
39
+ import { CHAIN_FAILURES_RULE, CHAIN_LARGE_RESULTS_RULE } from '@bevel-software/platform-mcp-core';
40
+
41
+ /** The heading the rules live under, in both places and in the pointer sentence. */
42
+ export const SHARED_RULES_SECTION = 'Working with files';
43
+
44
+ /**
45
+ * The guide's name on a knowledge base seeded before it was renamed to
46
+ * {@link LEGACY_AGENTS_FILE}. Named in the conventions rule because the seeder
47
+ * never deletes a file it did not expect, so such a knowledge base still
48
+ * carries one.
49
+ */
50
+ const PRE_RENAME_AGENTS_FILE = 'CLAUDE.md';
51
+
52
+ /**
53
+ * The ceiling on the whole section. Pinned by a test: the section lands in
54
+ * every conversation through two channels, and a rule moved here to shorten a
55
+ * description has only moved the cost if the section itself grows without
56
+ * limit. Measured against {@link sharedFileRulesSection} under the default
57
+ * layout.
58
+ *
59
+ * 7,000 since two rules moved in from descriptions they took past their cap:
60
+ * the chain's own (what a failed chain and an oversized result answer), true
61
+ * of every call rather than of how to write one, and why large, escape-heavy
62
+ * and binary content goes by upload, which three write tools each said in
63
+ * full.
64
+ */
65
+ export const SHARED_FILE_RULES_CAP = 7_000;
66
+
67
+ /** One shared rule: how the guide heads it, and the rule itself. */
68
+ export interface SharedFileRule {
69
+ /** Stable id, so a test can name the rule that went missing. */
70
+ id: string;
71
+ /** The heading this rule gets in the section. */
72
+ heading: string;
73
+ /** The rule, as both places state it. Markdown paragraphs, blank-line separated. */
74
+ body: string;
75
+ }
76
+
77
+ /**
78
+ * Every shared rule, in reading order: what to read first, what the content
79
+ * is, what a write may do, then the two protocols that guard a destructive
80
+ * call.
81
+ *
82
+ * The bodies are the sentences the descriptions used to carry, moved rather
83
+ * than rewritten wherever the wording still reads outside its old tool — the
84
+ * ones that said "this tool" or "this call" name the tools instead.
85
+ */
86
+ export function sharedFileRules(layout: KbLayout): readonly SharedFileRule[] {
87
+ const agentsFile = agentsFileOf(layout);
88
+ return [
89
+ {
90
+ id: 'agent-guide',
91
+ heading: "This knowledge base's own conventions",
92
+ body: conventionsRule(agentsFile),
93
+ },
94
+ {
95
+ id: 'content-kinds',
96
+ heading: 'Text, documents, images and other binaries',
97
+ body:
98
+ 'read_file returns text for text files and extracted text for documents ' +
99
+ '(.docx/.pptx/.xlsx/.odt/.odp/.ods/.pdf, .eml/.msg); write_file, write_files and edit_file accept TEXT only — ' +
100
+ 'they refuse documents, images, archives and other binary files (legacy .doc/.ppt/.xls included) with kind ' +
101
+ "`binary_not_writable`, naming the file's kind and the tool to use instead; copy_file, move_file and delete_file " +
102
+ 'act on bytes of any kind and unzip extracts the entries of a `.zip`; new binary content arrives by upload ' +
103
+ '(see below). file_stat reports `contentMode` ' +
104
+ '(`text` | `document` | `binary`) so you can decide before acting.\n\n' +
105
+ 'Office and OpenDocument files (.docx/.pptx/.xlsx, .odt/.odp/.ods) and PDFs read as EXTRACTED text under an ' +
106
+ 'honest `[extracted text of …]` header, with `[slide N]`/`[sheet: Name]`/`[page N]` markers — the extraction ' +
107
+ 'is READ-ONLY (layout/images omitted; such files cannot be edited as text, only replaced by uploading a new ' +
108
+ 'version). Email files (.eml/.msg) read the same way: a `[from]`/`[to]`/`[subject]`/`[date]` header block, the ' +
109
+ 'body (plain-text part preferred; an HTML-only body is stripped to text), and an `[attachments]` name list — ' +
110
+ 'attachments are listed, never extracted. grep searches inside all of those, through the same extractions.\n\n' +
111
+ 'Images (.png/.jpg/.jpeg/.gif/.webp) read as the IMAGE ITSELF, as native MCP image content with a one-line ' +
112
+ 'text note naming the file, so you can look at the picture — up to 3.5 MB of raw image data; a larger image ' +
113
+ 'gets an honest refusal asking for a locally downscaled copy or a smaller export (`.svg` is text and reads as ' +
114
+ 'text). Images come back only on a DIRECT read_file: inside `call_tool_chain` an image read yields an ' +
115
+ '`{ image_omitted, note }` stub instead, so read an image outside a chain when you mean to look at it. Any other ' +
116
+ 'binary file reads as a one-line description rather than raw bytes.',
117
+ },
118
+ {
119
+ id: 'write-mode',
120
+ heading: 'What a write may do at a path',
121
+ body:
122
+ 'On write_file and write_files, `mode` decides what may happen at a path and DEFAULTS TO `create`: `create` ' +
123
+ 'writes a new file and refuses a path that already exists (`exists`, with the path — pass `mode: overwrite` to ' +
124
+ 'replace it), `overwrite` replaces what is there (creating it if there is nothing), `update` replaces an ' +
125
+ 'existing file and refuses a path that does not exist (`missing`). A refused path is left exactly as it was.',
126
+ },
127
+ {
128
+ id: 'images-in-pages',
129
+ heading: 'Where the images a page uses go',
130
+ body:
131
+ 'Keep them in an `assets/` folder next to the page that uses them and link them with a relative path, e.g. ' +
132
+ '`![Approval screen](./assets/approval-screen.png)`; the page renders them inline.',
133
+ },
134
+ {
135
+ id: 'escape-sequences',
136
+ heading: 'Escape sequences in content you send',
137
+ body:
138
+ 'On write_file, write_files and edit_file, which take content as a JSON string: some clients decode escape ' +
139
+ 'sequences in arguments before sending, so content meant to CONTAIN an escape rather than what it stands for ' +
140
+ '(the six characters backslash, `u`, `0`, `0`, `4`, `1`, say, rather than the letter `A`) can reach the tool ' +
141
+ 'already decoded — what arrives is stored byte for byte, so when that distinction matters, verify what landed ' +
142
+ '(`read_file`, or a hash) and send such content by upload (see below), which lands it unchanged.',
143
+ },
144
+ {
145
+ // The reason the upload route exists, said once. Each of the three tools
146
+ // that take content as a JSON string names the route in one sentence of
147
+ // its own; WHY a file should go that way, and how, is here.
148
+ id: 'upload-route',
149
+ heading: 'Large, escape-heavy and binary content goes by upload',
150
+ body:
151
+ 'write_file, write_files and edit_file take content as a JSON string you have to type out in full, so a long ' +
152
+ 'file is cut off mid-answer, a file full of backslashes or `\\u` escapes fails to parse as a parameter, and ' +
153
+ 'an image, a PDF or a zip cannot be sent at all. For any of those: call `request_file_upload`, POST the file — ' +
154
+ 'or one zip holding many files — to the address it answers with any HTTP client ' +
155
+ '(`curl -X POST --data-binary @<file> "<uploadUrl>?filename=<name>"`), then `apply_file_upload` to land it on ' +
156
+ 'a branch in one commit. The bytes never pass through the conversation, so nothing is cut or mangled on the ' +
157
+ 'way. A person can also use Upload in the app.',
158
+ },
159
+ {
160
+ id: 'dry-run-confirm',
161
+ heading: 'Dry-run before a move, a copy or a folder delete',
162
+ body:
163
+ 'move_file, copy_file and delete_folder take `dryRun: true`: it changes nothing and answers the impact — ' +
164
+ 'what the call would touch, `allowed`, and `reason` when it may not run. A non-empty folder is deleted, and a ' +
165
+ 'move that changes your access runs, only with `confirm: true`; without it the call changes nothing and ' +
166
+ 'returns the same impact with `confirmationRequired: true`. Do NOT set `confirm: true` on your first call — ' +
167
+ 'dry-run, check the impact, then confirm. delete_file takes neither: it removes the one file you named, so ' +
168
+ 'check it first with file_stat (`deletable`) if you are unsure.',
169
+ },
170
+ {
171
+ id: 'managed-items',
172
+ heading: 'What these tools never move or delete',
173
+ body:
174
+ `A platform file (${platformFileList(layout)}) is refused with ` +
175
+ '"<name> is a platform file and stays in its folder." — a folder that moves ' +
176
+ 'takes its own platform files along, still in their folder, and a folder that is deleted takes them with it in ' +
177
+ 'the same one change, so its files are never left ungoverned part-way. A platform folder (the repository root ' +
178
+ `or a reserved root folder such as \`${layout.knowledgeBaseDir}/\`) and git metadata are refused, and so is creating a ` +
179
+ 'platform file or folder at a destination (renaming a note to `access.md` is refused). A path that is, or goes ' +
180
+ 'through, a symbolic link is refused: links are never followed or removed. On a protected branch you must be ' +
181
+ 'able to write everything the call touches — for a folder, every file under it, at its old and its new path. ' +
182
+ 'file_stat reports `managed`, `movable` and `deletable` so you can tell before the call.',
183
+ },
184
+ {
185
+ id: 'refused-for-permissions',
186
+ heading: 'When a write is refused for permissions',
187
+ body:
188
+ 'A refusal is not necessarily the end of the road: the `write-denied` error says whether you may propose the ' +
189
+ 'change instead (create a branch from this one, repeat the call on it, then `open_change_request` into this ' +
190
+ 'branch) and lists those steps.',
191
+ },
192
+ {
193
+ // What a chain DOES, as opposed to how to write one — which stays in
194
+ // `call_tool_chain`'s own description. The sentences are `mcp-core`'s,
195
+ // the same ones a surface with no shared rules puts in the description
196
+ // itself, so the rule reads the same wherever an agent finds it. What a
197
+ // chained read does to an image is in the content rule above.
198
+ id: 'tool-chain',
199
+ heading: 'When several calls run as one chain',
200
+ body: `On call_tool_chain: ${CHAIN_FAILURES_RULE}\n\n${CHAIN_LARGE_RESULTS_RULE}`,
201
+ },
202
+ ];
203
+ }
204
+
205
+ /**
206
+ * The platform files as the rules list them — from the one function that knows
207
+ * which they are, so the list cannot drift from what actually refuses a move,
208
+ * and the guide appears under this deployment's name for it.
209
+ *
210
+ * WITH THE DEPTH each name counts at, because the name alone is half the rule:
211
+ * `access.md` governs the folder it sits in and `.bevelignore` layers, so both
212
+ * are platform files wherever they are; `roles.yaml` and the guide are read
213
+ * from the repository root only, so a nested copy of either is ordinary
214
+ * content that moves and deletes like any page. An agent told only the names
215
+ * refuses a rename it may make, and trusts a nested `access.md` it may not.
216
+ */
217
+ function platformFileList(layout: KbLayout): string {
218
+ const { anyDepth, rootOnly } = platformFilesByDepth(layout);
219
+ const quoted = (names: readonly string[]): string => names.map((name) => `\`${name}\``).join(' or ');
220
+ return `${quoted(anyDepth)} in any folder, ${quoted(rootOnly)} at the repository root`;
221
+ }
222
+
223
+ /**
224
+ * The conventions reminder — which file holds the author's own rules for this
225
+ * knowledge base, and to read it first.
226
+ *
227
+ * `CLAUDE.md` is named as a fallback because knowledge bases seeded before the
228
+ * rename still carry one. WHEN THE GUIDE HAS BEEN RENAMED the sentence names
229
+ * two files, ours first: the second is the organisation's OWN `AGENTS.md`,
230
+ * which on such a deployment is ordinary content the platform never touches —
231
+ * and which no harness reads for a remote agent, because a remote agent has no
232
+ * checkout. Under the default name the wording collapses to the one file it
233
+ * has always named.
234
+ */
235
+ function conventionsRule(agentsFile: string): string {
236
+ if (agentsFile === LEGACY_AGENTS_FILE) {
237
+ return (
238
+ `Before your first read or change in a workspace, read \`${LEGACY_AGENTS_FILE}\` at the KB root — or ` +
239
+ `\`${PRE_RENAME_AGENTS_FILE}\` on a knowledge base seeded before it was renamed — if either exists: it holds ` +
240
+ "the author's conventions for this knowledge base, and you should follow them."
241
+ );
242
+ }
243
+ return (
244
+ `Before your first read or change in a workspace, read \`${agentsFile}\` at the KB root, then ` +
245
+ `\`${LEGACY_AGENTS_FILE}\` if it also exists (the organisation's own conventions) — or ` +
246
+ `\`${PRE_RENAME_AGENTS_FILE}\` on a knowledge base seeded before it was renamed: together they hold the ` +
247
+ 'conventions for this knowledge base, and you should follow them.'
248
+ );
249
+ }
250
+
251
+ /**
252
+ * The shared rules as ONE markdown section — the string both channels carry,
253
+ * byte for byte. A `##` section so it drops into the managed guide at that
254
+ * level and still reads as a block in the instructions blob.
255
+ */
256
+ export function sharedFileRulesSection(layout: KbLayout): string {
257
+ const body = sharedFileRules(layout)
258
+ .map((rule) => `### ${rule.heading}\n\n${rule.body}`)
259
+ .join('\n\n');
260
+ return `## ${SHARED_RULES_SECTION}\n\n${body}`;
261
+ }
262
+
263
+ /**
264
+ * The longest guide file name the pointer sentence spells out. Beyond this it
265
+ * names the guide by its ROLE instead (see {@link sharedRulesPointer}).
266
+ *
267
+ * There has to be a bound somewhere, because the pointer rides on every file
268
+ * tool and a file name is not a fixed cost: `validateFilename` allows a name
269
+ * of up to 255 bytes, so an unbounded pointer could reach 318 characters and
270
+ * push `file_stat` to 1,428 — over the description cap, recreating on a
271
+ * renamed deployment exactly the truncation this module exists to prevent, and
272
+ * invisibly, because every measurement is taken under the default layout.
273
+ *
274
+ * 40 is well past any name a deployment plausibly picks
275
+ * (`ENGINEERING-AGENT-CONVENTIONS.md` is 32) and the fallback below is only
276
+ * reachable past it.
277
+ */
278
+ export const POINTER_GUIDE_NAME_BUDGET = 40;
279
+
280
+ /**
281
+ * The one sentence a tool description ends with, in place of the paragraphs it
282
+ * used to carry. Short on purpose: it costs every description the same ~100
283
+ * characters at worst, and its whole job is to name the section and the file
284
+ * to read.
285
+ *
286
+ * BOUNDED BY CONSTRUCTION, which is what lets the description cap mean
287
+ * something on a deployment that renamed its guide: a name within
288
+ * {@link POINTER_GUIDE_NAME_BUDGET} is spelled out, and a longer one gets the
289
+ * generic wording. Naming the file is the better sentence and wins whenever it
290
+ * fits; a name past the budget is pathological, and there the choice is between
291
+ * a sentence that says where to look and a catalog entry the client cuts. The
292
+ * guide's own name is still in the section's first rule either way.
293
+ *
294
+ * An absent layout means the default one, as it does in
295
+ * `composeAgentInstructions`: a caller reading the layout from configuration
296
+ * gets `undefined` when none is set, and the pointer must still name a file.
297
+ */
298
+ export function sharedRulesPointer(layout: KbLayout = DEFAULT_KB_LAYOUT): string {
299
+ const agentsFile = agentsFileOf(layout);
300
+ const where =
301
+ agentsFile.length <= POINTER_GUIDE_NAME_BUDGET ? agentsFile : 'the agent guide at the KB root';
302
+ return ` Shared rules for all file tools: see "${SHARED_RULES_SECTION}" in ${where}.`;
303
+ }
304
+
305
+ /**
306
+ * The most the pointer can ever cost a description, over every layout. What the
307
+ * description cap is measured against, the way the tool prefix is measured at
308
+ * ITS cap rather than at whatever the current admin wrote: a description that
309
+ * only fits beside the short default guide name does not really fit.
310
+ */
311
+ export const SHARED_RULES_POINTER_MAX = Math.max(
312
+ sharedRulesPointer({ ...DEFAULT_KB_LAYOUT, agentsFile: `${'x'.repeat(POINTER_GUIDE_NAME_BUDGET - 3)}.md` }).length,
313
+ sharedRulesPointer({ ...DEFAULT_KB_LAYOUT, agentsFile: `${'x'.repeat(POINTER_GUIDE_NAME_BUDGET + 10)}.md` }).length,
314
+ );
@@ -1,4 +1,4 @@
1
- import { and, asc, count, desc, eq, isNull, lt, or, type SQL } from 'drizzle-orm';
1
+ import { and, count, desc, eq, isNull, lt, or, type SQL } from 'drizzle-orm';
2
2
  import { logger } from '../../shared/logging.js';
3
3
  import type { Database } from '../database/connection.js';
4
4
  import { agentConnections, agentEvents, oauthTokens, users } from '../database/schema.js';
@@ -101,7 +101,9 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
101
101
  .from(agentConnections)
102
102
  .innerJoin(users, eq(agentConnections.userId, users.id))
103
103
  .where(mine === null ? undefined : eq(agentConnections.userId, mine))
104
- .orderBy(asc(users.email), desc(agentConnections.connectedAt)),
104
+ // Not by email: that column is randomized ciphertext, and ORDER BY
105
+ // on it is noise. The sort below orders by email in-process.
106
+ .orderBy(desc(agentConnections.connectedAt)),
105
107
  this.db
106
108
  .select({ keyId: agentEvents.keyId, connectionId: agentEvents.connectionId, n: count() })
107
109
  .from(agentEvents)
@@ -240,7 +240,8 @@ describe('AuthService.createAccount without a password', () => {
240
240
  const { db, captured } = makeFakeDb([[{ ...ROW, passwordHash: null }]]);
241
241
  const user = await new AuthService(db, makeConfig()).createAccount('Alice@Example.com', '');
242
242
  expect(user.email).toBe('alice@example.com');
243
- expect(captured.values[0]).toEqual({ email: 'alice@example.com', name: 'alice' });
243
+ // The index column is written with the address; the handle indexes it.
244
+ expect(captured.values[0]).toEqual({ email: 'alice@example.com', emailBidx: 'alice@example.com', name: 'alice' });
244
245
  });
245
246
 
246
247
  it('asks the admission port, as any new account does', async () => {
@@ -70,6 +70,9 @@ function harness(opts: { participants?: IErasureParticipant[] } = {}) {
70
70
  locked.push((statement.queryChunks ?? []).filter((c) => typeof c === 'number'));
71
71
  return { rows: [] };
72
72
  },
73
+ // The one read the transaction makes: the refusals recorded before the
74
+ // encryption release, which it matches by name. None here.
75
+ select: () => ({ from: () => ({ where: async () => [] }) }),
73
76
  delete: (table: unknown) => ({
74
77
  where: () => {
75
78
  order.push(`delete:${tableOf(table)}`);
@@ -176,8 +179,9 @@ describe('AccountErasureService: approvals are rewritten under the approval lock
176
179
  const committed = h.order.indexOf('delete:users');
177
180
  const sweep = h.order.indexOf('after-commit:pr_file_approvals');
178
181
  expect(sweep).toBeGreaterThan(committed);
179
- // The change requests get the same treatment, and did before this.
180
- expect(h.order).toContain('after-commit:change_requests');
182
+ // The change requests get the same treatment, twice: once for the
183
+ // authorship, once for the refusal note of an apply that was in flight.
184
+ expect(h.order.filter((step) => step === 'after-commit:change_requests')).toHaveLength(2);
181
185
  // Both guarded on `users`: the marker is only recorded for a clause that
182
186
  // names it, so a sweep that dropped the `notExists` lands here instead.
183
187
  expect(h.order.filter((step) => step.startsWith('unguarded:'))).toEqual([]);
@@ -93,9 +93,11 @@ describe('account routes — admin gate', () => {
93
93
  { id: 'u2', email: 'b@example.com', name: 'B', passwordHash, createdAt: new Date() },
94
94
  ];
95
95
  // A real AuthService over a stub db — the route's body is what the
96
- // service produces from full `users` rows, hash column included.
96
+ // service produces from full `users` rows, hash column included. The
97
+ // listing is sorted in-process (the email column is ciphertext in the
98
+ // database), so the read is a bare `select().from()`.
97
99
  const db = {
98
- select: () => ({ from: () => ({ orderBy: async () => rows }) }),
100
+ select: () => ({ from: async () => rows }),
99
101
  } as unknown as Database;
100
102
  const realAuth = new AuthService(db, {
101
103
  jwtSecret: 'test-jwt-secret',
@@ -118,9 +120,10 @@ describe('account routes — admin gate', () => {
118
120
  const body = JSON.parse(text) as {
119
121
  accounts: Array<{ email: string; hasPassword: boolean; isEnvAdmin: boolean }>;
120
122
  };
123
+ // Sorted by email in-process, since the column is ciphertext in the database.
121
124
  expect(body.accounts.map((a) => [a.email, a.hasPassword, a.isEnvAdmin])).toEqual([
122
- ['root@example.com', false, true],
123
125
  ['b@example.com', true, false],
126
+ ['root@example.com', false, true],
124
127
  ]);
125
128
  expect(text).not.toContain('scrypt:');
126
129
  expect(text).not.toContain('passwordHash');
@@ -521,9 +521,11 @@ describe('AuthService.listAccounts', () => {
521
521
  const config = makeConfig({ adminEmail: 'root@example.com', adminPassword: 'sup3r-secret' });
522
522
 
523
523
  const withHash = await new AuthService(makeFakeDb([rows]).db, config).listAccounts();
524
+ // Sorted by email in-process: the email column is ciphertext in the
525
+ // database, so the listing orders itself after decrypting.
524
526
  expect(withHash.map((a) => [a.email, a.hasPassword, a.isEnvAdmin])).toEqual([
525
- ['root@example.com', true, true],
526
527
  ['bob@example.com', true, false],
528
+ ['root@example.com', true, true],
527
529
  ['sso@example.com', false, false],
528
530
  ]);
529
531
 
@@ -3,6 +3,7 @@ import { logger } from '../../shared/logging.js';
3
3
 
4
4
  const log = logger('account-erasure');
5
5
  import { and, eq, notExists } from 'drizzle-orm';
6
+ import { isEncryptedBlob } from '../../shared/column-crypto.js';
6
7
  import type { Database } from '../database/connection.js';
7
8
  import type { IReviewWorkflowService } from '../workflow/review-workflow/review-workflow.interface.js';
8
9
  import {
@@ -123,17 +124,34 @@ export class AccountErasureService implements IAccountErasureService {
123
124
  ) {}
124
125
 
125
126
  async listUsers(): Promise<AdminUserView[]> {
127
+ // Sorted in-process: `email` is ciphertext in the database, so ORDER BY
128
+ // would sort by IV noise. One row per team member.
126
129
  const rows = await this.db
127
130
  .select({ id: users.id, email: users.email, name: users.name, createdAt: users.createdAt })
128
- .from(users)
129
- .orderBy(users.email);
130
- return rows.map((r) => ({ ...r, createdAt: r.createdAt.getTime() }));
131
+ .from(users);
132
+ return rows
133
+ .map((r) => ({ ...r, createdAt: r.createdAt.getTime() }))
134
+ .sort((a, b) => a.email.localeCompare(b.email));
131
135
  }
132
136
 
133
137
  async eraseUser(userId: string, opts: { erasureId?: string } = {}): Promise<boolean> {
134
138
  const [user] = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
135
139
  if (!user) return false;
136
140
 
141
+ // Fail CLOSED on an undecryptable email. The encrypted column's read
142
+ // fallback returns the raw ciphertext when the configured key cannot open
143
+ // it (a rotated or wrong SECRETS_ENC_KEY); a blind index of that blob
144
+ // would match zero audit rows while the user delete still committed — an
145
+ // erasure that silently left the personal data behind. Nothing is
146
+ // deleted until the key is fixed.
147
+ if (isEncryptedBlob(user.email)) {
148
+ throw new Error(
149
+ `account-erasure: cannot decrypt the stored email for user id=${userId} with the ` +
150
+ 'configured SECRETS_ENC_KEY — refusing to erase, because the email-keyed audit rows ' +
151
+ 'could not be anonymised. Restore the correct key, then retry.',
152
+ );
153
+ }
154
+
137
155
  const target: ErasureTarget = {
138
156
  userId,
139
157
  email: user.email.toLowerCase(),
@@ -149,6 +167,14 @@ export class AccountErasureService implements IAccountErasureService {
149
167
  if (p.before) await p.before(target);
150
168
  }
151
169
 
170
+ // Every email-keyed row below is matched through its blind index — the
171
+ // email columns are randomized ciphertext — and the index is rewritten to
172
+ // the placeholder's too, so no value keyed to the erased address survives.
173
+ // An index column is compared and written with the address it is the
174
+ // index of; the handle the statement runs on makes the index.
175
+ const emailBidx = target.email;
176
+ const erasedBidx = target.erasedEmail;
177
+
152
178
  const postCommit: Array<() => Promise<void>> = [];
153
179
  await this.db.transaction(async (tx) => {
154
180
  // Token-shaped rows (all hashed, but they key to the user). Dependents
@@ -180,7 +206,7 @@ export class AccountErasureService implements IAccountErasureService {
180
206
  // writing anything back — so no row is resurrected here either.
181
207
  await tx
182
208
  .delete(pluginJoinRequests)
183
- .where(eq(pluginJoinRequests.requesterEmail, target.email));
209
+ .where(eq(pluginJoinRequests.requesterEmailBidx, emailBidx));
184
210
 
185
211
  // Audit rows: anonymize in place (no user FK on these; they key by email).
186
212
  //
@@ -197,23 +223,34 @@ export class AccountErasureService implements IAccountErasureService {
197
223
  });
198
224
  await tx
199
225
  .update(prMergeLog)
200
- .set({ triggeredByEmail: target.erasedEmail, triggeredByName: target.erasedName })
201
- .where(eq(prMergeLog.triggeredByEmail, target.email));
226
+ .set({ triggeredByEmail: target.erasedEmail, triggeredByEmailBidx: erasedBidx, triggeredByName: target.erasedName })
227
+ .where(eq(prMergeLog.triggeredByEmailBidx, emailBidx));
202
228
  await tx
203
229
  .update(prComments)
204
- .set({ authorEmail: target.erasedEmail, authorName: target.erasedName })
205
- .where(eq(prComments.authorEmail, target.email));
230
+ .set({ authorEmail: target.erasedEmail, authorEmailBidx: erasedBidx, authorName: target.erasedName })
231
+ .where(eq(prComments.authorEmailBidx, emailBidx));
206
232
  await tx
207
233
  .update(changeRequests)
208
- .set({ authorEmail: target.erasedEmail, authorName: target.erasedName })
209
- .where(eq(changeRequests.authorEmail, target.email));
234
+ .set({ authorEmail: target.erasedEmail, authorEmailBidx: erasedBidx, authorName: target.erasedName })
235
+ .where(eq(changeRequests.authorEmailBidx, emailBidx));
236
+ // The refusal a still-open request shows ("<name> could not apply
237
+ // this"), on requests of any author — found by the index of the address
238
+ // it was recorded with, like every other row. One recorded before the
239
+ // encryption release kept no address, and has no name to find either:
240
+ // the first start on this release took it off (`migrate.ts`,
241
+ // `clearUnindexedRefusalNames`), because matching such a row by display
242
+ // name missed a person who had been renamed and hit their namesake.
243
+ await tx
244
+ .update(changeRequests)
245
+ .set({ applyFailedByName: target.erasedName, applyFailedByEmailBidx: erasedBidx })
246
+ .where(eq(changeRequests.applyFailedByEmailBidx, emailBidx));
210
247
  // Queued-but-uncommitted saves: the eventual git commit is authored with
211
248
  // the placeholder instead of the erased identity. The file content still
212
249
  // lands — erasing an account must not lose other people's KB state.
213
250
  await tx
214
251
  .update(pendingCommits)
215
- .set({ authorEmail: target.erasedEmail, authorName: target.erasedName })
216
- .where(eq(pendingCommits.authorEmail, target.email));
252
+ .set({ authorEmail: target.erasedEmail, authorEmailBidx: erasedBidx, authorName: target.erasedName })
253
+ .where(eq(pendingCommits.authorEmailBidx, emailBidx));
217
254
 
218
255
  // Module-owned rows, before the users delete so FKs onto users are
219
256
  // still satisfiable — and so a participant that MISSES rows makes the
@@ -251,11 +288,11 @@ export class AccountErasureService implements IAccountErasureService {
251
288
  // approvals they make are their own.
252
289
  await this.db
253
290
  .update(prFileApprovals)
254
- .set({ approverEmail: target.erasedEmail, approverName: target.erasedName })
291
+ .set({ approverEmail: target.erasedEmail, approverEmailBidx: erasedBidx, approverName: target.erasedName })
255
292
  .where(
256
293
  and(
257
- eq(prFileApprovals.approverEmail, target.email),
258
- notExists(this.db.select({ id: users.id }).from(users).where(eq(users.email, target.email))),
294
+ eq(prFileApprovals.approverEmailBidx, emailBidx),
295
+ notExists(this.db.select({ id: users.id }).from(users).where(eq(users.emailBidx, emailBidx))),
259
296
  ),
260
297
  );
261
298
 
@@ -272,11 +309,25 @@ export class AccountErasureService implements IAccountErasureService {
272
309
  // person, and their requests are their own.
273
310
  await this.db
274
311
  .update(changeRequests)
275
- .set({ authorEmail: target.erasedEmail, authorName: target.erasedName })
312
+ .set({ authorEmail: target.erasedEmail, authorEmailBidx: erasedBidx, authorName: target.erasedName })
313
+ .where(
314
+ and(
315
+ eq(changeRequests.authorEmailBidx, emailBidx),
316
+ notExists(this.db.select({ id: users.id }).from(users).where(eq(users.emailBidx, emailBidx))),
317
+ ),
318
+ );
319
+
320
+ // And the refusal note, for the apply this person was inside when the
321
+ // commit landed: recording it takes no lock and names no user row, so it
322
+ // can write their name after the rewrite above. Same statement, same
323
+ // guard, same reason.
324
+ await this.db
325
+ .update(changeRequests)
326
+ .set({ applyFailedByName: target.erasedName, applyFailedByEmailBidx: erasedBidx })
276
327
  .where(
277
328
  and(
278
- eq(changeRequests.authorEmail, target.email),
279
- notExists(this.db.select({ id: users.id }).from(users).where(eq(users.email, target.email))),
329
+ eq(changeRequests.applyFailedByEmailBidx, emailBidx),
330
+ notExists(this.db.select({ id: users.id }).from(users).where(eq(users.emailBidx, emailBidx))),
280
331
  ),
281
332
  );
282
333