@bevel-software/platform-core-backend 0.23.0 → 0.24.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 (227) 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 +10 -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 +8 -0
  64. package/dist/modules/database/migrate.d.ts.map +1 -1
  65. package/dist/modules/database/migrate.js +271 -1
  66. package/dist/modules/database/migrate.js.map +1 -1
  67. package/dist/modules/mcp/mcp.service.d.ts +8 -0
  68. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  69. package/dist/modules/mcp/mcp.service.js +38 -8
  70. package/dist/modules/mcp/mcp.service.js.map +1 -1
  71. package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
  72. package/dist/modules/plugins/join-request-records.store.js +7 -4
  73. package/dist/modules/plugins/join-request-records.store.js.map +1 -1
  74. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  75. package/dist/modules/tool-auth/external-api-key.service.js +8 -2
  76. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  77. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  78. package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
  79. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  80. package/dist/modules/tool-registry/description-length.d.ts +80 -0
  81. package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
  82. package/dist/modules/tool-registry/description-length.js +108 -0
  83. package/dist/modules/tool-registry/description-length.js.map +1 -0
  84. package/dist/modules/workflow/git/git.service.d.ts +25 -0
  85. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  86. package/dist/modules/workflow/git/git.service.js +40 -1
  87. package/dist/modules/workflow/git/git.service.js.map +1 -1
  88. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  89. package/dist/modules/workflow/pending-commits.service.js +5 -1
  90. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  91. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
  92. package/dist/modules/workflow/recovery-bot.js +7 -3
  93. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  94. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  95. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  96. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  97. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  98. package/dist/modules/workflow/workflow.service.js +4 -1
  99. package/dist/modules/workflow/workflow.service.js.map +1 -1
  100. package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
  101. package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
  102. package/dist/modules/workspace/agent-upload.routes.js +210 -0
  103. package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
  104. package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
  105. package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
  106. package/dist/modules/workspace/agent-upload.store.js +553 -0
  107. package/dist/modules/workspace/agent-upload.store.js.map +1 -0
  108. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  109. package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
  110. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  111. package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
  112. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  113. package/dist/modules/workspace/startup/steps/template-source.js +46 -4
  114. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  115. package/dist/modules/workspace/upload-limits.d.ts +13 -0
  116. package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
  117. package/dist/modules/workspace/upload-limits.js +13 -0
  118. package/dist/modules/workspace/upload-limits.js.map +1 -0
  119. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  120. package/dist/modules/workspace/workspace.routes.js +1 -1
  121. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  122. package/dist/modules/workspace/workspace.service.d.ts +13 -0
  123. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  124. package/dist/modules/workspace/workspace.service.js +61 -33
  125. package/dist/modules/workspace/workspace.service.js.map +1 -1
  126. package/dist/modules/workspace/workspace.tools.d.ts +11 -9
  127. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  128. package/dist/modules/workspace/workspace.tools.js +512 -113
  129. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  130. package/dist/modules/workspace/write-denial.d.ts +0 -6
  131. package/dist/modules/workspace/write-denial.d.ts.map +1 -1
  132. package/dist/modules/workspace/write-denial.js +0 -6
  133. package/dist/modules/workspace/write-denial.js.map +1 -1
  134. package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
  135. package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
  136. package/dist/modules/workspace/zip-entry-rules.js +154 -0
  137. package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
  138. package/dist/shared/column-crypto.d.ts +194 -0
  139. package/dist/shared/column-crypto.d.ts.map +1 -0
  140. package/dist/shared/column-crypto.js +144 -0
  141. package/dist/shared/column-crypto.js.map +1 -0
  142. package/dist/shared/token-crypto.d.ts.map +1 -1
  143. package/dist/shared/token-crypto.js +25 -1
  144. package/dist/shared/token-crypto.js.map +1 -1
  145. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  146. package/dist/tenancy/static-tenant-source.js +1 -0
  147. package/dist/tenancy/static-tenant-source.js.map +1 -1
  148. package/dist/tenancy/tenant-secrets.d.ts +5 -1
  149. package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
  150. package/dist/tenancy/tenant-secrets.js +4 -0
  151. package/dist/tenancy/tenant-secrets.js.map +1 -1
  152. package/kb-template/AGENTS.md +2 -0
  153. package/migrations/0016_pii_encryption.sql +20 -0
  154. package/migrations/meta/0016_snapshot.json +2327 -0
  155. package/migrations/meta/_journal.json +7 -0
  156. package/package.json +3 -3
  157. package/src/core/__tests__/lifecycle.test.ts +71 -4
  158. package/src/core/create-core-server.ts +21 -3
  159. package/src/core/create-core-services.ts +27 -1
  160. package/src/core/lifecycle.ts +19 -0
  161. package/src/core-config.ts +18 -0
  162. package/src/index.ts +18 -0
  163. package/src/modules/access/__tests__/users-db-double.ts +21 -12
  164. package/src/modules/access/access.routes.ts +4 -1
  165. package/src/modules/access/directory-sync-bot.ts +7 -3
  166. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +8 -4
  167. package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
  168. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
  169. package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
  170. package/src/modules/agent-instructions/compose.ts +50 -7
  171. package/src/modules/agent-instructions/index.ts +12 -0
  172. package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
  173. package/src/modules/audit/agent-audit.service.ts +4 -2
  174. package/src/modules/auth/__tests__/account-deactivation.test.ts +2 -1
  175. package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
  176. package/src/modules/auth/__tests__/account.routes.test.ts +6 -3
  177. package/src/modules/auth/__tests__/auth.service.test.ts +3 -1
  178. package/src/modules/auth/account-erasure.service.ts +69 -18
  179. package/src/modules/auth/auth.service.ts +33 -23
  180. package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
  181. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
  182. package/src/modules/code-mode/code-mode.tool.ts +80 -34
  183. package/src/modules/database/__tests__/connection.test.ts +12 -0
  184. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +561 -0
  185. package/src/modules/database/connection.ts +117 -0
  186. package/src/modules/database/core-schema.ts +81 -31
  187. package/src/modules/database/migrate.ts +353 -1
  188. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
  189. package/src/modules/mcp/__tests__/mcp.service.test.ts +47 -7
  190. package/src/modules/mcp/mcp.service.ts +46 -7
  191. package/src/modules/plugins/join-request-records.store.ts +7 -4
  192. package/src/modules/tool-auth/external-api-key.service.ts +8 -2
  193. package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
  194. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
  195. package/src/modules/tool-registry/description-length.ts +111 -0
  196. package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
  197. package/src/modules/workflow/git/git.service.ts +47 -1
  198. package/src/modules/workflow/pending-commits.service.ts +5 -1
  199. package/src/modules/workflow/recovery-bot.ts +7 -3
  200. package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
  201. package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
  202. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  203. package/src/modules/workflow/workflow.service.ts +4 -1
  204. package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
  205. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +24 -14
  206. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
  207. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +77 -1
  208. package/src/modules/workspace/__tests__/workspace.service.test.ts +57 -0
  209. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +39 -36
  210. package/src/modules/workspace/__tests__/workspace.tools.test.ts +84 -45
  211. package/src/modules/workspace/agent-upload.routes.ts +214 -0
  212. package/src/modules/workspace/agent-upload.store.ts +668 -0
  213. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
  214. package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
  215. package/src/modules/workspace/startup/steps/template-source.ts +53 -5
  216. package/src/modules/workspace/upload-limits.ts +12 -0
  217. package/src/modules/workspace/workspace.routes.ts +1 -2
  218. package/src/modules/workspace/workspace.service.ts +63 -37
  219. package/src/modules/workspace/workspace.tools.ts +588 -127
  220. package/src/modules/workspace/write-denial.ts +0 -8
  221. package/src/modules/workspace/zip-entry-rules.ts +173 -0
  222. package/src/shared/__tests__/column-crypto.test.ts +217 -0
  223. package/src/shared/column-crypto.ts +218 -0
  224. package/src/shared/token-crypto.ts +28 -1
  225. package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -0
  226. package/src/tenancy/static-tenant-source.ts +1 -0
  227. 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
  );
@@ -96,3 +101,350 @@ export async function runEnterpriseMigrations(db: Database, folder: string): Pro
96
101
  { tenantKey },
97
102
  );
98
103
  }
104
+
105
+ /*
106
+ * ── PII column encryption backfill ──────────────────────────────────────────
107
+ *
108
+ * Migration 0016 adds the `*_bidx` columns; the DATA change — rewriting
109
+ * pre-existing plaintext PII to AES-256-GCM ciphertext and filling the blind
110
+ * indexes — happens here, programmatically, because it needs the key the
111
+ * handle holds (SQL migrations cannot encrypt). Runs on every start right after
112
+ * the core history, under the migrations lock, and is idempotent: sealed rows
113
+ * carry the `PII_CIPHERTEXT_PREFIX` marker and are excluded by the SELECT's
114
+ * own WHERE clause, so a completed backfill degenerates to one cheap,
115
+ * empty-result query per table.
116
+ *
117
+ * Concurrency: the migrations lock (keyed per tenant, like the history it
118
+ * guards) serialises two processes booting at once. Each UPDATE is also
119
+ * compare-and-swap — the WHERE clause pins every value the row was read with
120
+ * — so a writer that slips in between the scan and the write (an old-version
121
+ * instance in a mixed-version window) loses nothing: the CAS update matches
122
+ * zero rows and the next start's pass seals whatever that writer left
123
+ * behind. Deployments should still stop the old app before starting the new
124
+ * one (see UPGRADING.md); the lock and CAS make the failure mode of not doing
125
+ * so "unsealed until next start", never "silently overwritten".
126
+ *
127
+ * Once every row is sealed, the same transaction applies the constraints that
128
+ * could not ship in the SQL migration: SET NOT NULL on the bidx columns and
129
+ * the unique-index swaps (`users_email_unique` on the now-randomized
130
+ * ciphertext is meaningless, `users_email_bidx_unq` takes over; the same for
131
+ * the approvals and the join requests). The new unique index goes up BEFORE
132
+ * the old one is dropped so duplicate protection never has a gap. Legacy rows
133
+ * that collide under the normalized blind index are handled first: duplicate
134
+ * approval and join-request rows (the same logical row, a case-variant email)
135
+ * are collapsed; duplicate USERS are a genuine account conflict and abort the
136
+ * start with the row ids so an operator can merge them deliberately. All
137
+ * statements are IF-EXISTS-guarded — re-running is a no-op.
138
+ *
139
+ * Runs against whatever schema the handle searches first: a tenant's own, or
140
+ * `public`, under that handle's key. Every table name is unqualified for that
141
+ * reason.
142
+ *
143
+ * This is the one place that works on the STORED form. The handle's
144
+ * connection opens every sealed value of a result, which here would hide
145
+ * exactly what has to be told apart — so the columns are read as bytes
146
+ * ({@link asStored}), which it leaves alone, and written as ready-made
147
+ * ciphertext, which it passes through.
148
+ */
149
+
150
+ interface PiiBackfillTable {
151
+ table: string;
152
+ /** Columns that uniquely identify a row for the write-back UPDATE. */
153
+ key: string[];
154
+ /** Columns whose plaintext values get rewritten as ciphertext. */
155
+ encrypted: string[];
156
+ /** Blind-index column to fill from the plaintext of `source`. */
157
+ bidx?: { source: string; column: string };
158
+ }
159
+
160
+ const PII_BACKFILL_TABLES: PiiBackfillTable[] = [
161
+ { table: 'users', key: ['id'], encrypted: ['email', 'name', 'avatar_url'], bidx: { source: 'email', column: 'email_bidx' } },
162
+ { table: 'pr_file_approvals', key: ['id'], encrypted: ['approver_email', 'approver_name'], bidx: { source: 'approver_email', column: 'approver_email_bidx' } },
163
+ { table: 'pr_merge_log', key: ['id'], encrypted: ['triggered_by_email', 'triggered_by_name', 'error'], bidx: { source: 'triggered_by_email', column: 'triggered_by_email_bidx' } },
164
+ { table: 'pr_comments', key: ['id'], encrypted: ['author_email', 'author_name', 'body'], bidx: { source: 'author_email', column: 'author_email_bidx' } },
165
+ { 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' } },
166
+ { table: 'pending_commits', key: ['id'], encrypted: ['author_email', 'author_name', 'last_error'], bidx: { source: 'author_email', column: 'author_email_bidx' } },
167
+ { table: 'file_locks', key: ['workspace_id', 'branch', 'path'], encrypted: ['holder_name'] },
168
+ { table: 'plugin_join_requests', key: ['id'], encrypted: ['requester_email', 'requester_name', 'failure_reason'], bidx: { source: 'requester_email', column: 'requester_email_bidx' } },
169
+ ];
170
+
171
+ const ident = (name: string) => sql.raw(`"${name}"`);
172
+
173
+ /** What the backfill needs from a drizzle client — the db or a transaction. */
174
+ type Executor = Pick<Database, 'execute'>;
175
+
176
+ /** A text column as it is stored: bytes, so the handle's connection does not open it. */
177
+ const asStored = (col: string) => sql`convert_to(${ident(col)}, 'UTF8') AS ${ident(col)}`;
178
+
179
+ /** What {@link asStored} selected, back as text; anything else as it came. */
180
+ const storedText = (value: unknown): unknown => (Buffer.isBuffer(value) ? value.toString('utf8') : value);
181
+
182
+ /**
183
+ * A column "needs sealing" when it holds non-empty text that isn't a blob.
184
+ * The shape regex is deliberately used as a NEGATIVE filter: anything failing
185
+ * it — including plaintext that merely BEGINS with the prefix — is selected
186
+ * and sealed, and `isEncryptedBlob` is the same pattern, so the two can never
187
+ * disagree about a value. Sealed rows all match, keeping the steady-state scan
188
+ * an empty-result query.
189
+ *
190
+ * `trustShape` is false on the FIRST backfill of a database (see
191
+ * {@link isFirstBackfill}): nothing has been sealed yet, so a value in this
192
+ * shape is plaintext somebody typed, and every non-empty value is selected.
193
+ */
194
+ function needsSealing(col: string, trustShape: boolean) {
195
+ if (!trustShape) return sql`(${ident(col)} IS NOT NULL AND ${ident(col)} <> '')`;
196
+ return sql`(${ident(col)} IS NOT NULL AND ${ident(col)} <> '' AND ${ident(col)} !~ ${PII_SEALED_SHAPE_SQL_REGEX})`;
197
+ }
198
+
199
+ /**
200
+ * Whether this database has never been through the backfill: the blind-index
201
+ * column of `users` is still nullable. The backfill, the duplicate collapse
202
+ * and the constraints run in ONE transaction, so a nullable column means no
203
+ * backfill has ever committed here — and therefore that nothing in it is
204
+ * sealed, whatever it looks like.
205
+ *
206
+ * That is what lets a first backfill trust no shape at all. A blob is
207
+ * recognised by its shape, and a title or a display name is text a person
208
+ * chose: one typed in that shape before the upgrade would be read as sealed,
209
+ * skipped, and left in clear for good — and, sampled by
210
+ * {@link assertKeyOpensSealedRows}, would refuse the start for a key that is
211
+ * perfectly right. Once the first backfill has committed, every write goes
212
+ * through a handle that seals it, so no such value can arrive again.
213
+ */
214
+ async function isFirstBackfill(tx: Executor): Promise<boolean> {
215
+ const result = await tx.execute(sql`
216
+ SELECT is_nullable FROM information_schema.columns
217
+ WHERE table_schema = current_schema() AND table_name = 'users' AND column_name = 'email_bidx'
218
+ `);
219
+ return (result.rows[0] as { is_nullable?: string } | undefined)?.is_nullable === 'YES';
220
+ }
221
+
222
+ /** How many rows one backfill query reads: a table is walked in batches, never loaded whole. */
223
+ const BACKFILL_BATCH_ROWS = 500;
224
+
225
+ /**
226
+ * The configured key must open what earlier starts sealed. A rotated or
227
+ * mistyped `SECRETS_ENC_KEY` would otherwise go unnoticed here — sealed rows
228
+ * are exactly the ones the scan skips — and surface only as every login
229
+ * failing and every lookup by email missing, because the blind indexes would
230
+ * be computed under the new key. One sealed value per table is enough: all
231
+ * rows of a deployment are sealed under one key. Refusing the start is the
232
+ * loud failure; re-keying a database is a deliberate operation, not a boot.
233
+ */
234
+ async function assertKeyOpensSealedRows(tx: Executor, keys: PiiKeys): Promise<void> {
235
+ for (const t of PII_BACKFILL_TABLES) {
236
+ const col = t.bidx?.source ?? t.encrypted[0]!;
237
+ const sample = await tx.execute(
238
+ sql`SELECT ${asStored(col)} FROM ${ident(t.table)} WHERE ${ident(col)} ~ ${PII_SEALED_SHAPE_SQL_REGEX} LIMIT 1`,
239
+ );
240
+ const value = storedText((sample.rows[0] as Record<string, unknown> | undefined)?.[col]);
241
+ if (typeof value === 'string' && !keys.open(value).ok) {
242
+ throw new Error(
243
+ `PII encryption: ${t.table}.${col} is sealed with a key the configured SECRETS_ENC_KEY ` +
244
+ '(for a tenant: the one derived from TENANT_MASTER_KEY) does not open — refusing to start. ' +
245
+ 'Restore the key that sealed it; changing the key is a re-keying of the database, not a configuration change.',
246
+ );
247
+ }
248
+ }
249
+ }
250
+
251
+ async function backfillTable(
252
+ tx: Executor,
253
+ keys: PiiKeys,
254
+ t: PiiBackfillTable,
255
+ trustShape: boolean,
256
+ ): Promise<number> {
257
+ // The key columns are read as text beside themselves, so the next batch can
258
+ // be asked for by value whatever their type is (a uuid, a serial, a path).
259
+ const keyText = (k: string) => `${k}__key`;
260
+ const cols = [
261
+ ...t.key.map(ident),
262
+ ...t.key.map((k) => sql`${ident(k)}::text AS ${ident(keyText(k))}`),
263
+ ...t.encrypted.map(asStored),
264
+ ...(t.bidx ? [ident(t.bidx.column)] : []),
265
+ ];
266
+ // Only rows with work left: the ciphertext prefix makes "unsealed" a plain
267
+ // SQL predicate, so a fully-sealed table costs one empty-result query.
268
+ const pending = [
269
+ ...t.encrypted.map((col) => needsSealing(col, trustShape)),
270
+ ...(t.bidx ? [sql`${ident(t.bidx.column)} IS NULL`] : []),
271
+ ];
272
+ const keyTuple = sql`(${sql.join(t.key.map((k) => sql`${ident(k)}::text`), sql`, `)})`;
273
+ // A blob, as far as this pass may believe one. On a first backfill nothing
274
+ // is sealed yet, so nothing is believed (see `isFirstBackfill`).
275
+ const sealedAlready = (value: string): boolean => trustShape && isEncryptedBlob(value);
276
+ let rewritten = 0;
277
+ /** The key of the last row read, as text: the next batch starts after it. */
278
+ let after: string[] | null = null;
279
+ // IN BATCHES, in key order. A table is never loaded whole: a deployment with
280
+ // years of change requests holds every title and body it ever had, and
281
+ // reading them into one result held the first start after the upgrade — and
282
+ // the memory of the process making it — for as long as that took. Walking by
283
+ // key rather than re-asking for "what is left" also ends: a row this pass
284
+ // leaves as it found it (a write overtook it) is passed, not met again.
285
+ for (;;) {
286
+ const past = after ? sql` AND ${keyTuple} > (${sql.join(after.map((v) => sql`${v}`), sql`, `)})` : sql``;
287
+ const result = await tx.execute(
288
+ sql`SELECT ${sql.join(cols, sql`, `)} FROM ${ident(t.table)} WHERE (${sql.join(pending, sql` OR `)})${past} ORDER BY ${keyTuple} LIMIT ${BACKFILL_BATCH_ROWS}`,
289
+ );
290
+ const batch = result.rows as Array<Record<string, unknown>>;
291
+ for (const read of batch) {
292
+ const row = Object.fromEntries(Object.entries(read).map(([col, value]) => [col, storedText(value)]));
293
+ const sets = [];
294
+ // Compare-and-swap: pin every value this row was read with, so a write
295
+ // that lands between scan and update makes this UPDATE match zero rows
296
+ // instead of clobbering the newer value.
297
+ const where = t.key.map((k) => sql`${ident(k)} = ${row[k]}`);
298
+ for (const col of t.encrypted) {
299
+ const value = row[col];
300
+ if (typeof value === 'string' && value !== '' && !sealedAlready(value)) {
301
+ sets.push(sql`${ident(col)} = ${keys.seal(value)}`);
302
+ where.push(sql`${ident(col)} = ${value}`);
303
+ }
304
+ }
305
+ if (t.bidx) {
306
+ // The blind index is derived from its source, so it is (re)computed
307
+ // whenever the source is being sealed in this pass — a legacy writer
308
+ // that put a NEW plaintext email on an already-indexed row left a stale
309
+ // index behind, and sealing the email alone would freeze that mismatch
310
+ // — and whenever it was never filled. The source may already be
311
+ // ciphertext (an index that was never filled beside a sealed address);
312
+ // the index is always computed over the plaintext, never over a blob
313
+ // the key cannot open.
314
+ const source = row[t.bidx.source];
315
+ const stored = row[t.bidx.column];
316
+ const text = typeof source === 'string' ? source : '';
317
+ const sealingSource = text !== '' && !sealedAlready(text);
318
+ if (sealingSource || stored == null) {
319
+ const opened = sealedAlready(text) ? keys.open(text) : ({ ok: true, plain: text } as const);
320
+ if (!opened.ok) {
321
+ throw new Error(
322
+ `PII encryption backfill: ${t.table}.${t.bidx.source} cannot be decrypted with the ` +
323
+ 'configured SECRETS_ENC_KEY — refusing to derive a blind index from ciphertext. ' +
324
+ 'Restore the key that sealed it, then restart.',
325
+ );
326
+ }
327
+ sets.push(sql`${ident(t.bidx.column)} = ${keys.index(opened.plain)}`);
328
+ // Pin the index AND its source: if a concurrent writer replaces the
329
+ // email between scan and write, the CAS must not attach the OLD
330
+ // email's blind index to the NEW value.
331
+ where.push(sql`${ident(t.bidx.column)} IS NOT DISTINCT FROM ${stored ?? null}`);
332
+ where.push(sql`${ident(t.bidx.source)} IS NOT DISTINCT FROM ${source ?? null}`);
333
+ }
334
+ }
335
+ if (sets.length === 0) continue;
336
+ const updated = await tx.execute(
337
+ sql`UPDATE ${ident(t.table)} SET ${sql.join(sets, sql`, `)} WHERE ${sql.join(where, sql` AND `)}`,
338
+ );
339
+ rewritten += updated.rowCount ?? 0;
340
+ }
341
+ if (batch.length < BACKFILL_BATCH_ROWS) return rewritten;
342
+ const last = batch[batch.length - 1]!;
343
+ after = t.key.map((k) => String(last[keyText(k)]));
344
+ }
345
+ }
346
+
347
+ /**
348
+ * The name on a refused apply that was recorded BEFORE this release: the
349
+ * address of whoever it names was never kept, so no blind index stands beside
350
+ * it and an account erasure has nothing to find it by.
351
+ *
352
+ * Matching it by display name instead got both mistakes at once: a person
353
+ * who had since changed their name kept the old one on the request for good,
354
+ * and somebody else of the same name had theirs taken away. So the name goes,
355
+ * once, here. The refusal itself stays, with its reason and its time; it
356
+ * reads "could not apply" without saying who. A refusal recorded from this
357
+ * release on carries its index and is never touched by this.
358
+ */
359
+ async function clearUnindexedRefusalNames(tx: Executor): Promise<number> {
360
+ const cleared = await tx.execute(sql.raw(`
361
+ UPDATE "change_requests" SET "apply_failed_by_name" = NULL
362
+ WHERE "apply_failed_by_name" IS NOT NULL AND "apply_failed_by_email_bidx" IS NULL
363
+ `));
364
+ return cleared.rowCount ?? 0;
365
+ }
366
+
367
+ /**
368
+ * Legacy uniqueness was on the RAW email, so rows differing only by case or
369
+ * whitespace could coexist; under the normalized blind index they collide and
370
+ * the unique-index creation below would abort the start. Approval rows and
371
+ * join-request rows are the same logical row — collapse them, keeping the
372
+ * earliest. Colliding USER rows are distinct accounts; refuse loudly with the
373
+ * ids so an operator resolves the conflict deliberately instead of the
374
+ * upgrade guessing.
375
+ */
376
+ async function resolveBidxCollisions(tx: Executor): Promise<void> {
377
+ await tx.execute(sql.raw(`
378
+ DELETE FROM "pr_file_approvals" a USING "pr_file_approvals" b
379
+ WHERE a."pr_number" = b."pr_number" AND a."path" = b."path"
380
+ AND a."approver_email_bidx" = b."approver_email_bidx" AND a."head_sha" = b."head_sha"
381
+ AND (a."approved_at" > b."approved_at" OR (a."approved_at" = b."approved_at" AND a."id" > b."id"))
382
+ `));
383
+ await tx.execute(sql.raw(`
384
+ DELETE FROM "plugin_join_requests" a USING "plugin_join_requests" b
385
+ WHERE a."plugin_key" = b."plugin_key" AND a."requester_email_bidx" = b."requester_email_bidx"
386
+ AND (a."created_at" > b."created_at" OR (a."created_at" = b."created_at" AND a."id" > b."id"))
387
+ `));
388
+ const dupes = await tx.execute(sql.raw(`
389
+ SELECT array_agg("id") AS ids FROM "users" GROUP BY "email_bidx" HAVING count(*) > 1
390
+ `));
391
+ if (dupes.rows.length > 0) {
392
+ const groups = (dupes.rows as Array<{ ids: string[] }>).map((r) => r.ids.join(', '));
393
+ throw new Error(
394
+ 'PII encryption backfill: multiple user rows share the same email after normalization ' +
395
+ '(case/whitespace variants of one address). Merge or delete the duplicates, then restart. ' +
396
+ `Conflicting user ids: [${groups.join('], [')}]`,
397
+ );
398
+ }
399
+ }
400
+
401
+ const PII_FINALIZE_STATEMENTS = [
402
+ 'ALTER TABLE "users" ALTER COLUMN "email_bidx" SET NOT NULL',
403
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "users_email_bidx_unq" ON "users" ("email_bidx")',
404
+ 'ALTER TABLE "users" DROP CONSTRAINT IF EXISTS "users_email_unique"',
405
+ 'ALTER TABLE "pr_file_approvals" ALTER COLUMN "approver_email_bidx" SET NOT NULL',
406
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "pr_file_approvals_bidx_unq" ON "pr_file_approvals" ("pr_number","path","approver_email_bidx","head_sha")',
407
+ 'DROP INDEX IF EXISTS "pr_file_approvals_unq"',
408
+ 'ALTER TABLE "pr_merge_log" ALTER COLUMN "triggered_by_email_bidx" SET NOT NULL',
409
+ 'ALTER TABLE "pr_comments" ALTER COLUMN "author_email_bidx" SET NOT NULL',
410
+ 'ALTER TABLE "change_requests" ALTER COLUMN "author_email_bidx" SET NOT NULL',
411
+ 'ALTER TABLE "pending_commits" ALTER COLUMN "author_email_bidx" SET NOT NULL',
412
+ 'ALTER TABLE "plugin_join_requests" ALTER COLUMN "requester_email_bidx" SET NOT NULL',
413
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "plugin_join_requests_requester_bidx_plugin_unq" ON "plugin_join_requests" ("requester_email_bidx","plugin_key")',
414
+ 'DROP INDEX IF EXISTS "plugin_join_requests_requester_plugin_unq"',
415
+ ];
416
+
417
+ /**
418
+ * Encrypt pre-existing plaintext PII rows, fill the blind-index columns, and
419
+ * apply the constraints migration 0016 deferred. Idempotent; `runCoreMigrations`
420
+ * runs it under the migrations lock right after the history. The handle must
421
+ * hold the knowledge base's key (`createDb(url, { piiKey })`; the composition
422
+ * root's does): one that holds none is refused before anything is read.
423
+ */
424
+ export async function runPiiEncryptionBackfill(db: Database): Promise<void> {
425
+ const keys = piiKeysOf(db);
426
+ await db.transaction(async (tx) => {
427
+ const first = await isFirstBackfill(tx);
428
+ // Nothing is sealed before the first backfill, so there is no sealed row
429
+ // for the key to be checked against — and a value that only LOOKS sealed
430
+ // must not be taken for one (see `isFirstBackfill`).
431
+ if (!first) await assertKeyOpensSealedRows(tx, keys);
432
+ let rewritten = 0;
433
+ for (const t of PII_BACKFILL_TABLES) {
434
+ rewritten += await backfillTable(tx, keys, t, !first);
435
+ }
436
+ if (rewritten > 0) log.info(`PII encryption backfill: rewrote ${rewritten} row(s).`);
437
+ const cleared = await clearUnindexedRefusalNames(tx);
438
+ if (cleared > 0) {
439
+ log.info(`PII encryption backfill: took the name off ${cleared} refused apply(ies) recorded before this release.`);
440
+ }
441
+ // Only where the unique indexes are not up yet. Once they are, they are
442
+ // what keeps two rows from sharing an index, so there is nothing to
443
+ // collapse — and the collapse is two self-joins and an aggregate over
444
+ // whole tables, which every later start paid for nothing.
445
+ if (first) await resolveBidxCollisions(tx);
446
+ for (const statement of PII_FINALIZE_STATEMENTS) {
447
+ await tx.execute(sql.raw(statement));
448
+ }
449
+ });
450
+ }
@@ -16,7 +16,8 @@ import { SpillStore } from '../../workspace/spill-store.js';
16
16
  import { createManualRoutes } from '../../tool-registry/manual.routes.js';
17
17
  import { ToolRegistry } from '../../tool-registry/tool-registry.js';
18
18
  import { toolDef } from '../../tool-helpers/tool-def.js';
19
- import { PLATFORM_HEADER } from '../../agent-instructions/index.js';
19
+ import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
20
+ import { platformInstructions } from '../../agent-instructions/index.js';
20
21
  import { startFakeDownstreamMcpServer, type FakeDownstreamMcpServer } from './fake-downstream-mcp-server.js';
21
22
  import { registerBevelSecretsVariableLoader } from '../../secrets-vault/secrets-variable-loader.js';
22
23
  import type { ForcedRefreshOutcome, ISecretsVaultService } from '../../secrets-vault/secrets-vault.contract.js';
@@ -483,13 +484,13 @@ describe('per-request identity: catalog, metering and continuity', () => {
483
484
  });
484
485
 
485
486
  describe('agent instructions over the real transport', () => {
486
- it('the initialize result carries the header and the preamble body inline', async () => {
487
+ it('the initialize result carries the platform text and the preamble body inline', async () => {
487
488
  const { baseUrl } = await startPlatform({
488
489
  readAgentPreamble: async () => 'Acme builds solar farms.\n\n<!-- private -->Look in Projects/ first.',
489
490
  });
490
491
  const { client } = await connectSdkClient(baseUrl);
491
492
  const instructions = client.getInstructions();
492
- expect(instructions).toBe(`${PLATFORM_HEADER}\n\nAcme builds solar farms.\n\nLook in Projects/ first.`);
493
+ expect(instructions).toBe(`${platformInstructions(DEFAULT_KB_LAYOUT)}\n\nAcme builds solar farms.\n\nLook in Projects/ first.`);
493
494
  expect(instructions).not.toContain('private');
494
495
  });
495
496
 
@@ -11,9 +11,13 @@ import { SpillStore } from '../../workspace/spill-store.js';
11
11
  import { createManualRoutes } from '../../tool-registry/manual.routes.js';
12
12
  import { ToolRegistry } from '../../tool-registry/tool-registry.js';
13
13
  import { toolDef } from '../../tool-helpers/tool-def.js';
14
- import { PLATFORM_HEADER, TOOL_PREFIX_LINE } from '../../agent-instructions/index.js';
14
+ import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
15
+ import { TOOL_PREFIX_LINE, platformInstructions, sharedRulesPointer } from '../../agent-instructions/index.js';
15
16
  import type { AgentEventInput, IAgentEventRecorder } from '../../audit/audit.contract.js';
16
17
 
18
+ /** The platform-owned part of the handshake text: the header plus the shared file rules. */
19
+ const PLATFORM = platformInstructions(DEFAULT_KB_LAYOUT);
20
+
17
21
  /**
18
22
  * End-to-end proxy test: a real express app serving the registry-driven tool
19
23
  * surface over loopback, a real per-request `UtcpClient` discovering it, and a
@@ -262,6 +266,25 @@ describe('McpService (UTCP→MCP proxy)', () => {
262
266
  expect(askSchema.properties.body?.properties?.prompt).toBeDefined();
263
267
  });
264
268
 
269
+ it('ends the served call_tool_chain description with the shared-rules pointer', async () => {
270
+ // What a chained read does to an IMAGE is one of the rules the file tools
271
+ // share, so it is stated once — in the handshake instructions and in the
272
+ // managed guide — and the chain, like every file tool, ends with the one
273
+ // sentence saying where. The clients that drop `instructions` have only
274
+ // descriptions to go on, so that sentence is their way to the rule.
275
+ const client = await setup();
276
+ const { tools } = await client.listTools();
277
+ const chain = tools.find((t) => t.name === 'call_tool_chain')!;
278
+ const pointer = sharedRulesPointer(DEFAULT_KB_LAYOUT);
279
+ expect(chain.description!.endsWith(pointer)).toBe(true);
280
+ // Once, and not on the two meta-tools that describe the registry rather
281
+ // than a file.
282
+ expect(chain.description!.split(pointer)).toHaveLength(2);
283
+ for (const name of ['list_tools', 'tools_info']) {
284
+ expect(tools.find((t) => t.name === name)!.description, name).not.toContain(pointer);
285
+ }
286
+ });
287
+
265
288
  it('a $defs/$ref tool schema survives tools/list and a real MCP client accepts it', async () => {
266
289
  // No tokenId filter for this assertion — an OAuth-style session lists all.
267
290
  const client = await setup({ tokenId: null });
@@ -534,6 +557,23 @@ describe('McpService — per-user credential pre-check', () => {
534
557
  expect(await toolNames(client)).toEqual(['ask', 'boom', 'call_tool_chain', 'list_tools', 'refy', 'tools_info']);
535
558
  });
536
559
 
560
+ /**
561
+ * The worked example in `call_tool_chain`'s description has to be derived
562
+ * from the tools THIS caller is actually served. Derived from the unfiltered
563
+ * catalog it could name a credential-gated tool the filter just removed —
564
+ * an example a connection-key caller copies and cannot call at all.
565
+ */
566
+ it('never writes the worked example against a tool it just hid', async () => {
567
+ const hidden = await setup({ secretsVault: vault(false), toolManuals: manualsWithUserVar });
568
+ const chain = (await hidden.listTools()).tools.find((t) => t.name === 'call_tool_chain')!;
569
+ expect(chain.description).not.toMatch(/return KNOWLEDGE_BASE\.\w+\(/);
570
+ // The same catalog, with the credential set, does print one — so the
571
+ // assertion above is the filter at work, not an example that never exists.
572
+ const served = await setup({ secretsVault: vault(true), toolManuals: manualsWithUserVar });
573
+ const shown = (await served.listTools()).tools.find((t) => t.name === 'call_tool_chain')!;
574
+ expect(shown.description).toMatch(/return KNOWLEDGE_BASE\.\w+\(/);
575
+ });
576
+
537
577
  it('keeps the full listing for an OAuth/JWT session (tokenId null) — the caller configures interactively', async () => {
538
578
  const client = await setup({
539
579
  secretsVault: vault(false),
@@ -548,17 +588,17 @@ describe('McpService — per-user credential pre-check', () => {
548
588
  describe('McpService — agent instructions', () => {
549
589
  const KB_TOOLS = ['start_session', 'grep', 'list_files', 'read_file'];
550
590
 
551
- it('sends the header and the preamble as the session\'s instructions', async () => {
591
+ it("sends the platform text and the preamble as the session's instructions", async () => {
552
592
  const client = await setup({ readAgentPreamble: async () => 'Acme builds solar farms.\n\nProjects live in Projects/.' });
553
- expect(client.getInstructions()).toBe(`${PLATFORM_HEADER}\n\nAcme builds solar farms.\n\nProjects live in Projects/.`);
593
+ expect(client.getInstructions()).toBe(`${PLATFORM}\n\nAcme builds solar farms.\n\nProjects live in Projects/.`);
554
594
  });
555
595
 
556
- it('sends the header alone when no reader is wired', async () => {
596
+ it('sends the platform text alone when no reader is wired', async () => {
557
597
  const client = await setup();
558
- expect(client.getInstructions()).toBe(PLATFORM_HEADER);
598
+ expect(client.getInstructions()).toBe(PLATFORM);
559
599
  });
560
600
 
561
- it('a throwing reader still yields a session, with the header as its instructions and a warning', async () => {
601
+ it('a throwing reader still yields a session, with the platform text as its instructions and a warning', async () => {
562
602
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
563
603
  const client = await setup({
564
604
  readAgentPreamble: async () => {
@@ -566,7 +606,7 @@ describe('McpService — agent instructions', () => {
566
606
  },
567
607
  extraTools: KB_TOOLS,
568
608
  });
569
- expect(client.getInstructions()).toBe(PLATFORM_HEADER);
609
+ expect(client.getInstructions()).toBe(PLATFORM);
570
610
  // The error itself rides along, so a terminal shows its stack.
571
611
  expect(warn).toHaveBeenCalledWith(
572
612
  expect.stringContaining('mcp-description.md'),
@@ -27,7 +27,7 @@ import {
27
27
  } from '@utcp/sdk';
28
28
  import { CodeModeUtcpClient } from '@utcp/code-mode';
29
29
  import {
30
- CODE_MODE_META_TOOLS,
30
+ codeModeMetaTools,
31
31
  META_TOOL_NAMES,
32
32
  dispatchMetaTool,
33
33
  dispatchToolCall,
@@ -43,6 +43,7 @@ import {
43
43
  type SkillSummary,
44
44
  type LoadedSkill,
45
45
  } from '@bevel-software/platform-mcp-core';
46
+ import type { KbLayout } from '@bevel-software/platform-shared';
46
47
  import { bevelSecretsLoaderConfig } from '../secrets-vault/index.js';
47
48
  import {
48
49
  scopesCovered,
@@ -65,6 +66,7 @@ import {
65
66
  composeAgentInstructions,
66
67
  prefixToolDescription,
67
68
  PREFIXED_TOOLS,
69
+ sharedRulesPointer,
68
70
  type AgentPreambleReader,
69
71
  type ComposedAgentInstructions,
70
72
  } from '../agent-instructions/index.js';
@@ -91,6 +93,13 @@ export interface McpProxyOptions {
91
93
  * carries the platform header alone.
92
94
  */
93
95
  readAgentPreamble?: AgentPreambleReader;
96
+ /**
97
+ * The layout in effect, read per request: the shared file rules name the
98
+ * managed guide, and a deployment may rename it after boot (the setup save
99
+ * applies a name without a restart). A GETTER, so nothing snapshots the
100
+ * pre-setup default. Absent, the rules name `AGENTS.md`.
101
+ */
102
+ kbLayout?: () => KbLayout;
94
103
  /** Bounds of the downstream (`mcp.json`) connection pool; defaults are 4h idle / 5000 entries. */
95
104
  downstreamPool?: Pick<DownstreamPoolOptions<unknown>, 'idleTtlMs' | 'maxEntries' | 'now'>;
96
105
  /**
@@ -434,12 +443,26 @@ export class McpService {
434
443
  const seen = new Set(META_TOOL_NAMES);
435
444
  const dropped: string[] = [];
436
445
  const direct: McpTool[] = [];
446
+ const examplePool: ProxiedTool[] = [];
437
447
  for (const t of listed) {
438
448
  const entry = toListedTool(t); // logs its own reason on a name/schema drop
439
449
  if (!entry) {
440
450
  dropped.push(t.mcpName);
441
451
  continue;
442
452
  }
453
+ // Kept for the worked example in the meta-tool descriptions: it must
454
+ // be derived from the tools THIS caller actually gets, not from the
455
+ // whole catalog, or a connection-key caller is shown an example naming
456
+ // a credential-gated tool the filter above just removed from its
457
+ // listing — a copied call that cannot work.
458
+ //
459
+ // BEFORE the duplicate drop below, on purpose. A tool dropped from the
460
+ // LISTING for sharing its name with another is still in the catalog a
461
+ // chain dispatches to, so the chain sees two tools under one name and
462
+ // refuses the call as ambiguous. The example has to know about both to
463
+ // steer clear of either (`chainExample` skips a name two tools share);
464
+ // shown only the survivor, it took that name for a safe one.
465
+ examplePool.push(t);
443
466
  if (seen.has(entry.name)) {
444
467
  dropped.push(`${entry.name} (duplicate)`);
445
468
  continue;
@@ -456,18 +479,33 @@ export class McpService {
456
479
  : entry,
457
480
  );
458
481
  }
482
+ // The meta-tools' examples name the namespace THIS endpoint registers the
483
+ // knowledge-base tools under, and a tool this caller is really served, so
484
+ // a chain copied out of the description runs. Built here rather than held
485
+ // as a constant: the local MCP server registers the same tools under a
486
+ // different name, and one fixed example is necessarily wrong on one of the
487
+ // two surfaces.
488
+ //
489
+ // The chain's description ends with the same pointer every file tool ends
490
+ // with: what a chain does with a failure, a large result or an image is
491
+ // stated once, in the shared rules, and the clients that drop
492
+ // `instructions` have the description and the guide to go on. Composed
493
+ // here because the guide's name is this deployment's setting.
494
+ const metaTools = codeModeMetaTools(EXTERNAL_KB_MANUAL_NAME, examplePool, {
495
+ sharedRulesPointer: sharedRulesPointer(this.opts.kbLayout?.()),
496
+ });
459
497
  // Log only when a tool was dropped (name/schema/duplicate) — that's the
460
498
  // anomaly worth surfacing, since a downstream client would otherwise hide
461
499
  // it by rejecting the whole response.
462
500
  if (dropped.length) {
463
501
  log.warn(
464
- `tools/list: serving ${CODE_MODE_META_TOOLS.length + direct.length} tool(s); ` +
502
+ `tools/list: serving ${metaTools.length + direct.length} tool(s); ` +
465
503
  `dropped ${dropped.length} non-listable: ${dropped.join(', ')}`,
466
504
  );
467
505
  }
468
506
  return {
469
507
  // Code-mode meta-tools first, then every validated direct tool.
470
- tools: [...CODE_MODE_META_TOOLS, ...direct],
508
+ tools: [...metaTools, ...direct],
471
509
  };
472
510
  });
473
511
 
@@ -613,13 +651,14 @@ export class McpService {
613
651
  * fails over its preamble.
614
652
  */
615
653
  private async composeAgentInstructions(): Promise<ComposedAgentInstructions> {
654
+ const layout = this.opts.kbLayout?.();
616
655
  const read = this.opts.readAgentPreamble;
617
- if (!read) return composeAgentInstructions(null);
656
+ if (!read) return composeAgentInstructions(null, layout);
618
657
  try {
619
- return composeAgentInstructions(await read());
658
+ return composeAgentInstructions(await read(), layout);
620
659
  } catch (err) {
621
- log.warn('could not read mcp-description.md; this request gets the platform header alone:', { err });
622
- return composeAgentInstructions(null);
660
+ log.warn('could not read mcp-description.md; this request gets the platform text alone:', { err });
661
+ return composeAgentInstructions(null, layout);
623
662
  }
624
663
  }
625
664
 
@@ -143,11 +143,14 @@ export class DbJoinRequestStore implements JoinRequestStore {
143
143
  pluginKey: string;
144
144
  }): Promise<JoinRequestRecord> {
145
145
  const requesterEmail = input.requesterEmail.toLowerCase();
146
+ // Keyed by the blind index throughout: the email column is randomized
147
+ // ciphertext, so the uniqueness and every lookup go through its index,
148
+ // which is written and compared with the address.
146
149
  const [inserted] = await this.db
147
150
  .insert(pluginJoinRequests)
148
- .values({ requesterEmail, requesterName: input.requesterName, pluginKey: input.pluginKey })
151
+ .values({ requesterEmail, requesterEmailBidx: requesterEmail, requesterName: input.requesterName, pluginKey: input.pluginKey })
149
152
  .onConflictDoNothing({
150
- target: [pluginJoinRequests.requesterEmail, pluginJoinRequests.pluginKey],
153
+ target: [pluginJoinRequests.requesterEmailBidx, pluginJoinRequests.pluginKey],
151
154
  })
152
155
  .returning();
153
156
  if (inserted) return toRecord(inserted);
@@ -170,7 +173,7 @@ export class DbJoinRequestStore implements JoinRequestStore {
170
173
  })
171
174
  .where(
172
175
  and(
173
- eq(pluginJoinRequests.requesterEmail, requesterEmail),
176
+ eq(pluginJoinRequests.requesterEmailBidx, requesterEmail),
174
177
  eq(pluginJoinRequests.pluginKey, input.pluginKey),
175
178
  ),
176
179
  )
@@ -191,7 +194,7 @@ export class DbJoinRequestStore implements JoinRequestStore {
191
194
  const rows = await this.db
192
195
  .select()
193
196
  .from(pluginJoinRequests)
194
- .where(eq(pluginJoinRequests.requesterEmail, requesterEmail.toLowerCase()));
197
+ .where(eq(pluginJoinRequests.requesterEmailBidx, requesterEmail));
195
198
  return rows.map(toRecord);
196
199
  }
197
200