@opengeni/documents 0.2.72 → 0.3.2

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.
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  AddDocumentRequest,
4
4
  CreateDocumentBaseRequest,
5
5
  Document,
6
+ DocumentAuthorityKind,
6
7
  DocumentBase,
7
8
  DocumentCuration,
8
9
  DocumentCurationStatus,
@@ -13,7 +14,15 @@ import type {
13
14
  FileAsset,
14
15
  KnowledgeSourceKind,
15
16
  } from "@opengeni/contracts";
16
- import { requireFile, withRlsContext, withWorkspaceRls, type Database } from "@opengeni/db";
17
+ import {
18
+ requireFile,
19
+ rlsContextForWorkspace,
20
+ setSubjectRlsContext,
21
+ withRlsContext,
22
+ withWorkspaceRls,
23
+ withWorkspaceSubjectRls,
24
+ type Database,
25
+ } from "@opengeni/db";
17
26
  import * as schema from "@opengeni/db/schema";
18
27
  import type { ObjectStorage } from "@opengeni/storage";
19
28
  import { LiteParse } from "@llamaindex/liteparse";
@@ -29,6 +38,7 @@ export const DEFAULT_DOCUMENT_CURATION_MODEL = "gpt-4o-mini";
29
38
  // Curator input is a preview, not the whole document — enough to name and
30
39
  // classify without paying for a full-document prompt on every drop.
31
40
  export const DOCUMENT_CURATION_MAX_INPUT_CHARS = 24_000;
41
+ export const DOCUMENT_AUTHORITY_SUBJECT_MAX_BYTES = 1024;
32
42
  // A base move is applied automatically only at or above this curator
33
43
  // confidence; below it the suggestion is surfaced for human review instead.
34
44
  export const DOCUMENT_CURATION_AUTO_FILE_CONFIDENCE = 0.75;
@@ -118,6 +128,12 @@ export type DocumentAccessFilter = {
118
128
  agentOnly?: boolean | undefined;
119
129
  };
120
130
 
131
+ export type DocumentAuthority = {
132
+ kind: DocumentAuthorityKind;
133
+ workspaceId: string | null;
134
+ subjectId: string | null;
135
+ };
136
+
121
137
  export type DocumentInventoryStatusCounts = Record<DocumentStatus, number>;
122
138
  export type DocumentInventorySourceKindCounts = Record<KnowledgeSourceKind, number>;
123
139
 
@@ -150,6 +166,7 @@ const DOCUMENT_INVENTORY_MAX_TOPIC_LIMIT = 100;
150
166
  const DOCUMENT_INVENTORY_MAX_TOPIC_CHARS = 256;
151
167
 
152
168
  export type DocumentSearchInput = {
169
+ accountId: string;
153
170
  workspaceId: string;
154
171
  query: string;
155
172
  baseIds?: string[] | undefined;
@@ -160,6 +177,13 @@ export type DocumentSearchInput = {
160
177
  access?: DocumentAccessFilter | undefined;
161
178
  };
162
179
 
180
+ export type EffectiveDocumentSearchInput = Omit<DocumentSearchInput, "access"> & {
181
+ /** Immutable human subject accepted for the logical request/turn. */
182
+ initiatingSubjectId: string;
183
+ /** Agent retrieval additionally enforces documents.agent_access. */
184
+ surface: "human" | "agent";
185
+ };
186
+
163
187
  export type DocumentIndexHooks = {
164
188
  beforeEmbed?: (input: {
165
189
  accountId: string;
@@ -637,7 +661,7 @@ export async function getDocumentInventory(
637
661
  "topicMaxChars",
638
662
  );
639
663
 
640
- return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
664
+ return await withDocumentRls(db, workspaceId, input.access, async (scopedDb) => {
641
665
  const [baseTotal] = await scopedDb
642
666
  .select({ count: sql<number>`count(*)::int` })
643
667
  .from(schema.documentBases)
@@ -646,7 +670,7 @@ export async function getDocumentInventory(
646
670
  const baseJoin = and(
647
671
  eq(schema.documents.workspaceId, workspaceId),
648
672
  eq(schema.documents.baseId, schema.documentBases.id),
649
- ...documentAccessConditions(input.access),
673
+ ...documentAccessConditions(workspaceId, input.access),
650
674
  );
651
675
  const baseRows = await scopedDb
652
676
  .select({
@@ -668,7 +692,7 @@ export async function getDocumentInventory(
668
692
 
669
693
  const documentWhere = and(
670
694
  eq(schema.documents.workspaceId, workspaceId),
671
- ...documentAccessConditions(input.access),
695
+ ...documentAccessConditions(workspaceId, input.access),
672
696
  );
673
697
  const [summary] = await scopedDb
674
698
  .select({
@@ -859,6 +883,8 @@ export async function addDocumentToBase(
859
883
  workspaceId: string;
860
884
  baseId: string;
861
885
  createdBy?: string | null | undefined;
886
+ initiatingSubjectId?: string | null | undefined;
887
+ organizationAuthorityGranted?: boolean | undefined;
862
888
  curationStatus?: DocumentCurationStatus | undefined;
863
889
  access?: DocumentAccessFilter | undefined;
864
890
  },
@@ -867,12 +893,27 @@ export async function addDocumentToBase(
867
893
  db,
868
894
  { accountId: input.accountId, workspaceId: input.workspaceId },
869
895
  async (scopedDb) => {
896
+ const viewerSubjectId = cleanString(input.access?.viewerSubjectId ?? null);
897
+ const authority = resolveDocumentAuthority({
898
+ kind: input.authorityKind,
899
+ legacyVisibility: input.visibility,
900
+ workspaceId: input.workspaceId,
901
+ initiatingSubjectId: input.initiatingSubjectId,
902
+ });
903
+ if (authority.kind === "organization" && input.organizationAuthorityGranted !== true) {
904
+ throw new Error("organization document writes require exact account authority");
905
+ }
906
+ if (
907
+ authority.kind === "personal" &&
908
+ (viewerSubjectId !== authority.subjectId ||
909
+ cleanString(input.createdBy ?? null) !== authority.subjectId)
910
+ ) {
911
+ throw new Error("personal document writes require the exact initiating subject");
912
+ }
913
+ if (viewerSubjectId) await setSubjectRlsContext(scopedDb, viewerSubjectId);
870
914
  const base = await getDocumentBase(scopedDb, input.workspaceId, input.baseId);
871
915
  if (!base) throw new Error(`Document base not found: ${input.baseId}`);
872
916
  const file = await requireReadyFile(scopedDb, input.workspaceId, input.fileId);
873
- if (input.visibility === "private" && !cleanString(input.createdBy ?? null)) {
874
- throw new Error("private documents require a creating subject");
875
- }
876
917
  const now = new Date();
877
918
  const [existing] = await scopedDb
878
919
  .select()
@@ -889,6 +930,10 @@ export async function addDocumentToBase(
889
930
  if (!documentMatchesAccess(existing, input.access)) {
890
931
  throw new Error(`Document not found: ${existing.id}`);
891
932
  }
933
+ assertOrganizationDocumentAuthority(
934
+ existing.authorityKind,
935
+ input.organizationAuthorityGranted,
936
+ );
892
937
  // Idempotent re-add: refresh caller-supplied source metadata on the
893
938
  // existing row instead of silently discarding it (aclTags especially —
894
939
  // a re-add that tightens tags must not be a no-op). Access policy is
@@ -913,7 +958,7 @@ export async function addDocumentToBase(
913
958
  and(
914
959
  eq(schema.documents.workspaceId, input.workspaceId),
915
960
  eq(schema.documents.id, existing.id),
916
- ...documentAccessConditions(input.access),
961
+ ...documentAccessConditions(input.workspaceId, input.access),
917
962
  ),
918
963
  )
919
964
  .returning();
@@ -938,7 +983,10 @@ export async function addDocumentToBase(
938
983
  sourceUpdatedAt: parseOptionalDate(input.sourceUpdatedAt),
939
984
  sourceVersion: cleanString(input.sourceVersion) ?? null,
940
985
  aclTags: cleanStringArray(input.aclTags),
941
- visibility: input.visibility ?? "workspace",
986
+ authorityKind: authority.kind,
987
+ authorityWorkspaceId: authority.workspaceId,
988
+ authoritySubjectId: authority.subjectId,
989
+ visibility: authority.kind === "personal" ? "private" : "workspace",
942
990
  agentAccess: input.agentAccess ?? true,
943
991
  createdBy: input.createdBy ?? null,
944
992
  curationStatus: input.curationStatus ?? "none",
@@ -963,6 +1011,7 @@ export async function moveDocumentToBase(
963
1011
  workspaceId: string;
964
1012
  documentId: string;
965
1013
  targetBaseId?: string | null | undefined;
1014
+ organizationAuthorityGranted?: boolean | undefined;
966
1015
  access?: DocumentAccessFilter | undefined;
967
1016
  },
968
1017
  ): Promise<Document> {
@@ -970,6 +1019,8 @@ export async function moveDocumentToBase(
970
1019
  db,
971
1020
  { accountId: input.accountId, workspaceId: input.workspaceId },
972
1021
  async (scopedDb) => {
1022
+ const viewerSubjectId = cleanString(input.access?.viewerSubjectId ?? null);
1023
+ if (viewerSubjectId) await setSubjectRlsContext(scopedDb, viewerSubjectId);
973
1024
  const [row] = await scopedDb
974
1025
  .select()
975
1026
  .from(schema.documents)
@@ -977,11 +1028,12 @@ export async function moveDocumentToBase(
977
1028
  and(
978
1029
  eq(schema.documents.workspaceId, input.workspaceId),
979
1030
  eq(schema.documents.id, input.documentId),
980
- ...documentAccessConditions(input.access),
1031
+ ...documentAccessConditions(input.workspaceId, input.access),
981
1032
  ),
982
1033
  )
983
1034
  .limit(1);
984
1035
  if (!row) throw new Error(`Document not found: ${input.documentId}`);
1036
+ assertOrganizationDocumentAuthority(row.authorityKind, input.organizationAuthorityGranted);
985
1037
  const suggestion = (row.curation as { suggestedBaseId?: string | null } | null)
986
1038
  ?.suggestedBaseId;
987
1039
  const targetBaseId = input.targetBaseId ?? suggestion;
@@ -1020,7 +1072,7 @@ export async function moveDocumentToBase(
1020
1072
  and(
1021
1073
  eq(schema.documents.workspaceId, input.workspaceId),
1022
1074
  eq(schema.documents.id, input.documentId),
1023
- ...documentAccessConditions(input.access),
1075
+ ...documentAccessConditions(input.workspaceId, input.access),
1024
1076
  ),
1025
1077
  )
1026
1078
  .returning();
@@ -1050,6 +1102,7 @@ export async function deleteDocumentFromBase(
1050
1102
  workspaceId: string;
1051
1103
  baseId: string;
1052
1104
  documentId: string;
1105
+ organizationAuthorityGranted?: boolean | undefined;
1053
1106
  access?: DocumentAccessFilter | undefined;
1054
1107
  },
1055
1108
  ): Promise<void> {
@@ -1057,6 +1110,8 @@ export async function deleteDocumentFromBase(
1057
1110
  db,
1058
1111
  { accountId: input.accountId, workspaceId: input.workspaceId },
1059
1112
  async (scopedDb) => {
1113
+ const viewerSubjectId = cleanString(input.access?.viewerSubjectId ?? null);
1114
+ if (viewerSubjectId) await setSubjectRlsContext(scopedDb, viewerSubjectId);
1060
1115
  const [document] = await scopedDb
1061
1116
  .select()
1062
1117
  .from(schema.documents)
@@ -1064,13 +1119,17 @@ export async function deleteDocumentFromBase(
1064
1119
  and(
1065
1120
  eq(schema.documents.workspaceId, input.workspaceId),
1066
1121
  eq(schema.documents.id, input.documentId),
1067
- ...documentAccessConditions(input.access),
1122
+ ...documentAccessConditions(input.workspaceId, input.access),
1068
1123
  ),
1069
1124
  )
1070
1125
  .limit(1);
1071
1126
  if (!document) {
1072
1127
  throw new Error(`Document not found: ${input.documentId}`);
1073
1128
  }
1129
+ assertOrganizationDocumentAuthority(
1130
+ document.authorityKind,
1131
+ input.organizationAuthorityGranted,
1132
+ );
1074
1133
  if (document.baseId !== input.baseId) {
1075
1134
  throw new Error(`Document not found: ${input.documentId}`);
1076
1135
  }
@@ -1080,7 +1139,7 @@ export async function deleteDocumentFromBase(
1080
1139
  and(
1081
1140
  eq(schema.documents.workspaceId, input.workspaceId),
1082
1141
  eq(schema.documents.id, input.documentId),
1083
- ...documentAccessConditions(input.access),
1142
+ ...documentAccessConditions(input.workspaceId, input.access),
1084
1143
  ),
1085
1144
  );
1086
1145
  },
@@ -1093,7 +1152,7 @@ export async function listDocuments(
1093
1152
  baseId: string,
1094
1153
  access?: DocumentAccessFilter,
1095
1154
  ): Promise<Document[]> {
1096
- return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
1155
+ return await withDocumentRls(db, workspaceId, access, async (scopedDb) => {
1097
1156
  const rows = await scopedDb
1098
1157
  .select()
1099
1158
  .from(schema.documents)
@@ -1101,7 +1160,7 @@ export async function listDocuments(
1101
1160
  and(
1102
1161
  eq(schema.documents.workspaceId, workspaceId),
1103
1162
  eq(schema.documents.baseId, baseId),
1104
- ...documentAccessConditions(access),
1163
+ ...documentAccessConditions(workspaceId, access),
1105
1164
  ),
1106
1165
  )
1107
1166
  .orderBy(asc(schema.documents.createdAt));
@@ -1115,7 +1174,7 @@ export async function getDocument(
1115
1174
  documentId: string,
1116
1175
  access?: DocumentAccessFilter,
1117
1176
  ): Promise<Document | null> {
1118
- return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
1177
+ return await withDocumentRls(db, workspaceId, access, async (scopedDb) => {
1119
1178
  const [row] = await scopedDb
1120
1179
  .select()
1121
1180
  .from(schema.documents)
@@ -1123,7 +1182,7 @@ export async function getDocument(
1123
1182
  and(
1124
1183
  eq(schema.documents.workspaceId, workspaceId),
1125
1184
  eq(schema.documents.id, documentId),
1126
- ...documentAccessConditions(access),
1185
+ ...documentAccessConditions(workspaceId, access),
1127
1186
  ),
1128
1187
  )
1129
1188
  .limit(1);
@@ -1136,8 +1195,22 @@ export async function queueDocumentForReindex(
1136
1195
  workspaceId: string,
1137
1196
  documentId: string,
1138
1197
  access?: DocumentAccessFilter,
1198
+ organizationAuthorityGranted?: boolean,
1139
1199
  ): Promise<Document> {
1140
- return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
1200
+ return await withDocumentRls(db, workspaceId, access, async (scopedDb) => {
1201
+ const [document] = await scopedDb
1202
+ .select({ authorityKind: schema.documents.authorityKind })
1203
+ .from(schema.documents)
1204
+ .where(
1205
+ and(
1206
+ eq(schema.documents.workspaceId, workspaceId),
1207
+ eq(schema.documents.id, documentId),
1208
+ ...documentAccessConditions(workspaceId, access),
1209
+ ),
1210
+ )
1211
+ .limit(1);
1212
+ if (!document) throw new Error(`Document not found: ${documentId}`);
1213
+ assertOrganizationDocumentAuthority(document.authorityKind, organizationAuthorityGranted);
1141
1214
  const [row] = await scopedDb
1142
1215
  .update(schema.documents)
1143
1216
  .set({
@@ -1149,7 +1222,7 @@ export async function queueDocumentForReindex(
1149
1222
  and(
1150
1223
  eq(schema.documents.workspaceId, workspaceId),
1151
1224
  eq(schema.documents.id, documentId),
1152
- ...documentAccessConditions(access),
1225
+ ...documentAccessConditions(workspaceId, access),
1153
1226
  ),
1154
1227
  )
1155
1228
  .returning();
@@ -1158,6 +1231,15 @@ export async function queueDocumentForReindex(
1158
1231
  });
1159
1232
  }
1160
1233
 
1234
+ function assertOrganizationDocumentAuthority(
1235
+ authorityKind: string,
1236
+ organizationAuthorityGranted: boolean | undefined,
1237
+ ): void {
1238
+ if (authorityKind === "organization" && organizationAuthorityGranted !== true) {
1239
+ throw new Error("organization document mutations require exact account authority");
1240
+ }
1241
+ }
1242
+
1161
1243
  export async function indexDocumentNow(
1162
1244
  db: Database,
1163
1245
  objectStorage: ObjectStorage,
@@ -1165,10 +1247,12 @@ export async function indexDocumentNow(
1165
1247
  documentId: string,
1166
1248
  services: DocumentServices = createDocumentServices(),
1167
1249
  hooks: DocumentIndexHooks = {},
1250
+ access?: DocumentAccessFilter,
1168
1251
  ): Promise<Document> {
1169
- const [loadedDocument] = await withWorkspaceRls(
1252
+ const [loadedDocument] = await withDocumentRls(
1170
1253
  db,
1171
1254
  workspaceId,
1255
+ access,
1172
1256
  async (scopedDb) =>
1173
1257
  await scopedDb
1174
1258
  .select()
@@ -1181,7 +1265,7 @@ export async function indexDocumentNow(
1181
1265
  if (!loadedDocument) throw new Error(`Document not found: ${documentId}`);
1182
1266
  let document: DocumentRow = loadedDocument;
1183
1267
  const file = await requireReadyFile(db, workspaceId, document.fileId);
1184
- await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
1268
+ await withDocumentRls(db, workspaceId, access, async (scopedDb) => {
1185
1269
  await scopedDb
1186
1270
  .update(schema.documents)
1187
1271
  .set({
@@ -1217,9 +1301,10 @@ export async function indexDocumentNow(
1217
1301
  `Embedding provider returned ${embeddings.length} embeddings for ${chunks.length} chunks`,
1218
1302
  );
1219
1303
  }
1220
- await withWorkspaceRls(
1304
+ await withDocumentRls(
1221
1305
  db,
1222
1306
  workspaceId,
1307
+ access,
1223
1308
  async (scopedDb) =>
1224
1309
  await scopedDb.transaction(async (tx) => {
1225
1310
  await tx
@@ -1238,6 +1323,9 @@ export async function indexDocumentNow(
1238
1323
  documentId,
1239
1324
  baseId: document.baseId,
1240
1325
  fileId: file.id,
1326
+ authorityKind: document.authorityKind,
1327
+ authorityWorkspaceId: document.authorityWorkspaceId,
1328
+ authoritySubjectId: document.authoritySubjectId,
1241
1329
  chunkIndex: index,
1242
1330
  text: chunk.text,
1243
1331
  metadata: {
@@ -1280,9 +1368,10 @@ export async function indexDocumentNow(
1280
1368
  }),
1281
1369
  );
1282
1370
  } catch (error) {
1283
- const [failed] = await withWorkspaceRls(
1371
+ const [failed] = await withDocumentRls(
1284
1372
  db,
1285
1373
  workspaceId,
1374
+ access,
1286
1375
  async (scopedDb) =>
1287
1376
  await scopedDb
1288
1377
  .update(schema.documents)
@@ -1303,7 +1392,7 @@ export async function indexDocumentNow(
1303
1392
  // that created/queued it. Public reads remain fail-closed when no subject is
1304
1393
  // supplied; the creator subject is the document's frozen access principal.
1305
1394
  const updated = await getDocument(db, workspaceId, documentId, {
1306
- viewerSubjectId: document.createdBy,
1395
+ viewerSubjectId: document.authoritySubjectId,
1307
1396
  });
1308
1397
  if (!updated) throw new Error(`Document disappeared after indexing: ${documentId}`);
1309
1398
  return updated;
@@ -1322,9 +1411,10 @@ async function curateDroppedDocument(
1322
1411
  // it with heuristics: indexing still proceeds, but the drop remains an
1323
1412
  // ordinary uncured document with its caller-supplied title and metadata.
1324
1413
  if (!services.curator) {
1325
- const [updated] = await withWorkspaceRls(
1414
+ const [updated] = await withDocumentRls(
1326
1415
  db,
1327
1416
  document.workspaceId,
1417
+ { viewerSubjectId: document.authoritySubjectId },
1328
1418
  async (scopedDb) =>
1329
1419
  await scopedDb
1330
1420
  .update(schema.documents)
@@ -1380,9 +1470,10 @@ async function curateDroppedDocument(
1380
1470
  ) {
1381
1471
  // The (workspace, base, file) unique index means a same-file twin already
1382
1472
  // in the target base blocks the move; keep it as a suggestion instead.
1383
- const conflict = await withWorkspaceRls(
1473
+ const conflict = await withDocumentRls(
1384
1474
  db,
1385
1475
  document.workspaceId,
1476
+ { viewerSubjectId: document.authoritySubjectId },
1386
1477
  async (scopedDb) =>
1387
1478
  await scopedDb
1388
1479
  .select({ id: schema.documents.id })
@@ -1413,9 +1504,10 @@ async function curateDroppedDocument(
1413
1504
  : moveToBaseId
1414
1505
  ? "auto_filed"
1415
1506
  : "suggested";
1416
- const [updated] = await withWorkspaceRls(
1507
+ const [updated] = await withDocumentRls(
1417
1508
  db,
1418
1509
  document.workspaceId,
1510
+ { viewerSubjectId: document.authoritySubjectId },
1419
1511
  async (scopedDb) =>
1420
1512
  await scopedDb
1421
1513
  .update(schema.documents)
@@ -1445,6 +1537,7 @@ export async function searchDocuments(
1445
1537
  input: DocumentSearchInput,
1446
1538
  services: Pick<DocumentServices, "embedder"> = createDocumentServices(),
1447
1539
  ): Promise<DocumentSearchResult[]> {
1540
+ await assertDocumentAccountWorkspace(db, input.accountId, input.workspaceId);
1448
1541
  const mode = input.mode ?? "hybrid";
1449
1542
  const limit = Math.min(Math.max(input.limit ?? 5, 1), 50);
1450
1543
  const candidateLimit = mode === "hybrid" ? Math.min(limit * 4, 100) : limit;
@@ -1471,6 +1564,42 @@ export async function searchDocuments(
1471
1564
  return mergeDocumentSearchRows(rows, mode).slice(0, limit);
1472
1565
  }
1473
1566
 
1567
+ /**
1568
+ * Canonical effective retrieval composition for API, SDK, and MCP surfaces.
1569
+ * Callers supply the already-authorized immutable initiating subject; no
1570
+ * request/tool input can replace it with another user's personal authority.
1571
+ */
1572
+ export async function searchEffectiveDocuments(
1573
+ db: Database,
1574
+ input: EffectiveDocumentSearchInput,
1575
+ services: Pick<DocumentServices, "embedder"> = createDocumentServices(),
1576
+ ): Promise<DocumentSearchResult[]> {
1577
+ const initiatingSubjectId = cleanString(input.initiatingSubjectId);
1578
+ if (!initiatingSubjectId) {
1579
+ throw new Error("effective document retrieval requires an initiating subject");
1580
+ }
1581
+ return await searchDocuments(
1582
+ db,
1583
+ {
1584
+ accountId: input.accountId,
1585
+ workspaceId: input.workspaceId,
1586
+ query: input.query,
1587
+ baseIds: input.baseIds,
1588
+ limit: input.limit,
1589
+ mode: input.mode,
1590
+ sourceKinds: input.sourceKinds,
1591
+ aclTags: input.aclTags,
1592
+ // Construct the lower-level access filter here instead of spreading the
1593
+ // caller input, so an untyped/legacy access override is always ignored.
1594
+ access: {
1595
+ viewerSubjectId: initiatingSubjectId,
1596
+ ...(input.surface === "agent" ? { agentOnly: true } : {}),
1597
+ },
1598
+ },
1599
+ services,
1600
+ );
1601
+ }
1602
+
1474
1603
  async function vectorSearchDocuments(
1475
1604
  db: Database,
1476
1605
  input: DocumentSearchInput,
@@ -1480,13 +1609,16 @@ async function vectorSearchDocuments(
1480
1609
  const queryEmbedding = await services.embedder.embedQuery(input.query);
1481
1610
  validateEmbedding(queryEmbedding, services.embedder.dimensions, services.embedder.model);
1482
1611
  const distance = sql<number>`${schema.documentChunks.embedding} <=> ${vectorLiteral(queryEmbedding)}::vector`;
1483
- const rows = await withWorkspaceRls(
1612
+ const rows = await withDocumentAccountRls(
1484
1613
  db,
1614
+ input.accountId,
1485
1615
  input.workspaceId,
1616
+ input.access,
1486
1617
  async (scopedDb) =>
1487
1618
  await scopedDb
1488
1619
  .select({
1489
1620
  chunkId: schema.documentChunks.id,
1621
+ workspaceId: schema.documentChunks.workspaceId,
1490
1622
  documentId: schema.documentChunks.documentId,
1491
1623
  baseId: schema.documentChunks.baseId,
1492
1624
  fileId: schema.documentChunks.fileId,
@@ -1503,6 +1635,9 @@ async function vectorSearchDocuments(
1503
1635
  sourceUpdatedAt: schema.documents.sourceUpdatedAt,
1504
1636
  sourceVersion: schema.documents.sourceVersion,
1505
1637
  aclTags: schema.documents.aclTags,
1638
+ authorityKind: schema.documents.authorityKind,
1639
+ authorityWorkspaceId: schema.documents.authorityWorkspaceId,
1640
+ authoritySubjectId: schema.documents.authoritySubjectId,
1506
1641
  distance,
1507
1642
  })
1508
1643
  .from(schema.documentChunks)
@@ -1512,7 +1647,7 @@ async function vectorSearchDocuments(
1512
1647
  .limit(limit),
1513
1648
  );
1514
1649
  return rows.map((row) => ({
1515
- ...mapSearchRowBase(row, input.workspaceId),
1650
+ ...mapSearchRowBase(row),
1516
1651
  vectorScore: 1 / (1 + Number(row.distance)),
1517
1652
  keywordScore: null,
1518
1653
  }));
@@ -1524,13 +1659,16 @@ async function keywordSearchDocuments(
1524
1659
  limit: number,
1525
1660
  ): Promise<CombinedSearchRow[]> {
1526
1661
  const rank = sql<number>`ts_rank_cd(to_tsvector('simple', ${schema.documentChunks.text}), plainto_tsquery('simple', ${input.query}))`;
1527
- const rows = await withWorkspaceRls(
1662
+ const rows = await withDocumentAccountRls(
1528
1663
  db,
1664
+ input.accountId,
1529
1665
  input.workspaceId,
1666
+ input.access,
1530
1667
  async (scopedDb) =>
1531
1668
  await scopedDb
1532
1669
  .select({
1533
1670
  chunkId: schema.documentChunks.id,
1671
+ workspaceId: schema.documentChunks.workspaceId,
1534
1672
  documentId: schema.documentChunks.documentId,
1535
1673
  baseId: schema.documentChunks.baseId,
1536
1674
  fileId: schema.documentChunks.fileId,
@@ -1547,6 +1685,9 @@ async function keywordSearchDocuments(
1547
1685
  sourceUpdatedAt: schema.documents.sourceUpdatedAt,
1548
1686
  sourceVersion: schema.documents.sourceVersion,
1549
1687
  aclTags: schema.documents.aclTags,
1688
+ authorityKind: schema.documents.authorityKind,
1689
+ authorityWorkspaceId: schema.documents.authorityWorkspaceId,
1690
+ authoritySubjectId: schema.documents.authoritySubjectId,
1550
1691
  rank,
1551
1692
  })
1552
1693
  .from(schema.documentChunks)
@@ -1561,7 +1702,7 @@ async function keywordSearchDocuments(
1561
1702
  .limit(limit),
1562
1703
  );
1563
1704
  return rows.map((row) => ({
1564
- ...mapSearchRowBase(row, input.workspaceId),
1705
+ ...mapSearchRowBase(row),
1565
1706
  vectorScore: null,
1566
1707
  keywordScore: normalizeKeywordScore(Number(row.rank)),
1567
1708
  }));
@@ -1569,17 +1710,21 @@ async function keywordSearchDocuments(
1569
1710
 
1570
1711
  export async function getDocumentChunk(
1571
1712
  db: Database,
1713
+ accountId: string,
1572
1714
  workspaceId: string,
1573
1715
  chunkId: string,
1574
1716
  access?: DocumentAccessFilter,
1575
1717
  ): Promise<DocumentSearchResult | null> {
1576
- const [row] = await withWorkspaceRls(
1718
+ const [row] = await withDocumentAccountRls(
1577
1719
  db,
1720
+ accountId,
1578
1721
  workspaceId,
1722
+ access,
1579
1723
  async (scopedDb) =>
1580
1724
  await scopedDb
1581
1725
  .select({
1582
1726
  chunkId: schema.documentChunks.id,
1727
+ workspaceId: schema.documentChunks.workspaceId,
1583
1728
  documentId: schema.documentChunks.documentId,
1584
1729
  baseId: schema.documentChunks.baseId,
1585
1730
  fileId: schema.documentChunks.fileId,
@@ -1596,22 +1741,26 @@ export async function getDocumentChunk(
1596
1741
  sourceUpdatedAt: schema.documents.sourceUpdatedAt,
1597
1742
  sourceVersion: schema.documents.sourceVersion,
1598
1743
  aclTags: schema.documents.aclTags,
1744
+ authorityKind: schema.documents.authorityKind,
1745
+ authorityWorkspaceId: schema.documents.authorityWorkspaceId,
1746
+ authoritySubjectId: schema.documents.authoritySubjectId,
1599
1747
  })
1600
1748
  .from(schema.documentChunks)
1601
1749
  .innerJoin(schema.documents, eq(schema.documentChunks.documentId, schema.documents.id))
1602
1750
  .where(
1603
1751
  and(
1604
- eq(schema.documentChunks.workspaceId, workspaceId),
1752
+ eq(schema.documents.accountId, accountId),
1753
+ eq(schema.documentChunks.accountId, accountId),
1605
1754
  eq(schema.documentChunks.id, chunkId),
1606
1755
  eq(schema.documents.status, "ready"),
1607
- ...documentAccessConditions(access),
1756
+ ...documentAccessConditions(workspaceId, access),
1608
1757
  ),
1609
1758
  )
1610
1759
  .limit(1),
1611
1760
  );
1612
1761
  if (!row) return null;
1613
1762
  return {
1614
- ...mapSearchRowBase(row, workspaceId),
1763
+ ...mapSearchRowBase(row),
1615
1764
  score: 1,
1616
1765
  matchType: "hybrid",
1617
1766
  vectorScore: null,
@@ -1628,65 +1777,139 @@ type CombinedSearchRow = SearchRowBase & {
1628
1777
  keywordScore: number | null;
1629
1778
  };
1630
1779
 
1780
+ export function resolveDocumentAuthority(input: {
1781
+ kind?: DocumentAuthorityKind | undefined;
1782
+ legacyVisibility?: DocumentVisibility | undefined;
1783
+ workspaceId: string;
1784
+ initiatingSubjectId?: string | null | undefined;
1785
+ }): DocumentAuthority {
1786
+ const legacyKind = input.legacyVisibility === "private" ? "personal" : "workspace";
1787
+ const kind = input.kind ?? legacyKind;
1788
+ if (
1789
+ input.kind &&
1790
+ input.legacyVisibility &&
1791
+ (input.legacyVisibility === "private") !== (input.kind === "personal")
1792
+ ) {
1793
+ throw new Error("document authorityKind conflicts with legacy visibility");
1794
+ }
1795
+ if (kind === "organization") {
1796
+ return { kind, workspaceId: null, subjectId: null };
1797
+ }
1798
+ if (kind === "workspace") {
1799
+ return { kind, workspaceId: input.workspaceId, subjectId: null };
1800
+ }
1801
+ const subjectId = cleanString(input.initiatingSubjectId ?? null);
1802
+ if (!subjectId) throw new Error("personal documents require an initiating subject");
1803
+ if (new TextEncoder().encode(subjectId).byteLength > DOCUMENT_AUTHORITY_SUBJECT_MAX_BYTES) {
1804
+ throw new Error(
1805
+ `personal document initiating subject exceeds ${DOCUMENT_AUTHORITY_SUBJECT_MAX_BYTES} UTF-8 bytes`,
1806
+ );
1807
+ }
1808
+ return { kind, workspaceId: input.workspaceId, subjectId };
1809
+ }
1810
+
1811
+ async function withDocumentRls<T>(
1812
+ db: Database,
1813
+ workspaceId: string,
1814
+ access: DocumentAccessFilter | undefined,
1815
+ fn: (db: Database) => Promise<T>,
1816
+ ): Promise<T> {
1817
+ const subjectId = cleanString(access?.viewerSubjectId ?? null);
1818
+ return subjectId
1819
+ ? await withWorkspaceSubjectRls(db, workspaceId, subjectId, fn)
1820
+ : await withWorkspaceRls(db, workspaceId, fn);
1821
+ }
1822
+
1823
+ async function withDocumentAccountRls<T>(
1824
+ db: Database,
1825
+ accountId: string,
1826
+ workspaceId: string,
1827
+ access: DocumentAccessFilter | undefined,
1828
+ fn: (db: Database) => Promise<T>,
1829
+ ): Promise<T> {
1830
+ const context = await assertDocumentAccountWorkspace(db, accountId, workspaceId);
1831
+ const subjectId = cleanString(access?.viewerSubjectId ?? null);
1832
+ return await withRlsContext(db, context, async (scopedDb) => {
1833
+ if (subjectId) await setSubjectRlsContext(scopedDb, subjectId);
1834
+ return await fn(scopedDb);
1835
+ });
1836
+ }
1837
+
1838
+ async function assertDocumentAccountWorkspace(
1839
+ db: Database,
1840
+ accountId: string,
1841
+ workspaceId: string,
1842
+ ) {
1843
+ const context = await rlsContextForWorkspace(db, workspaceId);
1844
+ if (context.accountId !== accountId) {
1845
+ throw new Error("document account/workspace authority mismatch");
1846
+ }
1847
+ return context;
1848
+ }
1849
+
1631
1850
  /**
1632
1851
  * Visibility/agent scoping shared by every document read path. Fail-closed:
1633
1852
  * with no filter supplied, private documents are invisible.
1634
1853
  */
1635
- function documentAccessConditions(access: DocumentAccessFilter | undefined): SQL[] {
1854
+ function documentAccessConditions(
1855
+ workspaceId: string,
1856
+ access: DocumentAccessFilter | undefined,
1857
+ ): SQL[] {
1858
+ const organization = eq(schema.documents.authorityKind, "organization");
1859
+ const workspace = and(
1860
+ eq(schema.documents.authorityKind, "workspace"),
1861
+ eq(schema.documents.authorityWorkspaceId, workspaceId),
1862
+ );
1863
+ const viewer = cleanString(access?.viewerSubjectId ?? null);
1864
+ const personal = viewer
1865
+ ? and(
1866
+ eq(schema.documents.authorityKind, "personal"),
1867
+ eq(schema.documents.authorityWorkspaceId, workspaceId),
1868
+ eq(schema.documents.authoritySubjectId, viewer),
1869
+ )
1870
+ : undefined;
1871
+ const authority = viewer
1872
+ ? (or(organization, workspace, personal) ?? organization)
1873
+ : (or(organization, workspace) ?? organization);
1636
1874
  if (access?.agentOnly) {
1637
- const viewer = access.viewerSubjectId;
1638
- const visibility = viewer
1639
- ? or(eq(schema.documents.visibility, "workspace"), eq(schema.documents.createdBy, viewer))
1640
- : eq(schema.documents.visibility, "workspace");
1641
- return [eq(schema.documents.agentAccess, true), ...(visibility ? [visibility] : [])];
1875
+ return [eq(schema.documents.agentAccess, true), authority];
1642
1876
  }
1643
- const viewer = access?.viewerSubjectId;
1644
- if (viewer) {
1645
- const condition = or(
1646
- eq(schema.documents.visibility, "workspace"),
1647
- eq(schema.documents.createdBy, viewer),
1648
- );
1649
- return condition ? [condition] : [];
1650
- }
1651
- return [eq(schema.documents.visibility, "workspace")];
1877
+ return [authority];
1652
1878
  }
1653
1879
 
1654
1880
  function documentMatchesAccess(
1655
- document: Pick<DocumentAccessRecord, "visibility" | "createdBy" | "agentAccess">,
1881
+ document: Pick<DocumentAccessRecord, "authorityKind" | "authoritySubjectId" | "agentAccess">,
1656
1882
  access: DocumentAccessFilter | undefined,
1657
1883
  ): boolean {
1658
1884
  if (access?.agentOnly) {
1659
- return (
1660
- document.agentAccess &&
1661
- (document.visibility !== "private" ||
1662
- (!!access.viewerSubjectId && document.createdBy === access.viewerSubjectId))
1663
- );
1885
+ return document.agentAccess && canViewDocument(document, access.viewerSubjectId);
1664
1886
  }
1665
1887
  return canViewDocument(document, access?.viewerSubjectId);
1666
1888
  }
1667
1889
 
1668
1890
  /** Whether a single already-fetched document is readable by this human viewer. */
1669
1891
  export function canViewDocument(
1670
- document: Pick<DocumentAccessRecord, "visibility" | "createdBy">,
1892
+ document: Pick<DocumentAccessRecord, "authorityKind" | "authoritySubjectId">,
1671
1893
  viewerSubjectId: string | null | undefined,
1672
1894
  ): boolean {
1673
1895
  return (
1674
- document.visibility !== "private" ||
1675
- (!!viewerSubjectId && document.createdBy === viewerSubjectId)
1896
+ document.authorityKind !== "personal" ||
1897
+ (!!viewerSubjectId && document.authoritySubjectId === viewerSubjectId)
1676
1898
  );
1677
1899
  }
1678
1900
 
1679
1901
  type DocumentAccessRecord = {
1680
- visibility: string;
1681
- createdBy: string | null;
1902
+ authorityKind: string;
1903
+ authoritySubjectId: string | null;
1682
1904
  agentAccess: boolean;
1683
1905
  };
1684
1906
 
1685
1907
  function documentSearchConditions(input: DocumentSearchInput, embeddingModel?: string): SQL[] {
1686
1908
  const conditions: SQL[] = [
1687
1909
  eq(schema.documents.status, "ready"),
1688
- eq(schema.documentChunks.workspaceId, input.workspaceId),
1689
- ...documentAccessConditions(input.access),
1910
+ eq(schema.documents.accountId, input.accountId),
1911
+ eq(schema.documentChunks.accountId, input.accountId),
1912
+ ...documentAccessConditions(input.workspaceId, input.access),
1690
1913
  ];
1691
1914
  if (embeddingModel) {
1692
1915
  conditions.push(eq(schema.documentChunks.embeddingModel, embeddingModel));
@@ -1704,31 +1927,32 @@ function documentSearchConditions(input: DocumentSearchInput, embeddingModel?: s
1704
1927
  return conditions;
1705
1928
  }
1706
1929
 
1707
- function mapSearchRowBase(
1708
- row: {
1709
- chunkId: string;
1710
- documentId: string;
1711
- baseId: string;
1712
- fileId: string;
1713
- title: string;
1714
- text: string;
1715
- chunkIndex: number;
1716
- metadata: Record<string, unknown>;
1717
- sourceKind: string;
1718
- sourceUri: string | null;
1719
- sourceExternalId: string | null;
1720
- sourceTitle: string | null;
1721
- sourceAuthor: string | null;
1722
- sourceCreatedAt: Date | null;
1723
- sourceUpdatedAt: Date | null;
1724
- sourceVersion: string | null;
1725
- aclTags: string[];
1726
- },
1727
- workspaceId: string,
1728
- ): SearchRowBase {
1930
+ function mapSearchRowBase(row: {
1931
+ chunkId: string;
1932
+ workspaceId: string;
1933
+ documentId: string;
1934
+ baseId: string;
1935
+ fileId: string;
1936
+ title: string;
1937
+ text: string;
1938
+ chunkIndex: number;
1939
+ metadata: Record<string, unknown>;
1940
+ sourceKind: string;
1941
+ sourceUri: string | null;
1942
+ sourceExternalId: string | null;
1943
+ sourceTitle: string | null;
1944
+ sourceAuthor: string | null;
1945
+ sourceCreatedAt: Date | null;
1946
+ sourceUpdatedAt: Date | null;
1947
+ sourceVersion: string | null;
1948
+ aclTags: string[];
1949
+ authorityKind: string;
1950
+ authorityWorkspaceId: string | null;
1951
+ authoritySubjectId: string | null;
1952
+ }): SearchRowBase {
1729
1953
  return {
1730
1954
  chunkId: row.chunkId,
1731
- workspaceId,
1955
+ workspaceId: row.workspaceId,
1732
1956
  documentId: row.documentId,
1733
1957
  baseId: row.baseId,
1734
1958
  fileId: row.fileId,
@@ -1745,6 +1969,9 @@ function mapSearchRowBase(
1745
1969
  sourceUpdatedAt: row.sourceUpdatedAt?.toISOString() ?? null,
1746
1970
  sourceVersion: row.sourceVersion,
1747
1971
  aclTags: cleanStringArray(row.aclTags),
1972
+ authorityKind: normalizeDocumentAuthorityKind(row.authorityKind),
1973
+ authorityWorkspaceId: row.authorityWorkspaceId,
1974
+ authoritySubjectId: row.authoritySubjectId,
1748
1975
  };
1749
1976
  }
1750
1977
 
@@ -2029,6 +2256,9 @@ function mapDocument(row: typeof schema.documents.$inferSelect): Document {
2029
2256
  sourceUpdatedAt: row.sourceUpdatedAt?.toISOString() ?? null,
2030
2257
  sourceVersion: row.sourceVersion,
2031
2258
  aclTags: cleanStringArray(row.aclTags),
2259
+ authorityKind: normalizeDocumentAuthorityKind(row.authorityKind),
2260
+ authorityWorkspaceId: row.authorityWorkspaceId,
2261
+ authoritySubjectId: row.authoritySubjectId,
2032
2262
  visibility: normalizeDocumentVisibility(row.visibility),
2033
2263
  createdBy: row.createdBy,
2034
2264
  agentAccess: row.agentAccess,
@@ -2041,6 +2271,16 @@ function mapDocument(row: typeof schema.documents.$inferSelect): Document {
2041
2271
  };
2042
2272
  }
2043
2273
 
2274
+ function normalizeDocumentAuthorityKind(value: string): DocumentAuthorityKind {
2275
+ switch (value) {
2276
+ case "organization":
2277
+ case "personal":
2278
+ return value;
2279
+ default:
2280
+ return "workspace";
2281
+ }
2282
+ }
2283
+
2044
2284
  function normalizeDocumentVisibility(value: string): DocumentVisibility {
2045
2285
  return value === "private" ? "private" : "workspace";
2046
2286
  }