@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
@@ -1,9 +1,11 @@
1
1
  import { migrate } from 'drizzle-orm/node-postgres/migrator';
2
+ import { sql } from 'drizzle-orm';
2
3
  import { logger } from '../../shared/logging.js';
3
4
 
4
5
  const log = logger('database');
5
- import { DEFAULT_DB_SCHEMA, dbSchemaOf, type Database } from './connection.js';
6
+ import { DEFAULT_DB_SCHEMA, dbSchemaOf, piiKeysOf, type Database } from './connection.js';
6
7
  import { AdvisoryLock, withAdvisoryLock } from './advisory-lock.js';
8
+ import { PII_SEALED_SHAPE_SQL_REGEX, isEncryptedBlob, type PiiKeys } from '../../shared/column-crypto.js';
7
9
 
8
10
  /*
9
11
  * ── Per-tier migration folders ──────────────────────────────────────────────
@@ -69,6 +71,9 @@ export async function runCoreMigrations(db: Database, folder: string): Promise<v
69
71
  log.info('Running core database migrations...');
70
72
  await migrate(db, { migrationsFolder: folder, migrationsTable: '__drizzle_migrations_core', migrationsSchema });
71
73
  log.info('Core migrations complete.');
74
+ // Under the same lock, before anything reads or writes a PII column:
75
+ // the data half of migration 0016, which SQL cannot do (see below).
76
+ await runPiiEncryptionBackfill(db);
72
77
  },
73
78
  { tenantKey },
74
79
  );
@@ -78,8 +83,19 @@ export async function runCoreMigrations(db: Database, folder: string): Promise<v
78
83
  * Apply the ENTERPRISE migration history from `folder`, tracked in
79
84
  * `__drizzle_migrations_enterprise`. Run AFTER {@link runCoreMigrations} —
80
85
  * enterprise tables FK into core tables.
86
+ *
87
+ * `piiBackfill` is the overlay's own personal data, for the overlay that has
88
+ * sealed columns of its own (`encryptedText` / `blindIndexText` in its
89
+ * schema): the rows written before those columns were sealed are sealed here,
90
+ * right after the history that adds the index columns and under the same
91
+ * lock, exactly as {@link runCoreMigrations} does for core's tables. See
92
+ * {@link runPiiBackfill}.
81
93
  */
82
- export async function runEnterpriseMigrations(db: Database, folder: string): Promise<void> {
94
+ export async function runEnterpriseMigrations(
95
+ db: Database,
96
+ folder: string,
97
+ opts: { piiBackfill?: PiiBackfillSpec } = {},
98
+ ): Promise<void> {
83
99
  const { migrationsSchema, tenantKey } = ledgerOptions(db);
84
100
  await withAdvisoryLock(
85
101
  db,
@@ -92,7 +108,529 @@ export async function runEnterpriseMigrations(db: Database, folder: string): Pro
92
108
  migrationsSchema,
93
109
  });
94
110
  log.info('Enterprise migrations complete.');
111
+ if (opts.piiBackfill) await runPiiBackfill(db, opts.piiBackfill);
95
112
  },
96
113
  { tenantKey },
97
114
  );
98
115
  }
116
+
117
+ /*
118
+ * ── PII column encryption backfill ──────────────────────────────────────────
119
+ *
120
+ * Migration 0016 adds the `*_bidx` columns; the DATA change — rewriting
121
+ * pre-existing plaintext PII to AES-256-GCM ciphertext and filling the blind
122
+ * indexes — happens here, programmatically, because it needs the key the
123
+ * handle holds (SQL migrations cannot encrypt). Runs on every start right after
124
+ * the core history, under the migrations lock, and is idempotent: sealed rows
125
+ * carry the `PII_CIPHERTEXT_PREFIX` marker and are excluded by the SELECT's
126
+ * own WHERE clause, so a completed backfill degenerates to one cheap,
127
+ * empty-result query per table.
128
+ *
129
+ * Concurrency: the migrations lock (keyed per tenant, like the history it
130
+ * guards) serialises two processes booting at once. Each UPDATE is also
131
+ * compare-and-swap — the WHERE clause pins every value the row was read with
132
+ * — so a writer that slips in between the scan and the write (an old-version
133
+ * instance in a mixed-version window) loses nothing: the CAS update matches
134
+ * zero rows and the next start's pass seals whatever that writer left
135
+ * behind. Deployments should still stop the old app before starting the new
136
+ * one (see UPGRADING.md); the lock and CAS make the failure mode of not doing
137
+ * so "unsealed until next start", never "silently overwritten".
138
+ *
139
+ * Once every row is sealed, the same transaction applies the constraints that
140
+ * could not ship in the SQL migration: SET NOT NULL on the bidx columns and
141
+ * the unique-index swaps (`users_email_unique` on the now-randomized
142
+ * ciphertext is meaningless, `users_email_bidx_unq` takes over; the same for
143
+ * the approvals and the join requests). The new unique index goes up BEFORE
144
+ * the old one is dropped so duplicate protection never has a gap. Legacy rows
145
+ * that collide under the normalized blind index are handled first: duplicate
146
+ * approval and join-request rows (the same logical row, a case-variant email)
147
+ * are collapsed; duplicate USERS are a genuine account conflict and abort the
148
+ * start with the row ids so an operator can merge them deliberately. All
149
+ * statements are IF-EXISTS-guarded — re-running is a no-op.
150
+ *
151
+ * Runs against whatever schema the handle searches first: a tenant's own, or
152
+ * `public`, under that handle's key. Every table name is unqualified for that
153
+ * reason.
154
+ *
155
+ * This is the one place that works on the STORED form. The handle's
156
+ * connection opens every sealed value of a result, which here would hide
157
+ * exactly what has to be told apart — so the columns are read as bytes
158
+ * ({@link asStored}), which it leaves alone, and written as ready-made
159
+ * ciphertext, which it passes through.
160
+ */
161
+
162
+ /** A blind-index column and the sealed column whose plaintext it is the index of. */
163
+ export interface PiiBlindIndex {
164
+ /** One of the table's `encrypted` columns. */
165
+ source: string;
166
+ column: string;
167
+ }
168
+
169
+ /** One table of personal data, as the backfill works on it: the columns `encryptedText` and `blindIndexText` are declared on. */
170
+ export interface PiiBackfillTable {
171
+ table: string;
172
+ /** Columns that uniquely identify a row for the write-back UPDATE. */
173
+ key: string[];
174
+ /** Columns whose plaintext values get rewritten as ciphertext. */
175
+ encrypted: string[];
176
+ /** Blind-index column(s) to fill, each from the plaintext of its `source`. */
177
+ bidx?: PiiBlindIndex | PiiBlindIndex[];
178
+ }
179
+
180
+ /**
181
+ * Whose rows a backfill seals, and what closes it. Core's own tables are one
182
+ * such spec ({@link runPiiEncryptionBackfill}); an overlay that seals columns
183
+ * of its own schema writes another and hands it to
184
+ * {@link runEnterpriseMigrations} (or to {@link runPiiBackfill}, for a
185
+ * database it migrates under a lock of its own).
186
+ */
187
+ export interface PiiBackfillSpec {
188
+ /** Whose tables these are, for the log and the refusals: `core`, `enterprise`, … */
189
+ name: string;
190
+ tables: PiiBackfillTable[];
191
+ /**
192
+ * A blind-index column of one of `tables` that the SQL history adds
193
+ * NULLABLE and `finalize` makes NOT NULL. Its nullability is how a start
194
+ * knows whether this backfill has ever committed here: the sealing, the
195
+ * indexing and `finalize` are one transaction, so nullable means nothing is
196
+ * sealed yet, whatever a value looks like (see `needsSealing`), and NOT
197
+ * NULL means everything is. A spec whose `finalize` leaves it nullable is
198
+ * refused: every later start would take sealed rows for plaintext and seal
199
+ * them again.
200
+ */
201
+ marker: { table: string; column: string };
202
+ /**
203
+ * Runs in the same transaction once every row is sealed and indexed, before
204
+ * `finalize`: what has to happen before the constraints can go up (core
205
+ * collapses rows that collide under the normalised index). `first` is true
206
+ * on the first backfill of this database.
207
+ */
208
+ afterSealing?: (tx: PiiBackfillExecutor, run: { first: boolean }) => Promise<void>;
209
+ /**
210
+ * Statements applied last, on every start, in order: `SET NOT NULL` on the
211
+ * index columns, the unique indexes that move onto them. Each must be safe
212
+ * to run again (`IF EXISTS` / `IF NOT EXISTS`; `SET NOT NULL` is).
213
+ */
214
+ finalize: string[];
215
+ /** What the key is called where the operator sets it, for the refusals. Default: `SECRETS_ENC_KEY`, with its tenant note. */
216
+ keyName?: string;
217
+ }
218
+
219
+ const PII_BACKFILL_TABLES: PiiBackfillTable[] = [
220
+ { table: 'users', key: ['id'], encrypted: ['email', 'name', 'avatar_url'], bidx: { source: 'email', column: 'email_bidx' } },
221
+ { table: 'pr_file_approvals', key: ['id'], encrypted: ['approver_email', 'approver_name'], bidx: { source: 'approver_email', column: 'approver_email_bidx' } },
222
+ { table: 'pr_merge_log', key: ['id'], encrypted: ['triggered_by_email', 'triggered_by_name', 'error'], bidx: { source: 'triggered_by_email', column: 'triggered_by_email_bidx' } },
223
+ { table: 'pr_comments', key: ['id'], encrypted: ['author_email', 'author_name', 'body'], bidx: { source: 'author_email', column: 'author_email_bidx' } },
224
+ { table: 'change_requests', key: ['id'], encrypted: ['author_email', 'author_name', 'title', 'body', 'apply_failure_reason', 'apply_failed_by_name'], bidx: { source: 'author_email', column: 'author_email_bidx' } },
225
+ { table: 'pending_commits', key: ['id'], encrypted: ['author_email', 'author_name', 'last_error'], bidx: { source: 'author_email', column: 'author_email_bidx' } },
226
+ { table: 'file_locks', key: ['workspace_id', 'branch', 'path'], encrypted: ['holder_name'] },
227
+ { table: 'plugin_join_requests', key: ['id'], encrypted: ['requester_email', 'requester_name', 'failure_reason'], bidx: { source: 'requester_email', column: 'requester_email_bidx' } },
228
+ ];
229
+
230
+ const ident = (name: string) => sql.raw(`"${name}"`);
231
+
232
+ /** What the backfill needs from a drizzle client — the db or a transaction. */
233
+ export type PiiBackfillExecutor = Pick<Database, 'execute'>;
234
+ type Executor = PiiBackfillExecutor;
235
+
236
+ /** A table's blind indexes, however many it declares. */
237
+ const indexesOf = (t: PiiBackfillTable): PiiBlindIndex[] => (t.bidx === undefined ? [] : Array.isArray(t.bidx) ? t.bidx : [t.bidx]);
238
+
239
+ /** The name the refusals call the key by: the operator's own, with core's tenant note when it is core's. */
240
+ const keyNameOf = (spec: Pick<PiiBackfillSpec, 'keyName'>): string =>
241
+ spec.keyName ?? 'SECRETS_ENC_KEY (for a tenant: the one derived from TENANT_MASTER_KEY)';
242
+
243
+ /** A text column as it is stored: bytes, so the handle's connection does not open it. */
244
+ const asStored = (col: string) => sql`convert_to(${ident(col)}, 'UTF8') AS ${ident(col)}`;
245
+
246
+ /** What {@link asStored} selected, back as text; anything else as it came. */
247
+ const storedText = (value: unknown): unknown => (Buffer.isBuffer(value) ? value.toString('utf8') : value);
248
+
249
+ /**
250
+ * A column "needs sealing" when it holds non-empty text that isn't a blob.
251
+ * The shape regex is deliberately used as a NEGATIVE filter: anything failing
252
+ * it — including plaintext that merely BEGINS with the prefix — is selected
253
+ * and sealed, and `isEncryptedBlob` is the same pattern, so the two can never
254
+ * disagree about a value. Sealed rows all match, keeping the steady-state scan
255
+ * an empty-result query.
256
+ *
257
+ * `trustShape` is false on the FIRST backfill of a database (see
258
+ * {@link isFirstBackfill}): nothing has been sealed yet, so a value in this
259
+ * shape is plaintext somebody typed, and every non-empty value is selected.
260
+ */
261
+ function needsSealing(col: string, trustShape: boolean) {
262
+ if (!trustShape) return sql`(${ident(col)} IS NOT NULL AND ${ident(col)} <> '')`;
263
+ return sql`(${ident(col)} IS NOT NULL AND ${ident(col)} <> '' AND ${ident(col)} !~ ${PII_SEALED_SHAPE_SQL_REGEX})`;
264
+ }
265
+
266
+ /**
267
+ * Whether this database has never been through the backfill: the spec's
268
+ * marker column (for core, the blind index of `users`) is still nullable.
269
+ * The backfill, the duplicate collapse and the constraints run in ONE
270
+ * transaction, so a nullable column means no backfill has ever committed
271
+ * here — and therefore that nothing in it is sealed, whatever it looks like.
272
+ *
273
+ * That is what lets a first backfill trust no shape at all. A blob is
274
+ * recognised by its shape, and a title or a display name is text a person
275
+ * chose: one typed in that shape before the upgrade would be read as sealed,
276
+ * skipped, and left in clear for good — and, sampled by
277
+ * {@link assertKeyOpensSealedRows}, would refuse the start for a key that is
278
+ * perfectly right. Once the first backfill has committed, every write goes
279
+ * through a handle that seals it, so no such value can arrive again.
280
+ *
281
+ * A marker that is not there at all is the SQL history not having run (or a
282
+ * spec naming a column it does not add): refused, rather than read as "not
283
+ * the first run", which would trust every shape in a database nobody sealed.
284
+ */
285
+ async function isFirstBackfill(tx: Executor, spec: Pick<PiiBackfillSpec, 'name' | 'marker'>): Promise<boolean> {
286
+ const { table, column } = spec.marker;
287
+ const result = await tx.execute(sql`
288
+ SELECT is_nullable FROM information_schema.columns
289
+ WHERE table_schema = current_schema() AND table_name = ${table} AND column_name = ${column}
290
+ `);
291
+ const nullable = (result.rows[0] as { is_nullable?: string } | undefined)?.is_nullable;
292
+ if (nullable === undefined) {
293
+ throw new Error(
294
+ `PII encryption backfill (${spec.name}): the marker column ${table}.${column} does not exist. ` +
295
+ 'Apply the migration that adds the blind-index columns before the backfill runs.',
296
+ );
297
+ }
298
+ return nullable === 'YES';
299
+ }
300
+
301
+ /** How many rows one backfill query reads: a table is walked in batches, never loaded whole. */
302
+ const BACKFILL_BATCH_ROWS = 500;
303
+
304
+ /**
305
+ * The configured key must open what earlier starts sealed. A rotated or
306
+ * mistyped `SECRETS_ENC_KEY` would otherwise go unnoticed here — sealed rows
307
+ * are exactly the ones the scan skips — and surface only as every login
308
+ * failing and every lookup by email missing, because the blind indexes would
309
+ * be computed under the new key. One sealed value per table is enough: all
310
+ * rows of a deployment are sealed under one key, so any one of them answers
311
+ * for the rest. Refusing the start is the loud failure; re-keying a database
312
+ * is a deliberate operation, not a boot.
313
+ *
314
+ * The sample is a sealed value from WHICHEVER column has one, not from a
315
+ * column chosen to stand for its table. A column may hold nothing in any
316
+ * row (an optional address nobody gave, an error text that never occurred),
317
+ * and a check that looked only there would find no sample, say nothing, and
318
+ * let a wrong key through to a table whose other columns are full of values
319
+ * it cannot open.
320
+ */
321
+ async function assertKeyOpensSealedRows(tx: Executor, keys: PiiKeys, spec: Pick<PiiBackfillSpec, 'tables' | 'keyName'>): Promise<void> {
322
+ for (const t of spec.tables) {
323
+ // ONE pass over the table, which ends at the first row that has a sealed
324
+ // value in ANY of its sealed columns: on a sealed database, the first
325
+ // row. Whatever that row holds sealed is tried. (A value in it that is
326
+ // not sealed opens as itself, so it is no evidence either way.)
327
+ const sealedSomewhere = sql.join(
328
+ t.encrypted.map((col) => sql`${ident(col)} ~ ${PII_SEALED_SHAPE_SQL_REGEX}`),
329
+ sql` OR `,
330
+ );
331
+ const sample = await tx.execute(
332
+ sql`SELECT ${sql.join(t.encrypted.map(asStored), sql`, `)} FROM ${ident(t.table)} WHERE ${sealedSomewhere} LIMIT 1`,
333
+ );
334
+ const row = (sample.rows[0] ?? {}) as Record<string, unknown>;
335
+ for (const col of t.encrypted) {
336
+ const value = storedText(row[col]);
337
+ if (typeof value === 'string' && !keys.open(value).ok) {
338
+ throw new Error(
339
+ `PII encryption: ${t.table}.${col} is sealed with a key the configured ${keyNameOf(spec)} ` +
340
+ 'does not open — refusing to start. ' +
341
+ 'Restore the key that sealed it; changing the key is a re-keying of the database, not a configuration change.',
342
+ );
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ async function backfillTable(
349
+ tx: Executor,
350
+ keys: PiiKeys,
351
+ t: PiiBackfillTable,
352
+ trustShape: boolean,
353
+ keyName: string,
354
+ ): Promise<number> {
355
+ const indexes = indexesOf(t);
356
+ // The key columns are read as text beside themselves, so the next batch can
357
+ // be asked for by value whatever their type is (a uuid, a serial, a path).
358
+ const keyText = (k: string) => `${k}__key`;
359
+ const cols = [
360
+ ...t.key.map(ident),
361
+ ...t.key.map((k) => sql`${ident(k)}::text AS ${ident(keyText(k))}`),
362
+ ...t.encrypted.map(asStored),
363
+ ...indexes.map((index) => ident(index.column)),
364
+ ];
365
+ // Only rows with work left: the ciphertext prefix makes "unsealed" a plain
366
+ // SQL predicate, so a fully-sealed table costs one empty-result query.
367
+ // AN INDEX IS THERE EXACTLY WHERE ITS SOURCE IS. A row is owed work when
368
+ // the two disagree, either way: a source with no index beside it, and an
369
+ // index left beside a source that has since been emptied to NULL (which
370
+ // would go on answering for an address the row no longer holds). A source
371
+ // that is NULL with no index is how an optional address looks, and is not
372
+ // asked for: it would come back on every start for nothing.
373
+ const pending = [
374
+ ...t.encrypted.map((col) => needsSealing(col, trustShape)),
375
+ ...indexes.map((index) => sql`((${ident(index.column)} IS NULL) <> (${ident(index.source)} IS NULL))`),
376
+ ];
377
+ const keyTuple = sql`(${sql.join(t.key.map((k) => sql`${ident(k)}::text`), sql`, `)})`;
378
+ // A blob, as far as this pass may believe one. On a first backfill nothing
379
+ // is sealed yet, so nothing is believed (see `isFirstBackfill`).
380
+ const sealedAlready = (value: string): boolean => trustShape && isEncryptedBlob(value);
381
+ let rewritten = 0;
382
+ /** The key of the last row read, as text: the next batch starts after it. */
383
+ let after: string[] | null = null;
384
+ // IN BATCHES, in key order. A table is never loaded whole: a deployment with
385
+ // years of change requests holds every title and body it ever had, and
386
+ // reading them into one result held the first start after the upgrade — and
387
+ // the memory of the process making it — for as long as that took. Walking by
388
+ // key rather than re-asking for "what is left" also ends: a row this pass
389
+ // leaves as it found it (a write overtook it) is passed, not met again.
390
+ for (;;) {
391
+ const past = after ? sql` AND ${keyTuple} > (${sql.join(after.map((v) => sql`${v}`), sql`, `)})` : sql``;
392
+ const result = await tx.execute(
393
+ sql`SELECT ${sql.join(cols, sql`, `)} FROM ${ident(t.table)} WHERE (${sql.join(pending, sql` OR `)})${past} ORDER BY ${keyTuple} LIMIT ${BACKFILL_BATCH_ROWS}`,
394
+ );
395
+ const batch = result.rows as Array<Record<string, unknown>>;
396
+ for (const read of batch) {
397
+ const row = Object.fromEntries(Object.entries(read).map(([col, value]) => [col, storedText(value)]));
398
+ const sets = [];
399
+ // Compare-and-swap: pin every value this row was read with, so a write
400
+ // that lands between scan and update makes this UPDATE match zero rows
401
+ // instead of clobbering the newer value.
402
+ const where = t.key.map((k) => sql`${ident(k)} = ${row[k]}`);
403
+ for (const col of t.encrypted) {
404
+ const value = row[col];
405
+ if (typeof value === 'string' && value !== '' && !sealedAlready(value)) {
406
+ sets.push(sql`${ident(col)} = ${keys.seal(value)}`);
407
+ where.push(sql`${ident(col)} = ${value}`);
408
+ }
409
+ }
410
+ for (const index of indexes) {
411
+ // The blind index is derived from its source, so it is (re)computed
412
+ // whenever the source is being sealed in this pass — a legacy writer
413
+ // that put a NEW plaintext email on an already-indexed row left a stale
414
+ // index behind, and sealing the email alone would freeze that mismatch
415
+ // — and whenever it was never filled. The source may already be
416
+ // ciphertext (an index that was never filled beside a sealed address);
417
+ // the index is always computed over the plaintext, never over a blob
418
+ // the key cannot open.
419
+ const source = row[index.source];
420
+ const stored = row[index.column];
421
+ // NULL is "no value", and has no index: not the index of the empty
422
+ // string, which every row without an address would then share where
423
+ // a plain unique index let any number of NULLs stand. An index found
424
+ // beside a NULL source is taken away, pinned to what was read like
425
+ // every other write here.
426
+ //
427
+ // The EMPTY STRING is a value, and keeps its index: it is what the
428
+ // handle writes for one (`blindIndexText`), so a row is found by it
429
+ // whether the application wrote it or this did, and two empty
430
+ // strings are equal under a unique index, as they were in clear.
431
+ if (typeof source !== 'string') {
432
+ if (stored != null) {
433
+ sets.push(sql`${ident(index.column)} = NULL`);
434
+ where.push(sql`${ident(index.column)} = ${stored}`);
435
+ where.push(sql`${ident(index.source)} IS NULL`);
436
+ }
437
+ continue;
438
+ }
439
+ const text = source;
440
+ const sealingSource = text !== '' && !sealedAlready(text);
441
+ if (sealingSource || stored == null) {
442
+ const opened = sealedAlready(text) ? keys.open(text) : ({ ok: true, plain: text } as const);
443
+ if (!opened.ok) {
444
+ throw new Error(
445
+ `PII encryption backfill: ${t.table}.${index.source} cannot be decrypted with the ` +
446
+ `configured ${keyName} — refusing to derive a blind index from ciphertext. ` +
447
+ 'Restore the key that sealed it, then restart.',
448
+ );
449
+ }
450
+ sets.push(sql`${ident(index.column)} = ${keys.index(opened.plain)}`);
451
+ // Pin the index AND its source: if a concurrent writer replaces the
452
+ // email between scan and write, the CAS must not attach the OLD
453
+ // email's blind index to the NEW value.
454
+ where.push(sql`${ident(index.column)} IS NOT DISTINCT FROM ${stored ?? null}`);
455
+ where.push(sql`${ident(index.source)} IS NOT DISTINCT FROM ${source ?? null}`);
456
+ }
457
+ }
458
+ if (sets.length === 0) continue;
459
+ const updated = await tx.execute(
460
+ sql`UPDATE ${ident(t.table)} SET ${sql.join(sets, sql`, `)} WHERE ${sql.join(where, sql` AND `)}`,
461
+ );
462
+ rewritten += updated.rowCount ?? 0;
463
+ }
464
+ if (batch.length < BACKFILL_BATCH_ROWS) return rewritten;
465
+ const last = batch[batch.length - 1]!;
466
+ after = t.key.map((k) => String(last[keyText(k)]));
467
+ }
468
+ }
469
+
470
+ /**
471
+ * The name on a refused apply that was recorded BEFORE this release: the
472
+ * address of whoever it names was never kept, so no blind index stands beside
473
+ * it and an account erasure has nothing to find it by.
474
+ *
475
+ * Matching it by display name instead got both mistakes at once: a person
476
+ * who had since changed their name kept the old one on the request for good,
477
+ * and somebody else of the same name had theirs taken away. So the name goes,
478
+ * once, here. The refusal itself stays, with its reason and its time; it
479
+ * reads "could not apply" without saying who. A refusal recorded from this
480
+ * release on carries its index and is never touched by this.
481
+ */
482
+ async function clearUnindexedRefusalNames(tx: Executor): Promise<number> {
483
+ const cleared = await tx.execute(sql.raw(`
484
+ UPDATE "change_requests" SET "apply_failed_by_name" = NULL
485
+ WHERE "apply_failed_by_name" IS NOT NULL AND "apply_failed_by_email_bidx" IS NULL
486
+ `));
487
+ return cleared.rowCount ?? 0;
488
+ }
489
+
490
+ /**
491
+ * Legacy uniqueness was on the RAW email, so rows differing only by case or
492
+ * whitespace could coexist; under the normalized blind index they collide and
493
+ * the unique-index creation below would abort the start. Approval rows and
494
+ * join-request rows are the same logical row — collapse them, keeping the
495
+ * earliest. Colliding USER rows are distinct accounts; refuse loudly with the
496
+ * ids so an operator resolves the conflict deliberately instead of the
497
+ * upgrade guessing.
498
+ */
499
+ async function resolveBidxCollisions(tx: Executor): Promise<void> {
500
+ await tx.execute(sql.raw(`
501
+ DELETE FROM "pr_file_approvals" a USING "pr_file_approvals" b
502
+ WHERE a."pr_number" = b."pr_number" AND a."path" = b."path"
503
+ AND a."approver_email_bidx" = b."approver_email_bidx" AND a."head_sha" = b."head_sha"
504
+ AND (a."approved_at" > b."approved_at" OR (a."approved_at" = b."approved_at" AND a."id" > b."id"))
505
+ `));
506
+ await tx.execute(sql.raw(`
507
+ DELETE FROM "plugin_join_requests" a USING "plugin_join_requests" b
508
+ WHERE a."plugin_key" = b."plugin_key" AND a."requester_email_bidx" = b."requester_email_bidx"
509
+ AND (a."created_at" > b."created_at" OR (a."created_at" = b."created_at" AND a."id" > b."id"))
510
+ `));
511
+ const dupes = await tx.execute(sql.raw(`
512
+ SELECT array_agg("id") AS ids FROM "users" GROUP BY "email_bidx" HAVING count(*) > 1
513
+ `));
514
+ if (dupes.rows.length > 0) {
515
+ const groups = (dupes.rows as Array<{ ids: string[] }>).map((r) => r.ids.join(', '));
516
+ throw new Error(
517
+ 'PII encryption backfill: multiple user rows share the same email after normalization ' +
518
+ '(case/whitespace variants of one address). Merge or delete the duplicates, then restart. ' +
519
+ `Conflicting user ids: [${groups.join('], [')}]`,
520
+ );
521
+ }
522
+ }
523
+
524
+ const PII_FINALIZE_STATEMENTS = [
525
+ 'ALTER TABLE "users" ALTER COLUMN "email_bidx" SET NOT NULL',
526
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "users_email_bidx_unq" ON "users" ("email_bidx")',
527
+ 'ALTER TABLE "users" DROP CONSTRAINT IF EXISTS "users_email_unique"',
528
+ 'ALTER TABLE "pr_file_approvals" ALTER COLUMN "approver_email_bidx" SET NOT NULL',
529
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "pr_file_approvals_bidx_unq" ON "pr_file_approvals" ("pr_number","path","approver_email_bidx","head_sha")',
530
+ 'DROP INDEX IF EXISTS "pr_file_approvals_unq"',
531
+ 'ALTER TABLE "pr_merge_log" ALTER COLUMN "triggered_by_email_bidx" SET NOT NULL',
532
+ 'ALTER TABLE "pr_comments" ALTER COLUMN "author_email_bidx" SET NOT NULL',
533
+ 'ALTER TABLE "change_requests" ALTER COLUMN "author_email_bidx" SET NOT NULL',
534
+ 'ALTER TABLE "pending_commits" ALTER COLUMN "author_email_bidx" SET NOT NULL',
535
+ 'ALTER TABLE "plugin_join_requests" ALTER COLUMN "requester_email_bidx" SET NOT NULL',
536
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "plugin_join_requests_requester_bidx_plugin_unq" ON "plugin_join_requests" ("requester_email_bidx","plugin_key")',
537
+ 'DROP INDEX IF EXISTS "plugin_join_requests_requester_plugin_unq"',
538
+ ];
539
+
540
+ /** Core's own personal data: the tables above, and what migration 0016 deferred. */
541
+ const CORE_PII_BACKFILL: PiiBackfillSpec = {
542
+ name: 'core',
543
+ tables: PII_BACKFILL_TABLES,
544
+ marker: { table: 'users', column: 'email_bidx' },
545
+ afterSealing: async (tx, { first }) => {
546
+ const cleared = await clearUnindexedRefusalNames(tx);
547
+ if (cleared > 0) {
548
+ log.info(`PII encryption backfill: took the name off ${cleared} refused apply(ies) recorded before this release.`);
549
+ }
550
+ // Only where the unique indexes are not up yet. Once they are, they are
551
+ // what keeps two rows from sharing an index, so there is nothing to
552
+ // collapse — and the collapse is two self-joins and an aggregate over
553
+ // whole tables, which every later start paid for nothing.
554
+ if (first) await resolveBidxCollisions(tx);
555
+ },
556
+ finalize: PII_FINALIZE_STATEMENTS,
557
+ };
558
+
559
+ /**
560
+ * Encrypt pre-existing plaintext PII rows, fill the blind-index columns, and
561
+ * apply the constraints migration 0016 deferred. Idempotent; `runCoreMigrations`
562
+ * runs it under the migrations lock right after the history. The handle must
563
+ * hold the knowledge base's key (`createDb(url, { piiKey })`; the composition
564
+ * root's does): one that holds none is refused before anything is read.
565
+ */
566
+ export async function runPiiEncryptionBackfill(db: Database): Promise<void> {
567
+ await runPiiBackfill(db, CORE_PII_BACKFILL);
568
+ }
569
+
570
+ /**
571
+ * Seal the rows `spec` names that were written before their columns were
572
+ * sealed, fill their blind indexes, and apply `spec.finalize` — the DATA half
573
+ * of a migration that adds `encryptedText` / `blindIndexText` columns, which
574
+ * SQL cannot do because it holds no key. ONE implementation for every schema
575
+ * on a handle: core's own tables ({@link runPiiEncryptionBackfill}) and an
576
+ * overlay's, so the rules above (one transaction, a first run that trusts no
577
+ * shape, a key that must open what is sealed, columns read as stored, writes
578
+ * that pin what they read) are not rewritten per schema and cannot drift.
579
+ *
580
+ * Idempotent, and to be run on every start under the lock that guards the
581
+ * schema's history: {@link runEnterpriseMigrations} does that for an overlay
582
+ * that passes its spec. The handle must hold the key the rows are sealed
583
+ * with; one that holds none is refused before anything is read.
584
+ */
585
+ export async function runPiiBackfill(db: Database, spec: PiiBackfillSpec): Promise<void> {
586
+ const keys = piiKeysOf(db);
587
+ assertSpecIsWhole(spec);
588
+ const label = spec.name === CORE_PII_BACKFILL.name ? 'PII encryption backfill' : `PII encryption backfill (${spec.name})`;
589
+ await db.transaction(async (tx) => {
590
+ const first = await isFirstBackfill(tx, spec);
591
+ // Nothing is sealed before the first backfill, so there is no sealed row
592
+ // for the key to be checked against — and a value that only LOOKS sealed
593
+ // must not be taken for one (see `isFirstBackfill`).
594
+ if (!first) await assertKeyOpensSealedRows(tx, keys, spec);
595
+ let rewritten = 0;
596
+ for (const t of spec.tables) {
597
+ rewritten += await backfillTable(tx, keys, t, !first, keyNameOf(spec));
598
+ }
599
+ if (rewritten > 0) log.info(`${label}: rewrote ${rewritten} row(s).`);
600
+ await spec.afterSealing?.(tx, { first });
601
+ for (const statement of spec.finalize) {
602
+ await tx.execute(sql.raw(statement));
603
+ }
604
+ // The marker is what the NEXT start reads. Left nullable, that start
605
+ // would be a "first" one again: it would believe no shape, take every
606
+ // sealed value for plaintext and seal it a second time, and nothing
607
+ // would open afterwards. So a spec that does not close its own marker
608
+ // does not commit.
609
+ if (await isFirstBackfill(tx, spec)) {
610
+ throw new Error(
611
+ `${label}: finalize left the marker column ${spec.marker.table}.${spec.marker.column} nullable. ` +
612
+ 'It must set it NOT NULL, or the next start would seal the sealed rows again. Nothing was changed.',
613
+ );
614
+ }
615
+ });
616
+ }
617
+
618
+ /** A spec is refused for what would otherwise fail halfway: a marker that is no index of its tables, an index of a column that is not sealed. */
619
+ function assertSpecIsWhole(spec: PiiBackfillSpec): void {
620
+ let markerIsAnIndex = false;
621
+ for (const t of spec.tables) {
622
+ for (const index of indexesOf(t)) {
623
+ if (!t.encrypted.includes(index.source)) {
624
+ throw new Error(
625
+ `PII encryption backfill (${spec.name}): ${t.table}.${index.column} indexes "${index.source}", which is not one of the table's encrypted columns.`,
626
+ );
627
+ }
628
+ if (t.table === spec.marker.table && index.column === spec.marker.column) markerIsAnIndex = true;
629
+ }
630
+ }
631
+ if (!markerIsAnIndex) {
632
+ throw new Error(
633
+ `PII encryption backfill (${spec.name}): the marker ${spec.marker.table}.${spec.marker.column} is not a blind-index column of the spec's tables.`,
634
+ );
635
+ }
636
+ }
@@ -409,6 +409,99 @@ describe('LockingFilesystem — the caller judges under the lock', () => {
409
409
  expect(workflow.releaseLock).not.toHaveBeenCalled();
410
410
  });
411
411
 
412
+ it('rewriteFile reads, computes and writes with the lock HELD, from the bytes on disk at that moment', async () => {
413
+ await fs.mkdir(path.join(root, 'knowledge-base'), { recursive: true });
414
+ await fs.writeFile(path.join(root, 'knowledge-base/Foo.md'), 'owner: \n');
415
+
416
+ const workflow = makeWorkflow();
417
+ const order: string[] = [];
418
+ (workflow.acquireLock as ReturnType<typeof vi.fn>).mockImplementation(async () => {
419
+ order.push('acquire');
420
+ // Another writer lands while this call waits for the lock.
421
+ await fs.writeFile(path.join(root, 'knowledge-base/Foo.md'), 'owner: alice\n');
422
+ return { acquired: true, lock: { holderName: 'Alice' } };
423
+ });
424
+ (workflow.releaseLock as ReturnType<typeof vi.fn>).mockImplementation(async () => {
425
+ order.push('release');
426
+ return null;
427
+ });
428
+
429
+ let seen = '';
430
+ await layer(workflow).rewriteFile('knowledge-base/Foo.md', (current) => {
431
+ order.push('rewrite');
432
+ seen = current!.toString('utf8');
433
+ return `${seen}note: kept\n`;
434
+ });
435
+
436
+ expect(order).toEqual(['acquire', 'rewrite', 'release']);
437
+ // It was handed what the other writer left, not what was there when the call began.
438
+ expect(seen).toBe('owner: alice\n');
439
+ expect(await fs.readFile(path.join(root, 'knowledge-base/Foo.md'), 'utf-8')).toBe('owner: alice\nnote: kept\n');
440
+ expect(workflow.releaseLock).toHaveBeenCalledWith('ws-feat', 'feat', 'knowledge-base/Foo.md', USER);
441
+ });
442
+
443
+ it('a rewriteFile that throws writes nothing, releases UNTOUCHED, and gives the caller its own error', async () => {
444
+ await fs.mkdir(path.join(root, 'knowledge-base'), { recursive: true });
445
+ await fs.writeFile(path.join(root, 'knowledge-base/Foo.md'), 'theirs\n');
446
+
447
+ const workflow = makeWorkflow();
448
+ const refusal = new Error('old text is gone');
449
+ await expect(
450
+ layer(workflow).rewriteFile('knowledge-base/Foo.md', () => {
451
+ throw refusal;
452
+ }),
453
+ ).rejects.toBe(refusal);
454
+
455
+ expect(await fs.readFile(path.join(root, 'knowledge-base/Foo.md'), 'utf-8')).toBe('theirs\n');
456
+ expect(workflow.releaseLockUntouched).toHaveBeenCalledWith('ws-feat', 'feat', 'knowledge-base/Foo.md', USER);
457
+ expect(workflow.releaseLockNoCommit).not.toHaveBeenCalled();
458
+ expect(workflow.releaseLock).not.toHaveBeenCalled();
459
+ });
460
+
461
+ it('rewriteFile hands over null when nothing is at the path', async () => {
462
+ await fs.mkdir(path.join(root, 'knowledge-base'), { recursive: true });
463
+ const workflow = makeWorkflow();
464
+ let seen: Buffer | null | undefined;
465
+ await layer(workflow).rewriteFile('knowledge-base/New.md', (current) => {
466
+ seen = current;
467
+ return 'made\n';
468
+ });
469
+ expect(seen).toBeNull();
470
+ expect(await fs.readFile(path.join(root, 'knowledge-base/New.md'), 'utf-8')).toBe('made\n');
471
+ });
472
+
473
+ it('rewriteFile judges the bytes it is about to write with the pre-disk validator, under the lock', async () => {
474
+ await fs.mkdir(path.join(root, 'knowledge-base'), { recursive: true });
475
+ await fs.writeFile(path.join(root, 'knowledge-base/Foo.md'), 'ok\n');
476
+ const workflow = makeWorkflow();
477
+ const refusal = new Error('refused by the validator');
478
+ const judged: string[] = [];
479
+ const guarded = new LockingFilesystem(
480
+ { basePath: root, contained: true },
481
+ {
482
+ workflow,
483
+ workspaceId: 'ws-feat',
484
+ branch: 'feat',
485
+ user: USER,
486
+ kbDirName: KB,
487
+ validateWrite: async (_p, content) => {
488
+ judged.push(String(content));
489
+ throw refusal;
490
+ },
491
+ },
492
+ );
493
+ await expect(guarded.rewriteFile('knowledge-base/Foo.md', () => 'bad\n')).rejects.toBe(refusal);
494
+ expect(judged).toEqual(['bad\n']);
495
+ expect(await fs.readFile(path.join(root, 'knowledge-base/Foo.md'), 'utf-8')).toBe('ok\n');
496
+ expect(workflow.releaseLockUntouched).toHaveBeenCalled();
497
+ });
498
+
499
+ it('rewriteFile refuses a path outside the repository before any lock', async () => {
500
+ const workflow = makeWorkflow();
501
+ await expect(layer(workflow).rewriteFile('elsewhere/Foo.md', () => 'x')).rejects.toThrow();
502
+ expect(workflow.acquireLock).not.toHaveBeenCalled();
503
+ });
504
+
412
505
  it('writeFiles runs its check once EVERY lock is held, and lands only what the check keeps', async () => {
413
506
  const workflow = makeWorkflow();
414
507
  const acquired: string[] = [];