@agent-native/core 0.111.4 → 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 (104) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +12 -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/multi-frontier-runs.ts +841 -0
  7. package/corpus/core/src/coding-tools/index.ts +27 -8
  8. package/corpus/core/src/embeddings/index.ts +233 -0
  9. package/corpus/core/src/index.ts +2 -0
  10. package/corpus/core/src/search/index.ts +413 -0
  11. package/corpus/templates/brain/.agents/skills/brain/RUNBOOK.md +48 -5
  12. package/corpus/templates/brain/.agents/skills/brain/SKILL.md +46 -14
  13. package/corpus/templates/brain/README.md +34 -3
  14. package/corpus/templates/brain/actions/approve-proposal.ts +2 -0
  15. package/corpus/templates/brain/actions/claim-distillation.ts +24 -27
  16. package/corpus/templates/brain/actions/create-source.ts +1 -1
  17. package/corpus/templates/brain/actions/get-knowledge.ts +6 -2
  18. package/corpus/templates/brain/actions/get-pilot-report.ts +44 -8
  19. package/corpus/templates/brain/actions/import-capture.ts +21 -9
  20. package/corpus/templates/brain/actions/import-transcript.ts +42 -14
  21. package/corpus/templates/brain/actions/list-captures.ts +10 -1
  22. package/corpus/templates/brain/actions/list-distillation-queue.ts +39 -14
  23. package/corpus/templates/brain/actions/list-projects.ts +51 -0
  24. package/corpus/templates/brain/actions/list-proposals.ts +80 -4
  25. package/corpus/templates/brain/actions/list-sources.ts +17 -3
  26. package/corpus/templates/brain/actions/manage-project.ts +118 -0
  27. package/corpus/templates/brain/actions/mark-capture-distilled.ts +68 -20
  28. package/corpus/templates/brain/actions/navigate.ts +1 -0
  29. package/corpus/templates/brain/actions/reject-proposal.ts +6 -1
  30. package/corpus/templates/brain/actions/resanitize-captures.ts +159 -33
  31. package/corpus/templates/brain/actions/review-proposal.ts +2 -0
  32. package/corpus/templates/brain/actions/search-everything.ts +10 -1
  33. package/corpus/templates/brain/actions/search-knowledge.ts +1 -1
  34. package/corpus/templates/brain/actions/set-settings.ts +36 -1
  35. package/corpus/templates/brain/actions/update-proposal.ts +2 -0
  36. package/corpus/templates/brain/app/hooks/use-distillation-bridge.ts +18 -9
  37. package/corpus/templates/brain/app/hooks/use-navigation-state.ts +3 -0
  38. package/corpus/templates/brain/app/i18n-data.ts +731 -2
  39. package/corpus/templates/brain/app/lib/brain.ts +33 -1
  40. package/corpus/templates/brain/app/routes/ops.tsx +47 -0
  41. package/corpus/templates/brain/app/routes/review.tsx +41 -2
  42. package/corpus/templates/brain/app/routes/search.tsx +33 -2
  43. package/corpus/templates/brain/app/routes/settings.tsx +158 -0
  44. package/corpus/templates/brain/app/routes/sources.tsx +31 -0
  45. package/corpus/templates/brain/changelog/2026-07-19-added-private-source-safe-semantic-search.md +6 -0
  46. package/corpus/templates/brain/jobs/process-ingest-queue.ts +270 -36
  47. package/corpus/templates/brain/package.json +1 -0
  48. package/corpus/templates/brain/server/db/index.ts +18 -0
  49. package/corpus/templates/brain/server/db/schema.ts +197 -1
  50. package/corpus/templates/brain/server/jobs/distillation-queue.ts +12 -5
  51. package/corpus/templates/brain/server/lib/audiences.ts +708 -0
  52. package/corpus/templates/brain/server/lib/brain-health.ts +251 -38
  53. package/corpus/templates/brain/server/lib/brain.ts +877 -50
  54. package/corpus/templates/brain/server/lib/capture-sanitization.ts +217 -53
  55. package/corpus/templates/brain/server/lib/connectors.ts +556 -70
  56. package/corpus/templates/brain/server/lib/demo.ts +17 -4
  57. package/corpus/templates/brain/server/lib/hybrid-search.ts +363 -0
  58. package/corpus/templates/brain/server/lib/ingest-queue.ts +104 -0
  59. package/corpus/templates/brain/server/lib/privacy-readiness.ts +24 -0
  60. package/corpus/templates/brain/server/lib/search-index-contracts.ts +72 -0
  61. package/corpus/templates/brain/server/lib/search-index.ts +714 -0
  62. package/corpus/templates/brain/server/lib/search.ts +120 -4
  63. package/corpus/templates/brain/server/lib/sensitivity-policy.ts +174 -0
  64. package/corpus/templates/brain/server/lib/slack-events.ts +281 -0
  65. package/corpus/templates/brain/server/onboarding.ts +29 -0
  66. package/corpus/templates/brain/server/plugins/db.ts +204 -0
  67. package/corpus/templates/brain/server/register-secrets.ts +46 -0
  68. package/corpus/templates/brain/server/routes/api/_agent-native/brain/ingest.post.ts +36 -16
  69. package/corpus/templates/brain/server/routes/api/_agent-native/brain/slack-events.post.ts +28 -0
  70. package/corpus/templates/brain/shared/types.ts +10 -0
  71. package/dist/cli/code-agent-executor.d.ts +8 -9
  72. package/dist/cli/code-agent-executor.d.ts.map +1 -1
  73. package/dist/cli/code-agent-executor.js +9 -3
  74. package/dist/cli/code-agent-executor.js.map +1 -1
  75. package/dist/cli/code-agent-runs.d.ts +5 -0
  76. package/dist/cli/code-agent-runs.d.ts.map +1 -1
  77. package/dist/cli/code-agent-runs.js +33 -2
  78. package/dist/cli/code-agent-runs.js.map +1 -1
  79. package/dist/cli/multi-frontier-runs.d.ts +131 -0
  80. package/dist/cli/multi-frontier-runs.d.ts.map +1 -0
  81. package/dist/cli/multi-frontier-runs.js +529 -0
  82. package/dist/cli/multi-frontier-runs.js.map +1 -0
  83. package/dist/coding-tools/index.d.ts +1 -0
  84. package/dist/coding-tools/index.d.ts.map +1 -1
  85. package/dist/coding-tools/index.js +25 -7
  86. package/dist/coding-tools/index.js.map +1 -1
  87. package/dist/collab/awareness.d.ts +2 -2
  88. package/dist/collab/awareness.d.ts.map +1 -1
  89. package/dist/embeddings/index.d.ts +24 -0
  90. package/dist/embeddings/index.d.ts.map +1 -0
  91. package/dist/embeddings/index.js +172 -0
  92. package/dist/embeddings/index.js.map +1 -0
  93. package/dist/index.d.ts +2 -0
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/index.js +2 -0
  96. package/dist/index.js.map +1 -1
  97. package/dist/notifications/routes.d.ts +3 -3
  98. package/dist/progress/routes.d.ts +1 -1
  99. package/dist/resources/handlers.d.ts +1 -1
  100. package/dist/search/index.d.ts +88 -0
  101. package/dist/search/index.d.ts.map +1 -0
  102. package/dist/search/index.js +267 -0
  103. package/dist/search/index.js.map +1 -0
  104. package/package.json +3 -1
@@ -1,9 +1,15 @@
1
1
  import { readAppState } from "@agent-native/core/application-state";
2
+ import {
3
+ deletePrivateBlob,
4
+ putPrivateBlob,
5
+ type PrivateBlobHandle,
6
+ } from "@agent-native/core/private-blob";
2
7
  import {
3
8
  resourceDeleteByPath,
4
9
  resourcePut,
5
10
  SHARED_OWNER,
6
11
  } from "@agent-native/core/resources/store";
12
+ import { encryptSecretValue } from "@agent-native/core/secrets";
7
13
  import {
8
14
  getRequestOrgId,
9
15
  getRequestUserEmail,
@@ -15,7 +21,18 @@ import {
15
21
  resolveAccess,
16
22
  type ResolvedAccess,
17
23
  } from "@agent-native/core/sharing";
18
- import { and, desc, eq, inArray, isNull, like, or } from "drizzle-orm";
24
+ import {
25
+ and,
26
+ desc,
27
+ eq,
28
+ inArray,
29
+ isNull,
30
+ like,
31
+ lte,
32
+ notExists,
33
+ or,
34
+ sql,
35
+ } from "drizzle-orm";
19
36
 
20
37
  import {
21
38
  DEFAULT_BRAIN_SETTINGS,
@@ -31,7 +48,20 @@ import {
31
48
  type BrainSourceStatus,
32
49
  } from "../../shared/types.js";
33
50
  import { getDb, schema } from "../db/index.js";
51
+ import {
52
+ ensureCaptureAudience,
53
+ ensureEvidenceIntersectionAudience,
54
+ listAccessibleAudienceIds,
55
+ } from "./audiences.js";
34
56
  import { sanitizeCaptureForStorage } from "./capture-sanitization.js";
57
+ import {
58
+ enqueueCaptureInvalidation,
59
+ enqueueBrainOperation,
60
+ } from "./ingest-queue.js";
61
+ import type {
62
+ BrainAudienceAssignment,
63
+ BrainSensitivityDecision,
64
+ } from "./search-index-contracts.js";
35
65
 
36
66
  export const BRAIN_SETTINGS_KEY = "brain-settings";
37
67
 
@@ -95,7 +125,12 @@ export function safeCitationUrl(value: unknown): string | null {
95
125
  if (typeof value !== "string") return null;
96
126
  const trimmed = value.trim();
97
127
  if (!trimmed || isDirectGranolaSummaryUrl(trimmed)) return null;
98
- return trimmed;
128
+ try {
129
+ const url = new URL(trimmed);
130
+ return url.protocol === "https:" ? url.toString() : null;
131
+ } catch {
132
+ return null;
133
+ }
99
134
  }
100
135
 
101
136
  export function sourceUrlFromMetadataRecord(
@@ -429,6 +464,8 @@ export function serializeKnowledge(
429
464
  id: row.id,
430
465
  sourceId: row.sourceId,
431
466
  captureId: row.captureId,
467
+ audienceId: row.audienceId,
468
+ audienceAclHash: row.audienceAclHash,
432
469
  kind: row.kind as BrainKnowledgeKind,
433
470
  title: row.title,
434
471
  body: row.body,
@@ -464,6 +501,8 @@ export function serializeProposal(
464
501
  knowledgeId: row.knowledgeId,
465
502
  sourceId: row.sourceId,
466
503
  captureId: row.captureId,
504
+ audienceId: row.audienceId,
505
+ audienceAclHash: row.audienceAclHash,
467
506
  title: row.title,
468
507
  body: row.body,
469
508
  rationale: row.rationale,
@@ -505,15 +544,46 @@ export async function getAccessibleCapture(captureId: string) {
505
544
  if (!capture) return null;
506
545
  const sourceAccess = await resolveAccess("brain-source", capture.sourceId);
507
546
  if (!sourceAccess) return null;
547
+ if (capture.sensitivityDisposition !== "allowed") return null;
548
+ const audienceIds = await listAccessibleAudienceIds([capture.sourceId]);
549
+ if (!audienceIds.length) return null;
550
+ const [captureAudience] = await db
551
+ .select({ id: schema.brainCaptureAudiences.id })
552
+ .from(schema.brainCaptureAudiences)
553
+ .where(
554
+ and(
555
+ eq(schema.brainCaptureAudiences.captureId, captureId),
556
+ inArray(schema.brainCaptureAudiences.audienceId, audienceIds),
557
+ ),
558
+ )
559
+ .limit(1);
560
+ if (!captureAudience) return null;
508
561
  return { capture, source: sourceAccess.resource, role: sourceAccess.role };
509
562
  }
510
563
 
564
+ export async function assertDerivedAudienceAccess(row: {
565
+ captureId?: string | null;
566
+ audienceId?: string | null;
567
+ sourceId?: string | null;
568
+ }) {
569
+ if (!row.captureId) return;
570
+ if (!row.audienceId || !row.sourceId) {
571
+ throw new Error("No access to derived Brain evidence");
572
+ }
573
+ const sourceAccess = await resolveAccess("brain-source", row.sourceId);
574
+ if (!sourceAccess) throw new Error("No access to derived Brain evidence");
575
+ const audienceIds = await listAccessibleAudienceIds([row.sourceId]);
576
+ if (!audienceIds.includes(row.audienceId)) {
577
+ throw new Error("No access to derived Brain evidence");
578
+ }
579
+ }
580
+
511
581
  export async function createSource(values: {
512
582
  id?: string;
513
583
  title: string;
514
584
  provider: BrainSourceProvider;
515
585
  config?: Record<string, unknown>;
516
- visibility?: "private" | "org" | "public";
586
+ visibility?: "private" | "org";
517
587
  }) {
518
588
  const db = getDb();
519
589
  const now = nowIso();
@@ -577,6 +647,52 @@ function isUniqueConflict(error: unknown): boolean {
577
647
  return /unique constraint|duplicate key|unique/i.test(message);
578
648
  }
579
649
 
650
+ const UPSTREAM_DELETED_POLICY_VERSION = "upstream-deleted-v1";
651
+
652
+ async function upstreamDeletionLocatorHmac(
653
+ sourceId: string,
654
+ externalId: string,
655
+ ) {
656
+ return sha256Hex(`${sourceId}\0${externalId}`);
657
+ }
658
+
659
+ async function findUpstreamDeletionReceipt(
660
+ sourceId: string,
661
+ externalId: string,
662
+ ): Promise<BrainSensitivityReceipt | null> {
663
+ const [receipt] = await getDb()
664
+ .select({
665
+ id: schema.brainSensitivityEvents.id,
666
+ sourceId: schema.brainSensitivityEvents.sourceId,
667
+ })
668
+ .from(schema.brainSensitivityEvents)
669
+ .where(
670
+ and(
671
+ eq(
672
+ schema.brainSensitivityEvents.locatorHmac,
673
+ await upstreamDeletionLocatorHmac(sourceId, externalId),
674
+ ),
675
+ eq(
676
+ schema.brainSensitivityEvents.policyVersion,
677
+ UPSTREAM_DELETED_POLICY_VERSION,
678
+ ),
679
+ eq(schema.brainSensitivityEvents.disposition, "suppressed"),
680
+ ),
681
+ )
682
+ .limit(1);
683
+ return receipt
684
+ ? {
685
+ id: receipt.id,
686
+ sourceId: receipt.sourceId,
687
+ disposition: "suppressed",
688
+ categories: [],
689
+ confidenceBand: "deterministic",
690
+ policyVersion: UPSTREAM_DELETED_POLICY_VERSION,
691
+ expiresAt: null,
692
+ }
693
+ : null;
694
+ }
695
+
580
696
  export async function createCapture(values: {
581
697
  id?: string;
582
698
  sourceId: string;
@@ -587,6 +703,11 @@ export async function createCapture(values: {
587
703
  metadata?: Record<string, unknown>;
588
704
  capturedAt?: string;
589
705
  status?: "queued" | "distilling" | "distilled" | "ignored";
706
+ audience?: {
707
+ kind: BrainAudienceAssignment["kind"];
708
+ memberEmails?: string[];
709
+ upstreamRefHash?: string | null;
710
+ };
590
711
  }) {
591
712
  const sourceAccess = await getAccessibleSource(values.sourceId, "editor");
592
713
  const source =
@@ -594,18 +715,23 @@ export async function createCapture(values: {
594
715
  const db = getDb();
595
716
  const now = nowIso();
596
717
  const id = values.id ?? nanoid();
597
- if (values.externalId) {
598
- const [existing] = await db
599
- .select()
600
- .from(schema.brainRawCaptures)
601
- .where(
602
- and(
603
- eq(schema.brainRawCaptures.sourceId, values.sourceId),
604
- eq(schema.brainRawCaptures.externalId, values.externalId),
605
- ),
606
- )
607
- .limit(1);
608
- if (existing) return existing;
718
+ const [existing] = values.externalId
719
+ ? await db
720
+ .select()
721
+ .from(schema.brainRawCaptures)
722
+ .where(
723
+ and(
724
+ eq(schema.brainRawCaptures.sourceId, values.sourceId),
725
+ eq(schema.brainRawCaptures.externalId, values.externalId),
726
+ ),
727
+ )
728
+ .limit(1)
729
+ : [];
730
+ const upstreamDeletionReceipt = values.externalId
731
+ ? await findUpstreamDeletionReceipt(values.sourceId, values.externalId)
732
+ : null;
733
+ if (upstreamDeletionReceipt) {
734
+ throw new BrainCaptureBlockedError(upstreamDeletionReceipt);
609
735
  }
610
736
  const settings = await readBrainSettings();
611
737
  const metadata = sanitizeGranolaCaptureMetadataLinks(
@@ -627,24 +753,62 @@ export async function createCapture(values: {
627
753
  sourceConfig: parseJson<Record<string, unknown>>(source.configJson, {}),
628
754
  settings,
629
755
  });
630
- try {
631
- await db.insert(schema.brainRawCaptures).values({
756
+ if (!sanitized.decision || sanitized.decision.disposition !== "allowed") {
757
+ const receipt = await recordBlockedCapture({
632
758
  id,
633
- sourceId: values.sourceId,
634
- externalId: values.externalId ?? null,
635
- title: sanitized.title,
636
- kind: values.kind,
637
- content: sanitized.content,
638
- contentHash: await contentHash(sanitized.content),
639
- metadataJson: stableJson(sanitized.metadata),
640
- capturedAt: values.capturedAt ?? now,
641
- importedBy: requireUserEmail(),
642
- status: values.status ?? "queued",
643
- distilledAt: values.status === "distilled" ? now : null,
644
- createdAt: now,
645
- updatedAt: now,
759
+ existing: existing ?? null,
760
+ source,
761
+ values,
762
+ decision: sanitized.decision ?? {
763
+ disposition: "quarantined",
764
+ categories: [],
765
+ confidenceBand: "uncertain",
766
+ policyVersion: "1",
767
+ safeSegments: [],
768
+ safeContent: "",
769
+ classifier: "deterministic",
770
+ },
771
+ retentionHours: settings.quarantineRetentionHours ?? 72,
646
772
  });
773
+ throw new BrainCaptureBlockedError(receipt);
774
+ }
775
+ const nextContentHash = await contentHash(sanitized.content);
776
+ const nextCapturedAt = values.capturedAt ?? existing?.capturedAt ?? now;
777
+ const contentChanged = existing?.contentHash !== nextContentHash;
778
+ const indexedMetadataChanged = Boolean(
779
+ existing &&
780
+ (existing.title !== sanitized.title ||
781
+ existing.capturedAt !== nextCapturedAt),
782
+ );
783
+ const captureId = existing?.id ?? id;
784
+ try {
785
+ if (!existing) {
786
+ await db.insert(schema.brainRawCaptures).values({
787
+ id,
788
+ sourceId: values.sourceId,
789
+ externalId: values.externalId ?? null,
790
+ title: sanitized.title,
791
+ kind: values.kind,
792
+ content: sanitized.content,
793
+ contentHash: nextContentHash,
794
+ metadataJson: stableJson(sanitized.metadata),
795
+ capturedAt: nextCapturedAt,
796
+ importedBy: requireUserEmail(),
797
+ status: values.status ?? "queued",
798
+ distilledAt: values.status === "distilled" ? now : null,
799
+ sensitivityDisposition: "pending",
800
+ sensitivityPolicyVersion: sanitized.decision.policyVersion,
801
+ audienceAclHash: null,
802
+ createdAt: now,
803
+ updatedAt: now,
804
+ });
805
+ }
647
806
  } catch (err) {
807
+ if (!existing) {
808
+ await db
809
+ .delete(schema.brainCaptureAudiences)
810
+ .where(eq(schema.brainCaptureAudiences.captureId, captureId));
811
+ }
648
812
  if (values.externalId && isUniqueConflict(err)) {
649
813
  const [existing] = await db
650
814
  .select()
@@ -660,14 +824,538 @@ export async function createCapture(values: {
660
824
  }
661
825
  throw err;
662
826
  }
827
+ const racedDeletionReceipt = values.externalId
828
+ ? await findUpstreamDeletionReceipt(values.sourceId, values.externalId)
829
+ : null;
830
+ if (racedDeletionReceipt) {
831
+ await retireUpstreamDeletedCapture({
832
+ sourceId: values.sourceId,
833
+ externalId: values.externalId!,
834
+ provider: source.provider as BrainSourceProvider,
835
+ });
836
+ throw new BrainCaptureBlockedError(racedDeletionReceipt);
837
+ }
838
+ const audience = await ensureCaptureAudience({
839
+ captureId,
840
+ source,
841
+ kind: values.audience?.kind,
842
+ memberEmails:
843
+ values.audience?.memberEmails ??
844
+ (source.visibility === "org" ? undefined : [source.ownerEmail]),
845
+ upstreamRefHash: values.audience?.upstreamRefHash,
846
+ });
847
+ const finalizationClauses = [eq(schema.brainRawCaptures.id, captureId)];
848
+ if (values.externalId) {
849
+ const locatorHmac = await upstreamDeletionLocatorHmac(
850
+ values.sourceId,
851
+ values.externalId,
852
+ );
853
+ finalizationClauses.push(
854
+ notExists(
855
+ db
856
+ .select({ id: schema.brainSensitivityEvents.id })
857
+ .from(schema.brainSensitivityEvents)
858
+ .where(
859
+ and(
860
+ eq(schema.brainSensitivityEvents.locatorHmac, locatorHmac),
861
+ eq(
862
+ schema.brainSensitivityEvents.policyVersion,
863
+ UPSTREAM_DELETED_POLICY_VERSION,
864
+ ),
865
+ eq(schema.brainSensitivityEvents.disposition, "suppressed"),
866
+ ),
867
+ ),
868
+ ),
869
+ );
870
+ }
871
+ const finalized = await db
872
+ .update(schema.brainRawCaptures)
873
+ .set({
874
+ title: sanitized.title,
875
+ kind: values.kind,
876
+ content: sanitized.content,
877
+ contentHash: nextContentHash,
878
+ metadataJson: stableJson(sanitized.metadata),
879
+ capturedAt: nextCapturedAt,
880
+ status: values.status ?? "queued",
881
+ distilledAt: values.status === "distilled" ? now : null,
882
+ sensitivityDisposition: "allowed",
883
+ sensitivityPolicyVersion: sanitized.decision.policyVersion,
884
+ audienceAclHash: audience.aclHash,
885
+ updatedAt: now,
886
+ })
887
+ .where(and(...finalizationClauses));
888
+ if (finalized.rowsAffected === 0 && values.externalId) {
889
+ await db
890
+ .delete(schema.brainCaptureAudiences)
891
+ .where(eq(schema.brainCaptureAudiences.captureId, captureId));
892
+ await retireUpstreamDeletedCapture({
893
+ sourceId: values.sourceId,
894
+ externalId: values.externalId,
895
+ provider: source.provider as BrainSourceProvider,
896
+ });
897
+ const receipt = await findUpstreamDeletionReceipt(
898
+ values.sourceId,
899
+ values.externalId,
900
+ );
901
+ if (receipt) throw new BrainCaptureBlockedError(receipt);
902
+ throw new Error("Capture finalization was blocked");
903
+ }
904
+ if (existing?.contentHash && contentChanged) {
905
+ await invalidateDerivedForCapture(captureId);
906
+ }
663
907
  const [capture] = await db
664
908
  .select()
665
909
  .from(schema.brainRawCaptures)
666
- .where(eq(schema.brainRawCaptures.id, id))
910
+ .where(eq(schema.brainRawCaptures.id, captureId))
667
911
  .limit(1);
912
+ const invalidationReason =
913
+ !existing || contentChanged || indexedMetadataChanged
914
+ ? "content-changed"
915
+ : existing.audienceAclHash !== audience.aclHash
916
+ ? "access-changed"
917
+ : existing.sensitivityPolicyVersion !== sanitized.decision.policyVersion
918
+ ? "sensitivity-changed"
919
+ : null;
920
+ if (!invalidationReason) return capture;
921
+ try {
922
+ await enqueueCaptureInvalidation({
923
+ captureId,
924
+ sourceId: source.id,
925
+ reason: invalidationReason,
926
+ previous: existing
927
+ ? {
928
+ contentHash: existing.contentHash ?? undefined,
929
+ sensitivityPolicyVersion:
930
+ existing.sensitivityPolicyVersion ?? undefined,
931
+ aclHash: existing.audienceAclHash ?? undefined,
932
+ }
933
+ : undefined,
934
+ next: {
935
+ contentHash: nextContentHash,
936
+ sensitivityPolicyVersion: sanitized.decision.policyVersion,
937
+ aclHash: audience.aclHash,
938
+ },
939
+ });
940
+ } catch (error) {
941
+ const { unindexBrainCapture } = await import("./search-index.js");
942
+ await unindexBrainCapture(captureId);
943
+ throw error;
944
+ }
668
945
  return capture;
669
946
  }
670
947
 
948
+ export interface BrainSensitivityReceipt {
949
+ id: string;
950
+ sourceId: string;
951
+ disposition: "suppressed" | "quarantined";
952
+ categories: BrainSensitivityDecision["categories"];
953
+ confidenceBand: BrainSensitivityDecision["confidenceBand"];
954
+ policyVersion: string;
955
+ expiresAt: string | null;
956
+ }
957
+
958
+ export class BrainCaptureBlockedError extends Error {
959
+ constructor(public readonly receipt: BrainSensitivityReceipt) {
960
+ super(`Capture ${receipt.disposition} by Brain privacy policy`);
961
+ this.name = "BrainCaptureBlockedError";
962
+ }
963
+ }
964
+
965
+ export async function retireUpstreamDeletedCapture(input: {
966
+ sourceId: string;
967
+ externalId: string;
968
+ provider: BrainSourceProvider;
969
+ }) {
970
+ const db = getDb();
971
+ const now = nowIso();
972
+ const locatorHmac = await upstreamDeletionLocatorHmac(
973
+ input.sourceId,
974
+ input.externalId,
975
+ );
976
+ const eventId = `sensitivity_${nanoid(18)}`;
977
+ const emptyContentHash = await contentHash("");
978
+ const [captureBeforeRetirement] = await db
979
+ .select({ id: schema.brainRawCaptures.id })
980
+ .from(schema.brainRawCaptures)
981
+ .where(
982
+ and(
983
+ eq(schema.brainRawCaptures.sourceId, input.sourceId),
984
+ eq(schema.brainRawCaptures.externalId, input.externalId),
985
+ ),
986
+ )
987
+ .limit(1);
988
+ if (captureBeforeRetirement) {
989
+ const knowledge = await db
990
+ .select({
991
+ publishedResourcePath: schema.brainKnowledge.publishedResourcePath,
992
+ })
993
+ .from(schema.brainKnowledge)
994
+ .where(
995
+ or(
996
+ eq(schema.brainKnowledge.captureId, captureBeforeRetirement.id),
997
+ like(
998
+ schema.brainKnowledge.evidenceJson,
999
+ `%${captureBeforeRetirement.id}%`,
1000
+ ),
1001
+ ),
1002
+ );
1003
+ for (const row of knowledge) {
1004
+ if (row.publishedResourcePath) {
1005
+ await resourceDeleteByPath(SHARED_OWNER, row.publishedResourcePath);
1006
+ }
1007
+ }
1008
+ }
1009
+ let capture:
1010
+ | {
1011
+ id: string;
1012
+ contentHash: string | null;
1013
+ sensitivityPolicyVersion: string | null;
1014
+ audienceAclHash: string | null;
1015
+ }
1016
+ | undefined;
1017
+ await db.transaction(async (tx) => {
1018
+ await tx
1019
+ .insert(schema.brainSensitivityEvents)
1020
+ .values({
1021
+ id: eventId,
1022
+ sourceId: input.sourceId,
1023
+ captureId: null,
1024
+ locatorHmac,
1025
+ disposition: "suppressed",
1026
+ categoriesJson: "[]",
1027
+ confidenceBand: "deterministic",
1028
+ policyVersion: UPSTREAM_DELETED_POLICY_VERSION,
1029
+ upstreamProvider: input.provider,
1030
+ quarantineBlobHandle: null,
1031
+ expiresAt: null,
1032
+ createdAt: now,
1033
+ updatedAt: now,
1034
+ })
1035
+ .onConflictDoUpdate({
1036
+ target: [
1037
+ schema.brainSensitivityEvents.locatorHmac,
1038
+ schema.brainSensitivityEvents.policyVersion,
1039
+ ],
1040
+ set: {
1041
+ disposition: "suppressed",
1042
+ quarantineBlobHandle: null,
1043
+ expiresAt: null,
1044
+ updatedAt: now,
1045
+ },
1046
+ });
1047
+ [capture] = await tx
1048
+ .select({
1049
+ id: schema.brainRawCaptures.id,
1050
+ contentHash: schema.brainRawCaptures.contentHash,
1051
+ sensitivityPolicyVersion:
1052
+ schema.brainRawCaptures.sensitivityPolicyVersion,
1053
+ audienceAclHash: schema.brainRawCaptures.audienceAclHash,
1054
+ })
1055
+ .from(schema.brainRawCaptures)
1056
+ .where(
1057
+ and(
1058
+ eq(schema.brainRawCaptures.sourceId, input.sourceId),
1059
+ eq(schema.brainRawCaptures.externalId, input.externalId),
1060
+ ),
1061
+ )
1062
+ .limit(1);
1063
+ if (!capture) return;
1064
+
1065
+ await tx
1066
+ .update(schema.brainSensitivityEvents)
1067
+ .set({ captureId: capture.id, updatedAt: now })
1068
+ .where(
1069
+ and(
1070
+ eq(schema.brainSensitivityEvents.locatorHmac, locatorHmac),
1071
+ eq(
1072
+ schema.brainSensitivityEvents.policyVersion,
1073
+ UPSTREAM_DELETED_POLICY_VERSION,
1074
+ ),
1075
+ ),
1076
+ );
1077
+ await tx
1078
+ .update(schema.brainRawCaptures)
1079
+ .set({
1080
+ title: "Deleted upstream capture",
1081
+ content: "",
1082
+ contentHash: emptyContentHash,
1083
+ metadataJson: "{}",
1084
+ status: "ignored",
1085
+ sensitivityDisposition: "pending",
1086
+ audienceAclHash: null,
1087
+ updatedAt: now,
1088
+ })
1089
+ .where(eq(schema.brainRawCaptures.id, capture.id));
1090
+ await tx
1091
+ .delete(schema.brainCaptureAudiences)
1092
+ .where(eq(schema.brainCaptureAudiences.captureId, capture.id));
1093
+ await invalidateDerivedForCapture(
1094
+ capture.id,
1095
+ tx as unknown as ReturnType<typeof getDb>,
1096
+ { deletePublishedResources: false },
1097
+ );
1098
+ });
1099
+ if (!capture) return false;
1100
+ await enqueueCaptureInvalidation({
1101
+ captureId: capture.id,
1102
+ sourceId: input.sourceId,
1103
+ reason: "upstream-deleted",
1104
+ previous: {
1105
+ contentHash: capture.contentHash ?? undefined,
1106
+ sensitivityPolicyVersion: capture.sensitivityPolicyVersion ?? undefined,
1107
+ aclHash: capture.audienceAclHash ?? undefined,
1108
+ },
1109
+ });
1110
+ return true;
1111
+ }
1112
+
1113
+ function privateQuarantineProvider(provider: BrainSourceProvider) {
1114
+ return provider === "generic" || provider === "clips";
1115
+ }
1116
+
1117
+ async function deleteQuarantineHandle(value: string | null | undefined) {
1118
+ if (!value) return;
1119
+ try {
1120
+ await deletePrivateBlob(JSON.parse(value) as PrivateBlobHandle);
1121
+ } catch {}
1122
+ }
1123
+
1124
+ export async function invalidateDerivedForCapture(
1125
+ captureId: string,
1126
+ db = getDb(),
1127
+ options: { deletePublishedResources?: boolean } = {},
1128
+ ) {
1129
+ const needle = `%${captureId}%`;
1130
+ const knowledge = await db
1131
+ .select({
1132
+ id: schema.brainKnowledge.id,
1133
+ publishedResourcePath: schema.brainKnowledge.publishedResourcePath,
1134
+ })
1135
+ .from(schema.brainKnowledge)
1136
+ .where(
1137
+ or(
1138
+ eq(schema.brainKnowledge.captureId, captureId),
1139
+ like(schema.brainKnowledge.evidenceJson, needle),
1140
+ ),
1141
+ );
1142
+ if (options.deletePublishedResources !== false) {
1143
+ for (const row of knowledge) {
1144
+ if (row.publishedResourcePath) {
1145
+ await resourceDeleteByPath(SHARED_OWNER, row.publishedResourcePath);
1146
+ }
1147
+ }
1148
+ }
1149
+ if (knowledge.length) {
1150
+ await db
1151
+ .update(schema.brainKnowledge)
1152
+ .set({
1153
+ title: "Redacted Brain knowledge",
1154
+ summary: "",
1155
+ body: "",
1156
+ evidenceJson: "[]",
1157
+ status: "redacted",
1158
+ publishedResourcePath: null,
1159
+ updatedAt: nowIso(),
1160
+ })
1161
+ .where(
1162
+ inArray(
1163
+ schema.brainKnowledge.id,
1164
+ knowledge.map((row) => row.id),
1165
+ ),
1166
+ );
1167
+ }
1168
+ const proposals = await db
1169
+ .select({ id: schema.brainProposals.id })
1170
+ .from(schema.brainProposals)
1171
+ .where(
1172
+ or(
1173
+ eq(schema.brainProposals.captureId, captureId),
1174
+ like(schema.brainProposals.evidenceJson, needle),
1175
+ ),
1176
+ );
1177
+ if (proposals.length) {
1178
+ await db
1179
+ .update(schema.brainProposals)
1180
+ .set({
1181
+ title: "Redacted Brain proposal",
1182
+ body: "",
1183
+ rationale: "",
1184
+ payloadJson: "{}",
1185
+ evidenceJson: "[]",
1186
+ status: "rejected",
1187
+ updatedAt: nowIso(),
1188
+ })
1189
+ .where(
1190
+ inArray(
1191
+ schema.brainProposals.id,
1192
+ proposals.map((row) => row.id),
1193
+ ),
1194
+ );
1195
+ }
1196
+ }
1197
+
1198
+ export async function recordBlockedCapture(input: {
1199
+ id: string;
1200
+ existing: typeof schema.brainRawCaptures.$inferSelect | null;
1201
+ source: typeof schema.brainSources.$inferSelect;
1202
+ values: Parameters<typeof createCapture>[0];
1203
+ decision: BrainSensitivityDecision;
1204
+ retentionHours: number;
1205
+ }): Promise<BrainSensitivityReceipt> {
1206
+ const db = getDb();
1207
+ const now = nowIso();
1208
+ const locatorHmac = await sha256Hex(
1209
+ `${input.source.id}\0${input.values.externalId ?? (await contentHash(input.values.content))}`,
1210
+ );
1211
+ const [prior] = await db
1212
+ .select({
1213
+ id: schema.brainSensitivityEvents.id,
1214
+ quarantineBlobHandle: schema.brainSensitivityEvents.quarantineBlobHandle,
1215
+ })
1216
+ .from(schema.brainSensitivityEvents)
1217
+ .where(
1218
+ and(
1219
+ eq(schema.brainSensitivityEvents.locatorHmac, locatorHmac),
1220
+ eq(
1221
+ schema.brainSensitivityEvents.policyVersion,
1222
+ input.decision.policyVersion,
1223
+ ),
1224
+ ),
1225
+ )
1226
+ .limit(1);
1227
+ await deleteQuarantineHandle(prior?.quarantineBlobHandle);
1228
+ let disposition: "suppressed" | "quarantined" =
1229
+ input.decision.disposition === "suppressed" ? "suppressed" : "quarantined";
1230
+ let quarantineBlobHandle: string | null = null;
1231
+ let expiresAt: string | null = null;
1232
+ if (
1233
+ disposition === "quarantined" &&
1234
+ privateQuarantineProvider(input.source.provider as BrainSourceProvider)
1235
+ ) {
1236
+ const handle = await putPrivateBlob({
1237
+ data: new TextEncoder().encode(encryptSecretValue(input.values.content)),
1238
+ filename: `brain-quarantine-${input.id}.bin`,
1239
+ mimeType: "application/octet-stream",
1240
+ ownerEmail: input.source.ownerEmail,
1241
+ metadata: { app: "brain", kind: "privacy-quarantine" },
1242
+ });
1243
+ if (handle) {
1244
+ quarantineBlobHandle = stableJson(handle);
1245
+ expiresAt = new Date(
1246
+ Date.parse(now) +
1247
+ Math.max(1, Math.min(168, input.retentionHours)) * 60 * 60 * 1000,
1248
+ ).toISOString();
1249
+ } else {
1250
+ disposition = "suppressed";
1251
+ }
1252
+ }
1253
+ const eventId = prior?.id ?? `sensitivity_${nanoid(18)}`;
1254
+ await db
1255
+ .insert(schema.brainSensitivityEvents)
1256
+ .values({
1257
+ id: eventId,
1258
+ sourceId: input.source.id,
1259
+ captureId: input.existing?.id ?? null,
1260
+ locatorHmac,
1261
+ disposition,
1262
+ categoriesJson: stableJson(input.decision.categories),
1263
+ confidenceBand: input.decision.confidenceBand,
1264
+ policyVersion: input.decision.policyVersion,
1265
+ upstreamProvider: input.source.provider,
1266
+ quarantineBlobHandle,
1267
+ expiresAt,
1268
+ createdAt: now,
1269
+ updatedAt: now,
1270
+ })
1271
+ .onConflictDoUpdate({
1272
+ target: [
1273
+ schema.brainSensitivityEvents.locatorHmac,
1274
+ schema.brainSensitivityEvents.policyVersion,
1275
+ ],
1276
+ set: {
1277
+ captureId: input.existing?.id ?? null,
1278
+ disposition,
1279
+ categoriesJson: stableJson(input.decision.categories),
1280
+ confidenceBand: input.decision.confidenceBand,
1281
+ quarantineBlobHandle,
1282
+ expiresAt,
1283
+ reviewedBy: null,
1284
+ reviewedAt: null,
1285
+ updatedAt: now,
1286
+ },
1287
+ });
1288
+ if (input.existing) {
1289
+ await invalidateDerivedForCapture(input.existing.id);
1290
+ await db
1291
+ .update(schema.brainRawCaptures)
1292
+ .set({
1293
+ title: "Privacy-blocked capture",
1294
+ content: "",
1295
+ contentHash: await contentHash(""),
1296
+ metadataJson: "{}",
1297
+ status: "ignored",
1298
+ sensitivityDisposition: "pending",
1299
+ sensitivityPolicyVersion: input.decision.policyVersion,
1300
+ audienceAclHash: null,
1301
+ updatedAt: now,
1302
+ })
1303
+ .where(eq(schema.brainRawCaptures.id, input.existing.id));
1304
+ await enqueueBrainOperation({
1305
+ operation: "search-unindex",
1306
+ dedupeKey: `search-unindex:${input.existing.id}:${input.decision.policyVersion}`,
1307
+ sourceId: input.source.id,
1308
+ captureId: input.existing.id,
1309
+ priority: 1,
1310
+ });
1311
+ }
1312
+ return {
1313
+ id: eventId,
1314
+ sourceId: input.source.id,
1315
+ disposition,
1316
+ categories: input.decision.categories,
1317
+ confidenceBand: input.decision.confidenceBand,
1318
+ policyVersion: input.decision.policyVersion,
1319
+ expiresAt,
1320
+ };
1321
+ }
1322
+
1323
+ export async function expireSensitivityQuarantines(at = nowIso()) {
1324
+ const db = getDb();
1325
+ const rows = await db
1326
+ .select({
1327
+ id: schema.brainSensitivityEvents.id,
1328
+ quarantineBlobHandle: schema.brainSensitivityEvents.quarantineBlobHandle,
1329
+ })
1330
+ .from(schema.brainSensitivityEvents)
1331
+ .where(
1332
+ and(
1333
+ eq(schema.brainSensitivityEvents.disposition, "quarantined"),
1334
+ lte(schema.brainSensitivityEvents.expiresAt, at),
1335
+ ),
1336
+ );
1337
+ for (const row of rows) {
1338
+ await deleteQuarantineHandle(row.quarantineBlobHandle);
1339
+ }
1340
+ if (rows.length) {
1341
+ await db
1342
+ .update(schema.brainSensitivityEvents)
1343
+ .set({
1344
+ disposition: "expired",
1345
+ quarantineBlobHandle: null,
1346
+ expiresAt: null,
1347
+ updatedAt: at,
1348
+ })
1349
+ .where(
1350
+ inArray(
1351
+ schema.brainSensitivityEvents.id,
1352
+ rows.map((row) => row.id),
1353
+ ),
1354
+ );
1355
+ }
1356
+ return rows.length;
1357
+ }
1358
+
671
1359
  export async function validateEvidence(
672
1360
  evidence: BrainEvidenceInput[],
673
1361
  ): Promise<BrainEvidence[]> {
@@ -891,12 +1579,23 @@ async function publishKnowledgeResource(values: {
891
1579
  return resource.path;
892
1580
  }
893
1581
 
1582
+ async function canPublishCanonicalAudience(audienceId: string | null) {
1583
+ if (!audienceId) return true;
1584
+ const [audience] = await getDb()
1585
+ .select({ kind: schema.brainAudiences.kind })
1586
+ .from(schema.brainAudiences)
1587
+ .where(eq(schema.brainAudiences.id, audienceId))
1588
+ .limit(1);
1589
+ return audience?.kind === "org";
1590
+ }
1591
+
894
1592
  export async function setKnowledgeCanonicalResource(
895
1593
  knowledgeId: string,
896
1594
  published: boolean,
897
1595
  ) {
898
1596
  const access = await assertAccess("brain-knowledge", knowledgeId, "editor");
899
1597
  const row = access.resource;
1598
+ await assertDerivedAudienceAccess(row);
900
1599
  const db = getDb();
901
1600
 
902
1601
  if (!published) {
@@ -908,6 +1607,11 @@ export async function setKnowledgeCanonicalResource(
908
1607
  .set({ publishedResourcePath: null, updatedAt: nowIso() })
909
1608
  .where(eq(schema.brainKnowledge.id, knowledgeId));
910
1609
  } else {
1610
+ if (!(await canPublishCanonicalAudience(row.audienceId))) {
1611
+ throw new Error(
1612
+ "Audience-restricted Brain knowledge cannot become workspace-wide canonical context.",
1613
+ );
1614
+ }
911
1615
  if (row.status !== "published") {
912
1616
  throw new Error(
913
1617
  "Only published Brain knowledge can become company context.",
@@ -1051,14 +1755,23 @@ export async function previewKnowledgeCanonicalResource(input: {
1051
1755
  "viewer",
1052
1756
  );
1053
1757
  const row = access.resource;
1758
+ await assertDerivedAudienceAccess(row);
1054
1759
  const values = canonicalValuesFromKnowledgeRow(row);
1055
1760
  const resource = buildCanonicalResource(values);
1056
1761
  const warnings: string[] = [];
1762
+ const audienceCanPublish = await canPublishCanonicalAudience(
1763
+ row.audienceId,
1764
+ );
1057
1765
  if (row.status !== "published") {
1058
1766
  warnings.push(
1059
1767
  "Only published Brain knowledge can become company context.",
1060
1768
  );
1061
1769
  }
1770
+ if (!audienceCanPublish) {
1771
+ warnings.push(
1772
+ "Audience-restricted Brain knowledge cannot become workspace-wide canonical context.",
1773
+ );
1774
+ }
1062
1775
  if (input.operation === "unpublish" && !row.publishedResourcePath) {
1063
1776
  warnings.push(
1064
1777
  "This knowledge is not currently mirrored to workspace context.",
@@ -1072,7 +1785,7 @@ export async function previewKnowledgeCanonicalResource(input: {
1072
1785
  pathExact: true,
1073
1786
  contentType: resource.contentType,
1074
1787
  markdown: resource.markdown,
1075
- canPublish: row.status === "published",
1788
+ canPublish: row.status === "published" && audienceCanPublish,
1076
1789
  alreadyPublishedPath: row.publishedResourcePath,
1077
1790
  warnings,
1078
1791
  };
@@ -1084,6 +1797,7 @@ export async function previewKnowledgeCanonicalResource(input: {
1084
1797
  "viewer",
1085
1798
  );
1086
1799
  const row = access.resource;
1800
+ await assertDerivedAudienceAccess(row);
1087
1801
  const { payload, values } = canonicalValuesFromProposalRow(row, input.draft);
1088
1802
  const resource = buildCanonicalResource(values);
1089
1803
  const status =
@@ -1091,6 +1805,7 @@ export async function previewKnowledgeCanonicalResource(input: {
1091
1805
  ? payload.status
1092
1806
  : statusForTier(payload.publishTier ?? "company");
1093
1807
  const warnings: string[] = [];
1808
+ const audienceCanPublish = await canPublishCanonicalAudience(row.audienceId);
1094
1809
  if (status !== "published") {
1095
1810
  warnings.push(
1096
1811
  "Approving this proposal would not publish canonical context because its resulting knowledge status is not published.",
@@ -1101,6 +1816,11 @@ export async function previewKnowledgeCanonicalResource(input: {
1101
1816
  "Approval will assign the final knowledge id, so the Markdown is exact but the path suffix is shown as <new-id>.",
1102
1817
  );
1103
1818
  }
1819
+ if (!audienceCanPublish) {
1820
+ warnings.push(
1821
+ "Audience-restricted Brain knowledge cannot become workspace-wide canonical context.",
1822
+ );
1823
+ }
1104
1824
  if (row.status !== "pending") {
1105
1825
  warnings.push(`This proposal is already ${row.status}.`);
1106
1826
  }
@@ -1113,7 +1833,7 @@ export async function previewKnowledgeCanonicalResource(input: {
1113
1833
  pathExact: resource.pathExact,
1114
1834
  contentType: resource.contentType,
1115
1835
  markdown: resource.markdown,
1116
- canPublish: status === "published",
1836
+ canPublish: status === "published" && audienceCanPublish,
1117
1837
  warnings,
1118
1838
  };
1119
1839
  }
@@ -1125,10 +1845,40 @@ export async function writeKnowledgeRecord(
1125
1845
  const db = getDb();
1126
1846
  const userEmail = requireUserEmail();
1127
1847
  const settings = await readBrainSettings();
1128
- const tier = input.publishTier ?? settings.defaultPublishTier;
1129
- const evidence = await validateEvidence(input.evidence ?? []);
1848
+ const existingAccess = input.knowledgeId
1849
+ ? await assertAccess("brain-knowledge", input.knowledgeId, "editor")
1850
+ : null;
1851
+ const existing = existingAccess?.resource ?? null;
1852
+ if (existing) await assertDerivedAudienceAccess(existing);
1853
+ const tier =
1854
+ input.publishTier ?? existing?.publishTier ?? settings.defaultPublishTier;
1855
+ const evidenceInput =
1856
+ existing?.captureId && !(input.evidence ?? []).length
1857
+ ? parseJson<BrainEvidenceInput[]>(existing.evidenceJson, [])
1858
+ : (input.evidence ?? []);
1859
+ if (existing?.captureId && !evidenceInput.length) {
1860
+ throw new Error(
1861
+ "Evidence-derived Brain knowledge cannot clear its evidence",
1862
+ );
1863
+ }
1864
+ const evidence = await validateEvidence(evidenceInput);
1130
1865
  const sourceId = evidence[0]?.sourceId ?? null;
1131
1866
  const captureId = evidence[0]?.captureId ?? null;
1867
+ const evidenceAudience = evidence.length
1868
+ ? await ensureEvidenceIntersectionAudience({
1869
+ captureIds: evidence.map((item) => item.captureId),
1870
+ sourceId: sourceId!,
1871
+ })
1872
+ : null;
1873
+ if (
1874
+ input.publishCanonical &&
1875
+ evidenceAudience &&
1876
+ evidenceAudience.kind !== "org"
1877
+ ) {
1878
+ throw new Error(
1879
+ "Audience-restricted Brain evidence cannot become workspace-wide canonical context.",
1880
+ );
1881
+ }
1132
1882
  const redacted = applyRedactions({
1133
1883
  title: input.title,
1134
1884
  body: input.body,
@@ -1140,13 +1890,9 @@ export async function writeKnowledgeRecord(
1140
1890
  autoRedactEmails: settings.autoRedactEmails,
1141
1891
  });
1142
1892
  const now = nowIso();
1143
- const existingAccess = input.knowledgeId
1144
- ? await assertAccess("brain-knowledge", input.knowledgeId, "editor")
1145
- : null;
1146
1893
  if (input.supersedesId) {
1147
1894
  await assertAccess("brain-knowledge", input.supersedesId, "editor");
1148
1895
  }
1149
- const existing = existingAccess?.resource ?? null;
1150
1896
  const ownerEmail = existing?.ownerEmail ?? userEmail;
1151
1897
  const orgId = existing?.orgId ?? getRequestOrgId() ?? null;
1152
1898
  const visibility = visibilityForTier(tier);
@@ -1188,6 +1934,8 @@ export async function writeKnowledgeRecord(
1188
1934
  knowledgeId: input.knowledgeId ?? null,
1189
1935
  sourceId,
1190
1936
  captureId,
1937
+ audienceId: evidenceAudience?.audienceId ?? null,
1938
+ audienceAclHash: evidenceAudience?.aclHash ?? null,
1191
1939
  title: redacted.title,
1192
1940
  body: redacted.body,
1193
1941
  rationale: input.rationale ?? "",
@@ -1220,6 +1968,8 @@ export async function writeKnowledgeRecord(
1220
1968
  .set({
1221
1969
  sourceId,
1222
1970
  captureId,
1971
+ audienceId: evidenceAudience?.audienceId ?? null,
1972
+ audienceAclHash: evidenceAudience?.aclHash ?? null,
1223
1973
  kind: input.kind ?? "fact",
1224
1974
  title: redacted.title,
1225
1975
  body: redacted.body,
@@ -1243,6 +1993,8 @@ export async function writeKnowledgeRecord(
1243
1993
  id,
1244
1994
  sourceId,
1245
1995
  captureId,
1996
+ audienceId: evidenceAudience?.audienceId ?? null,
1997
+ audienceAclHash: evidenceAudience?.aclHash ?? null,
1246
1998
  kind: input.kind ?? "fact",
1247
1999
  title: redacted.title,
1248
2000
  body: redacted.body,
@@ -1271,7 +2023,25 @@ export async function writeKnowledgeRecord(
1271
2023
  .where(eq(schema.brainKnowledge.id, id))
1272
2024
  .limit(1);
1273
2025
  let returned = knowledge;
1274
- if (input.publishCanonical && status === "published") {
2026
+ const audienceCanPublish = await canPublishCanonicalAudience(
2027
+ evidenceAudience?.audienceId ?? null,
2028
+ );
2029
+ const shouldMaintainCanonical = Boolean(existing?.publishedResourcePath);
2030
+ if (
2031
+ existing?.publishedResourcePath &&
2032
+ (status !== "published" || !audienceCanPublish)
2033
+ ) {
2034
+ await resourceDeleteByPath(SHARED_OWNER, existing.publishedResourcePath);
2035
+ await db
2036
+ .update(schema.brainKnowledge)
2037
+ .set({ publishedResourcePath: null, updatedAt: nowIso() })
2038
+ .where(eq(schema.brainKnowledge.id, id));
2039
+ returned = { ...knowledge, publishedResourcePath: null };
2040
+ } else if (
2041
+ status === "published" &&
2042
+ audienceCanPublish &&
2043
+ (input.publishCanonical || shouldMaintainCanonical)
2044
+ ) {
1275
2045
  const publishedResourcePath = await publishKnowledgeResource({
1276
2046
  id,
1277
2047
  title: redacted.title,
@@ -1281,6 +2051,12 @@ export async function writeKnowledgeRecord(
1281
2051
  tags: redacted.tags,
1282
2052
  evidence: redacted.evidence,
1283
2053
  });
2054
+ if (
2055
+ existing?.publishedResourcePath &&
2056
+ existing.publishedResourcePath !== publishedResourcePath
2057
+ ) {
2058
+ await resourceDeleteByPath(SHARED_OWNER, existing.publishedResourcePath);
2059
+ }
1284
2060
  await db
1285
2061
  .update(schema.brainKnowledge)
1286
2062
  .set({ publishedResourcePath, updatedAt: nowIso() })
@@ -1313,8 +2089,23 @@ export async function searchKnowledgeRows(args: {
1313
2089
  limit?: number;
1314
2090
  }) {
1315
2091
  const db = getDb();
2092
+ const audienceIds = await listAccessibleAudienceIds();
2093
+ const accessibleSourceExists = sql`exists (
2094
+ select 1 from ${schema.brainSources}
2095
+ where ${schema.brainSources.id} = ${schema.brainKnowledge.sourceId}
2096
+ and ${accessFilter(schema.brainSources, schema.brainSourceShares)}
2097
+ )`;
1316
2098
  const clauses = [
1317
2099
  accessFilter(schema.brainKnowledge, schema.brainKnowledgeShares),
2100
+ or(
2101
+ isNull(schema.brainKnowledge.captureId),
2102
+ audienceIds.length
2103
+ ? and(
2104
+ accessibleSourceExists,
2105
+ inArray(schema.brainKnowledge.audienceId, audienceIds),
2106
+ )
2107
+ : undefined,
2108
+ )!,
1318
2109
  ];
1319
2110
  if (args.query) {
1320
2111
  const q = `%${args.query}%`;
@@ -1361,16 +2152,32 @@ export async function readBrainScreen() {
1361
2152
  const source = await resolveAccess("brain-source", nav.sourceId);
1362
2153
  if (source) {
1363
2154
  screen.source = serializeSource(source.resource);
1364
- const captures = await getDb()
1365
- .select()
1366
- .from(schema.brainRawCaptures)
1367
- .where(eq(schema.brainRawCaptures.sourceId, source.resource.id))
1368
- .orderBy(desc(schema.brainRawCaptures.capturedAt))
1369
- .limit(10);
2155
+ const audienceIds = await listAccessibleAudienceIds([source.resource.id]);
2156
+ const captures = audienceIds.length
2157
+ ? await getDb()
2158
+ .select({ capture: schema.brainRawCaptures })
2159
+ .from(schema.brainRawCaptures)
2160
+ .innerJoin(
2161
+ schema.brainCaptureAudiences,
2162
+ eq(
2163
+ schema.brainCaptureAudiences.captureId,
2164
+ schema.brainRawCaptures.id,
2165
+ ),
2166
+ )
2167
+ .where(
2168
+ and(
2169
+ eq(schema.brainRawCaptures.sourceId, source.resource.id),
2170
+ eq(schema.brainRawCaptures.sensitivityDisposition, "allowed"),
2171
+ inArray(schema.brainCaptureAudiences.audienceId, audienceIds),
2172
+ ),
2173
+ )
2174
+ .orderBy(desc(schema.brainRawCaptures.capturedAt))
2175
+ .limit(10)
2176
+ : [];
1370
2177
  const queueByCapture = await latestDistillationQueuesForCaptures(
1371
- captures.map((capture) => capture.id),
2178
+ captures.map(({ capture }) => capture.id),
1372
2179
  );
1373
- screen.sourceCaptures = captures.map((capture) => ({
2180
+ screen.sourceCaptures = captures.map(({ capture }) => ({
1374
2181
  id: capture.id,
1375
2182
  sourceId: capture.sourceId,
1376
2183
  title: capture.title,
@@ -1386,18 +2193,25 @@ export async function readBrainScreen() {
1386
2193
  }
1387
2194
  if (nav?.knowledgeId) {
1388
2195
  const knowledge = await resolveAccess("brain-knowledge", nav.knowledgeId);
1389
- if (knowledge) screen.knowledge = serializeKnowledge(knowledge.resource);
2196
+ if (knowledge) {
2197
+ await assertDerivedAudienceAccess(knowledge.resource);
2198
+ screen.knowledge = serializeKnowledge(knowledge.resource);
2199
+ }
1390
2200
  }
1391
2201
  const proposalId = nav?.proposalId ?? nav?.reviewItemId;
1392
2202
  if (proposalId) {
1393
2203
  const proposal = await resolveAccess("brain-proposal", proposalId);
1394
- if (proposal) screen.proposal = serializeProposal(proposal.resource);
2204
+ if (proposal) {
2205
+ await assertDerivedAudienceAccess(proposal.resource);
2206
+ screen.proposal = serializeProposal(proposal.resource);
2207
+ }
1395
2208
  }
1396
2209
  if (nav?.view === "review") {
1397
2210
  const params = searchParamsFromPath(nav.path);
1398
2211
  const status = proposalStatusFromNavigation(
1399
2212
  typeof nav.status === "string" ? nav.status : params.get("status"),
1400
2213
  );
2214
+ const audienceIds = await listAccessibleAudienceIds();
1401
2215
  const proposals = await getDb()
1402
2216
  .select()
1403
2217
  .from(schema.brainProposals)
@@ -1405,6 +2219,19 @@ export async function readBrainScreen() {
1405
2219
  and(
1406
2220
  accessFilter(schema.brainProposals, schema.brainProposalShares),
1407
2221
  eq(schema.brainProposals.status, status),
2222
+ or(
2223
+ isNull(schema.brainProposals.captureId),
2224
+ audienceIds.length
2225
+ ? and(
2226
+ sql`exists (
2227
+ select 1 from ${schema.brainSources}
2228
+ where ${schema.brainSources.id} = ${schema.brainProposals.sourceId}
2229
+ and ${accessFilter(schema.brainSources, schema.brainSourceShares)}
2230
+ )`,
2231
+ inArray(schema.brainProposals.audienceId, audienceIds),
2232
+ )
2233
+ : undefined,
2234
+ ),
1408
2235
  ),
1409
2236
  )
1410
2237
  .orderBy(desc(schema.brainProposals.updatedAt))