@pithy-sh/support 0.5.0 → 0.6.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 (173) hide show
  1. package/README.md +8 -0
  2. package/dist/audit/actions.d.ts +9 -0
  3. package/dist/audit/actions.d.ts.map +1 -1
  4. package/dist/audit/actions.js +9 -0
  5. package/dist/capability.d.ts +2 -1
  6. package/dist/capability.d.ts.map +1 -1
  7. package/dist/capability.js +30 -6
  8. package/dist/config/config.d.ts +63 -2
  9. package/dist/config/config.d.ts.map +1 -1
  10. package/dist/config/config.js +63 -5
  11. package/dist/data/assignment.d.ts +43 -0
  12. package/dist/data/assignment.d.ts.map +1 -0
  13. package/dist/data/assignment.js +38 -0
  14. package/dist/data/embedding.d.ts +21 -0
  15. package/dist/data/embedding.d.ts.map +1 -0
  16. package/dist/data/embedding.js +23 -0
  17. package/dist/data/tables.d.ts +28 -0
  18. package/dist/data/tables.d.ts.map +1 -1
  19. package/dist/data/tables.js +10 -2
  20. package/dist/data/thread.d.ts +9 -4
  21. package/dist/data/thread.d.ts.map +1 -1
  22. package/dist/data/thread.js +9 -4
  23. package/dist/http/guards.d.ts +32 -14
  24. package/dist/http/guards.d.ts.map +1 -1
  25. package/dist/http/guards.js +44 -16
  26. package/dist/http/handlers.d.ts +35 -2
  27. package/dist/http/handlers.d.ts.map +1 -1
  28. package/dist/http/handlers.js +81 -8
  29. package/dist/http/resolve.d.ts +8 -0
  30. package/dist/http/resolve.d.ts.map +1 -1
  31. package/dist/http/resolve.js +20 -1
  32. package/dist/http/responses.d.ts +355 -0
  33. package/dist/http/responses.d.ts.map +1 -1
  34. package/dist/http/responses.js +64 -2
  35. package/dist/http/routes.d.ts +25 -11
  36. package/dist/http/routes.d.ts.map +1 -1
  37. package/dist/http/routes.js +34 -5
  38. package/dist/http/schemas.d.ts +67 -3
  39. package/dist/http/schemas.d.ts.map +1 -1
  40. package/dist/http/schemas.js +44 -5
  41. package/dist/http/scopes.d.ts +10 -5
  42. package/dist/http/scopes.d.ts.map +1 -1
  43. package/dist/http/scopes.js +42 -6
  44. package/dist/http/views.d.ts +9 -1
  45. package/dist/http/views.d.ts.map +1 -1
  46. package/dist/http/views.js +40 -1
  47. package/dist/inbound/ingest.d.ts +1 -1
  48. package/dist/inbound/ingest.d.ts.map +1 -1
  49. package/dist/inbound/ingest.js +3 -1
  50. package/dist/index.d.ts +5 -3
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +6 -4
  53. package/dist/migrations/0002_assignment.d.ts +20 -0
  54. package/dist/migrations/0002_assignment.d.ts.map +1 -0
  55. package/dist/migrations/0002_assignment.js +45 -0
  56. package/dist/migrations/0003_embeddings.d.ts +15 -0
  57. package/dist/migrations/0003_embeddings.d.ts.map +1 -0
  58. package/dist/migrations/0003_embeddings.js +23 -0
  59. package/dist/migrations/set.d.ts +13 -0
  60. package/dist/migrations/set.d.ts.map +1 -0
  61. package/dist/migrations/set.js +21 -0
  62. package/dist/presence/room.d.ts +35 -0
  63. package/dist/presence/room.d.ts.map +1 -0
  64. package/dist/presence/room.js +66 -0
  65. package/dist/provision/provisionSupport.d.ts +32 -0
  66. package/dist/provision/provisionSupport.d.ts.map +1 -1
  67. package/dist/provision/provisionSupport.js +10 -2
  68. package/dist/provision/resolveSupportConfig.d.ts.map +1 -1
  69. package/dist/provision/resolveSupportConfig.js +13 -2
  70. package/dist/purge/contribution.d.ts +49 -0
  71. package/dist/purge/contribution.d.ts.map +1 -0
  72. package/dist/purge/contribution.js +164 -0
  73. package/dist/secret/registry.d.ts +26 -2
  74. package/dist/secret/registry.d.ts.map +1 -1
  75. package/dist/secret/registry.js +15 -3
  76. package/dist/semantic/documents.d.ts +21 -0
  77. package/dist/semantic/documents.d.ts.map +1 -0
  78. package/dist/semantic/documents.js +128 -0
  79. package/dist/semantic/embed.d.ts +59 -0
  80. package/dist/semantic/embed.d.ts.map +1 -0
  81. package/dist/semantic/embed.js +59 -0
  82. package/dist/semantic/messageIndex.d.ts +46 -0
  83. package/dist/semantic/messageIndex.d.ts.map +1 -0
  84. package/dist/semantic/messageIndex.js +67 -0
  85. package/dist/semantic/query.d.ts +37 -0
  86. package/dist/semantic/query.d.ts.map +1 -0
  87. package/dist/semantic/query.js +46 -0
  88. package/dist/staff/staff.d.ts +22 -0
  89. package/dist/staff/staff.d.ts.map +1 -0
  90. package/dist/staff/staff.js +64 -0
  91. package/dist/store/assignment.d.ts +63 -0
  92. package/dist/store/assignment.d.ts.map +1 -0
  93. package/dist/store/assignment.js +86 -0
  94. package/dist/store/hybrid.d.ts +134 -0
  95. package/dist/store/hybrid.d.ts.map +1 -0
  96. package/dist/store/hybrid.js +185 -0
  97. package/dist/store/paging.d.ts +10 -0
  98. package/dist/store/paging.d.ts.map +1 -1
  99. package/dist/store/paging.js +11 -1
  100. package/dist/store/provisionIndex.d.ts +21 -0
  101. package/dist/store/provisionIndex.d.ts.map +1 -0
  102. package/dist/store/provisionIndex.js +36 -0
  103. package/dist/store/search.d.ts +18 -9
  104. package/dist/store/search.d.ts.map +1 -1
  105. package/dist/store/search.js +36 -34
  106. package/dist/store/searchIndex.d.ts +4 -4
  107. package/dist/store/searchIndex.js +4 -4
  108. package/dist/store/threads.d.ts +174 -8
  109. package/dist/store/threads.d.ts.map +1 -1
  110. package/dist/store/threads.js +33 -40
  111. package/dist/submission/submit.d.ts +1 -1
  112. package/dist/submission/submit.d.ts.map +1 -1
  113. package/dist/submission/submit.js +2 -1
  114. package/dist/test-utils/seed.d.ts +33 -0
  115. package/dist/test-utils/seed.d.ts.map +1 -0
  116. package/dist/test-utils/seed.js +102 -0
  117. package/dist/test-utils/semantic.d.ts +21 -0
  118. package/dist/test-utils/semantic.d.ts.map +1 -0
  119. package/dist/test-utils/semantic.js +107 -0
  120. package/dist/version.generated.d.ts +1 -1
  121. package/dist/version.generated.js +1 -1
  122. package/dist/workflows/specs.d.ts +23 -0
  123. package/dist/workflows/specs.d.ts.map +1 -1
  124. package/dist/workflows/specs.js +27 -7
  125. package/dist/workflows/worker.d.ts +15 -2
  126. package/dist/workflows/worker.d.ts.map +1 -1
  127. package/dist/workflows/worker.js +48 -4
  128. package/package.json +9 -7
  129. package/pithy.manifest.json +21 -3
  130. package/src/audit/actions.ts +9 -0
  131. package/src/capability.ts +43 -4
  132. package/src/config/config.ts +104 -5
  133. package/src/data/assignment.ts +55 -0
  134. package/src/data/embedding.ts +27 -0
  135. package/src/data/tables.ts +8 -0
  136. package/src/data/thread.ts +26 -4
  137. package/src/http/guards.ts +52 -15
  138. package/src/http/handlers.ts +125 -4
  139. package/src/http/resolve.ts +19 -0
  140. package/src/http/responses.ts +154 -0
  141. package/src/http/routes.ts +98 -12
  142. package/src/http/schemas.ts +117 -28
  143. package/src/http/scopes.ts +43 -6
  144. package/src/http/views.ts +44 -0
  145. package/src/inbound/ingest.ts +5 -2
  146. package/src/index.ts +5 -0
  147. package/src/migrations/0002_assignment.ts +65 -0
  148. package/src/migrations/0003_embeddings.ts +29 -0
  149. package/src/migrations/set.ts +21 -0
  150. package/src/presence/room.ts +94 -0
  151. package/src/provision/provisionSupport.ts +33 -1
  152. package/src/provision/resolveSupportConfig.ts +24 -13
  153. package/src/purge/contribution.ts +294 -0
  154. package/src/secret/registry.ts +17 -2
  155. package/src/semantic/documents.ts +178 -0
  156. package/src/semantic/embed.ts +99 -0
  157. package/src/semantic/messageIndex.ts +74 -0
  158. package/src/semantic/query.ts +66 -0
  159. package/src/staff/staff.ts +128 -0
  160. package/src/store/assignment.ts +179 -0
  161. package/src/store/hybrid.ts +314 -0
  162. package/src/store/paging.ts +14 -0
  163. package/src/store/provisionIndex.ts +46 -0
  164. package/src/store/search.ts +58 -37
  165. package/src/store/searchIndex.ts +4 -4
  166. package/src/store/threads.ts +91 -91
  167. package/src/submission/submit.ts +3 -2
  168. package/src/test-utils/seed.ts +141 -0
  169. package/src/test-utils/semantic.ts +110 -0
  170. package/src/version.generated.ts +1 -1
  171. package/src/workflows/specs.ts +34 -0
  172. package/src/workflows/worker.ts +84 -8
  173. package/src/workflows/wrangler.jsonc +8 -3
@@ -0,0 +1,294 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database, R2Bucket } from "@cloudflare/workers-types";
5
+ import { normalizeAddress } from "@pithy-sh/core/src/address/address";
6
+ import {
7
+ type ErasingPurgeContribution,
8
+ exportRows,
9
+ type PurgeContext,
10
+ purgeBinding,
11
+ } from "@pithy-sh/core/src/capability/purge";
12
+ import { deleteVectors, type VectorStore } from "@pithy-sh/vector/src/index/index";
13
+ import { type Expression, type ExpressionBuilder, type Kysely, type SqlBool, sql } from "kysely";
14
+ import { SupportAssignment } from "../data/assignment";
15
+ import { SupportAttachment } from "../data/attachment";
16
+ import { SupportMessage } from "../data/message";
17
+ import {
18
+ SUPPORT_ASSIGNMENTS_TABLE,
19
+ SUPPORT_ATTACHMENTS_TABLE,
20
+ SUPPORT_CLASSIFICATIONS_TABLE,
21
+ SUPPORT_EMBEDDINGS_TABLE,
22
+ SUPPORT_FLAGS_TABLE,
23
+ SUPPORT_MESSAGES_TABLE,
24
+ SUPPORT_THREADS_TABLE,
25
+ type SupportDatabase,
26
+ supportDatabase,
27
+ } from "../data/tables";
28
+ import { SupportThread } from "../data/thread";
29
+ import { SUPPORT_BUCKET_BINDING } from "../secret/registry";
30
+ import { SUPPORT_VECTORIZE_BINDING } from "../semantic/messageIndex";
31
+
32
+ /**
33
+ * What erasing one person means for `@pithy-sh/support` (#814): every thread they opened — linked to their account,
34
+ * or sent from their address and linked to no account — with every message in it, its attachments and raw mail in
35
+ * R2, its classifications, every operator's read and snooze state on it, and the history of who had it (#816). And
36
+ * what exporting them hands back.
37
+ *
38
+ * **A thread is the person's when it is linked to their account or came from their address.** An email thread from
39
+ * someone who never signed in carries no `userId`, only the address it came from; the address is read from the
40
+ * account that owns it (`PurgeContext.address`), so a thread sent before the person had an account is theirs too.
41
+ * The address side takes only a thread linked to no account: an address can move, and a thread linked to whoever
42
+ * held it before is that account's, never handed to the person who holds it now, nor erased with them.
43
+ *
44
+ * **The bytes go first, then the rows.** An attachment's row and a message's `rawKey` are the only things that name
45
+ * their objects, so a row deleted ahead of its object would leave bytes nothing can find. An R2 delete of a key
46
+ * already gone succeeds, so a retried step is **idempotent**, and a person with no threads finds nothing. Deleting
47
+ * a message drops it from the full-text index through the index's own triggers. Its vector in the semantic index
48
+ * is deleted by the message's id, stamped or not, before the message row that names it.
49
+ *
50
+ * **Who had a thread goes with the thread; who handed it over is the operator's.** The assignment history is a
51
+ * thread's own record — each change names the thread, who had it before and after, and the assigner's note about
52
+ * the person's request — so it is erased with the thread and exported with it, oldest first. Who made each change,
53
+ * `assignedBy`, is the operator's subject or a staff member's id, not the person's: the export leaves it out of the
54
+ * history and of the thread, as leaderboard leaves out a moderator, and the audit trail keeps every handover with its
55
+ * actor.
56
+ *
57
+ * **A staff member's own handovers, on threads that are not theirs, lose their id.** On the staff session door,
58
+ * `assignedBy` is the signed-in user's id, written onto a customer's thread and its history. Their purge sets it to
59
+ * `ERASED_ASSIGNER` wherever they acted as a user — every history row with `actorType = 'user'` and their id, and the
60
+ * thread's own `assignedBy` when that row made its current assignment — so the handover stays in the customer's
61
+ * record and stops naming them. A control-plane subject that happens to share the id is a client, not the person, and
62
+ * is left alone. Their export hands those handovers back as which thread, when, and how they were verified, never the
63
+ * assignee or the note, which are about the customer's request. Who **held** a thread is kept: an assignee is an
64
+ * opaque reference and a name the assigner chose, support cannot tell which reference is the person, and both go
65
+ * when the customer's thread does.
66
+ */
67
+
68
+ /** What a purged staff member's id becomes on the handovers they made (#816). */
69
+ export const ERASED_ASSIGNER = "erased-user";
70
+
71
+ /** The one sentence the purge records with this step. */
72
+ export const SUPPORT_PURGE_SUMMARY =
73
+ "Deletes every support thread the user opened, linked to their account or sent from their address and linked to no other account, with its messages, attachments, raw mail, classifications, read state and assignment history. On other people's threads, the handovers they made as a signed-in user lose their id; an assignee is an opaque reference the assigner named, so who held a thread stays until that thread goes.";
74
+
75
+ /** How many threads one read takes, so a person with a long history is erased in bounded statements. */
76
+ const PAGE = 50;
77
+
78
+ /** R2's ceiling on the keys one `delete` takes. */
79
+ const R2_DELETE_KEYS = 1000;
80
+
81
+ /** Vector ids one `deleteByIds` call takes — Vectorize's own write batch, the same ceiling an upsert has. */
82
+ const VECTOR_DELETE_IDS = 1000;
83
+
84
+ /** The person's threads, as a predicate: linked to the account, or sent from its address and linked to none. */
85
+ type Theirs = (
86
+ eb: ExpressionBuilder<SupportDatabase extends Kysely<infer Tables> ? Tables : never, typeof SUPPORT_THREADS_TABLE>,
87
+ ) => Expression<SqlBool>;
88
+
89
+ /**
90
+ * Which threads are the person's: linked to the account, or sent from its address and linked to no account. Both
91
+ * sides are `normalizeAddress`'s form — a thread's `fromAddress` is normalized on the way in — so the index answers.
92
+ * It binds at most two parameters, however many threads it matches, so a statement built on it stays under D1's cap.
93
+ */
94
+ async function theirs(context: PurgeContext): Promise<Theirs> {
95
+ const read = (await context.address?.()) ?? null;
96
+ const address = read === null ? null : normalizeAddress(read);
97
+ const userId = context.subject.id;
98
+ return (eb) =>
99
+ address === null
100
+ ? eb("userId", "=", userId)
101
+ : eb.or([eb("userId", "=", userId), eb.and([eb("fromAddress", "=", address), eb("userId", "is", null)])]);
102
+ }
103
+
104
+ /** One handover a staff member made, as their export carries it: which thread, when, and how they were verified. */
105
+ const SupportHandover = SupportAssignment.pick({
106
+ id: true,
107
+ threadId: true,
108
+ actorType: true,
109
+ assignedBy: true,
110
+ createdAt: true,
111
+ });
112
+
113
+ /** A row without who made its assignment: the operator's, never the person's (#816). */
114
+ function withoutAssigner<T extends { assignedBy?: unknown }>(row: T): Omit<T, "assignedBy"> {
115
+ const { assignedBy: _assigner, ...rest } = row;
116
+ return rest;
117
+ }
118
+
119
+ /** The person's thread ids, oldest first, at most `limit`. */
120
+ async function threadIds(db: SupportDatabase, predicate: Theirs, limit: number): Promise<string[]> {
121
+ const rows = await db
122
+ .selectFrom(SUPPORT_THREADS_TABLE)
123
+ .select("id")
124
+ .where(predicate)
125
+ .orderBy("createdAt")
126
+ .orderBy("id")
127
+ .limit(limit)
128
+ .execute();
129
+ return rows.map((row) => row.id);
130
+ }
131
+
132
+ /** How the purge is composed. `semantic` is whether `search.semantic` is on, so the index must be reachable. */
133
+ export interface SupportPurgeOptions {
134
+ semantic?: boolean;
135
+ }
136
+
137
+ /** The user contribution `support()` declares. */
138
+ export function supportUserPurge(database = "DB", options: SupportPurgeOptions = {}): ErasingPurgeContribution {
139
+ const open = (env: Record<string, unknown>) => {
140
+ const d1 = purgeBinding<D1Database>(env, database, "support");
141
+ return { d1, db: supportDatabase(d1) };
142
+ };
143
+ return {
144
+ subject: "user",
145
+ summary: SUPPORT_PURGE_SUMMARY,
146
+ erase: async (context) => {
147
+ const { d1, db } = open(context.env);
148
+ const predicate = await theirs(context);
149
+ // Their own acts as a signed-in user, on anybody's thread: the thread first, while its history still names them.
150
+ const userId = context.subject.id;
151
+ const anonymize = [
152
+ db
153
+ .updateTable(SUPPORT_THREADS_TABLE)
154
+ .set({ assignedBy: ERASED_ASSIGNER })
155
+ .where("assignedBy", "=", userId)
156
+ .where((eb) =>
157
+ eb.exists(
158
+ eb
159
+ .selectFrom(SUPPORT_ASSIGNMENTS_TABLE)
160
+ .select(sql.lit(1).as("one"))
161
+ .whereRef(`${SUPPORT_ASSIGNMENTS_TABLE}.threadId`, "=", `${SUPPORT_THREADS_TABLE}.id`)
162
+ .whereRef(`${SUPPORT_ASSIGNMENTS_TABLE}.createdAt`, "=", `${SUPPORT_THREADS_TABLE}.assignedAt`)
163
+ .where(`${SUPPORT_ASSIGNMENTS_TABLE}.actorType`, "=", "user")
164
+ .where(`${SUPPORT_ASSIGNMENTS_TABLE}.assignedBy`, "=", userId),
165
+ ),
166
+ )
167
+ .compile(),
168
+ db
169
+ .updateTable(SUPPORT_ASSIGNMENTS_TABLE)
170
+ .set({ assignedBy: ERASED_ASSIGNER })
171
+ .where("actorType", "=", "user")
172
+ .where("assignedBy", "=", userId)
173
+ .compile(),
174
+ ];
175
+ await d1.batch(anonymize.map((compiled) => d1.prepare(compiled.sql).bind(...(compiled.parameters as unknown[]))));
176
+ let bucket: R2Bucket | undefined;
177
+ let vectors: VectorStore | undefined;
178
+ for (;;) {
179
+ const ids = await threadIds(db, predicate, PAGE);
180
+ if (ids.length === 0) return;
181
+ const [attachments, messages] = await Promise.all([
182
+ db.selectFrom(SUPPORT_ATTACHMENTS_TABLE).select("storageKey").where("threadId", "in", ids).execute(),
183
+ db.selectFrom(SUPPORT_MESSAGES_TABLE).select(["id", "rawKey"]).where("threadId", "in", ids).execute(),
184
+ ]);
185
+ const keys = [
186
+ ...attachments.map((row) => row.storageKey),
187
+ ...messages.map((row) => row.rawKey).filter((key): key is string => typeof key === "string" && key !== ""),
188
+ ];
189
+ if (keys.length > 0) {
190
+ // Bound only once there are bytes to delete, so a person whose threads hold none needs no bucket.
191
+ bucket ??= purgeBinding<R2Bucket>(context.env, SUPPORT_BUCKET_BINDING, "support", "R2 bucket");
192
+ // One page's threads can hold any number of messages and attachments; R2 takes a thousand keys a call.
193
+ for (let start = 0; start < keys.length; start += R2_DELETE_KEYS) {
194
+ await bucket.delete(keys.slice(start, start + R2_DELETE_KEYS));
195
+ }
196
+ }
197
+ // Their messages' vectors, before the rows that name them: a vector is derived from their words. Deleted by
198
+ // every message id in the page, not only the stamped ones, because a vector can outlive a failed stamp
199
+ // write, and deleting an id the index does not hold is harmless. The index is required when semantic
200
+ // search is on or a stamp says a vector exists; otherwise it is used when bound, so a project with
201
+ // semantic search off needs none.
202
+ if (vectors === undefined && context.env[SUPPORT_VECTORIZE_BINDING] === undefined) {
203
+ const required =
204
+ options.semantic === true ||
205
+ (await db
206
+ .selectFrom(SUPPORT_EMBEDDINGS_TABLE)
207
+ .select("messageId")
208
+ .where("messageId", "in", (eb) =>
209
+ eb.selectFrom(SUPPORT_MESSAGES_TABLE).select("id").where("threadId", "in", ids),
210
+ )
211
+ .executeTakeFirst()) !== undefined;
212
+ if (required) {
213
+ vectors = purgeBinding<VectorStore>(context.env, SUPPORT_VECTORIZE_BINDING, "support", "Vectorize index");
214
+ }
215
+ } else {
216
+ vectors ??= purgeBinding<VectorStore>(context.env, SUPPORT_VECTORIZE_BINDING, "support", "Vectorize index");
217
+ }
218
+ if (vectors) {
219
+ const vectorIds = messages.map((row) => row.id);
220
+ for (let start = 0; start < vectorIds.length; start += VECTOR_DELETE_IDS) {
221
+ await deleteVectors(vectors, vectorIds.slice(start, start + VECTOR_DELETE_IDS));
222
+ }
223
+ }
224
+ // One batch, children first, so a failure leaves the page whole and the retry erases it again.
225
+ const statements = [
226
+ db
227
+ .deleteFrom(SUPPORT_EMBEDDINGS_TABLE)
228
+ .where("messageId", "in", (eb) =>
229
+ eb.selectFrom(SUPPORT_MESSAGES_TABLE).select("id").where("threadId", "in", ids),
230
+ )
231
+ .compile(),
232
+ db.deleteFrom(SUPPORT_ATTACHMENTS_TABLE).where("threadId", "in", ids).compile(),
233
+ db.deleteFrom(SUPPORT_ASSIGNMENTS_TABLE).where("threadId", "in", ids).compile(),
234
+ db.deleteFrom(SUPPORT_CLASSIFICATIONS_TABLE).where("threadId", "in", ids).compile(),
235
+ db.deleteFrom(SUPPORT_FLAGS_TABLE).where("threadId", "in", ids).compile(),
236
+ db.deleteFrom(SUPPORT_MESSAGES_TABLE).where("threadId", "in", ids).compile(),
237
+ db.deleteFrom(SUPPORT_THREADS_TABLE).where("id", "in", ids).compile(),
238
+ ];
239
+ await d1.batch(
240
+ statements.map((compiled) => d1.prepare(compiled.sql).bind(...(compiled.parameters as unknown[]))),
241
+ );
242
+ }
243
+ },
244
+ export: async (context) => {
245
+ const { db } = open(context.env);
246
+ const predicate = await theirs(context);
247
+ // The thread set as a subquery, never a bound id list: a person with more threads than D1 binds in one
248
+ // statement is answered in full.
249
+ const threadIdsOf = db.selectFrom(SUPPORT_THREADS_TABLE).select("id").where(predicate);
250
+ const [threads, messages, attachments, assignments, handovers] = await Promise.all([
251
+ db.selectFrom(SUPPORT_THREADS_TABLE).selectAll().where(predicate).orderBy("createdAt").orderBy("id").execute(),
252
+ db
253
+ .selectFrom(SUPPORT_MESSAGES_TABLE)
254
+ .selectAll()
255
+ .where("threadId", "in", threadIdsOf)
256
+ .orderBy("receivedAt")
257
+ .orderBy("id")
258
+ .execute(),
259
+ db
260
+ .selectFrom(SUPPORT_ATTACHMENTS_TABLE)
261
+ .selectAll()
262
+ .where("threadId", "in", threadIdsOf)
263
+ .orderBy("createdAt")
264
+ .orderBy("id")
265
+ .execute(),
266
+ db
267
+ .selectFrom(SUPPORT_ASSIGNMENTS_TABLE)
268
+ .selectAll()
269
+ .where("threadId", "in", threadIdsOf)
270
+ .orderBy("createdAt")
271
+ .orderBy("id")
272
+ .execute(),
273
+ // The handovers they made as a signed-in user, on any thread: which, when and how — never the assignee or the
274
+ // note, which are about somebody else's request.
275
+ db
276
+ .selectFrom(SUPPORT_ASSIGNMENTS_TABLE)
277
+ .select(["id", "threadId", "actorType", "assignedBy", "createdAt"])
278
+ .where("actorType", "=", "user")
279
+ .where("assignedBy", "=", context.subject.id)
280
+ .orderBy("threadId")
281
+ .orderBy("createdAt")
282
+ .orderBy("id")
283
+ .execute(),
284
+ ]);
285
+ return {
286
+ threads: exportRows(threads.map((row) => withoutAssigner(SupportThread.parse(row)))),
287
+ messages: exportRows(messages.map((row) => SupportMessage.parse(row))),
288
+ attachments: exportRows(attachments.map((row) => SupportAttachment.parse(row))),
289
+ assignments: exportRows(assignments.map((row) => withoutAssigner(SupportAssignment.parse(row)))),
290
+ handovers: exportRows(handovers.map((row) => SupportHandover.parse(row))),
291
+ };
292
+ },
293
+ };
294
+ }
@@ -1,11 +1,14 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import { PRESENCE_TICKET_KEY_ENTRY, PRESENCE_TICKET_KEY_SECRET } from "@pithy-sh/core/src/presence/declaration";
5
+ import { defineSecretRegistry } from "@pithy-sh/secrets/src/registry";
4
6
  import { r2CredentialsRegistry } from "@pithy-sh/storage/src/secret/registry";
5
7
  import { type SupportConfig, supportNeedsBucket } from "../config/config";
6
8
 
7
9
  /**
8
- * The secrets support reads — exactly one, and it is not support's own shape.
10
+ * The secrets support reads — two, and neither is support's own shape: the R2 credential bundle
11
+ * attachments are signed with, and the presence ticket key its `support.thread` room is joined with.
9
12
  *
10
13
  * Attachment bytes are written through the R2 binding and served as short-lived signed URLs, and
11
14
  * signing needs an S3 key pair. Rather than declare another credential shape, support declares the
@@ -32,8 +35,20 @@ export const SUPPORT_R2_SECRET = "support-r2-credentials";
32
35
  */
33
36
  export const SUPPORT_BUCKET_BINDING = "SUPPORT_BUCKET";
34
37
 
38
+ /**
39
+ * The presence ticket key (#816), declared exactly as core states it. The name is the join key, so every
40
+ * capability in a Worker that declares rooms signs and verifies with the one key, and nobody chooses its
41
+ * value: `pithy add` mints the local one and `pithy secrets provision` the deployed ones.
42
+ */
43
+ export const supportPresenceRegistry = defineSecretRegistry({
44
+ [PRESENCE_TICKET_KEY_SECRET]: PRESENCE_TICKET_KEY_ENTRY,
45
+ });
46
+
35
47
  /** The support capability's secret-registry slice — aggregated into the shared accessor at startup. */
36
- export const supportSecretsRegistry = r2CredentialsRegistry(SUPPORT_R2_SECRET, SUPPORT_BUCKET_BINDING);
48
+ export const supportSecretsRegistry = defineSecretRegistry({
49
+ ...r2CredentialsRegistry(SUPPORT_R2_SECRET, SUPPORT_BUCKET_BINDING),
50
+ ...supportPresenceRegistry,
51
+ });
37
52
 
38
53
  /**
39
54
  * **The credential this composition cannot reach**, as the `inapplicableSecrets` contribution (#541).
@@ -0,0 +1,178 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { chunkByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
5
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { VectorDocument } from "@pithy-sh/vector/src/data/document";
7
+ import type { DocumentStore } from "@pithy-sh/vector/src/data/documents";
8
+ import { deleteVectors, type VectorStore } from "@pithy-sh/vector/src/index/index";
9
+ import { SupportEmbedding } from "../data/embedding";
10
+ import { SUPPORT_EMBEDDINGS_TABLE, SUPPORT_MESSAGES_TABLE, type SupportDatabase } from "../data/tables";
11
+ import { embeddingText, SUPPORT_MESSAGES_INDEX } from "./messageIndex";
12
+
13
+ /**
14
+ * `@pithy-sh/vector`'s `DocumentStore` seam over `pithy_support_messages` — so the semantic index is
15
+ * re-embedded by `vector`'s own reprocess orchestration rather than a second one.
16
+ *
17
+ * **The text stays in support's table.** A document is a message read through the join: its content is the text
18
+ * {@link embeddingText} embeds, and its `model` is the stamp in `pithy_support_embeddings`, null for a message
19
+ * never embedded. `markEmbedded` writes stamps. Nothing here copies a message into `pithy_vector_documents`.
20
+ *
21
+ * **Inbound messages only**, because only they are embedded on arrival: an operator's own reply says nothing
22
+ * about what the customer asked, and embedding it would put the adopter's words beside the customer's.
23
+ *
24
+ * **Metadata is empty, always.** Reprocess upserts a document's metadata onto its vector, so `{}` here is what
25
+ * keeps a re-embed to ids only, the same as the write in `embed.ts`.
26
+ *
27
+ * One index: every call names `support-messages` or reads nothing.
28
+ */
29
+
30
+ /** A stamp insert binds the model and the time before the id list. */
31
+ const STAMP_FIXED_PARAMETERS = 2;
32
+
33
+ /** Message reads bind `direction` before the id list. */
34
+ const READ_FIXED_PARAMETERS = 1;
35
+
36
+ interface MessageRow {
37
+ id: string;
38
+ subject: string;
39
+ textBody: string;
40
+ receivedAt: number;
41
+ model: string | null;
42
+ embeddedAt: number | null;
43
+ }
44
+
45
+ /**
46
+ * Stamp `ids` with `model`, for the messages that still exist, and delete the vector of every one that does not.
47
+ *
48
+ * The stamp is an `INSERT … SELECT` from `pithy_support_messages`, so a message deleted after its vector was read
49
+ * gets no stamp. Its vector was upserted after the purge deleted it, so it is deleted here, by id: nothing else
50
+ * names it once the message row is gone. Returns the ids that were stamped.
51
+ */
52
+ export async function writeStamps(
53
+ db: SupportDatabase,
54
+ store: VectorStore | undefined,
55
+ ids: readonly string[],
56
+ model: string,
57
+ at: Date,
58
+ ): Promise<Set<string>> {
59
+ const stamped = new Set<string>();
60
+ if (ids.length === 0) return stamped;
61
+ const embeddedAt = SupportEmbedding.shape.embeddedAt.encode(at);
62
+ for (const group of chunkByBoundParameters(ids, STAMP_FIXED_PARAMETERS)) {
63
+ // One statement per chunk, upserting: a re-embed replaces the stamp a model change left behind.
64
+ await db
65
+ .insertInto(SUPPORT_EMBEDDINGS_TABLE)
66
+ .columns(["messageId", "model", "embeddedAt"])
67
+ .expression((eb) =>
68
+ eb
69
+ .selectFrom(SUPPORT_MESSAGES_TABLE)
70
+ .select((inner) => ["id", inner.val(model).as("model"), inner.val(embeddedAt).as("embeddedAt")])
71
+ .where("id", "in", group),
72
+ )
73
+ .onConflict((conflict) =>
74
+ conflict.column("messageId").doUpdateSet((eb) => ({
75
+ model: eb.ref("excluded.model"),
76
+ embeddedAt: eb.ref("excluded.embeddedAt"),
77
+ })),
78
+ )
79
+ .execute();
80
+ const present = await db.selectFrom(SUPPORT_MESSAGES_TABLE).select("id").where("id", "in", group).execute();
81
+ for (const row of present) stamped.add(row.id);
82
+ }
83
+ const gone = ids.filter((id) => !stamped.has(id));
84
+ if (gone.length > 0 && store) await deleteVectors(store, gone);
85
+ return stamped;
86
+ }
87
+
88
+ function documentOf(row: MessageRow): VectorDocument {
89
+ const receivedAt = new Date(Number(row.receivedAt));
90
+ return {
91
+ id: row.id,
92
+ indexName: SUPPORT_MESSAGES_INDEX,
93
+ namespace: null,
94
+ content: embeddingText(row.subject, row.textBody),
95
+ metadata: {},
96
+ model: row.model,
97
+ createdAt: receivedAt,
98
+ updatedAt: row.embeddedAt === null ? receivedAt : new Date(Number(row.embeddedAt)),
99
+ };
100
+ }
101
+
102
+ /**
103
+ * The message corpus as a `DocumentStore`. `store` is the index a re-embed writes to, so a stamp that finds its
104
+ * message gone deletes the vector the re-embed just wrote for it.
105
+ */
106
+ export function supportMessageDocuments(db: SupportDatabase, options: { store?: VectorStore } = {}): DocumentStore {
107
+ const base = () =>
108
+ db
109
+ .selectFrom(SUPPORT_MESSAGES_TABLE)
110
+ .leftJoin(SUPPORT_EMBEDDINGS_TABLE, `${SUPPORT_EMBEDDINGS_TABLE}.messageId`, `${SUPPORT_MESSAGES_TABLE}.id`)
111
+ .select([
112
+ `${SUPPORT_MESSAGES_TABLE}.id as id`,
113
+ `${SUPPORT_MESSAGES_TABLE}.subject as subject`,
114
+ `${SUPPORT_MESSAGES_TABLE}.textBody as textBody`,
115
+ `${SUPPORT_MESSAGES_TABLE}.receivedAt as receivedAt`,
116
+ `${SUPPORT_EMBEDDINGS_TABLE}.model as model`,
117
+ `${SUPPORT_EMBEDDINGS_TABLE}.embeddedAt as embeddedAt`,
118
+ ])
119
+ .where(`${SUPPORT_MESSAGES_TABLE}.direction`, "=", "inbound");
120
+
121
+ const ours = (indexName: string) => indexName === SUPPORT_MESSAGES_INDEX;
122
+
123
+ return {
124
+ async put() {
125
+ // Messages are written by the mail path and the submission route, never by a re-embed.
126
+ throw new InternalError({
127
+ message: "Support messages cannot be written through the vector document store.",
128
+ detail: "supportMessageDocuments.put: the corpus is pithy_support_messages, written by support alone",
129
+ });
130
+ },
131
+
132
+ async get(indexName, id) {
133
+ if (!ours(indexName)) return null;
134
+ const row = await base().where(`${SUPPORT_MESSAGES_TABLE}.id`, "=", id).executeTakeFirst();
135
+ return row ? documentOf(row as MessageRow) : null;
136
+ },
137
+
138
+ async byIds(indexName, ids) {
139
+ if (!ours(indexName) || ids.length === 0) return [];
140
+ const documents: VectorDocument[] = [];
141
+ for (const group of chunkByBoundParameters(ids, READ_FIXED_PARAMETERS)) {
142
+ const rows = await base().where(`${SUPPORT_MESSAGES_TABLE}.id`, "in", group).execute();
143
+ for (const row of rows) documents.push(documentOf(row as MessageRow));
144
+ }
145
+ return documents;
146
+ },
147
+
148
+ async remove(indexName, id) {
149
+ // The message is support's to delete. What a re-embed may forget is its stamp.
150
+ if (!ours(indexName)) return false;
151
+ const result = await db.deleteFrom(SUPPORT_EMBEDDINGS_TABLE).where("messageId", "=", id).executeTakeFirst();
152
+ return (result.numDeletedRows ?? 0n) > 0n;
153
+ },
154
+
155
+ async page(request) {
156
+ if (!ours(request.indexName)) return [];
157
+ let query = base().orderBy(`${SUPPORT_MESSAGES_TABLE}.id`, "asc").limit(request.limit);
158
+ if (request.after !== null) query = query.where(`${SUPPORT_MESSAGES_TABLE}.id`, ">", request.after);
159
+ const stale = request.staleModel;
160
+ if (stale !== undefined) {
161
+ // Never stamped counts as stale: a message that missed its embedding is exactly what a pass is for.
162
+ query = query.where((eb) =>
163
+ eb.or([
164
+ eb(`${SUPPORT_EMBEDDINGS_TABLE}.model`, "is", null),
165
+ eb(`${SUPPORT_EMBEDDINGS_TABLE}.model`, "!=", stale),
166
+ ]),
167
+ );
168
+ }
169
+ const rows = await query.execute();
170
+ return rows.map((row) => documentOf(row as MessageRow));
171
+ },
172
+
173
+ async markEmbedded(indexName, ids, model, at) {
174
+ if (!ours(indexName)) return;
175
+ await writeStamps(db, options.store, ids, model, at);
176
+ },
177
+ };
178
+ }
@@ -0,0 +1,99 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Logger } from "@pithy-sh/core/src/logger/logger";
5
+ import type { VectorIndexConfig } from "@pithy-sh/vector/src/config/config";
6
+ import { embedForIndex, type VectorAi } from "@pithy-sh/vector/src/embed/embed";
7
+ import { upsertVectors, type VectorStore } from "@pithy-sh/vector/src/index/index";
8
+ import { SUPPORT_MESSAGES_TABLE, type SupportDatabase } from "../data/tables";
9
+ import { writeStamps } from "./documents";
10
+ import { embeddingText } from "./messageIndex";
11
+
12
+ /**
13
+ * Embed one stored message into the semantic index — the step the classification Workflow runs after it
14
+ * classifies.
15
+ *
16
+ * **The vector is written, then the stamp.** A step that dies between the two leaves a message with no stamp,
17
+ * which is exactly the set `pithy vector reprocess --index support-messages` picks up, so a half-done write heals
18
+ * rather than hides.
19
+ *
20
+ * **A message gone by the stamp keeps no vector.** The stamp is written only while the message exists, and a
21
+ * message deleted between the read and the upsert has its vector deleted again, so a purge that lands mid-step
22
+ * leaves nothing derived from the person's words.
23
+ *
24
+ * **Ids only.** The upsert carries the message id and the components, and no metadata at all.
25
+ */
26
+
27
+ /** What embedding a message needs. Every one is a seam, so a test hands in fakes. */
28
+ export interface EmbedMessageDeps {
29
+ /** The support tables. */
30
+ db: SupportDatabase;
31
+ /** The Workers AI binding. */
32
+ ai: VectorAi;
33
+ /** The Vectorize binding the message index arrives on. */
34
+ store: VectorStore;
35
+ /** The index's config — the model it is pinned to and the width it holds. */
36
+ index: VectorIndexConfig;
37
+ /** Now. */
38
+ now: () => Date;
39
+ }
40
+
41
+ /** Embed one message and stamp it. Returns false when the message no longer exists, read or stamp. */
42
+ export async function embedMessage(deps: EmbedMessageDeps, messageId: string): Promise<boolean> {
43
+ const message = await deps.db
44
+ .selectFrom(SUPPORT_MESSAGES_TABLE)
45
+ .select(["id", "subject", "textBody"])
46
+ .where("id", "=", messageId)
47
+ .executeTakeFirst();
48
+ // A Workflow instance can outlive the row that started it; a purge is the ordinary way.
49
+ if (!message) return false;
50
+
51
+ const [values] = await embedForIndex(deps.ai, deps.index, [embeddingText(message.subject, message.textBody)]);
52
+ await upsertVectors(deps.store, deps.index, [{ id: message.id, values: values as number[] }]);
53
+
54
+ // A message deleted since the read gets no stamp, and its vector is deleted again.
55
+ const stamped = await writeStamps(deps.db, deps.store, [message.id], deps.index.model, deps.now());
56
+ return stamped.has(message.id);
57
+ }
58
+
59
+ /** What one embedding step did. Returned through the step, so a replay restores it. */
60
+ export type EmbedOutcome =
61
+ | { embedded: true }
62
+ | {
63
+ embedded: false;
64
+ /**
65
+ * `gone` — the message no longer exists. `unbound` — the index's binding is not on this worker yet, until
66
+ * `pithy support provision` deploys it. `failed` — the model or the index did not answer.
67
+ */
68
+ reason: "gone" | "unbound" | "failed";
69
+ };
70
+
71
+ /**
72
+ * The embedding step, as the classification Workflow runs it: **never fatal**.
73
+ *
74
+ * A message that could not be embedded is stored, classified, and missing from semantic results only — and,
75
+ * having no stamp, it is exactly what the next `pithy vector reprocess --index support-messages` picks up. So a
76
+ * failure is reported in the outcome and the log, and never thrown into the Workflow that also classifies.
77
+ */
78
+ export async function runEmbedding(
79
+ deps: Omit<EmbedMessageDeps, "store"> & { store: VectorStore | undefined; log?: Logger },
80
+ messageId: string,
81
+ ): Promise<EmbedOutcome> {
82
+ if (!deps.store) {
83
+ deps.log?.warn("support semantic search is on but the index is not bound to this worker — not embedding", {
84
+ messageId,
85
+ });
86
+ return { embedded: false, reason: "unbound" };
87
+ }
88
+ try {
89
+ return (await embedMessage({ ...deps, store: deps.store }, messageId))
90
+ ? { embedded: true }
91
+ : { embedded: false, reason: "gone" };
92
+ } catch (error) {
93
+ deps.log?.warn("support message not embedded — it stays out of semantic results until a reprocess", {
94
+ messageId,
95
+ error,
96
+ });
97
+ return { embedded: false, reason: "failed" };
98
+ }
99
+ }