@agent-native/core 0.111.3 → 0.112.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 (109) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +18 -0
  3. package/corpus/core/package.json +3 -1
  4. package/corpus/core/src/cli/code-agent-executor.ts +17 -5
  5. package/corpus/core/src/cli/code-agent-runs.ts +43 -2
  6. package/corpus/core/src/cli/migration-codemod.ts +59 -9
  7. package/corpus/core/src/cli/multi-frontier-runs.ts +841 -0
  8. package/corpus/core/src/coding-tools/index.ts +27 -8
  9. package/corpus/core/src/embeddings/index.ts +233 -0
  10. package/corpus/core/src/index.ts +2 -0
  11. package/corpus/core/src/search/index.ts +413 -0
  12. package/corpus/templates/brain/.agents/skills/brain/RUNBOOK.md +48 -5
  13. package/corpus/templates/brain/.agents/skills/brain/SKILL.md +46 -14
  14. package/corpus/templates/brain/README.md +34 -3
  15. package/corpus/templates/brain/actions/approve-proposal.ts +2 -0
  16. package/corpus/templates/brain/actions/claim-distillation.ts +24 -27
  17. package/corpus/templates/brain/actions/create-source.ts +1 -1
  18. package/corpus/templates/brain/actions/get-knowledge.ts +6 -2
  19. package/corpus/templates/brain/actions/get-pilot-report.ts +44 -8
  20. package/corpus/templates/brain/actions/import-capture.ts +21 -9
  21. package/corpus/templates/brain/actions/import-transcript.ts +42 -14
  22. package/corpus/templates/brain/actions/list-captures.ts +10 -1
  23. package/corpus/templates/brain/actions/list-distillation-queue.ts +39 -14
  24. package/corpus/templates/brain/actions/list-projects.ts +51 -0
  25. package/corpus/templates/brain/actions/list-proposals.ts +80 -4
  26. package/corpus/templates/brain/actions/list-sources.ts +17 -3
  27. package/corpus/templates/brain/actions/manage-project.ts +118 -0
  28. package/corpus/templates/brain/actions/mark-capture-distilled.ts +68 -20
  29. package/corpus/templates/brain/actions/navigate.ts +1 -0
  30. package/corpus/templates/brain/actions/reject-proposal.ts +6 -1
  31. package/corpus/templates/brain/actions/resanitize-captures.ts +159 -33
  32. package/corpus/templates/brain/actions/review-proposal.ts +2 -0
  33. package/corpus/templates/brain/actions/search-everything.ts +10 -1
  34. package/corpus/templates/brain/actions/search-knowledge.ts +1 -1
  35. package/corpus/templates/brain/actions/set-settings.ts +36 -1
  36. package/corpus/templates/brain/actions/update-proposal.ts +2 -0
  37. package/corpus/templates/brain/app/hooks/use-distillation-bridge.ts +18 -9
  38. package/corpus/templates/brain/app/hooks/use-navigation-state.ts +3 -0
  39. package/corpus/templates/brain/app/i18n-data.ts +731 -2
  40. package/corpus/templates/brain/app/lib/brain.ts +33 -1
  41. package/corpus/templates/brain/app/routes/ops.tsx +47 -0
  42. package/corpus/templates/brain/app/routes/review.tsx +41 -2
  43. package/corpus/templates/brain/app/routes/search.tsx +33 -2
  44. package/corpus/templates/brain/app/routes/settings.tsx +158 -0
  45. package/corpus/templates/brain/app/routes/sources.tsx +31 -0
  46. package/corpus/templates/brain/changelog/2026-07-19-added-private-source-safe-semantic-search.md +6 -0
  47. package/corpus/templates/brain/jobs/process-ingest-queue.ts +270 -36
  48. package/corpus/templates/brain/package.json +1 -0
  49. package/corpus/templates/brain/server/db/index.ts +18 -0
  50. package/corpus/templates/brain/server/db/schema.ts +197 -1
  51. package/corpus/templates/brain/server/jobs/distillation-queue.ts +12 -5
  52. package/corpus/templates/brain/server/lib/audiences.ts +708 -0
  53. package/corpus/templates/brain/server/lib/brain-health.ts +251 -38
  54. package/corpus/templates/brain/server/lib/brain.ts +877 -50
  55. package/corpus/templates/brain/server/lib/capture-sanitization.ts +217 -53
  56. package/corpus/templates/brain/server/lib/connectors.ts +556 -70
  57. package/corpus/templates/brain/server/lib/demo.ts +17 -4
  58. package/corpus/templates/brain/server/lib/hybrid-search.ts +363 -0
  59. package/corpus/templates/brain/server/lib/ingest-queue.ts +104 -0
  60. package/corpus/templates/brain/server/lib/privacy-readiness.ts +24 -0
  61. package/corpus/templates/brain/server/lib/search-index-contracts.ts +72 -0
  62. package/corpus/templates/brain/server/lib/search-index.ts +714 -0
  63. package/corpus/templates/brain/server/lib/search.ts +120 -4
  64. package/corpus/templates/brain/server/lib/sensitivity-policy.ts +174 -0
  65. package/corpus/templates/brain/server/lib/slack-events.ts +281 -0
  66. package/corpus/templates/brain/server/onboarding.ts +29 -0
  67. package/corpus/templates/brain/server/plugins/db.ts +204 -0
  68. package/corpus/templates/brain/server/register-secrets.ts +46 -0
  69. package/corpus/templates/brain/server/routes/api/_agent-native/brain/ingest.post.ts +36 -16
  70. package/corpus/templates/brain/server/routes/api/_agent-native/brain/slack-events.post.ts +28 -0
  71. package/corpus/templates/brain/shared/types.ts +10 -0
  72. package/dist/cli/code-agent-executor.d.ts +8 -9
  73. package/dist/cli/code-agent-executor.d.ts.map +1 -1
  74. package/dist/cli/code-agent-executor.js +9 -3
  75. package/dist/cli/code-agent-executor.js.map +1 -1
  76. package/dist/cli/code-agent-runs.d.ts +5 -0
  77. package/dist/cli/code-agent-runs.d.ts.map +1 -1
  78. package/dist/cli/code-agent-runs.js +33 -2
  79. package/dist/cli/code-agent-runs.js.map +1 -1
  80. package/dist/cli/migration-codemod.d.ts.map +1 -1
  81. package/dist/cli/migration-codemod.js +48 -10
  82. package/dist/cli/migration-codemod.js.map +1 -1
  83. package/dist/cli/multi-frontier-runs.d.ts +131 -0
  84. package/dist/cli/multi-frontier-runs.d.ts.map +1 -0
  85. package/dist/cli/multi-frontier-runs.js +529 -0
  86. package/dist/cli/multi-frontier-runs.js.map +1 -0
  87. package/dist/coding-tools/index.d.ts +1 -0
  88. package/dist/coding-tools/index.d.ts.map +1 -1
  89. package/dist/coding-tools/index.js +25 -7
  90. package/dist/coding-tools/index.js.map +1 -1
  91. package/dist/collab/awareness.d.ts +2 -2
  92. package/dist/collab/awareness.d.ts.map +1 -1
  93. package/dist/embeddings/index.d.ts +24 -0
  94. package/dist/embeddings/index.d.ts.map +1 -0
  95. package/dist/embeddings/index.js +172 -0
  96. package/dist/embeddings/index.js.map +1 -0
  97. package/dist/index.d.ts +2 -0
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +2 -0
  100. package/dist/index.js.map +1 -1
  101. package/dist/notifications/routes.d.ts +3 -3
  102. package/dist/progress/routes.d.ts +1 -1
  103. package/dist/resources/handlers.d.ts +1 -1
  104. package/dist/search/index.d.ts +88 -0
  105. package/dist/search/index.d.ts.map +1 -0
  106. package/dist/search/index.js +267 -0
  107. package/dist/search/index.js.map +1 -0
  108. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  109. package/package.json +3 -1
@@ -0,0 +1,708 @@
1
+ import {
2
+ getRequestOrgId,
3
+ getRequestUserEmail,
4
+ } from "@agent-native/core/server/request-context";
5
+ import { accessFilter } from "@agent-native/core/sharing";
6
+ import { and, eq, gte, inArray, ne, or } from "drizzle-orm";
7
+
8
+ import { getDb, schema } from "../db/index.js";
9
+ import { nanoid, nowIso } from "./brain.js";
10
+ import type { BrainAudienceAssignment } from "./search-index-contracts.js";
11
+
12
+ export const SLACK_PRIVATE_AUDIENCE_TTL_MS = 15 * 60 * 1000;
13
+
14
+ export function isAudienceMembershipFresh(
15
+ kind: BrainAudienceAssignment["kind"],
16
+ lastSyncedAt: string,
17
+ nowMs = Date.now(),
18
+ ) {
19
+ return (
20
+ kind !== "slack-private-channel" ||
21
+ Date.parse(lastSyncedAt) >= nowMs - SLACK_PRIVATE_AUDIENCE_TTL_MS
22
+ );
23
+ }
24
+
25
+ async function sha256(value: string) {
26
+ const digest = await crypto.subtle.digest(
27
+ "SHA-256",
28
+ new TextEncoder().encode(value),
29
+ );
30
+ return Array.from(new Uint8Array(digest), (byte) =>
31
+ byte.toString(16).padStart(2, "0"),
32
+ ).join("");
33
+ }
34
+
35
+ function normalizeEmail(value: string) {
36
+ return value.trim().toLowerCase();
37
+ }
38
+
39
+ export async function computeAudienceAclHash(
40
+ kind: BrainAudienceAssignment["kind"],
41
+ principals: string[],
42
+ ) {
43
+ const principalKey = Array.from(new Set(principals)).sort().join(",");
44
+ return sha256(`${kind}:${principalKey}`);
45
+ }
46
+
47
+ export function audienceMembershipNeedsReplace(
48
+ previousAclHash: string | undefined,
49
+ nextAclHash: string,
50
+ ) {
51
+ return previousAclHash !== nextAclHash;
52
+ }
53
+
54
+ export async function computeCaptureAudienceId(input: {
55
+ sourceId: string;
56
+ kind: BrainAudienceAssignment["kind"];
57
+ upstreamRefHash: string | null;
58
+ aclHash: string;
59
+ }) {
60
+ const partitionKey = input.upstreamRefHash ?? input.aclHash;
61
+ const identityHash = await sha256(
62
+ `${input.sourceId}:${input.kind}:${partitionKey}`,
63
+ );
64
+ return `aud_${identityHash.slice(0, 24)}`;
65
+ }
66
+
67
+ type BrainDb = ReturnType<typeof getDb>;
68
+
69
+ interface CaptureAudienceInput {
70
+ captureId: string;
71
+ source: typeof schema.brainSources.$inferSelect;
72
+ kind?: BrainAudienceAssignment["kind"];
73
+ memberEmails?: string[];
74
+ upstreamRefHash?: string | null;
75
+ }
76
+
77
+ interface DeferredAudienceInvalidation {
78
+ captureId: string;
79
+ sourceId: string;
80
+ previousAclHash: string;
81
+ aclHash: string;
82
+ }
83
+
84
+ async function flushAudienceInvalidations(
85
+ invalidations: DeferredAudienceInvalidation[],
86
+ ) {
87
+ if (!invalidations.length) return;
88
+ const [{ enqueueCaptureInvalidation }, { invalidateDerivedForCapture }] =
89
+ await Promise.all([import("./ingest-queue.js"), import("./brain.js")]);
90
+ for (const invalidation of invalidations) {
91
+ await invalidateDerivedForCapture(invalidation.captureId);
92
+ await enqueueCaptureInvalidation({
93
+ captureId: invalidation.captureId,
94
+ sourceId: invalidation.sourceId,
95
+ reason: "access-changed",
96
+ previous: { aclHash: invalidation.previousAclHash },
97
+ next: { aclHash: invalidation.aclHash },
98
+ });
99
+ }
100
+ }
101
+
102
+ export async function ensureCaptureAudience(
103
+ input: CaptureAudienceInput,
104
+ ): Promise<BrainAudienceAssignment> {
105
+ const db = getDb();
106
+ const invalidations: DeferredAudienceInvalidation[] = [];
107
+ const assignment = await db.transaction((tx) =>
108
+ ensureCaptureAudienceMutation(
109
+ input,
110
+ tx as unknown as BrainDb,
111
+ invalidations,
112
+ ),
113
+ );
114
+ await flushAudienceInvalidations(invalidations);
115
+ return assignment;
116
+ }
117
+
118
+ async function ensureCaptureAudienceMutation(
119
+ input: CaptureAudienceInput,
120
+ db: BrainDb,
121
+ invalidations: DeferredAudienceInvalidation[],
122
+ ): Promise<BrainAudienceAssignment> {
123
+ const now = nowIso();
124
+ const kind =
125
+ input.kind ??
126
+ (input.source.visibility === "private" ? "restricted" : "org");
127
+ let members = Array.from(
128
+ new Set((input.memberEmails ?? []).map(normalizeEmail).filter(Boolean)),
129
+ ).sort();
130
+ if (input.source.visibility === "private" && kind !== "org") {
131
+ members = [normalizeEmail(input.source.ownerEmail)];
132
+ }
133
+ const principalKey =
134
+ kind === "org" ? `org:${input.source.orgId ?? "local"}` : members.join(",");
135
+ const aclHash = await computeAudienceAclHash(kind, [principalKey]);
136
+ const upstreamRefHash = input.upstreamRefHash
137
+ ? await sha256(input.upstreamRefHash)
138
+ : null;
139
+ const audienceId = await computeCaptureAudienceId({
140
+ sourceId: input.source.id,
141
+ kind,
142
+ upstreamRefHash,
143
+ aclHash,
144
+ });
145
+ const [previousAudience] = await db
146
+ .select({ aclHash: schema.brainAudiences.aclHash })
147
+ .from(schema.brainAudiences)
148
+ .where(eq(schema.brainAudiences.id, audienceId))
149
+ .limit(1);
150
+
151
+ await db
152
+ .insert(schema.brainAudiences)
153
+ .values({
154
+ id: audienceId,
155
+ sourceId: input.source.id,
156
+ kind,
157
+ upstreamRefHash,
158
+ aclHash,
159
+ membershipState: "current",
160
+ lastSyncedAt: now,
161
+ createdAt: now,
162
+ updatedAt: now,
163
+ })
164
+ .onConflictDoUpdate({
165
+ target: schema.brainAudiences.id,
166
+ set: {
167
+ sourceId: input.source.id,
168
+ kind,
169
+ upstreamRefHash,
170
+ aclHash,
171
+ membershipState: "current",
172
+ lastSyncedAt: now,
173
+ updatedAt: now,
174
+ },
175
+ });
176
+
177
+ if (kind === "org" && input.source.orgId) {
178
+ members.splice(0, members.length);
179
+ await upsertAudienceMember(
180
+ {
181
+ audienceId,
182
+ principalType: "org",
183
+ principalId: input.source.orgId,
184
+ syncedAt: now,
185
+ },
186
+ db,
187
+ );
188
+ } else if (kind === "org") {
189
+ await upsertAudienceMember(
190
+ {
191
+ audienceId,
192
+ principalType: "user",
193
+ principalId: normalizeEmail(input.source.ownerEmail),
194
+ syncedAt: now,
195
+ },
196
+ db,
197
+ );
198
+ } else if (
199
+ audienceMembershipNeedsReplace(previousAudience?.aclHash, aclHash)
200
+ ) {
201
+ await replaceAudienceUserMembersMutation(
202
+ audienceId,
203
+ members.map((email) => ({ email })),
204
+ { previousAclHash: previousAudience?.aclHash },
205
+ db,
206
+ invalidations,
207
+ );
208
+ }
209
+
210
+ await db
211
+ .delete(schema.brainCaptureAudiences)
212
+ .where(eq(schema.brainCaptureAudiences.captureId, input.captureId));
213
+ await db
214
+ .insert(schema.brainCaptureAudiences)
215
+ .values({
216
+ id: nanoid(),
217
+ captureId: input.captureId,
218
+ audienceId,
219
+ aclHash,
220
+ createdAt: now,
221
+ })
222
+ .onConflictDoUpdate({
223
+ target: [
224
+ schema.brainCaptureAudiences.captureId,
225
+ schema.brainCaptureAudiences.audienceId,
226
+ ],
227
+ set: { aclHash },
228
+ });
229
+
230
+ return { audienceId, aclHash, kind };
231
+ }
232
+
233
+ export async function upsertAudienceMember(
234
+ input: {
235
+ audienceId: string;
236
+ principalType: "user" | "org";
237
+ principalId: string;
238
+ upstreamPrincipalHash?: string | null;
239
+ syncedAt: string;
240
+ },
241
+ db = getDb(),
242
+ ) {
243
+ const now = nowIso();
244
+ await db
245
+ .insert(schema.brainAudienceMembers)
246
+ .values({
247
+ id: nanoid(),
248
+ ...input,
249
+ upstreamPrincipalHash: input.upstreamPrincipalHash ?? null,
250
+ status: "active",
251
+ createdAt: now,
252
+ updatedAt: now,
253
+ })
254
+ .onConflictDoUpdate({
255
+ target: [
256
+ schema.brainAudienceMembers.audienceId,
257
+ schema.brainAudienceMembers.principalType,
258
+ schema.brainAudienceMembers.principalId,
259
+ ],
260
+ set: {
261
+ upstreamPrincipalHash: input.upstreamPrincipalHash ?? null,
262
+ status: "active",
263
+ syncedAt: input.syncedAt,
264
+ updatedAt: now,
265
+ },
266
+ });
267
+ }
268
+
269
+ export async function ensureEvidenceIntersectionAudience(input: {
270
+ captureIds: string[];
271
+ sourceId: string;
272
+ }): Promise<BrainAudienceAssignment> {
273
+ const captureIds = Array.from(new Set(input.captureIds));
274
+ if (!captureIds.length) {
275
+ throw new Error("Evidence is required to derive a Brain audience");
276
+ }
277
+ const db = getDb();
278
+ const assignments = await db
279
+ .select({
280
+ captureId: schema.brainCaptureAudiences.captureId,
281
+ audienceId: schema.brainCaptureAudiences.audienceId,
282
+ aclHash: schema.brainCaptureAudiences.aclHash,
283
+ kind: schema.brainAudiences.kind,
284
+ sourceId: schema.brainRawCaptures.sourceId,
285
+ sourceOrgId: schema.brainSources.orgId,
286
+ sourceOwnerEmail: schema.brainSources.ownerEmail,
287
+ })
288
+ .from(schema.brainCaptureAudiences)
289
+ .innerJoin(
290
+ schema.brainAudiences,
291
+ eq(schema.brainAudiences.id, schema.brainCaptureAudiences.audienceId),
292
+ )
293
+ .innerJoin(
294
+ schema.brainRawCaptures,
295
+ eq(schema.brainRawCaptures.id, schema.brainCaptureAudiences.captureId),
296
+ )
297
+ .innerJoin(
298
+ schema.brainSources,
299
+ eq(schema.brainSources.id, schema.brainRawCaptures.sourceId),
300
+ )
301
+ .where(inArray(schema.brainCaptureAudiences.captureId, captureIds));
302
+ if (
303
+ new Set(assignments.map((assignment) => assignment.captureId)).size !==
304
+ captureIds.length
305
+ ) {
306
+ throw new Error("Every evidence capture must have a current audience");
307
+ }
308
+ const tenantKey = assertSingleEvidenceTenant(assignments);
309
+ const audienceIds = Array.from(
310
+ new Set(assignments.map((assignment) => assignment.audienceId)),
311
+ );
312
+ if (audienceIds.length === 1) {
313
+ const assignment = assignments[0]!;
314
+ return {
315
+ audienceId: assignment.audienceId,
316
+ aclHash: assignment.aclHash,
317
+ kind: assignment.kind as BrainAudienceAssignment["kind"],
318
+ };
319
+ }
320
+ const members = await db
321
+ .select({
322
+ audienceId: schema.brainAudienceMembers.audienceId,
323
+ principalType: schema.brainAudienceMembers.principalType,
324
+ principalId: schema.brainAudienceMembers.principalId,
325
+ })
326
+ .from(schema.brainAudienceMembers)
327
+ .where(
328
+ and(
329
+ inArray(schema.brainAudienceMembers.audienceId, audienceIds),
330
+ eq(schema.brainAudienceMembers.status, "active"),
331
+ ),
332
+ );
333
+ const nonOrgAudienceIds = assignments
334
+ .filter((assignment) => assignment.kind !== "org")
335
+ .map((assignment) => assignment.audienceId);
336
+ const restrictedSets = Array.from(new Set(nonOrgAudienceIds)).map(
337
+ (audienceId) =>
338
+ new Set(
339
+ members
340
+ .filter(
341
+ (member) =>
342
+ member.audienceId === audienceId &&
343
+ member.principalType === "user",
344
+ )
345
+ .map((member) => normalizeEmail(member.principalId)),
346
+ ),
347
+ );
348
+ let principalType: "user" | "org";
349
+ let principals: string[];
350
+ let kind: BrainAudienceAssignment["kind"];
351
+ if (restrictedSets.length) {
352
+ principalType = "user";
353
+ principals = [...restrictedSets[0]!].filter((principal) =>
354
+ restrictedSets.every((set) => set.has(principal)),
355
+ );
356
+ kind = "restricted";
357
+ } else {
358
+ principalType = "org";
359
+ principals = Array.from(
360
+ new Set(
361
+ members
362
+ .filter((member) => member.principalType === "org")
363
+ .map((member) => member.principalId),
364
+ ),
365
+ );
366
+ if (principals.length > 1) principals = [];
367
+ kind = "org";
368
+ }
369
+ if (!principals.length) {
370
+ throw new Error("Evidence audiences have no common members");
371
+ }
372
+ const aclHash = await computeAudienceAclHash(kind, principals);
373
+ const identityHash = await sha256(
374
+ `evidence:${tenantKey}:${audienceIds.sort().join(",")}`,
375
+ );
376
+ const audienceId = `aud_${identityHash.slice(0, 24)}`;
377
+ const now = nowIso();
378
+ await db
379
+ .insert(schema.brainAudiences)
380
+ .values({
381
+ id: audienceId,
382
+ sourceId: input.sourceId,
383
+ kind,
384
+ upstreamRefHash: null,
385
+ aclHash,
386
+ membershipState: "current",
387
+ lastSyncedAt: now,
388
+ createdAt: now,
389
+ updatedAt: now,
390
+ })
391
+ .onConflictDoUpdate({
392
+ target: schema.brainAudiences.id,
393
+ set: {
394
+ aclHash,
395
+ membershipState: "current",
396
+ lastSyncedAt: now,
397
+ updatedAt: now,
398
+ },
399
+ });
400
+ await db
401
+ .delete(schema.brainAudienceDependencies)
402
+ .where(eq(schema.brainAudienceDependencies.audienceId, audienceId));
403
+ for (const dependsOnAudienceId of audienceIds) {
404
+ await db
405
+ .insert(schema.brainAudienceDependencies)
406
+ .values({
407
+ id: nanoid(),
408
+ audienceId,
409
+ dependsOnAudienceId,
410
+ createdAt: now,
411
+ })
412
+ .onConflictDoUpdate({
413
+ target: [
414
+ schema.brainAudienceDependencies.audienceId,
415
+ schema.brainAudienceDependencies.dependsOnAudienceId,
416
+ ],
417
+ set: { dependsOnAudienceId },
418
+ });
419
+ }
420
+ await db
421
+ .delete(schema.brainAudienceSourceDependencies)
422
+ .where(eq(schema.brainAudienceSourceDependencies.audienceId, audienceId));
423
+ const evidenceSourceIds = Array.from(
424
+ new Set(assignments.map((assignment) => assignment.sourceId)),
425
+ );
426
+ for (const sourceId of evidenceSourceIds) {
427
+ await db
428
+ .insert(schema.brainAudienceSourceDependencies)
429
+ .values({ id: nanoid(), audienceId, sourceId, createdAt: now })
430
+ .onConflictDoUpdate({
431
+ target: [
432
+ schema.brainAudienceSourceDependencies.audienceId,
433
+ schema.brainAudienceSourceDependencies.sourceId,
434
+ ],
435
+ set: { sourceId },
436
+ });
437
+ }
438
+ for (const principalId of principals) {
439
+ await upsertAudienceMember({
440
+ audienceId,
441
+ principalType,
442
+ principalId,
443
+ syncedAt: now,
444
+ });
445
+ }
446
+ return { audienceId, aclHash, kind };
447
+ }
448
+
449
+ export function assertSingleEvidenceTenant(
450
+ assignments: Array<{
451
+ sourceOrgId: string | null;
452
+ sourceOwnerEmail: string;
453
+ }>,
454
+ ) {
455
+ const tenantKeys = new Set(
456
+ assignments.map((assignment) =>
457
+ assignment.sourceOrgId
458
+ ? `org:${assignment.sourceOrgId}`
459
+ : `owner:${normalizeEmail(assignment.sourceOwnerEmail)}`,
460
+ ),
461
+ );
462
+ if (tenantKeys.size !== 1) {
463
+ throw new Error("Evidence audiences must belong to the same tenant");
464
+ }
465
+ return Array.from(tenantKeys)[0]!;
466
+ }
467
+
468
+ export async function replaceAudienceUserMembers(
469
+ audienceId: string,
470
+ members: Array<{ email: string; upstreamPrincipalHash?: string }>,
471
+ options: { previousAclHash?: string } = {},
472
+ ) {
473
+ const db = getDb();
474
+ const invalidations: DeferredAudienceInvalidation[] = [];
475
+ const result = await db.transaction((tx) =>
476
+ replaceAudienceUserMembersMutation(
477
+ audienceId,
478
+ members,
479
+ options,
480
+ tx as unknown as BrainDb,
481
+ invalidations,
482
+ ),
483
+ );
484
+ await flushAudienceInvalidations(invalidations);
485
+ return result;
486
+ }
487
+
488
+ async function replaceAudienceUserMembersMutation(
489
+ audienceId: string,
490
+ members: Array<{ email: string; upstreamPrincipalHash?: string }>,
491
+ options: { previousAclHash?: string },
492
+ db: BrainDb,
493
+ invalidations: DeferredAudienceInvalidation[],
494
+ ) {
495
+ const now = nowIso();
496
+ const emails = Array.from(
497
+ new Set(members.map((member) => normalizeEmail(member.email))),
498
+ ).sort();
499
+ const [audience] = await db
500
+ .select({
501
+ kind: schema.brainAudiences.kind,
502
+ aclHash: schema.brainAudiences.aclHash,
503
+ sourceId: schema.brainAudiences.sourceId,
504
+ })
505
+ .from(schema.brainAudiences)
506
+ .where(eq(schema.brainAudiences.id, audienceId))
507
+ .limit(1);
508
+ if (!audience) throw new Error(`Audience not found: ${audienceId}`);
509
+ await db
510
+ .update(schema.brainAudienceMembers)
511
+ .set({ status: "revoked", updatedAt: now })
512
+ .where(
513
+ and(
514
+ eq(schema.brainAudienceMembers.audienceId, audienceId),
515
+ eq(schema.brainAudienceMembers.principalType, "user"),
516
+ ),
517
+ );
518
+ for (const email of emails) {
519
+ const member = members.find(
520
+ (candidate) => normalizeEmail(candidate.email) === email,
521
+ );
522
+ await upsertAudienceMember(
523
+ {
524
+ audienceId,
525
+ principalType: "user",
526
+ principalId: email,
527
+ upstreamPrincipalHash: member?.upstreamPrincipalHash,
528
+ syncedAt: now,
529
+ },
530
+ db,
531
+ );
532
+ }
533
+ const aclHash = await computeAudienceAclHash(
534
+ audience.kind as BrainAudienceAssignment["kind"],
535
+ emails,
536
+ );
537
+ await db
538
+ .update(schema.brainAudiences)
539
+ .set({
540
+ aclHash,
541
+ membershipState: "current",
542
+ lastSyncedAt: now,
543
+ updatedAt: now,
544
+ })
545
+ .where(eq(schema.brainAudiences.id, audienceId));
546
+ const previousAclHash = options.previousAclHash ?? audience.aclHash;
547
+ if (previousAclHash !== aclHash) {
548
+ const captures = await db
549
+ .select({ captureId: schema.brainCaptureAudiences.captureId })
550
+ .from(schema.brainCaptureAudiences)
551
+ .where(eq(schema.brainCaptureAudiences.audienceId, audienceId));
552
+ const captureIds = captures.map((capture) => capture.captureId);
553
+ await db
554
+ .update(schema.brainCaptureAudiences)
555
+ .set({ aclHash })
556
+ .where(eq(schema.brainCaptureAudiences.audienceId, audienceId));
557
+ if (captureIds.length) {
558
+ await db
559
+ .update(schema.brainRawCaptures)
560
+ .set({ audienceAclHash: aclHash, updatedAt: now })
561
+ .where(inArray(schema.brainRawCaptures.id, captureIds));
562
+ for (const captureId of captureIds) {
563
+ invalidations.push({
564
+ captureId,
565
+ sourceId: audience.sourceId,
566
+ previousAclHash,
567
+ aclHash,
568
+ });
569
+ }
570
+ }
571
+ }
572
+ return { audienceId, aclHash, members: emails.length };
573
+ }
574
+
575
+ export async function listAccessibleAudienceIds(sourceIds?: string[]) {
576
+ const userEmail = getRequestUserEmail()?.trim().toLowerCase();
577
+ const orgId = getRequestOrgId();
578
+ if (!userEmail && !orgId) return [];
579
+ const principalFilter = or(
580
+ userEmail
581
+ ? and(
582
+ eq(schema.brainAudienceMembers.principalType, "user"),
583
+ eq(schema.brainAudienceMembers.principalId, userEmail),
584
+ )
585
+ : undefined,
586
+ orgId
587
+ ? and(
588
+ eq(schema.brainAudienceMembers.principalType, "org"),
589
+ eq(schema.brainAudienceMembers.principalId, orgId),
590
+ )
591
+ : undefined,
592
+ );
593
+ if (!principalFilter) return [];
594
+ const slackFreshnessCutoff = new Date(
595
+ Date.now() - SLACK_PRIVATE_AUDIENCE_TTL_MS,
596
+ ).toISOString();
597
+ const rows = await getDb()
598
+ .selectDistinct({
599
+ id: schema.brainAudiences.id,
600
+ sourceId: schema.brainAudiences.sourceId,
601
+ })
602
+ .from(schema.brainAudiences)
603
+ .innerJoin(
604
+ schema.brainAudienceMembers,
605
+ eq(schema.brainAudienceMembers.audienceId, schema.brainAudiences.id),
606
+ )
607
+ .where(
608
+ and(
609
+ eq(schema.brainAudiences.membershipState, "current"),
610
+ or(
611
+ ne(schema.brainAudiences.kind, "slack-private-channel"),
612
+ gte(schema.brainAudiences.lastSyncedAt, slackFreshnessCutoff),
613
+ ),
614
+ eq(schema.brainAudienceMembers.status, "active"),
615
+ principalFilter,
616
+ ),
617
+ );
618
+ if (!rows.length) return [];
619
+ const dependencies = await getDb()
620
+ .select({
621
+ audienceId: schema.brainAudienceDependencies.audienceId,
622
+ dependsOnAudienceId: schema.brainAudienceDependencies.dependsOnAudienceId,
623
+ })
624
+ .from(schema.brainAudienceDependencies)
625
+ .where(
626
+ inArray(
627
+ schema.brainAudienceDependencies.audienceId,
628
+ rows.map((row) => row.id),
629
+ ),
630
+ );
631
+ const sourceDependencies = await getDb()
632
+ .select({
633
+ audienceId: schema.brainAudienceSourceDependencies.audienceId,
634
+ sourceId: schema.brainAudienceSourceDependencies.sourceId,
635
+ })
636
+ .from(schema.brainAudienceSourceDependencies)
637
+ .where(
638
+ inArray(
639
+ schema.brainAudienceSourceDependencies.audienceId,
640
+ rows.map((row) => row.id),
641
+ ),
642
+ );
643
+ const dependencySourceIds = Array.from(
644
+ new Set(sourceDependencies.map((dependency) => dependency.sourceId)),
645
+ );
646
+ const accessibleDependencySourceIds = dependencySourceIds.length
647
+ ? (
648
+ await getDb()
649
+ .select({ id: schema.brainSources.id })
650
+ .from(schema.brainSources)
651
+ .where(
652
+ and(
653
+ inArray(schema.brainSources.id, dependencySourceIds),
654
+ accessFilter(schema.brainSources, schema.brainSourceShares),
655
+ ),
656
+ )
657
+ ).map((source) => source.id)
658
+ : [];
659
+ const accessibleIds = filterAudienceIdsByDependencies(
660
+ rows.map((row) => row.id),
661
+ dependencies,
662
+ sourceDependencies,
663
+ accessibleDependencySourceIds,
664
+ );
665
+ return rows
666
+ .filter(
667
+ (row) =>
668
+ accessibleIds.has(row.id) &&
669
+ (!sourceIds?.length || sourceIds.includes(row.sourceId)),
670
+ )
671
+ .map((row) => row.id);
672
+ }
673
+
674
+ export function filterAudienceIdsByDependencies(
675
+ candidateIds: string[],
676
+ dependencies: Array<{
677
+ audienceId: string;
678
+ dependsOnAudienceId: string;
679
+ }>,
680
+ sourceDependencies: Array<{ audienceId: string; sourceId: string }> = [],
681
+ accessibleSourceIds: string[] = [],
682
+ ) {
683
+ const accessibleIds = new Set(candidateIds);
684
+ const accessibleSources = new Set(accessibleSourceIds);
685
+ let changed = true;
686
+ while (changed) {
687
+ changed = false;
688
+ for (const dependency of dependencies) {
689
+ if (
690
+ accessibleIds.has(dependency.audienceId) &&
691
+ !accessibleIds.has(dependency.dependsOnAudienceId)
692
+ ) {
693
+ accessibleIds.delete(dependency.audienceId);
694
+ changed = true;
695
+ }
696
+ }
697
+ for (const dependency of sourceDependencies) {
698
+ if (
699
+ accessibleIds.has(dependency.audienceId) &&
700
+ !accessibleSources.has(dependency.sourceId)
701
+ ) {
702
+ accessibleIds.delete(dependency.audienceId);
703
+ changed = true;
704
+ }
705
+ }
706
+ }
707
+ return accessibleIds;
708
+ }