@opengeni/db 0.19.0 → 0.22.1

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.
@@ -0,0 +1,2891 @@
1
+ import { createHash } from "node:crypto";
2
+ import type {
3
+ EligibleKnowledgeClaim,
4
+ KnowledgeChangeProposalRecord,
5
+ KnowledgeClaimEvidencePolarity,
6
+ KnowledgeClaimOrigin,
7
+ KnowledgeClaimRecord,
8
+ KnowledgeClaimRelationType,
9
+ KnowledgeClaimReviewState,
10
+ KnowledgeDocumentVersionRecord,
11
+ KnowledgeFactObjectKind,
12
+ KnowledgeFactRecord,
13
+ KnowledgeLifecycleEventType,
14
+ KnowledgeLifecycleState,
15
+ KnowledgeProviderRecord,
16
+ KnowledgeSourceAclVersionRecord,
17
+ KnowledgeSourceObjectRecord,
18
+ KnowledgeSourceRecord,
19
+ KnowledgeSyncRunRecord,
20
+ ScopedKnowledgeActor,
21
+ ScopedKnowledgeScope,
22
+ } from "@opengeni/contracts";
23
+ import { and, eq, inArray, sql } from "drizzle-orm";
24
+ import type { Database } from "./index";
25
+ import { setSubjectRlsContext, withRlsContext } from "./index";
26
+ import { nestedPostgresSqlState, safeDatabaseErrorFacts } from "./persistence-errors";
27
+ import * as schema from "./schema";
28
+
29
+ const SHA256_RE = /^[0-9a-f]{64}$/;
30
+ const OPERATION_ID_MAX_CHARS = 256;
31
+ const STABLE_KEY_RE = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/;
32
+
33
+ type KnowledgeWriteContext = {
34
+ accountId: string;
35
+ workspaceId: string;
36
+ scope: ScopedKnowledgeScope;
37
+ operationId: string;
38
+ actor: ScopedKnowledgeActor;
39
+ };
40
+
41
+ type KnowledgeReadContext = {
42
+ accountId: string;
43
+ workspaceId: string;
44
+ initiatingSubjectId: string;
45
+ surface: "human" | "agent";
46
+ };
47
+
48
+ type ScopedRow = {
49
+ scopeKind: string;
50
+ scopeWorkspaceId: string | null;
51
+ scopeSubjectId: string | null;
52
+ scopeKey: string;
53
+ };
54
+
55
+ type ConvergentKnowledgeOperationKind =
56
+ | "provider"
57
+ | "source"
58
+ | "source_object"
59
+ | "document_version"
60
+ | "entity"
61
+ | "entity_alias"
62
+ | "fact"
63
+ | "claim_relation";
64
+
65
+ export class ScopedKnowledgeConflictError extends Error {
66
+ readonly name = "ScopedKnowledgeConflictError";
67
+ readonly code = "SCOPED_KNOWLEDGE_CONFLICT";
68
+ }
69
+
70
+ export class ScopedKnowledgeGenerationConflictError extends Error {
71
+ readonly name = "ScopedKnowledgeGenerationConflictError";
72
+ readonly code = "SCOPED_KNOWLEDGE_GENERATION_CONFLICT";
73
+ }
74
+
75
+ export class ScopedKnowledgeNotFoundError extends Error {
76
+ readonly name = "ScopedKnowledgeNotFoundError";
77
+ }
78
+
79
+ export class ScopedKnowledgeInvalidOperationError extends Error {
80
+ readonly name = "ScopedKnowledgeInvalidOperationError";
81
+ }
82
+
83
+ export class ScopedKnowledgeAuthorityError extends Error {
84
+ readonly name = "ScopedKnowledgeAuthorityError";
85
+ }
86
+
87
+ function iso(value: Date | string): string {
88
+ return (value instanceof Date ? value : new Date(value)).toISOString();
89
+ }
90
+
91
+ function optionalIso(value: Date | string | null): string | null {
92
+ return value === null ? null : iso(value);
93
+ }
94
+
95
+ function canonicalize(value: unknown): unknown {
96
+ if (Array.isArray(value)) return value.map(canonicalize);
97
+ if (value && typeof value === "object") {
98
+ return Object.fromEntries(
99
+ Object.entries(value as Record<string, unknown>)
100
+ .filter(([, item]) => item !== undefined)
101
+ .sort(([left], [right]) => left.localeCompare(right))
102
+ .map(([key, item]) => [key, canonicalize(item)]),
103
+ );
104
+ }
105
+ return value;
106
+ }
107
+
108
+ export function scopedKnowledgeInputHash(value: unknown): string {
109
+ return createHash("sha256")
110
+ .update(JSON.stringify(canonicalize(value)), "utf8")
111
+ .digest("hex");
112
+ }
113
+
114
+ export function normalizeScopedKnowledgeKey(value: string): string {
115
+ return value.normalize("NFKC").replace(/\s+/gu, " ").trim().toLowerCase();
116
+ }
117
+
118
+ export function scopedKnowledgeScopeKey(scope: ScopedKnowledgeScope): string {
119
+ return `${scope.kind}:${scope.workspaceId ?? "-"}:${scope.subjectId ?? "-"}`;
120
+ }
121
+
122
+ function normalizedStableKey(value: string, label: string, maxChars: number): string {
123
+ const normalized = normalizeScopedKnowledgeKey(value);
124
+ if (
125
+ normalized.length < 1 ||
126
+ normalized.length > maxChars ||
127
+ !STABLE_KEY_RE.test(normalized) ||
128
+ normalized.includes("--")
129
+ ) {
130
+ throw new ScopedKnowledgeInvalidOperationError(`${label} is not a valid normalized key`);
131
+ }
132
+ return normalized;
133
+ }
134
+
135
+ function boundedText(value: string, label: string, maxChars: number): string {
136
+ const trimmed = value.trim();
137
+ if (!trimmed || trimmed.length > maxChars) {
138
+ throw new ScopedKnowledgeInvalidOperationError(
139
+ `${label} must contain between 1 and ${maxChars} characters`,
140
+ );
141
+ }
142
+ return trimmed;
143
+ }
144
+
145
+ function sha256(value: string, label: string): string {
146
+ const normalized = value.trim().toLowerCase();
147
+ if (!SHA256_RE.test(normalized)) {
148
+ throw new ScopedKnowledgeInvalidOperationError(`${label} must be a lowercase SHA-256 hex`);
149
+ }
150
+ return normalized;
151
+ }
152
+
153
+ function validateOperationId(operationId: string): string {
154
+ return boundedText(operationId, "operationId", OPERATION_ID_MAX_CHARS);
155
+ }
156
+
157
+ function validateActor(actor: ScopedKnowledgeActor): void {
158
+ boundedText(actor.subjectId, "actor.subjectId", 1024);
159
+ if (actor.kind === "human") {
160
+ if (actor.initiatingHumanSubjectId !== actor.subjectId) {
161
+ throw new ScopedKnowledgeAuthorityError(
162
+ "A human knowledge actor must preserve itself as the initiating human",
163
+ );
164
+ }
165
+ return;
166
+ }
167
+ if (actor.initiatingHumanSubjectId !== null) {
168
+ boundedText(actor.initiatingHumanSubjectId, "actor.initiatingHumanSubjectId", 1024);
169
+ }
170
+ }
171
+
172
+ function validateScope(
173
+ scope: ScopedKnowledgeScope,
174
+ workspaceId: string,
175
+ initiatingHumanSubjectId: string | null,
176
+ ): void {
177
+ if (scope.kind === "workspace" && scope.workspaceId !== workspaceId) {
178
+ throw new ScopedKnowledgeAuthorityError(
179
+ "Workspace-scoped knowledge must use the active workspace",
180
+ );
181
+ }
182
+ if (
183
+ scope.kind === "personal" &&
184
+ (scope.subjectId !== initiatingHumanSubjectId ||
185
+ (scope.workspaceId !== null && scope.workspaceId !== workspaceId))
186
+ ) {
187
+ throw new ScopedKnowledgeAuthorityError(
188
+ "Personal knowledge requires the exact initiating human and active workspace anchor",
189
+ );
190
+ }
191
+ }
192
+
193
+ function scopeColumns(scope: ScopedKnowledgeScope) {
194
+ return {
195
+ scopeKind: scope.kind,
196
+ scopeWorkspaceId: scope.workspaceId,
197
+ scopeSubjectId: scope.subjectId,
198
+ scopeKey: scopedKnowledgeScopeKey(scope),
199
+ };
200
+ }
201
+
202
+ function actorColumns(actor: ScopedKnowledgeActor) {
203
+ return {
204
+ actorKind: actor.kind,
205
+ actorSubjectId: actor.subjectId,
206
+ initiatingHumanSubjectId: actor.initiatingHumanSubjectId,
207
+ };
208
+ }
209
+
210
+ async function lockConvergentKnowledgeOperation(
211
+ db: Database,
212
+ input: {
213
+ accountId: string;
214
+ operationKind: ConvergentKnowledgeOperationKind;
215
+ operationNamespace: string;
216
+ operationId: string;
217
+ inputHash: string;
218
+ },
219
+ ): Promise<string | null> {
220
+ const lockIdentity = [
221
+ "scoped-knowledge",
222
+ input.accountId,
223
+ input.operationKind,
224
+ input.operationNamespace,
225
+ input.operationId,
226
+ ].join(":");
227
+ await db.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockIdentity}, 0::bigint))`);
228
+ const [receipt] = await db
229
+ .select()
230
+ .from(schema.knowledgeOperationReceipts)
231
+ .where(
232
+ and(
233
+ eq(schema.knowledgeOperationReceipts.accountId, input.accountId),
234
+ eq(schema.knowledgeOperationReceipts.operationKind, input.operationKind),
235
+ eq(schema.knowledgeOperationReceipts.operationNamespace, input.operationNamespace),
236
+ eq(schema.knowledgeOperationReceipts.operationId, input.operationId),
237
+ ),
238
+ )
239
+ .limit(1);
240
+ if (!receipt) return null;
241
+ if (receipt.inputHash !== input.inputHash) {
242
+ throw new ScopedKnowledgeConflictError(
243
+ "Knowledge operation id was replayed with different immutable input",
244
+ );
245
+ }
246
+ return receipt.resultId;
247
+ }
248
+
249
+ async function recordConvergentKnowledgeOperation(
250
+ db: Database,
251
+ input: {
252
+ accountId: string;
253
+ scope: ScopedKnowledgeScope;
254
+ operationKind: ConvergentKnowledgeOperationKind;
255
+ operationNamespace: string;
256
+ operationId: string;
257
+ inputHash: string;
258
+ resultId: string;
259
+ actor: ScopedKnowledgeActor;
260
+ },
261
+ ): Promise<void> {
262
+ await db.insert(schema.knowledgeOperationReceipts).values({
263
+ accountId: input.accountId,
264
+ ...scopeColumns(input.scope),
265
+ operationKind: input.operationKind,
266
+ operationNamespace: input.operationNamespace,
267
+ operationId: input.operationId,
268
+ inputHash: input.inputHash,
269
+ resultId: input.resultId,
270
+ ...actorColumns(input.actor),
271
+ });
272
+ }
273
+
274
+ function scopeFromRow(row: ScopedRow): ScopedKnowledgeScope {
275
+ if (row.scopeKind === "organization") {
276
+ return { kind: "organization", workspaceId: null, subjectId: null };
277
+ }
278
+ if (row.scopeKind === "workspace") {
279
+ return { kind: "workspace", workspaceId: row.scopeWorkspaceId!, subjectId: null };
280
+ }
281
+ return {
282
+ kind: "personal",
283
+ workspaceId: row.scopeWorkspaceId,
284
+ subjectId: row.scopeSubjectId!,
285
+ };
286
+ }
287
+
288
+ function sameScope(row: ScopedRow, scope: ScopedKnowledgeScope): boolean {
289
+ return row.scopeKey === scopedKnowledgeScopeKey(scope);
290
+ }
291
+
292
+ async function withKnowledgeWriteRls<T>(
293
+ db: Database,
294
+ input: KnowledgeWriteContext,
295
+ fn: (db: Database) => Promise<T>,
296
+ ): Promise<T> {
297
+ validateOperationId(input.operationId);
298
+ validateActor(input.actor);
299
+ validateScope(input.scope, input.workspaceId, input.actor.initiatingHumanSubjectId);
300
+ return await withRlsContext(
301
+ db,
302
+ { accountId: input.accountId, workspaceId: input.workspaceId },
303
+ async (scopedDb) => {
304
+ if (input.actor.initiatingHumanSubjectId !== null) {
305
+ await setSubjectRlsContext(scopedDb, input.actor.initiatingHumanSubjectId);
306
+ }
307
+ return await fn(scopedDb);
308
+ },
309
+ );
310
+ }
311
+
312
+ async function withKnowledgeReadRls<T>(
313
+ db: Database,
314
+ input: KnowledgeReadContext,
315
+ fn: (db: Database) => Promise<T>,
316
+ ): Promise<T> {
317
+ const subjectId = boundedText(input.initiatingSubjectId, "initiatingSubjectId", 1024);
318
+ return await withRlsContext(
319
+ db,
320
+ { accountId: input.accountId, workspaceId: input.workspaceId },
321
+ async (scopedDb) => {
322
+ await setSubjectRlsContext(scopedDb, subjectId);
323
+ return await fn(scopedDb);
324
+ },
325
+ );
326
+ }
327
+
328
+ async function withKnowledgeAuthorityRls<T>(
329
+ db: Database,
330
+ input: { accountId: string; workspaceId: string; initiatingSubjectId: string | null },
331
+ fn: (db: Database) => Promise<T>,
332
+ ): Promise<T> {
333
+ const subjectId =
334
+ input.initiatingSubjectId === null
335
+ ? null
336
+ : boundedText(input.initiatingSubjectId, "initiatingSubjectId", 1024);
337
+ return await withRlsContext(
338
+ db,
339
+ { accountId: input.accountId, workspaceId: input.workspaceId },
340
+ async (scopedDb) => {
341
+ if (subjectId !== null) await setSubjectRlsContext(scopedDb, subjectId);
342
+ return await fn(scopedDb);
343
+ },
344
+ );
345
+ }
346
+
347
+ function translatePersistenceError(error: unknown, fallback: string): never {
348
+ const state = nestedPostgresSqlState(error);
349
+ const facts = safeDatabaseErrorFacts(error);
350
+ if (state === "40001") {
351
+ throw new ScopedKnowledgeGenerationConflictError(fallback, { cause: error });
352
+ }
353
+ if (state === "P0002") {
354
+ throw new ScopedKnowledgeNotFoundError(fallback, { cause: error });
355
+ }
356
+ if (state === "42501") {
357
+ throw new ScopedKnowledgeAuthorityError(fallback, { cause: error });
358
+ }
359
+ if (state === "23505") {
360
+ if ((facts.constraint ?? "").includes("generation")) {
361
+ throw new ScopedKnowledgeGenerationConflictError(fallback, { cause: error });
362
+ }
363
+ throw new ScopedKnowledgeConflictError(fallback, { cause: error });
364
+ }
365
+ if (state === "23514" || state === "55000") {
366
+ throw new ScopedKnowledgeInvalidOperationError(fallback, { cause: error });
367
+ }
368
+ throw error;
369
+ }
370
+
371
+ type ProviderRow = typeof schema.knowledgeProviders.$inferSelect;
372
+ type SourceRow = typeof schema.knowledgeSources.$inferSelect;
373
+ type AclRow = typeof schema.knowledgeSourceAclVersions.$inferSelect;
374
+ type SyncRow = typeof schema.knowledgeSyncRuns.$inferSelect;
375
+ type ObjectRow = typeof schema.knowledgeSourceObjects.$inferSelect;
376
+ type VersionRow = typeof schema.knowledgeDocumentVersions.$inferSelect;
377
+ type FactRow = typeof schema.knowledgeFacts.$inferSelect;
378
+ type ClaimRow = typeof schema.knowledgeClaims.$inferSelect;
379
+ type ProposalRow = typeof schema.knowledgeChangeProposals.$inferSelect;
380
+
381
+ function providerFromRow(row: ProviderRow): KnowledgeProviderRecord {
382
+ return {
383
+ id: row.id,
384
+ accountId: row.accountId,
385
+ scope: scopeFromRow(row),
386
+ providerKey: row.providerKey,
387
+ externalTenantId: row.externalTenantId,
388
+ lifecycleState: row.lifecycleState as KnowledgeLifecycleState,
389
+ lifecycleGeneration: row.lifecycleGeneration,
390
+ createdAt: iso(row.createdAt),
391
+ updatedAt: iso(row.updatedAt),
392
+ };
393
+ }
394
+
395
+ function sourceFromRow(row: SourceRow): KnowledgeSourceRecord {
396
+ return {
397
+ id: row.id,
398
+ accountId: row.accountId,
399
+ providerId: row.providerId,
400
+ scope: scopeFromRow(row),
401
+ externalSourceId: row.externalSourceId,
402
+ sourceKind: row.sourceKind,
403
+ sourceUri: row.sourceUri,
404
+ currentAclGeneration: row.currentAclGeneration,
405
+ syncGeneration: row.syncGeneration,
406
+ syncCursor: row.syncCursor,
407
+ lifecycleState: row.lifecycleState as KnowledgeLifecycleState,
408
+ lifecycleGeneration: row.lifecycleGeneration,
409
+ createdAt: iso(row.createdAt),
410
+ updatedAt: iso(row.updatedAt),
411
+ };
412
+ }
413
+
414
+ function aclFromRow(row: AclRow): KnowledgeSourceAclVersionRecord {
415
+ return {
416
+ id: row.id,
417
+ accountId: row.accountId,
418
+ sourceId: row.sourceId,
419
+ generation: row.generation,
420
+ aclVersion: row.aclVersion,
421
+ aclHash: row.aclHash,
422
+ audience: scopeFromRow(row),
423
+ agentAccess: row.agentAccess,
424
+ createdAt: iso(row.createdAt),
425
+ };
426
+ }
427
+
428
+ function syncFromRow(row: SyncRow): KnowledgeSyncRunRecord {
429
+ return {
430
+ id: row.id,
431
+ accountId: row.accountId,
432
+ sourceId: row.sourceId,
433
+ operationId: row.operationId,
434
+ state: row.state as KnowledgeSyncRunRecord["state"],
435
+ inputSyncGeneration: row.inputSyncGeneration,
436
+ inputLifecycleGeneration: row.inputLifecycleGeneration,
437
+ inputCursor: row.inputCursor,
438
+ outputCursor: row.outputCursor,
439
+ watermark: optionalIso(row.watermark),
440
+ metadata: row.metadata,
441
+ errorCode: row.errorCode,
442
+ startedAt: iso(row.startedAt),
443
+ completedAt: optionalIso(row.completedAt),
444
+ };
445
+ }
446
+
447
+ function objectFromRow(row: ObjectRow): KnowledgeSourceObjectRecord {
448
+ return {
449
+ id: row.id,
450
+ accountId: row.accountId,
451
+ sourceId: row.sourceId,
452
+ scope: scopeFromRow(row),
453
+ externalObjectId: row.externalObjectId,
454
+ documentId: row.documentId,
455
+ lifecycleState: row.lifecycleState as KnowledgeLifecycleState,
456
+ lifecycleGeneration: row.lifecycleGeneration,
457
+ versionGeneration: row.versionGeneration,
458
+ currentVersionId: row.currentVersionId,
459
+ createdAt: iso(row.createdAt),
460
+ updatedAt: iso(row.updatedAt),
461
+ };
462
+ }
463
+
464
+ function versionFromRow(row: VersionRow): KnowledgeDocumentVersionRecord {
465
+ return {
466
+ id: row.id,
467
+ accountId: row.accountId,
468
+ sourceId: row.sourceId,
469
+ objectId: row.objectId,
470
+ scope: scopeFromRow(row),
471
+ versionGeneration: row.versionGeneration,
472
+ externalVersionId: row.externalVersionId,
473
+ contentSha256: row.contentSha256,
474
+ ingestionKey: row.ingestionKey,
475
+ aclVersionId: row.aclVersionId,
476
+ aclGeneration: row.aclGeneration,
477
+ documentId: row.documentId,
478
+ fileId: row.fileId,
479
+ createdAt: iso(row.createdAt),
480
+ };
481
+ }
482
+
483
+ function factFromRow(row: FactRow): KnowledgeFactRecord {
484
+ return {
485
+ id: row.id,
486
+ accountId: row.accountId,
487
+ scope: scopeFromRow(row),
488
+ subjectEntityId: row.subjectEntityId,
489
+ predicateKey: row.predicateKey,
490
+ objectKind: row.objectKind as KnowledgeFactObjectKind,
491
+ objectEntityId: row.objectEntityId,
492
+ objectValue: row.objectValue ?? null,
493
+ objectHash: row.objectHash,
494
+ createdAt: iso(row.createdAt),
495
+ };
496
+ }
497
+
498
+ function claimFromRow(row: ClaimRow): KnowledgeClaimRecord {
499
+ return {
500
+ id: row.id,
501
+ accountId: row.accountId,
502
+ scope: scopeFromRow(row),
503
+ factId: row.factId,
504
+ origin: row.origin as KnowledgeClaimOrigin,
505
+ confidenceBps: row.confidenceBps,
506
+ effectiveAt: iso(row.effectiveAt),
507
+ expiresAt: optionalIso(row.expiresAt),
508
+ extractionMethod: row.extractionMethod,
509
+ modelProvider: row.modelProvider,
510
+ modelName: row.modelName,
511
+ modelVersion: row.modelVersion,
512
+ createdAt: iso(row.createdAt),
513
+ };
514
+ }
515
+
516
+ function proposalFromRow(row: ProposalRow): KnowledgeChangeProposalRecord {
517
+ return {
518
+ id: row.id,
519
+ accountId: row.accountId,
520
+ scope: scopeFromRow(row),
521
+ targetKind: row.targetKind as KnowledgeChangeProposalRecord["targetKind"],
522
+ targetScope: row.targetScope,
523
+ targetKey: row.targetKey,
524
+ content: row.content,
525
+ contentHash: row.contentHash,
526
+ claimId: row.claimId,
527
+ evidenceId: row.evidenceId,
528
+ status: "proposed",
529
+ createdAt: iso(row.createdAt),
530
+ };
531
+ }
532
+
533
+ export async function upsertKnowledgeProvider(
534
+ db: Database,
535
+ input: KnowledgeWriteContext & {
536
+ providerKey: string;
537
+ externalTenantId: string;
538
+ },
539
+ ): Promise<KnowledgeProviderRecord> {
540
+ const providerKey = normalizedStableKey(input.providerKey, "providerKey", 96);
541
+ const externalTenantId = boundedText(input.externalTenantId, "externalTenantId", 1024);
542
+ const inputHash = scopedKnowledgeInputHash({
543
+ scope: input.scope,
544
+ providerKey,
545
+ externalTenantId,
546
+ actor: input.actor,
547
+ });
548
+ return await withKnowledgeWriteRls(db, input, async (scopedDb) => {
549
+ try {
550
+ const receiptResultId = await lockConvergentKnowledgeOperation(scopedDb, {
551
+ accountId: input.accountId,
552
+ operationKind: "provider",
553
+ operationNamespace: "account",
554
+ operationId: input.operationId,
555
+ inputHash,
556
+ });
557
+ if (receiptResultId) {
558
+ const [replayed] = await scopedDb
559
+ .select()
560
+ .from(schema.knowledgeProviders)
561
+ .where(eq(schema.knowledgeProviders.id, receiptResultId))
562
+ .limit(1);
563
+ if (!replayed) {
564
+ throw new ScopedKnowledgeInvalidOperationError(
565
+ "Knowledge provider operation receipt has no visible result",
566
+ );
567
+ }
568
+ return providerFromRow(replayed);
569
+ }
570
+ const [created] = await scopedDb
571
+ .insert(schema.knowledgeProviders)
572
+ .values({
573
+ accountId: input.accountId,
574
+ ...scopeColumns(input.scope),
575
+ providerKey,
576
+ externalTenantId,
577
+ operationId: input.operationId,
578
+ inputHash,
579
+ ...actorColumns(input.actor),
580
+ })
581
+ .onConflictDoNothing()
582
+ .returning();
583
+ if (created) {
584
+ await recordConvergentKnowledgeOperation(scopedDb, {
585
+ accountId: input.accountId,
586
+ scope: input.scope,
587
+ operationKind: "provider",
588
+ operationNamespace: "account",
589
+ operationId: input.operationId,
590
+ inputHash,
591
+ resultId: created.id,
592
+ actor: input.actor,
593
+ });
594
+ return providerFromRow(created);
595
+ }
596
+ const [operation, natural] = await Promise.all([
597
+ scopedDb
598
+ .select()
599
+ .from(schema.knowledgeProviders)
600
+ .where(
601
+ and(
602
+ eq(schema.knowledgeProviders.accountId, input.accountId),
603
+ eq(schema.knowledgeProviders.operationId, input.operationId),
604
+ ),
605
+ )
606
+ .limit(1),
607
+ scopedDb
608
+ .select()
609
+ .from(schema.knowledgeProviders)
610
+ .where(
611
+ and(
612
+ eq(schema.knowledgeProviders.accountId, input.accountId),
613
+ eq(schema.knowledgeProviders.providerKey, providerKey),
614
+ eq(schema.knowledgeProviders.externalTenantId, externalTenantId),
615
+ ),
616
+ )
617
+ .limit(1),
618
+ ]);
619
+ const operationRow = operation[0];
620
+ if (operationRow) {
621
+ if (operationRow.inputHash !== inputHash) {
622
+ throw new ScopedKnowledgeConflictError(
623
+ "Provider operation id was replayed with different immutable input",
624
+ );
625
+ }
626
+ await recordConvergentKnowledgeOperation(scopedDb, {
627
+ accountId: input.accountId,
628
+ scope: input.scope,
629
+ operationKind: "provider",
630
+ operationNamespace: "account",
631
+ operationId: input.operationId,
632
+ inputHash,
633
+ resultId: operationRow.id,
634
+ actor: input.actor,
635
+ });
636
+ return providerFromRow(operationRow);
637
+ }
638
+ const naturalRow = natural[0];
639
+ if (!naturalRow || !sameScope(naturalRow, input.scope)) {
640
+ throw new ScopedKnowledgeConflictError("Provider external identity is already bound");
641
+ }
642
+ if (naturalRow.lifecycleState !== "active") {
643
+ throw new ScopedKnowledgeInvalidOperationError(
644
+ "Ordinary provider upsert cannot resurrect a tombstone",
645
+ );
646
+ }
647
+ await recordConvergentKnowledgeOperation(scopedDb, {
648
+ accountId: input.accountId,
649
+ scope: input.scope,
650
+ operationKind: "provider",
651
+ operationNamespace: "account",
652
+ operationId: input.operationId,
653
+ inputHash,
654
+ resultId: naturalRow.id,
655
+ actor: input.actor,
656
+ });
657
+ return providerFromRow(naturalRow);
658
+ } catch (error) {
659
+ if (error instanceof Error && error.name.startsWith("ScopedKnowledge")) throw error;
660
+ translatePersistenceError(error, "Knowledge provider upsert conflicted");
661
+ }
662
+ });
663
+ }
664
+
665
+ export async function upsertKnowledgeSource(
666
+ db: Database,
667
+ input: KnowledgeWriteContext & {
668
+ providerId: string;
669
+ externalSourceId: string;
670
+ sourceKind: string;
671
+ sourceUri?: string | null;
672
+ },
673
+ ): Promise<KnowledgeSourceRecord> {
674
+ const externalSourceId = boundedText(input.externalSourceId, "externalSourceId", 1024);
675
+ const sourceKind = normalizedStableKey(input.sourceKind, "sourceKind", 96);
676
+ const sourceUri =
677
+ input.sourceUri == null ? null : boundedText(input.sourceUri, "sourceUri", 4096);
678
+ const inputHash = scopedKnowledgeInputHash({
679
+ scope: input.scope,
680
+ providerId: input.providerId,
681
+ externalSourceId,
682
+ sourceKind,
683
+ sourceUri,
684
+ actor: input.actor,
685
+ });
686
+ return await withKnowledgeWriteRls(db, input, async (scopedDb) => {
687
+ try {
688
+ const receiptResultId = await lockConvergentKnowledgeOperation(scopedDb, {
689
+ accountId: input.accountId,
690
+ operationKind: "source",
691
+ operationNamespace: "account",
692
+ operationId: input.operationId,
693
+ inputHash,
694
+ });
695
+ if (receiptResultId) {
696
+ const [replayed] = await scopedDb
697
+ .select()
698
+ .from(schema.knowledgeSources)
699
+ .where(eq(schema.knowledgeSources.id, receiptResultId))
700
+ .limit(1);
701
+ if (!replayed) {
702
+ throw new ScopedKnowledgeInvalidOperationError(
703
+ "Knowledge source operation receipt has no visible result",
704
+ );
705
+ }
706
+ return sourceFromRow(replayed);
707
+ }
708
+ const [created] = await scopedDb
709
+ .insert(schema.knowledgeSources)
710
+ .values({
711
+ accountId: input.accountId,
712
+ ...scopeColumns(input.scope),
713
+ providerId: input.providerId,
714
+ externalSourceId,
715
+ sourceKind,
716
+ sourceUri,
717
+ operationId: input.operationId,
718
+ inputHash,
719
+ ...actorColumns(input.actor),
720
+ })
721
+ .onConflictDoNothing()
722
+ .returning();
723
+ if (created) {
724
+ await recordConvergentKnowledgeOperation(scopedDb, {
725
+ accountId: input.accountId,
726
+ scope: input.scope,
727
+ operationKind: "source",
728
+ operationNamespace: "account",
729
+ operationId: input.operationId,
730
+ inputHash,
731
+ resultId: created.id,
732
+ actor: input.actor,
733
+ });
734
+ return sourceFromRow(created);
735
+ }
736
+ const [operation, natural] = await Promise.all([
737
+ scopedDb
738
+ .select()
739
+ .from(schema.knowledgeSources)
740
+ .where(
741
+ and(
742
+ eq(schema.knowledgeSources.accountId, input.accountId),
743
+ eq(schema.knowledgeSources.operationId, input.operationId),
744
+ ),
745
+ )
746
+ .limit(1),
747
+ scopedDb
748
+ .select()
749
+ .from(schema.knowledgeSources)
750
+ .where(
751
+ and(
752
+ eq(schema.knowledgeSources.providerId, input.providerId),
753
+ eq(schema.knowledgeSources.externalSourceId, externalSourceId),
754
+ ),
755
+ )
756
+ .limit(1),
757
+ ]);
758
+ const operationRow = operation[0];
759
+ if (operationRow) {
760
+ if (operationRow.inputHash !== inputHash) {
761
+ throw new ScopedKnowledgeConflictError(
762
+ "Source operation id was replayed with different immutable input",
763
+ );
764
+ }
765
+ await recordConvergentKnowledgeOperation(scopedDb, {
766
+ accountId: input.accountId,
767
+ scope: input.scope,
768
+ operationKind: "source",
769
+ operationNamespace: "account",
770
+ operationId: input.operationId,
771
+ inputHash,
772
+ resultId: operationRow.id,
773
+ actor: input.actor,
774
+ });
775
+ return sourceFromRow(operationRow);
776
+ }
777
+ const naturalRow = natural[0];
778
+ if (
779
+ !naturalRow ||
780
+ !sameScope(naturalRow, input.scope) ||
781
+ naturalRow.sourceKind !== sourceKind ||
782
+ naturalRow.sourceUri !== sourceUri
783
+ ) {
784
+ throw new ScopedKnowledgeConflictError(
785
+ "Source external identity is already bound to different immutable metadata",
786
+ );
787
+ }
788
+ if (naturalRow.lifecycleState !== "active") {
789
+ throw new ScopedKnowledgeInvalidOperationError(
790
+ "Ordinary source upsert cannot resurrect a tombstone",
791
+ );
792
+ }
793
+ await recordConvergentKnowledgeOperation(scopedDb, {
794
+ accountId: input.accountId,
795
+ scope: input.scope,
796
+ operationKind: "source",
797
+ operationNamespace: "account",
798
+ operationId: input.operationId,
799
+ inputHash,
800
+ resultId: naturalRow.id,
801
+ actor: input.actor,
802
+ });
803
+ return sourceFromRow(naturalRow);
804
+ } catch (error) {
805
+ if (error instanceof Error && error.name.startsWith("ScopedKnowledge")) throw error;
806
+ translatePersistenceError(error, "Knowledge source upsert conflicted");
807
+ }
808
+ });
809
+ }
810
+
811
+ export async function appendKnowledgeSourceAclVersion(
812
+ db: Database,
813
+ input: Omit<KnowledgeWriteContext, "scope"> & {
814
+ sourceId: string;
815
+ audience: ScopedKnowledgeScope;
816
+ expectedSourceLifecycleGeneration: number;
817
+ expectedAclGeneration: number;
818
+ aclVersion?: string | null;
819
+ agentAccess: boolean;
820
+ reasonCode: string;
821
+ },
822
+ ): Promise<KnowledgeSourceAclVersionRecord> {
823
+ const aclVersion =
824
+ input.aclVersion == null ? null : boundedText(input.aclVersion, "aclVersion", 512);
825
+ const reasonCode = normalizedStableKey(input.reasonCode, "reasonCode", 128);
826
+ const inputHash = scopedKnowledgeInputHash({
827
+ sourceId: input.sourceId,
828
+ audience: input.audience,
829
+ expectedSourceLifecycleGeneration: input.expectedSourceLifecycleGeneration,
830
+ expectedAclGeneration: input.expectedAclGeneration,
831
+ aclVersion,
832
+ agentAccess: input.agentAccess,
833
+ reasonCode,
834
+ actor: input.actor,
835
+ });
836
+ const aclHash = scopedKnowledgeInputHash({
837
+ audience: input.audience,
838
+ aclVersion,
839
+ agentAccess: input.agentAccess,
840
+ });
841
+ return await withKnowledgeWriteRls(db, { ...input, scope: input.audience }, async (scopedDb) => {
842
+ try {
843
+ const [existing] = await scopedDb
844
+ .select()
845
+ .from(schema.knowledgeSourceAclVersions)
846
+ .where(
847
+ and(
848
+ eq(schema.knowledgeSourceAclVersions.sourceId, input.sourceId),
849
+ eq(schema.knowledgeSourceAclVersions.operationId, input.operationId),
850
+ ),
851
+ )
852
+ .limit(1);
853
+ if (existing) {
854
+ if (existing.inputHash !== inputHash) {
855
+ throw new ScopedKnowledgeConflictError(
856
+ "ACL operation id was replayed with different input",
857
+ );
858
+ }
859
+ return aclFromRow(existing);
860
+ }
861
+ const [source] = await scopedDb
862
+ .select()
863
+ .from(schema.knowledgeSources)
864
+ .where(eq(schema.knowledgeSources.id, input.sourceId))
865
+ .limit(1);
866
+ if (!source) throw new ScopedKnowledgeNotFoundError("Knowledge source was not found");
867
+ if (source.lifecycleState !== "active") {
868
+ throw new ScopedKnowledgeInvalidOperationError(
869
+ "Ordinary source-object upsert cannot write beneath a source tombstone",
870
+ );
871
+ }
872
+ const [created] = await scopedDb
873
+ .insert(schema.knowledgeSourceAclVersions)
874
+ .values({
875
+ accountId: input.accountId,
876
+ ...scopeColumns(input.audience),
877
+ sourceId: input.sourceId,
878
+ sourceScopeKey: source.scopeKey,
879
+ generation: input.expectedAclGeneration + 1,
880
+ aclVersion,
881
+ aclHash,
882
+ agentAccess: input.agentAccess,
883
+ operationId: input.operationId,
884
+ inputHash,
885
+ ...actorColumns(input.actor),
886
+ })
887
+ .onConflictDoNothing()
888
+ .returning();
889
+ if (!created) {
890
+ const [replayed] = await scopedDb
891
+ .select()
892
+ .from(schema.knowledgeSourceAclVersions)
893
+ .where(
894
+ and(
895
+ eq(schema.knowledgeSourceAclVersions.sourceId, input.sourceId),
896
+ eq(schema.knowledgeSourceAclVersions.operationId, input.operationId),
897
+ ),
898
+ )
899
+ .limit(1);
900
+ if (replayed?.inputHash === inputHash) return aclFromRow(replayed);
901
+ throw new ScopedKnowledgeGenerationConflictError(
902
+ "Another ACL version already advanced this source generation",
903
+ );
904
+ }
905
+ await scopedDb.execute(sql`
906
+ SELECT scoped_knowledge_advance_source_acl(
907
+ ${input.accountId}::uuid,
908
+ ${input.sourceId}::uuid,
909
+ ${input.expectedSourceLifecycleGeneration}::bigint,
910
+ ${input.expectedAclGeneration}::bigint,
911
+ ${created.id}::uuid,
912
+ ${input.operationId},
913
+ ${inputHash},
914
+ ${reasonCode},
915
+ ${input.actor.kind},
916
+ ${input.actor.subjectId},
917
+ ${input.actor.initiatingHumanSubjectId}
918
+ )
919
+ `);
920
+ return aclFromRow(created);
921
+ } catch (error) {
922
+ if (error instanceof Error && error.name.startsWith("ScopedKnowledge")) throw error;
923
+ translatePersistenceError(error, "Knowledge ACL append conflicted");
924
+ }
925
+ });
926
+ }
927
+
928
+ export async function beginKnowledgeSyncRun(
929
+ db: Database,
930
+ input: Omit<KnowledgeWriteContext, "scope"> & {
931
+ sourceId: string;
932
+ expectedSourceLifecycleGeneration: number;
933
+ expectedSyncGeneration: number;
934
+ inputCursor: string | null;
935
+ },
936
+ ): Promise<KnowledgeSyncRunRecord> {
937
+ const inputCursor =
938
+ input.inputCursor == null ? null : boundedText(input.inputCursor, "inputCursor", 4096);
939
+ const inputHash = scopedKnowledgeInputHash({
940
+ sourceId: input.sourceId,
941
+ expectedSourceLifecycleGeneration: input.expectedSourceLifecycleGeneration,
942
+ expectedSyncGeneration: input.expectedSyncGeneration,
943
+ inputCursor,
944
+ actor: input.actor,
945
+ });
946
+ const placeholderScope: ScopedKnowledgeScope = {
947
+ kind: "workspace",
948
+ workspaceId: input.workspaceId,
949
+ subjectId: null,
950
+ };
951
+ return await withKnowledgeWriteRls(
952
+ db,
953
+ { ...input, scope: placeholderScope },
954
+ async (scopedDb) => {
955
+ try {
956
+ const [existing] = await scopedDb
957
+ .select()
958
+ .from(schema.knowledgeSyncRuns)
959
+ .where(
960
+ and(
961
+ eq(schema.knowledgeSyncRuns.sourceId, input.sourceId),
962
+ eq(schema.knowledgeSyncRuns.operationId, input.operationId),
963
+ ),
964
+ )
965
+ .limit(1);
966
+ if (existing) {
967
+ if (existing.inputHash !== inputHash) {
968
+ throw new ScopedKnowledgeConflictError(
969
+ "Sync operation id was replayed with different input",
970
+ );
971
+ }
972
+ return syncFromRow(existing);
973
+ }
974
+ const [source] = await scopedDb
975
+ .select()
976
+ .from(schema.knowledgeSources)
977
+ .where(eq(schema.knowledgeSources.id, input.sourceId))
978
+ .limit(1);
979
+ if (!source) throw new ScopedKnowledgeNotFoundError("Knowledge source was not found");
980
+ if (
981
+ source.lifecycleState !== "active" ||
982
+ source.lifecycleGeneration !== input.expectedSourceLifecycleGeneration ||
983
+ source.syncGeneration !== input.expectedSyncGeneration ||
984
+ source.syncCursor !== inputCursor
985
+ ) {
986
+ throw new ScopedKnowledgeGenerationConflictError(
987
+ "Knowledge source sync generation, cursor, or lifecycle changed",
988
+ );
989
+ }
990
+ const [created] = await scopedDb
991
+ .insert(schema.knowledgeSyncRuns)
992
+ .values({
993
+ accountId: input.accountId,
994
+ scopeKind: source.scopeKind,
995
+ scopeWorkspaceId: source.scopeWorkspaceId,
996
+ scopeSubjectId: source.scopeSubjectId,
997
+ scopeKey: source.scopeKey,
998
+ sourceId: input.sourceId,
999
+ inputSyncGeneration: input.expectedSyncGeneration,
1000
+ inputLifecycleGeneration: input.expectedSourceLifecycleGeneration,
1001
+ inputCursor,
1002
+ inputHash,
1003
+ operationId: input.operationId,
1004
+ ...actorColumns(input.actor),
1005
+ })
1006
+ .onConflictDoNothing()
1007
+ .returning();
1008
+ if (created) return syncFromRow(created);
1009
+ const [replayed] = await scopedDb
1010
+ .select()
1011
+ .from(schema.knowledgeSyncRuns)
1012
+ .where(
1013
+ and(
1014
+ eq(schema.knowledgeSyncRuns.sourceId, input.sourceId),
1015
+ eq(schema.knowledgeSyncRuns.operationId, input.operationId),
1016
+ ),
1017
+ )
1018
+ .limit(1);
1019
+ if (replayed?.inputHash === inputHash) return syncFromRow(replayed);
1020
+ throw new ScopedKnowledgeConflictError("Sync operation identity conflicted");
1021
+ } catch (error) {
1022
+ if (error instanceof Error && error.name.startsWith("ScopedKnowledge")) throw error;
1023
+ translatePersistenceError(error, "Knowledge sync start conflicted");
1024
+ }
1025
+ },
1026
+ );
1027
+ }
1028
+
1029
+ export async function completeKnowledgeSyncRun(
1030
+ db: Database,
1031
+ input: {
1032
+ accountId: string;
1033
+ workspaceId: string;
1034
+ initiatingSubjectId: string | null;
1035
+ runId: string;
1036
+ state: "succeeded" | "failed";
1037
+ outputCursor?: string | null;
1038
+ watermark?: string | null;
1039
+ metadata?: Record<string, unknown> | undefined;
1040
+ errorCode?: string | null;
1041
+ reasonCode: string;
1042
+ },
1043
+ ): Promise<KnowledgeSyncRunRecord> {
1044
+ const outputCursor =
1045
+ input.outputCursor == null ? null : boundedText(input.outputCursor, "outputCursor", 4096);
1046
+ const errorCode =
1047
+ input.errorCode == null ? null : normalizedStableKey(input.errorCode, "errorCode", 128);
1048
+ const reasonCode = normalizedStableKey(input.reasonCode, "reasonCode", 128);
1049
+ const metadata = input.metadata ?? {};
1050
+ if (input.state === "failed" && !errorCode) {
1051
+ throw new ScopedKnowledgeInvalidOperationError("A failed sync completion requires errorCode");
1052
+ }
1053
+ const completionHash = scopedKnowledgeInputHash({
1054
+ state: input.state,
1055
+ outputCursor: input.state === "succeeded" ? outputCursor : null,
1056
+ watermark: input.watermark ?? null,
1057
+ metadata,
1058
+ errorCode: input.state === "failed" ? errorCode : null,
1059
+ reasonCode,
1060
+ });
1061
+ return await withKnowledgeAuthorityRls(db, input, async (scopedDb) => {
1062
+ try {
1063
+ await scopedDb.execute(sql`
1064
+ SELECT * FROM scoped_knowledge_complete_sync(
1065
+ ${input.accountId}::uuid,
1066
+ ${input.runId}::uuid,
1067
+ ${input.state},
1068
+ ${input.state === "succeeded" ? outputCursor : null},
1069
+ ${input.watermark ? new Date(input.watermark) : null}::timestamptz,
1070
+ ${JSON.stringify(metadata)}::jsonb,
1071
+ ${input.state === "failed" ? errorCode : null},
1072
+ ${completionHash},
1073
+ ${reasonCode}
1074
+ )
1075
+ `);
1076
+ const [row] = await scopedDb
1077
+ .select()
1078
+ .from(schema.knowledgeSyncRuns)
1079
+ .where(
1080
+ and(
1081
+ eq(schema.knowledgeSyncRuns.accountId, input.accountId),
1082
+ eq(schema.knowledgeSyncRuns.id, input.runId),
1083
+ ),
1084
+ )
1085
+ .limit(1);
1086
+ if (!row) throw new Error("Knowledge sync completion returned no row");
1087
+ return syncFromRow(row);
1088
+ } catch (error) {
1089
+ if (error instanceof Error && error.name.startsWith("ScopedKnowledge")) throw error;
1090
+ translatePersistenceError(error, "Knowledge sync completion conflicted");
1091
+ }
1092
+ });
1093
+ }
1094
+
1095
+ export async function upsertKnowledgeSourceObject(
1096
+ db: Database,
1097
+ input: Omit<KnowledgeWriteContext, "scope"> & {
1098
+ sourceId: string;
1099
+ externalObjectId: string;
1100
+ documentId?: string | null;
1101
+ },
1102
+ ): Promise<KnowledgeSourceObjectRecord> {
1103
+ const externalObjectId = boundedText(input.externalObjectId, "externalObjectId", 1024);
1104
+ const placeholderScope: ScopedKnowledgeScope = {
1105
+ kind: "workspace",
1106
+ workspaceId: input.workspaceId,
1107
+ subjectId: null,
1108
+ };
1109
+ return await withKnowledgeWriteRls(
1110
+ db,
1111
+ { ...input, scope: placeholderScope },
1112
+ async (scopedDb) => {
1113
+ try {
1114
+ const [source] = await scopedDb
1115
+ .select()
1116
+ .from(schema.knowledgeSources)
1117
+ .where(eq(schema.knowledgeSources.id, input.sourceId))
1118
+ .limit(1);
1119
+ if (!source) throw new ScopedKnowledgeNotFoundError("Knowledge source was not found");
1120
+ const sourceScope = scopeFromRow(source);
1121
+ validateScope(sourceScope, input.workspaceId, input.actor.initiatingHumanSubjectId);
1122
+ const inputHash = scopedKnowledgeInputHash({
1123
+ sourceId: input.sourceId,
1124
+ scope: sourceScope,
1125
+ externalObjectId,
1126
+ documentId: input.documentId ?? null,
1127
+ actor: input.actor,
1128
+ });
1129
+ const receiptResultId = await lockConvergentKnowledgeOperation(scopedDb, {
1130
+ accountId: input.accountId,
1131
+ operationKind: "source_object",
1132
+ operationNamespace: input.sourceId,
1133
+ operationId: input.operationId,
1134
+ inputHash,
1135
+ });
1136
+ if (receiptResultId) {
1137
+ const [replayed] = await scopedDb
1138
+ .select()
1139
+ .from(schema.knowledgeSourceObjects)
1140
+ .where(eq(schema.knowledgeSourceObjects.id, receiptResultId))
1141
+ .limit(1);
1142
+ if (!replayed) {
1143
+ throw new ScopedKnowledgeInvalidOperationError(
1144
+ "Knowledge source-object operation receipt has no visible result",
1145
+ );
1146
+ }
1147
+ return objectFromRow(replayed);
1148
+ }
1149
+ const [created] = await scopedDb
1150
+ .insert(schema.knowledgeSourceObjects)
1151
+ .values({
1152
+ accountId: input.accountId,
1153
+ scopeKind: source.scopeKind,
1154
+ scopeWorkspaceId: source.scopeWorkspaceId,
1155
+ scopeSubjectId: source.scopeSubjectId,
1156
+ scopeKey: source.scopeKey,
1157
+ sourceId: input.sourceId,
1158
+ externalObjectId,
1159
+ documentId: input.documentId ?? null,
1160
+ operationId: input.operationId,
1161
+ inputHash,
1162
+ ...actorColumns(input.actor),
1163
+ })
1164
+ .onConflictDoNothing()
1165
+ .returning();
1166
+ if (created) {
1167
+ await recordConvergentKnowledgeOperation(scopedDb, {
1168
+ accountId: input.accountId,
1169
+ scope: sourceScope,
1170
+ operationKind: "source_object",
1171
+ operationNamespace: input.sourceId,
1172
+ operationId: input.operationId,
1173
+ inputHash,
1174
+ resultId: created.id,
1175
+ actor: input.actor,
1176
+ });
1177
+ return objectFromRow(created);
1178
+ }
1179
+ const [operation, natural] = await Promise.all([
1180
+ scopedDb
1181
+ .select()
1182
+ .from(schema.knowledgeSourceObjects)
1183
+ .where(
1184
+ and(
1185
+ eq(schema.knowledgeSourceObjects.sourceId, input.sourceId),
1186
+ eq(schema.knowledgeSourceObjects.operationId, input.operationId),
1187
+ ),
1188
+ )
1189
+ .limit(1),
1190
+ scopedDb
1191
+ .select()
1192
+ .from(schema.knowledgeSourceObjects)
1193
+ .where(
1194
+ and(
1195
+ eq(schema.knowledgeSourceObjects.sourceId, input.sourceId),
1196
+ eq(schema.knowledgeSourceObjects.externalObjectId, externalObjectId),
1197
+ ),
1198
+ )
1199
+ .limit(1),
1200
+ ]);
1201
+ const operationRow = operation[0];
1202
+ if (operationRow) {
1203
+ if (operationRow.inputHash !== inputHash) {
1204
+ throw new ScopedKnowledgeConflictError(
1205
+ "Source-object operation id was replayed with different input",
1206
+ );
1207
+ }
1208
+ await recordConvergentKnowledgeOperation(scopedDb, {
1209
+ accountId: input.accountId,
1210
+ scope: sourceScope,
1211
+ operationKind: "source_object",
1212
+ operationNamespace: input.sourceId,
1213
+ operationId: input.operationId,
1214
+ inputHash,
1215
+ resultId: operationRow.id,
1216
+ actor: input.actor,
1217
+ });
1218
+ return objectFromRow(operationRow);
1219
+ }
1220
+ const naturalRow = natural[0];
1221
+ if (!naturalRow || naturalRow.documentId !== (input.documentId ?? null)) {
1222
+ throw new ScopedKnowledgeConflictError(
1223
+ "Source-object external identity is already bound to different immutable metadata",
1224
+ );
1225
+ }
1226
+ if (naturalRow.lifecycleState !== "active") {
1227
+ throw new ScopedKnowledgeInvalidOperationError(
1228
+ "Ordinary source-object upsert cannot resurrect a tombstone",
1229
+ );
1230
+ }
1231
+ await recordConvergentKnowledgeOperation(scopedDb, {
1232
+ accountId: input.accountId,
1233
+ scope: sourceScope,
1234
+ operationKind: "source_object",
1235
+ operationNamespace: input.sourceId,
1236
+ operationId: input.operationId,
1237
+ inputHash,
1238
+ resultId: naturalRow.id,
1239
+ actor: input.actor,
1240
+ });
1241
+ return objectFromRow(naturalRow);
1242
+ } catch (error) {
1243
+ if (error instanceof Error && error.name.startsWith("ScopedKnowledge")) throw error;
1244
+ translatePersistenceError(error, "Knowledge source-object upsert conflicted");
1245
+ }
1246
+ },
1247
+ );
1248
+ }
1249
+
1250
+ export async function appendKnowledgeDocumentVersion(
1251
+ db: Database,
1252
+ input: Omit<KnowledgeWriteContext, "scope"> & {
1253
+ objectId: string;
1254
+ expectedObjectLifecycleGeneration: number;
1255
+ expectedVersionGeneration: number;
1256
+ externalVersionId: string;
1257
+ contentSha256: string;
1258
+ ingestionKey: string;
1259
+ sourceCursor?: string | null;
1260
+ sourceMetadata?: Record<string, unknown> | undefined;
1261
+ sourceCreatedAt?: string | null;
1262
+ sourceUpdatedAt?: string | null;
1263
+ aclVersionId: string;
1264
+ aclGeneration: number;
1265
+ documentId?: string | null;
1266
+ fileId?: string | null;
1267
+ locationMetadata?: Record<string, unknown> | undefined;
1268
+ reasonCode: string;
1269
+ },
1270
+ ): Promise<KnowledgeDocumentVersionRecord> {
1271
+ const externalVersionId = boundedText(input.externalVersionId, "externalVersionId", 1024);
1272
+ const contentSha256 = sha256(input.contentSha256, "contentSha256");
1273
+ const ingestionKey = boundedText(input.ingestionKey, "ingestionKey", 512);
1274
+ const sourceCursor =
1275
+ input.sourceCursor == null ? null : boundedText(input.sourceCursor, "sourceCursor", 4096);
1276
+ const reasonCode = normalizedStableKey(input.reasonCode, "reasonCode", 128);
1277
+ const placeholderScope: ScopedKnowledgeScope = {
1278
+ kind: "workspace",
1279
+ workspaceId: input.workspaceId,
1280
+ subjectId: null,
1281
+ };
1282
+ return await withKnowledgeWriteRls(
1283
+ db,
1284
+ { ...input, scope: placeholderScope },
1285
+ async (scopedDb) => {
1286
+ try {
1287
+ const [object] = await scopedDb
1288
+ .select()
1289
+ .from(schema.knowledgeSourceObjects)
1290
+ .where(eq(schema.knowledgeSourceObjects.id, input.objectId))
1291
+ .limit(1);
1292
+ if (!object)
1293
+ throw new ScopedKnowledgeNotFoundError("Knowledge source object was not found");
1294
+ const objectScope = scopeFromRow(object);
1295
+ validateScope(objectScope, input.workspaceId, input.actor.initiatingHumanSubjectId);
1296
+ const inputHash = scopedKnowledgeInputHash({
1297
+ objectId: input.objectId,
1298
+ expectedObjectLifecycleGeneration: input.expectedObjectLifecycleGeneration,
1299
+ expectedVersionGeneration: input.expectedVersionGeneration,
1300
+ externalVersionId,
1301
+ contentSha256,
1302
+ ingestionKey,
1303
+ sourceCursor,
1304
+ sourceMetadata: input.sourceMetadata ?? {},
1305
+ sourceCreatedAt: input.sourceCreatedAt ?? null,
1306
+ sourceUpdatedAt: input.sourceUpdatedAt ?? null,
1307
+ aclVersionId: input.aclVersionId,
1308
+ aclGeneration: input.aclGeneration,
1309
+ documentId: input.documentId ?? null,
1310
+ fileId: input.fileId ?? null,
1311
+ locationMetadata: input.locationMetadata ?? {},
1312
+ reasonCode,
1313
+ actor: input.actor,
1314
+ });
1315
+ const receiptResultId = await lockConvergentKnowledgeOperation(scopedDb, {
1316
+ accountId: input.accountId,
1317
+ operationKind: "document_version",
1318
+ operationNamespace: input.objectId,
1319
+ operationId: input.operationId,
1320
+ inputHash,
1321
+ });
1322
+ if (receiptResultId) {
1323
+ const [replayed] = await scopedDb
1324
+ .select()
1325
+ .from(schema.knowledgeDocumentVersions)
1326
+ .where(eq(schema.knowledgeDocumentVersions.id, receiptResultId))
1327
+ .limit(1);
1328
+ if (!replayed) {
1329
+ throw new ScopedKnowledgeInvalidOperationError(
1330
+ "Knowledge document-version operation receipt has no visible result",
1331
+ );
1332
+ }
1333
+ return versionFromRow(replayed);
1334
+ }
1335
+ const existingRows = await scopedDb
1336
+ .select()
1337
+ .from(schema.knowledgeDocumentVersions)
1338
+ .where(
1339
+ and(
1340
+ eq(schema.knowledgeDocumentVersions.objectId, input.objectId),
1341
+ sql`(
1342
+ ${schema.knowledgeDocumentVersions.operationId} = ${input.operationId}
1343
+ OR ${schema.knowledgeDocumentVersions.externalVersionId} = ${externalVersionId}
1344
+ OR ${schema.knowledgeDocumentVersions.ingestionKey} = ${ingestionKey}
1345
+ )`,
1346
+ ),
1347
+ )
1348
+ .limit(3);
1349
+ const existing = existingRows[0];
1350
+ if (existing) {
1351
+ if (
1352
+ existingRows.some((row) => row.id !== existing.id) ||
1353
+ existing.inputHash !== inputHash
1354
+ ) {
1355
+ throw new ScopedKnowledgeConflictError(
1356
+ "Document-version key was replayed with different immutable input",
1357
+ );
1358
+ }
1359
+ await recordConvergentKnowledgeOperation(scopedDb, {
1360
+ accountId: input.accountId,
1361
+ scope: objectScope,
1362
+ operationKind: "document_version",
1363
+ operationNamespace: input.objectId,
1364
+ operationId: input.operationId,
1365
+ inputHash,
1366
+ resultId: existing.id,
1367
+ actor: input.actor,
1368
+ });
1369
+ return versionFromRow(existing);
1370
+ }
1371
+ const [source, acl] = await Promise.all([
1372
+ scopedDb
1373
+ .select()
1374
+ .from(schema.knowledgeSources)
1375
+ .where(eq(schema.knowledgeSources.id, object.sourceId))
1376
+ .limit(1),
1377
+ scopedDb
1378
+ .select()
1379
+ .from(schema.knowledgeSourceAclVersions)
1380
+ .where(
1381
+ and(
1382
+ eq(schema.knowledgeSourceAclVersions.id, input.aclVersionId),
1383
+ eq(schema.knowledgeSourceAclVersions.sourceId, object.sourceId),
1384
+ eq(schema.knowledgeSourceAclVersions.generation, input.aclGeneration),
1385
+ ),
1386
+ )
1387
+ .limit(1),
1388
+ ]);
1389
+ const sourceRow = source[0];
1390
+ if (!sourceRow || !acl[0]) {
1391
+ throw new ScopedKnowledgeNotFoundError("Knowledge source or ACL version was not found");
1392
+ }
1393
+ if (
1394
+ object.lifecycleState !== "active" ||
1395
+ object.lifecycleGeneration !== input.expectedObjectLifecycleGeneration ||
1396
+ object.versionGeneration !== input.expectedVersionGeneration ||
1397
+ sourceRow.lifecycleState !== "active" ||
1398
+ sourceRow.currentAclGeneration !== input.aclGeneration
1399
+ ) {
1400
+ throw new ScopedKnowledgeGenerationConflictError(
1401
+ "Knowledge object/source lifecycle, version, or ACL generation changed",
1402
+ );
1403
+ }
1404
+ const [created] = await scopedDb
1405
+ .insert(schema.knowledgeDocumentVersions)
1406
+ .values({
1407
+ accountId: input.accountId,
1408
+ scopeKind: object.scopeKind,
1409
+ scopeWorkspaceId: object.scopeWorkspaceId,
1410
+ scopeSubjectId: object.scopeSubjectId,
1411
+ scopeKey: object.scopeKey,
1412
+ sourceId: object.sourceId,
1413
+ objectId: object.id,
1414
+ versionGeneration: input.expectedVersionGeneration + 1,
1415
+ externalVersionId,
1416
+ contentSha256,
1417
+ ingestionKey,
1418
+ sourceCursor,
1419
+ sourceMetadata: input.sourceMetadata ?? {},
1420
+ sourceCreatedAt: input.sourceCreatedAt ? new Date(input.sourceCreatedAt) : null,
1421
+ sourceUpdatedAt: input.sourceUpdatedAt ? new Date(input.sourceUpdatedAt) : null,
1422
+ aclVersionId: input.aclVersionId,
1423
+ aclGeneration: input.aclGeneration,
1424
+ documentId: input.documentId ?? null,
1425
+ fileId: input.fileId ?? null,
1426
+ locationMetadata: input.locationMetadata ?? {},
1427
+ operationId: input.operationId,
1428
+ inputHash,
1429
+ ...actorColumns(input.actor),
1430
+ })
1431
+ .onConflictDoNothing()
1432
+ .returning();
1433
+ if (!created) {
1434
+ const [converged] = await scopedDb
1435
+ .select()
1436
+ .from(schema.knowledgeDocumentVersions)
1437
+ .where(
1438
+ and(
1439
+ eq(schema.knowledgeDocumentVersions.objectId, input.objectId),
1440
+ sql`(
1441
+ ${schema.knowledgeDocumentVersions.operationId} = ${input.operationId}
1442
+ OR ${schema.knowledgeDocumentVersions.externalVersionId} = ${externalVersionId}
1443
+ OR ${schema.knowledgeDocumentVersions.ingestionKey} = ${ingestionKey}
1444
+ )`,
1445
+ ),
1446
+ )
1447
+ .limit(1);
1448
+ if (converged?.inputHash === inputHash) {
1449
+ await recordConvergentKnowledgeOperation(scopedDb, {
1450
+ accountId: input.accountId,
1451
+ scope: objectScope,
1452
+ operationKind: "document_version",
1453
+ operationNamespace: input.objectId,
1454
+ operationId: input.operationId,
1455
+ inputHash,
1456
+ resultId: converged.id,
1457
+ actor: input.actor,
1458
+ });
1459
+ return versionFromRow(converged);
1460
+ }
1461
+ throw new ScopedKnowledgeGenerationConflictError(
1462
+ "Another document version already advanced this object generation",
1463
+ );
1464
+ }
1465
+ await scopedDb.execute(sql`
1466
+ SELECT scoped_knowledge_advance_object_version(
1467
+ ${input.accountId}::uuid,
1468
+ ${input.objectId}::uuid,
1469
+ ${input.expectedObjectLifecycleGeneration}::bigint,
1470
+ ${input.expectedVersionGeneration}::bigint,
1471
+ ${created.id}::uuid,
1472
+ ${input.operationId},
1473
+ ${inputHash},
1474
+ ${reasonCode},
1475
+ ${input.actor.kind},
1476
+ ${input.actor.subjectId},
1477
+ ${input.actor.initiatingHumanSubjectId}
1478
+ )
1479
+ `);
1480
+ await recordConvergentKnowledgeOperation(scopedDb, {
1481
+ accountId: input.accountId,
1482
+ scope: objectScope,
1483
+ operationKind: "document_version",
1484
+ operationNamespace: input.objectId,
1485
+ operationId: input.operationId,
1486
+ inputHash,
1487
+ resultId: created.id,
1488
+ actor: input.actor,
1489
+ });
1490
+ return versionFromRow(created);
1491
+ } catch (error) {
1492
+ if (error instanceof Error && error.name.startsWith("ScopedKnowledge")) throw error;
1493
+ translatePersistenceError(error, "Knowledge document-version append conflicted");
1494
+ }
1495
+ },
1496
+ );
1497
+ }
1498
+
1499
+ export async function recordKnowledgeLifecycleEvent(
1500
+ db: Database,
1501
+ input: Omit<KnowledgeWriteContext, "scope"> & {
1502
+ targetKind: "provider" | "source" | "object";
1503
+ targetId: string;
1504
+ eventType: Extract<KnowledgeLifecycleEventType, "deleted" | "revoked" | "restored">;
1505
+ expectedGeneration: number;
1506
+ reasonCode: string;
1507
+ },
1508
+ ): Promise<{
1509
+ targetId: string;
1510
+ lifecycleState: KnowledgeLifecycleState;
1511
+ lifecycleGeneration: number;
1512
+ replayed: boolean;
1513
+ }> {
1514
+ const reasonCode = normalizedStableKey(input.reasonCode, "reasonCode", 128);
1515
+ const inputHash = scopedKnowledgeInputHash({
1516
+ targetKind: input.targetKind,
1517
+ targetId: input.targetId,
1518
+ eventType: input.eventType,
1519
+ expectedGeneration: input.expectedGeneration,
1520
+ reasonCode,
1521
+ actor: input.actor,
1522
+ });
1523
+ const placeholderScope: ScopedKnowledgeScope = {
1524
+ kind: "workspace",
1525
+ workspaceId: input.workspaceId,
1526
+ subjectId: null,
1527
+ };
1528
+ return await withKnowledgeWriteRls(
1529
+ db,
1530
+ { ...input, scope: placeholderScope },
1531
+ async (scopedDb) => {
1532
+ try {
1533
+ const rows = (await scopedDb.execute(sql`
1534
+ SELECT * FROM scoped_knowledge_apply_lifecycle(
1535
+ ${input.accountId}::uuid,
1536
+ ${input.targetKind},
1537
+ ${input.targetId}::uuid,
1538
+ ${input.eventType},
1539
+ ${input.expectedGeneration}::bigint,
1540
+ ${input.operationId},
1541
+ ${inputHash},
1542
+ ${reasonCode},
1543
+ ${input.actor.kind},
1544
+ ${input.actor.subjectId},
1545
+ ${input.actor.initiatingHumanSubjectId}
1546
+ )
1547
+ `)) as unknown as Array<{
1548
+ target_id: string;
1549
+ lifecycle_state: KnowledgeLifecycleState;
1550
+ lifecycle_generation: number | string;
1551
+ replayed: boolean;
1552
+ }>;
1553
+ const row = rows[0];
1554
+ if (!row) throw new Error("Knowledge lifecycle transition returned no row");
1555
+ return {
1556
+ targetId: row.target_id,
1557
+ lifecycleState: row.lifecycle_state,
1558
+ lifecycleGeneration: Number(row.lifecycle_generation),
1559
+ replayed: row.replayed,
1560
+ };
1561
+ } catch (error) {
1562
+ if (error instanceof Error && error.name.startsWith("ScopedKnowledge")) throw error;
1563
+ translatePersistenceError(error, "Knowledge lifecycle transition conflicted");
1564
+ }
1565
+ },
1566
+ );
1567
+ }
1568
+
1569
+ export async function restoreKnowledgeSourceObject(
1570
+ db: Database,
1571
+ input: Omit<Parameters<typeof recordKnowledgeLifecycleEvent>[1], "targetKind" | "eventType">,
1572
+ ) {
1573
+ return await recordKnowledgeLifecycleEvent(db, {
1574
+ ...input,
1575
+ targetKind: "object",
1576
+ eventType: "restored",
1577
+ });
1578
+ }
1579
+
1580
+ export async function upsertKnowledgeEntity(
1581
+ db: Database,
1582
+ input: KnowledgeWriteContext & {
1583
+ entityType: string;
1584
+ normalizedKey: string;
1585
+ displayName: string;
1586
+ },
1587
+ ): Promise<{
1588
+ id: string;
1589
+ accountId: string;
1590
+ scope: ScopedKnowledgeScope;
1591
+ entityType: string;
1592
+ normalizedKey: string;
1593
+ displayName: string;
1594
+ createdAt: string;
1595
+ }> {
1596
+ const entityType = normalizedStableKey(input.entityType, "entityType", 96);
1597
+ const normalizedKey = boundedText(
1598
+ normalizeScopedKnowledgeKey(input.normalizedKey),
1599
+ "normalizedKey",
1600
+ 512,
1601
+ );
1602
+ const displayName = boundedText(input.displayName, "displayName", 512);
1603
+ const inputHash = scopedKnowledgeInputHash({
1604
+ scope: input.scope,
1605
+ entityType,
1606
+ normalizedKey,
1607
+ displayName,
1608
+ actor: input.actor,
1609
+ });
1610
+ return await withKnowledgeWriteRls(db, input, async (scopedDb) => {
1611
+ try {
1612
+ const receiptResultId = await lockConvergentKnowledgeOperation(scopedDb, {
1613
+ accountId: input.accountId,
1614
+ operationKind: "entity",
1615
+ operationNamespace: "account",
1616
+ operationId: input.operationId,
1617
+ inputHash,
1618
+ });
1619
+ if (receiptResultId) {
1620
+ const [replayed] = await scopedDb
1621
+ .select()
1622
+ .from(schema.knowledgeEntities)
1623
+ .where(eq(schema.knowledgeEntities.id, receiptResultId))
1624
+ .limit(1);
1625
+ if (!replayed) {
1626
+ throw new ScopedKnowledgeInvalidOperationError(
1627
+ "Knowledge entity operation receipt has no visible result",
1628
+ );
1629
+ }
1630
+ return {
1631
+ id: replayed.id,
1632
+ accountId: replayed.accountId,
1633
+ scope: scopeFromRow(replayed),
1634
+ entityType: replayed.entityType,
1635
+ normalizedKey: replayed.normalizedKey,
1636
+ displayName: replayed.displayName,
1637
+ createdAt: iso(replayed.createdAt),
1638
+ };
1639
+ }
1640
+ const [created] = await scopedDb
1641
+ .insert(schema.knowledgeEntities)
1642
+ .values({
1643
+ accountId: input.accountId,
1644
+ ...scopeColumns(input.scope),
1645
+ entityType,
1646
+ normalizedKey,
1647
+ displayName,
1648
+ operationId: input.operationId,
1649
+ inputHash,
1650
+ ...actorColumns(input.actor),
1651
+ })
1652
+ .onConflictDoNothing()
1653
+ .returning();
1654
+ if (created) {
1655
+ await recordConvergentKnowledgeOperation(scopedDb, {
1656
+ accountId: input.accountId,
1657
+ scope: input.scope,
1658
+ operationKind: "entity",
1659
+ operationNamespace: "account",
1660
+ operationId: input.operationId,
1661
+ inputHash,
1662
+ resultId: created.id,
1663
+ actor: input.actor,
1664
+ });
1665
+ return {
1666
+ id: created.id,
1667
+ accountId: created.accountId,
1668
+ scope: scopeFromRow(created),
1669
+ entityType: created.entityType,
1670
+ normalizedKey: created.normalizedKey,
1671
+ displayName: created.displayName,
1672
+ createdAt: iso(created.createdAt),
1673
+ };
1674
+ }
1675
+ const [operationRows, naturalRows] = await Promise.all([
1676
+ scopedDb
1677
+ .select()
1678
+ .from(schema.knowledgeEntities)
1679
+ .where(
1680
+ and(
1681
+ eq(schema.knowledgeEntities.accountId, input.accountId),
1682
+ eq(schema.knowledgeEntities.operationId, input.operationId),
1683
+ ),
1684
+ )
1685
+ .limit(1),
1686
+ scopedDb
1687
+ .select()
1688
+ .from(schema.knowledgeEntities)
1689
+ .where(
1690
+ and(
1691
+ eq(schema.knowledgeEntities.accountId, input.accountId),
1692
+ eq(schema.knowledgeEntities.scopeKey, scopedKnowledgeScopeKey(input.scope)),
1693
+ eq(schema.knowledgeEntities.entityType, entityType),
1694
+ eq(schema.knowledgeEntities.normalizedKey, normalizedKey),
1695
+ ),
1696
+ )
1697
+ .limit(1),
1698
+ ]);
1699
+ const operationRow = operationRows[0];
1700
+ if (operationRow && operationRow.inputHash !== inputHash) {
1701
+ throw new ScopedKnowledgeConflictError(
1702
+ "Entity operation id was replayed with different immutable input",
1703
+ );
1704
+ }
1705
+ const row = operationRow ?? naturalRows[0];
1706
+ if (!row || row.displayName !== displayName) {
1707
+ throw new ScopedKnowledgeConflictError(
1708
+ "Entity identity is already bound to different immutable metadata",
1709
+ );
1710
+ }
1711
+ await recordConvergentKnowledgeOperation(scopedDb, {
1712
+ accountId: input.accountId,
1713
+ scope: input.scope,
1714
+ operationKind: "entity",
1715
+ operationNamespace: "account",
1716
+ operationId: input.operationId,
1717
+ inputHash,
1718
+ resultId: row.id,
1719
+ actor: input.actor,
1720
+ });
1721
+ return {
1722
+ id: row.id,
1723
+ accountId: row.accountId,
1724
+ scope: scopeFromRow(row),
1725
+ entityType: row.entityType,
1726
+ normalizedKey: row.normalizedKey,
1727
+ displayName: row.displayName,
1728
+ createdAt: iso(row.createdAt),
1729
+ };
1730
+ } catch (error) {
1731
+ if (error instanceof Error && error.name.startsWith("ScopedKnowledge")) throw error;
1732
+ translatePersistenceError(error, "Knowledge entity upsert conflicted");
1733
+ }
1734
+ });
1735
+ }
1736
+
1737
+ export async function attachKnowledgeEntityAlias(
1738
+ db: Database,
1739
+ input: Omit<KnowledgeWriteContext, "scope"> & { entityId: string; alias: string },
1740
+ ): Promise<{ id: string; entityId: string; alias: string; normalizedAlias: string }> {
1741
+ const alias = boundedText(input.alias, "alias", 512);
1742
+ const normalizedAlias = boundedText(normalizeScopedKnowledgeKey(alias), "normalizedAlias", 512);
1743
+ const placeholderScope: ScopedKnowledgeScope = {
1744
+ kind: "workspace",
1745
+ workspaceId: input.workspaceId,
1746
+ subjectId: null,
1747
+ };
1748
+ return await withKnowledgeWriteRls(
1749
+ db,
1750
+ { ...input, scope: placeholderScope },
1751
+ async (scopedDb) => {
1752
+ try {
1753
+ const [entity] = await scopedDb
1754
+ .select()
1755
+ .from(schema.knowledgeEntities)
1756
+ .where(eq(schema.knowledgeEntities.id, input.entityId))
1757
+ .limit(1);
1758
+ if (!entity) throw new ScopedKnowledgeNotFoundError("Knowledge entity was not found");
1759
+ validateScope(
1760
+ scopeFromRow(entity),
1761
+ input.workspaceId,
1762
+ input.actor.initiatingHumanSubjectId,
1763
+ );
1764
+ const inputHash = scopedKnowledgeInputHash({
1765
+ entityId: input.entityId,
1766
+ alias,
1767
+ normalizedAlias,
1768
+ actor: input.actor,
1769
+ });
1770
+ const receiptResultId = await lockConvergentKnowledgeOperation(scopedDb, {
1771
+ accountId: input.accountId,
1772
+ operationKind: "entity_alias",
1773
+ operationNamespace: "account",
1774
+ operationId: input.operationId,
1775
+ inputHash,
1776
+ });
1777
+ if (receiptResultId) {
1778
+ const [replayed] = await scopedDb
1779
+ .select()
1780
+ .from(schema.knowledgeEntityAliases)
1781
+ .where(eq(schema.knowledgeEntityAliases.id, receiptResultId))
1782
+ .limit(1);
1783
+ if (!replayed) {
1784
+ throw new ScopedKnowledgeInvalidOperationError(
1785
+ "Knowledge entity-alias operation receipt has no visible result",
1786
+ );
1787
+ }
1788
+ return {
1789
+ id: replayed.id,
1790
+ entityId: replayed.entityId,
1791
+ alias: replayed.alias,
1792
+ normalizedAlias: replayed.normalizedAlias,
1793
+ };
1794
+ }
1795
+ const [created] = await scopedDb
1796
+ .insert(schema.knowledgeEntityAliases)
1797
+ .values({
1798
+ accountId: input.accountId,
1799
+ scopeKind: entity.scopeKind,
1800
+ scopeWorkspaceId: entity.scopeWorkspaceId,
1801
+ scopeSubjectId: entity.scopeSubjectId,
1802
+ scopeKey: entity.scopeKey,
1803
+ entityId: entity.id,
1804
+ entityType: entity.entityType,
1805
+ alias,
1806
+ normalizedAlias,
1807
+ operationId: input.operationId,
1808
+ inputHash,
1809
+ ...actorColumns(input.actor),
1810
+ })
1811
+ .onConflictDoNothing()
1812
+ .returning();
1813
+ if (created) {
1814
+ await recordConvergentKnowledgeOperation(scopedDb, {
1815
+ accountId: input.accountId,
1816
+ scope: scopeFromRow(entity),
1817
+ operationKind: "entity_alias",
1818
+ operationNamespace: "account",
1819
+ operationId: input.operationId,
1820
+ inputHash,
1821
+ resultId: created.id,
1822
+ actor: input.actor,
1823
+ });
1824
+ return {
1825
+ id: created.id,
1826
+ entityId: created.entityId,
1827
+ alias: created.alias,
1828
+ normalizedAlias: created.normalizedAlias,
1829
+ };
1830
+ }
1831
+ const [operationRow] = await scopedDb
1832
+ .select()
1833
+ .from(schema.knowledgeEntityAliases)
1834
+ .where(
1835
+ and(
1836
+ eq(schema.knowledgeEntityAliases.accountId, input.accountId),
1837
+ eq(schema.knowledgeEntityAliases.operationId, input.operationId),
1838
+ ),
1839
+ )
1840
+ .limit(1);
1841
+ if (operationRow) {
1842
+ if (
1843
+ operationRow.inputHash !== inputHash ||
1844
+ operationRow.scopeKind !== entity.scopeKind ||
1845
+ operationRow.scopeWorkspaceId !== entity.scopeWorkspaceId ||
1846
+ operationRow.scopeSubjectId !== entity.scopeSubjectId ||
1847
+ operationRow.scopeKey !== entity.scopeKey ||
1848
+ operationRow.entityId !== entity.id ||
1849
+ operationRow.entityType !== entity.entityType ||
1850
+ operationRow.alias !== alias ||
1851
+ operationRow.normalizedAlias !== normalizedAlias
1852
+ ) {
1853
+ throw new ScopedKnowledgeConflictError(
1854
+ "Entity-alias operation id was replayed with different immutable input",
1855
+ );
1856
+ }
1857
+ await recordConvergentKnowledgeOperation(scopedDb, {
1858
+ accountId: input.accountId,
1859
+ scope: scopeFromRow(entity),
1860
+ operationKind: "entity_alias",
1861
+ operationNamespace: "account",
1862
+ operationId: input.operationId,
1863
+ inputHash,
1864
+ resultId: operationRow.id,
1865
+ actor: input.actor,
1866
+ });
1867
+ return {
1868
+ id: operationRow.id,
1869
+ entityId: operationRow.entityId,
1870
+ alias: operationRow.alias,
1871
+ normalizedAlias: operationRow.normalizedAlias,
1872
+ };
1873
+ }
1874
+ const [row] = await scopedDb
1875
+ .select()
1876
+ .from(schema.knowledgeEntityAliases)
1877
+ .where(
1878
+ and(
1879
+ eq(schema.knowledgeEntityAliases.accountId, input.accountId),
1880
+ eq(schema.knowledgeEntityAliases.scopeKey, entity.scopeKey),
1881
+ eq(schema.knowledgeEntityAliases.entityType, entity.entityType),
1882
+ eq(schema.knowledgeEntityAliases.normalizedAlias, normalizedAlias),
1883
+ ),
1884
+ )
1885
+ .limit(1);
1886
+ if (!row) throw new ScopedKnowledgeConflictError("Entity alias identity conflicted");
1887
+ if (row.entityId !== entity.id) {
1888
+ throw new ScopedKnowledgeConflictError(
1889
+ "Entity alias is already bound to a different entity",
1890
+ );
1891
+ }
1892
+ await recordConvergentKnowledgeOperation(scopedDb, {
1893
+ accountId: input.accountId,
1894
+ scope: scopeFromRow(entity),
1895
+ operationKind: "entity_alias",
1896
+ operationNamespace: "account",
1897
+ operationId: input.operationId,
1898
+ inputHash,
1899
+ resultId: row.id,
1900
+ actor: input.actor,
1901
+ });
1902
+ return {
1903
+ id: row.id,
1904
+ entityId: row.entityId,
1905
+ alias: row.alias,
1906
+ normalizedAlias: row.normalizedAlias,
1907
+ };
1908
+ } catch (error) {
1909
+ if (error instanceof Error && error.name.startsWith("ScopedKnowledge")) throw error;
1910
+ translatePersistenceError(error, "Knowledge entity-alias attach conflicted");
1911
+ }
1912
+ },
1913
+ );
1914
+ }
1915
+
1916
+ export async function upsertKnowledgeFact(
1917
+ db: Database,
1918
+ input: Omit<KnowledgeWriteContext, "scope"> & {
1919
+ subjectEntityId: string;
1920
+ predicateKey: string;
1921
+ object:
1922
+ | { kind: "entity"; entityId: string }
1923
+ | { kind: Exclude<KnowledgeFactObjectKind, "entity">; value: unknown };
1924
+ },
1925
+ ): Promise<KnowledgeFactRecord> {
1926
+ const predicateKey = normalizedStableKey(input.predicateKey, "predicateKey", 128);
1927
+ const placeholderScope: ScopedKnowledgeScope = {
1928
+ kind: "workspace",
1929
+ workspaceId: input.workspaceId,
1930
+ subjectId: null,
1931
+ };
1932
+ return await withKnowledgeWriteRls(
1933
+ db,
1934
+ { ...input, scope: placeholderScope },
1935
+ async (scopedDb) => {
1936
+ try {
1937
+ const objectEntityId = input.object.kind === "entity" ? input.object.entityId : null;
1938
+ const ids = [input.subjectEntityId, ...(objectEntityId ? [objectEntityId] : [])];
1939
+ const entities = await scopedDb
1940
+ .select()
1941
+ .from(schema.knowledgeEntities)
1942
+ .where(inArray(schema.knowledgeEntities.id, ids));
1943
+ const subject = entities.find((row) => row.id === input.subjectEntityId);
1944
+ const objectEntity =
1945
+ objectEntityId === null ? null : entities.find((row) => row.id === objectEntityId);
1946
+ if (!subject || (input.object.kind === "entity" && !objectEntity)) {
1947
+ throw new ScopedKnowledgeNotFoundError("Knowledge fact entity was not found");
1948
+ }
1949
+ if (objectEntity && objectEntity.scopeKey !== subject.scopeKey) {
1950
+ throw new ScopedKnowledgeInvalidOperationError(
1951
+ "Knowledge fact entities must use the same exact scope",
1952
+ );
1953
+ }
1954
+ const objectValue =
1955
+ input.object.kind === "entity" ? null : canonicalize(input.object.value);
1956
+ if (input.object.kind !== "entity" && objectValue === undefined) {
1957
+ throw new ScopedKnowledgeInvalidOperationError("Knowledge fact object value is required");
1958
+ }
1959
+ const objectHash = scopedKnowledgeInputHash(
1960
+ input.object.kind === "entity"
1961
+ ? { kind: "entity", entityId: input.object.entityId }
1962
+ : { kind: input.object.kind, value: objectValue },
1963
+ );
1964
+ const inputHash = scopedKnowledgeInputHash({
1965
+ subjectEntityId: input.subjectEntityId,
1966
+ predicateKey,
1967
+ object: input.object,
1968
+ actor: input.actor,
1969
+ });
1970
+ const receiptResultId = await lockConvergentKnowledgeOperation(scopedDb, {
1971
+ accountId: input.accountId,
1972
+ operationKind: "fact",
1973
+ operationNamespace: "account",
1974
+ operationId: input.operationId,
1975
+ inputHash,
1976
+ });
1977
+ if (receiptResultId) {
1978
+ const [replayed] = await scopedDb
1979
+ .select()
1980
+ .from(schema.knowledgeFacts)
1981
+ .where(eq(schema.knowledgeFacts.id, receiptResultId))
1982
+ .limit(1);
1983
+ if (!replayed) {
1984
+ throw new ScopedKnowledgeInvalidOperationError(
1985
+ "Knowledge fact operation receipt has no visible result",
1986
+ );
1987
+ }
1988
+ return factFromRow(replayed);
1989
+ }
1990
+ const [created] = await scopedDb
1991
+ .insert(schema.knowledgeFacts)
1992
+ .values({
1993
+ accountId: input.accountId,
1994
+ scopeKind: subject.scopeKind,
1995
+ scopeWorkspaceId: subject.scopeWorkspaceId,
1996
+ scopeSubjectId: subject.scopeSubjectId,
1997
+ scopeKey: subject.scopeKey,
1998
+ subjectEntityId: subject.id,
1999
+ predicateKey,
2000
+ objectKind: input.object.kind,
2001
+ objectEntityId,
2002
+ objectValue,
2003
+ objectHash,
2004
+ operationId: input.operationId,
2005
+ inputHash,
2006
+ ...actorColumns(input.actor),
2007
+ })
2008
+ .onConflictDoNothing()
2009
+ .returning();
2010
+ if (created) {
2011
+ await recordConvergentKnowledgeOperation(scopedDb, {
2012
+ accountId: input.accountId,
2013
+ scope: scopeFromRow(subject),
2014
+ operationKind: "fact",
2015
+ operationNamespace: "account",
2016
+ operationId: input.operationId,
2017
+ inputHash,
2018
+ resultId: created.id,
2019
+ actor: input.actor,
2020
+ });
2021
+ return factFromRow(created);
2022
+ }
2023
+ const [operationRows, naturalRows] = await Promise.all([
2024
+ scopedDb
2025
+ .select()
2026
+ .from(schema.knowledgeFacts)
2027
+ .where(
2028
+ and(
2029
+ eq(schema.knowledgeFacts.accountId, input.accountId),
2030
+ eq(schema.knowledgeFacts.operationId, input.operationId),
2031
+ ),
2032
+ )
2033
+ .limit(1),
2034
+ scopedDb
2035
+ .select()
2036
+ .from(schema.knowledgeFacts)
2037
+ .where(
2038
+ and(
2039
+ eq(schema.knowledgeFacts.scopeKey, subject.scopeKey),
2040
+ eq(schema.knowledgeFacts.subjectEntityId, subject.id),
2041
+ eq(schema.knowledgeFacts.predicateKey, predicateKey),
2042
+ eq(schema.knowledgeFacts.objectHash, objectHash),
2043
+ ),
2044
+ )
2045
+ .limit(1),
2046
+ ]);
2047
+ const operationRow = operationRows[0];
2048
+ if (operationRow && operationRow.inputHash !== inputHash) {
2049
+ throw new ScopedKnowledgeConflictError(
2050
+ "Fact operation id was replayed with different immutable input",
2051
+ );
2052
+ }
2053
+ const row = operationRow ?? naturalRows[0];
2054
+ if (!row) throw new ScopedKnowledgeConflictError("Knowledge fact identity conflicted");
2055
+ await recordConvergentKnowledgeOperation(scopedDb, {
2056
+ accountId: input.accountId,
2057
+ scope: scopeFromRow(subject),
2058
+ operationKind: "fact",
2059
+ operationNamespace: "account",
2060
+ operationId: input.operationId,
2061
+ inputHash,
2062
+ resultId: row.id,
2063
+ actor: input.actor,
2064
+ });
2065
+ return factFromRow(row);
2066
+ } catch (error) {
2067
+ if (error instanceof Error && error.name.startsWith("ScopedKnowledge")) throw error;
2068
+ translatePersistenceError(error, "Knowledge fact upsert conflicted");
2069
+ }
2070
+ },
2071
+ );
2072
+ }
2073
+
2074
+ export async function appendKnowledgeClaim(
2075
+ db: Database,
2076
+ input: Omit<KnowledgeWriteContext, "scope"> & {
2077
+ factId: string;
2078
+ origin: KnowledgeClaimOrigin;
2079
+ confidenceBps: number;
2080
+ effectiveAt: string;
2081
+ expiresAt?: string | null;
2082
+ extractionMethod: string;
2083
+ extractionMetadata?: Record<string, unknown> | undefined;
2084
+ modelProvider?: string | null;
2085
+ modelName?: string | null;
2086
+ modelVersion?: string | null;
2087
+ },
2088
+ ): Promise<KnowledgeClaimRecord> {
2089
+ if (
2090
+ !Number.isInteger(input.confidenceBps) ||
2091
+ input.confidenceBps < 0 ||
2092
+ input.confidenceBps > 10_000
2093
+ ) {
2094
+ throw new ScopedKnowledgeInvalidOperationError("confidenceBps must be between 0 and 10000");
2095
+ }
2096
+ const extractionMethod = normalizedStableKey(input.extractionMethod, "extractionMethod", 128);
2097
+ const placeholderScope: ScopedKnowledgeScope = {
2098
+ kind: "workspace",
2099
+ workspaceId: input.workspaceId,
2100
+ subjectId: null,
2101
+ };
2102
+ return await withKnowledgeWriteRls(
2103
+ db,
2104
+ { ...input, scope: placeholderScope },
2105
+ async (scopedDb) => {
2106
+ try {
2107
+ const [fact] = await scopedDb
2108
+ .select()
2109
+ .from(schema.knowledgeFacts)
2110
+ .where(eq(schema.knowledgeFacts.id, input.factId))
2111
+ .limit(1);
2112
+ if (!fact) throw new ScopedKnowledgeNotFoundError("Knowledge fact was not found");
2113
+ validateScope(scopeFromRow(fact), input.workspaceId, input.actor.initiatingHumanSubjectId);
2114
+ const inputHash = scopedKnowledgeInputHash({
2115
+ factId: input.factId,
2116
+ origin: input.origin,
2117
+ confidenceBps: input.confidenceBps,
2118
+ effectiveAt: new Date(input.effectiveAt).toISOString(),
2119
+ expiresAt: input.expiresAt ? new Date(input.expiresAt).toISOString() : null,
2120
+ extractionMethod,
2121
+ extractionMetadata: input.extractionMetadata ?? {},
2122
+ modelProvider: input.modelProvider ?? null,
2123
+ modelName: input.modelName ?? null,
2124
+ modelVersion: input.modelVersion ?? null,
2125
+ actor: input.actor,
2126
+ });
2127
+ const [created] = await scopedDb
2128
+ .insert(schema.knowledgeClaims)
2129
+ .values({
2130
+ accountId: input.accountId,
2131
+ scopeKind: fact.scopeKind,
2132
+ scopeWorkspaceId: fact.scopeWorkspaceId,
2133
+ scopeSubjectId: fact.scopeSubjectId,
2134
+ scopeKey: fact.scopeKey,
2135
+ factId: fact.id,
2136
+ origin: input.origin,
2137
+ confidenceBps: input.confidenceBps,
2138
+ effectiveAt: new Date(input.effectiveAt),
2139
+ expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
2140
+ extractionMethod,
2141
+ extractionMetadata: input.extractionMetadata ?? {},
2142
+ modelProvider: input.modelProvider ?? null,
2143
+ modelName: input.modelName ?? null,
2144
+ modelVersion: input.modelVersion ?? null,
2145
+ operationId: input.operationId,
2146
+ inputHash,
2147
+ ...actorColumns(input.actor),
2148
+ })
2149
+ .onConflictDoNothing()
2150
+ .returning();
2151
+ const row =
2152
+ created ??
2153
+ (
2154
+ await scopedDb
2155
+ .select()
2156
+ .from(schema.knowledgeClaims)
2157
+ .where(
2158
+ and(
2159
+ eq(schema.knowledgeClaims.accountId, input.accountId),
2160
+ eq(schema.knowledgeClaims.operationId, input.operationId),
2161
+ ),
2162
+ )
2163
+ .limit(1)
2164
+ )[0];
2165
+ if (!row || row.inputHash !== inputHash) {
2166
+ throw new ScopedKnowledgeConflictError(
2167
+ "Claim operation id was replayed with different immutable input",
2168
+ );
2169
+ }
2170
+ return claimFromRow(row);
2171
+ } catch (error) {
2172
+ if (error instanceof Error && error.name.startsWith("ScopedKnowledge")) throw error;
2173
+ translatePersistenceError(error, "Knowledge claim append conflicted");
2174
+ }
2175
+ },
2176
+ );
2177
+ }
2178
+
2179
+ export async function linkKnowledgeClaims(
2180
+ db: Database,
2181
+ input: Omit<KnowledgeWriteContext, "scope"> & {
2182
+ relationType: KnowledgeClaimRelationType;
2183
+ fromClaimId: string;
2184
+ toClaimId: string;
2185
+ },
2186
+ ): Promise<{
2187
+ id: string;
2188
+ relationType: KnowledgeClaimRelationType;
2189
+ fromClaimId: string;
2190
+ toClaimId: string;
2191
+ }> {
2192
+ const placeholderScope: ScopedKnowledgeScope = {
2193
+ kind: "workspace",
2194
+ workspaceId: input.workspaceId,
2195
+ subjectId: null,
2196
+ };
2197
+ return await withKnowledgeWriteRls(
2198
+ db,
2199
+ { ...input, scope: placeholderScope },
2200
+ async (scopedDb) => {
2201
+ try {
2202
+ let fromClaimId = input.fromClaimId;
2203
+ let toClaimId = input.toClaimId;
2204
+ if (input.relationType === "conflicts_with" && fromClaimId.localeCompare(toClaimId) > 0) {
2205
+ [fromClaimId, toClaimId] = [toClaimId, fromClaimId];
2206
+ }
2207
+ const claims = await scopedDb
2208
+ .select()
2209
+ .from(schema.knowledgeClaims)
2210
+ .where(inArray(schema.knowledgeClaims.id, [fromClaimId, toClaimId]));
2211
+ if (claims.length !== 2 || claims[0]!.scopeKey !== claims[1]!.scopeKey) {
2212
+ throw new ScopedKnowledgeInvalidOperationError(
2213
+ "Claim relations require two claims in the same exact scope",
2214
+ );
2215
+ }
2216
+ const claim = claims[0]!;
2217
+ const inputHash = scopedKnowledgeInputHash({
2218
+ relationType: input.relationType,
2219
+ fromClaimId,
2220
+ toClaimId,
2221
+ actor: input.actor,
2222
+ });
2223
+ const receiptResultId = await lockConvergentKnowledgeOperation(scopedDb, {
2224
+ accountId: input.accountId,
2225
+ operationKind: "claim_relation",
2226
+ operationNamespace: "account",
2227
+ operationId: input.operationId,
2228
+ inputHash,
2229
+ });
2230
+ if (receiptResultId) {
2231
+ const [replayed] = await scopedDb
2232
+ .select()
2233
+ .from(schema.knowledgeClaimRelations)
2234
+ .where(eq(schema.knowledgeClaimRelations.id, receiptResultId))
2235
+ .limit(1);
2236
+ if (!replayed) {
2237
+ throw new ScopedKnowledgeInvalidOperationError(
2238
+ "Knowledge claim-relation operation receipt has no visible result",
2239
+ );
2240
+ }
2241
+ return {
2242
+ id: replayed.id,
2243
+ relationType: replayed.relationType as KnowledgeClaimRelationType,
2244
+ fromClaimId: replayed.fromClaimId,
2245
+ toClaimId: replayed.toClaimId,
2246
+ };
2247
+ }
2248
+ const [created] = await scopedDb
2249
+ .insert(schema.knowledgeClaimRelations)
2250
+ .values({
2251
+ accountId: input.accountId,
2252
+ scopeKind: claim.scopeKind,
2253
+ scopeWorkspaceId: claim.scopeWorkspaceId,
2254
+ scopeSubjectId: claim.scopeSubjectId,
2255
+ scopeKey: claim.scopeKey,
2256
+ relationType: input.relationType,
2257
+ fromClaimId,
2258
+ toClaimId,
2259
+ operationId: input.operationId,
2260
+ inputHash,
2261
+ ...actorColumns(input.actor),
2262
+ })
2263
+ .onConflictDoNothing()
2264
+ .returning();
2265
+ if (created) {
2266
+ await recordConvergentKnowledgeOperation(scopedDb, {
2267
+ accountId: input.accountId,
2268
+ scope: scopeFromRow(claim),
2269
+ operationKind: "claim_relation",
2270
+ operationNamespace: "account",
2271
+ operationId: input.operationId,
2272
+ inputHash,
2273
+ resultId: created.id,
2274
+ actor: input.actor,
2275
+ });
2276
+ return {
2277
+ id: created.id,
2278
+ relationType: created.relationType as KnowledgeClaimRelationType,
2279
+ fromClaimId: created.fromClaimId,
2280
+ toClaimId: created.toClaimId,
2281
+ };
2282
+ }
2283
+ const [operationRow] = await scopedDb
2284
+ .select()
2285
+ .from(schema.knowledgeClaimRelations)
2286
+ .where(
2287
+ and(
2288
+ eq(schema.knowledgeClaimRelations.accountId, input.accountId),
2289
+ eq(schema.knowledgeClaimRelations.operationId, input.operationId),
2290
+ ),
2291
+ )
2292
+ .limit(1);
2293
+ if (operationRow) {
2294
+ if (
2295
+ operationRow.inputHash !== inputHash ||
2296
+ operationRow.scopeKind !== claim.scopeKind ||
2297
+ operationRow.scopeWorkspaceId !== claim.scopeWorkspaceId ||
2298
+ operationRow.scopeSubjectId !== claim.scopeSubjectId ||
2299
+ operationRow.scopeKey !== claim.scopeKey ||
2300
+ operationRow.relationType !== input.relationType ||
2301
+ operationRow.fromClaimId !== fromClaimId ||
2302
+ operationRow.toClaimId !== toClaimId
2303
+ ) {
2304
+ throw new ScopedKnowledgeConflictError(
2305
+ "Claim-relation operation id was replayed with different immutable input",
2306
+ );
2307
+ }
2308
+ await recordConvergentKnowledgeOperation(scopedDb, {
2309
+ accountId: input.accountId,
2310
+ scope: scopeFromRow(claim),
2311
+ operationKind: "claim_relation",
2312
+ operationNamespace: "account",
2313
+ operationId: input.operationId,
2314
+ inputHash,
2315
+ resultId: operationRow.id,
2316
+ actor: input.actor,
2317
+ });
2318
+ return {
2319
+ id: operationRow.id,
2320
+ relationType: operationRow.relationType as KnowledgeClaimRelationType,
2321
+ fromClaimId: operationRow.fromClaimId,
2322
+ toClaimId: operationRow.toClaimId,
2323
+ };
2324
+ }
2325
+ const [row] = await scopedDb
2326
+ .select()
2327
+ .from(schema.knowledgeClaimRelations)
2328
+ .where(
2329
+ and(
2330
+ eq(schema.knowledgeClaimRelations.accountId, input.accountId),
2331
+ eq(schema.knowledgeClaimRelations.scopeKey, claim.scopeKey),
2332
+ eq(schema.knowledgeClaimRelations.relationType, input.relationType),
2333
+ eq(schema.knowledgeClaimRelations.fromClaimId, fromClaimId),
2334
+ eq(schema.knowledgeClaimRelations.toClaimId, toClaimId),
2335
+ ),
2336
+ )
2337
+ .limit(1);
2338
+ if (!row) throw new ScopedKnowledgeConflictError("Claim relation identity conflicted");
2339
+ await recordConvergentKnowledgeOperation(scopedDb, {
2340
+ accountId: input.accountId,
2341
+ scope: scopeFromRow(claim),
2342
+ operationKind: "claim_relation",
2343
+ operationNamespace: "account",
2344
+ operationId: input.operationId,
2345
+ inputHash,
2346
+ resultId: row.id,
2347
+ actor: input.actor,
2348
+ });
2349
+ return {
2350
+ id: row.id,
2351
+ relationType: row.relationType as KnowledgeClaimRelationType,
2352
+ fromClaimId: row.fromClaimId,
2353
+ toClaimId: row.toClaimId,
2354
+ };
2355
+ } catch (error) {
2356
+ if (error instanceof Error && error.name.startsWith("ScopedKnowledge")) throw error;
2357
+ translatePersistenceError(error, "Knowledge claim relation conflicted");
2358
+ }
2359
+ },
2360
+ );
2361
+ }
2362
+
2363
+ export async function appendKnowledgeClaimEvidence(
2364
+ db: Database,
2365
+ input: Omit<KnowledgeWriteContext, "scope"> & {
2366
+ claimId: string;
2367
+ documentVersionId: string;
2368
+ polarity: KnowledgeClaimEvidencePolarity;
2369
+ documentChunkId?: string | null;
2370
+ chunkIndex?: number | null;
2371
+ locator?: string | null;
2372
+ quoteHash?: string | null;
2373
+ contentHash: string;
2374
+ },
2375
+ ): Promise<{
2376
+ id: string;
2377
+ claimId: string;
2378
+ documentVersionId: string;
2379
+ polarity: KnowledgeClaimEvidencePolarity;
2380
+ }> {
2381
+ const locator = input.locator == null ? null : boundedText(input.locator, "locator", 2048);
2382
+ const quoteHash = input.quoteHash == null ? null : sha256(input.quoteHash, "quoteHash");
2383
+ const contentHash = sha256(input.contentHash, "contentHash");
2384
+ const placeholderScope: ScopedKnowledgeScope = {
2385
+ kind: "workspace",
2386
+ workspaceId: input.workspaceId,
2387
+ subjectId: null,
2388
+ };
2389
+ return await withKnowledgeWriteRls(
2390
+ db,
2391
+ { ...input, scope: placeholderScope },
2392
+ async (scopedDb) => {
2393
+ try {
2394
+ const [claims, versions] = await Promise.all([
2395
+ scopedDb
2396
+ .select()
2397
+ .from(schema.knowledgeClaims)
2398
+ .where(eq(schema.knowledgeClaims.id, input.claimId))
2399
+ .limit(1),
2400
+ scopedDb
2401
+ .select()
2402
+ .from(schema.knowledgeDocumentVersions)
2403
+ .where(eq(schema.knowledgeDocumentVersions.id, input.documentVersionId))
2404
+ .limit(1),
2405
+ ]);
2406
+ const claim = claims[0];
2407
+ const version = versions[0];
2408
+ if (!claim || !version) {
2409
+ throw new ScopedKnowledgeNotFoundError(
2410
+ "Knowledge claim or document version was not found",
2411
+ );
2412
+ }
2413
+ if (claim.scopeKey !== version.scopeKey) {
2414
+ throw new ScopedKnowledgeInvalidOperationError(
2415
+ "Claim evidence cannot cross account or scope provenance",
2416
+ );
2417
+ }
2418
+ const inputHash = scopedKnowledgeInputHash({
2419
+ claimId: input.claimId,
2420
+ documentVersionId: input.documentVersionId,
2421
+ polarity: input.polarity,
2422
+ documentChunkId: input.documentChunkId ?? null,
2423
+ chunkIndex: input.chunkIndex ?? null,
2424
+ locator,
2425
+ quoteHash,
2426
+ contentHash,
2427
+ actor: input.actor,
2428
+ });
2429
+ const [created] = await scopedDb
2430
+ .insert(schema.knowledgeClaimEvidence)
2431
+ .values({
2432
+ accountId: input.accountId,
2433
+ scopeKind: claim.scopeKind,
2434
+ scopeWorkspaceId: claim.scopeWorkspaceId,
2435
+ scopeSubjectId: claim.scopeSubjectId,
2436
+ scopeKey: claim.scopeKey,
2437
+ claimId: claim.id,
2438
+ documentVersionId: version.id,
2439
+ polarity: input.polarity,
2440
+ documentChunkId: input.documentChunkId ?? null,
2441
+ chunkIndex: input.chunkIndex ?? null,
2442
+ locator,
2443
+ quoteHash,
2444
+ contentHash,
2445
+ operationId: input.operationId,
2446
+ inputHash,
2447
+ ...actorColumns(input.actor),
2448
+ })
2449
+ .onConflictDoNothing()
2450
+ .returning();
2451
+ const row =
2452
+ created ??
2453
+ (
2454
+ await scopedDb
2455
+ .select()
2456
+ .from(schema.knowledgeClaimEvidence)
2457
+ .where(
2458
+ and(
2459
+ eq(schema.knowledgeClaimEvidence.accountId, input.accountId),
2460
+ eq(schema.knowledgeClaimEvidence.operationId, input.operationId),
2461
+ ),
2462
+ )
2463
+ .limit(1)
2464
+ )[0];
2465
+ if (!row || row.inputHash !== inputHash) {
2466
+ throw new ScopedKnowledgeConflictError(
2467
+ "Claim-evidence operation id was replayed with different input",
2468
+ );
2469
+ }
2470
+ return {
2471
+ id: row.id,
2472
+ claimId: row.claimId,
2473
+ documentVersionId: row.documentVersionId,
2474
+ polarity: row.polarity as KnowledgeClaimEvidencePolarity,
2475
+ };
2476
+ } catch (error) {
2477
+ if (error instanceof Error && error.name.startsWith("ScopedKnowledge")) throw error;
2478
+ translatePersistenceError(error, "Knowledge claim evidence conflicted");
2479
+ }
2480
+ },
2481
+ );
2482
+ }
2483
+
2484
+ export async function appendKnowledgeClaimReview(
2485
+ db: Database,
2486
+ input: Omit<KnowledgeWriteContext, "scope"> & {
2487
+ claimId: string;
2488
+ state: KnowledgeClaimReviewState;
2489
+ reason: string;
2490
+ },
2491
+ ): Promise<{
2492
+ id: string;
2493
+ claimId: string;
2494
+ state: KnowledgeClaimReviewState;
2495
+ reviewRevision: number;
2496
+ }> {
2497
+ const reason = boundedText(input.reason, "reason", 4096);
2498
+ if (input.state !== "proposed" && input.actor.kind !== "human") {
2499
+ throw new ScopedKnowledgeAuthorityError(
2500
+ "Claim approval, rejection, and revocation require the initiating human",
2501
+ );
2502
+ }
2503
+ const placeholderScope: ScopedKnowledgeScope = {
2504
+ kind: "workspace",
2505
+ workspaceId: input.workspaceId,
2506
+ subjectId: null,
2507
+ };
2508
+ return await withKnowledgeWriteRls(
2509
+ db,
2510
+ { ...input, scope: placeholderScope },
2511
+ async (scopedDb) => {
2512
+ try {
2513
+ const [claim] = await scopedDb
2514
+ .select()
2515
+ .from(schema.knowledgeClaims)
2516
+ .where(eq(schema.knowledgeClaims.id, input.claimId))
2517
+ .limit(1);
2518
+ if (!claim) throw new ScopedKnowledgeNotFoundError("Knowledge claim was not found");
2519
+ const inputHash = scopedKnowledgeInputHash({
2520
+ claimId: input.claimId,
2521
+ state: input.state,
2522
+ reason,
2523
+ actor: input.actor,
2524
+ });
2525
+ const [created] = await scopedDb
2526
+ .insert(schema.knowledgeClaimReviews)
2527
+ .values({
2528
+ accountId: input.accountId,
2529
+ scopeKind: claim.scopeKind,
2530
+ scopeWorkspaceId: claim.scopeWorkspaceId,
2531
+ scopeSubjectId: claim.scopeSubjectId,
2532
+ scopeKey: claim.scopeKey,
2533
+ claimId: claim.id,
2534
+ state: input.state,
2535
+ reason,
2536
+ operationId: input.operationId,
2537
+ inputHash,
2538
+ ...actorColumns(input.actor),
2539
+ })
2540
+ .onConflictDoNothing()
2541
+ .returning();
2542
+ const row =
2543
+ created ??
2544
+ (
2545
+ await scopedDb
2546
+ .select()
2547
+ .from(schema.knowledgeClaimReviews)
2548
+ .where(
2549
+ and(
2550
+ eq(schema.knowledgeClaimReviews.accountId, input.accountId),
2551
+ eq(schema.knowledgeClaimReviews.operationId, input.operationId),
2552
+ ),
2553
+ )
2554
+ .limit(1)
2555
+ )[0];
2556
+ if (!row || row.inputHash !== inputHash) {
2557
+ throw new ScopedKnowledgeConflictError(
2558
+ "Claim-review operation id was replayed with different input",
2559
+ );
2560
+ }
2561
+ return {
2562
+ id: row.id,
2563
+ claimId: row.claimId,
2564
+ state: row.state as KnowledgeClaimReviewState,
2565
+ reviewRevision: row.reviewRevision,
2566
+ };
2567
+ } catch (error) {
2568
+ if (error instanceof Error && error.name.startsWith("ScopedKnowledge")) throw error;
2569
+ translatePersistenceError(error, "Knowledge claim review conflicted");
2570
+ }
2571
+ },
2572
+ );
2573
+ }
2574
+
2575
+ export async function createKnowledgeChangeProposal(
2576
+ db: Database,
2577
+ input: Omit<KnowledgeWriteContext, "scope"> & {
2578
+ claimId: string;
2579
+ evidenceId: string;
2580
+ targetKind: "instruction_policy" | "preference";
2581
+ targetScope: string;
2582
+ targetKey?: string | null;
2583
+ content: string;
2584
+ },
2585
+ ): Promise<KnowledgeChangeProposalRecord> {
2586
+ const content = boundedText(input.content, "content", 1_048_576);
2587
+ const contentHash = createHash("sha256").update(content, "utf8").digest("hex");
2588
+ const targetKey =
2589
+ input.targetKey == null
2590
+ ? null
2591
+ : input.targetKind === "preference"
2592
+ ? normalizedStableKey(input.targetKey, "targetKey", 96)
2593
+ : boundedText(input.targetKey, "targetKey", 96);
2594
+ const placeholderScope: ScopedKnowledgeScope = {
2595
+ kind: "workspace",
2596
+ workspaceId: input.workspaceId,
2597
+ subjectId: null,
2598
+ };
2599
+ return await withKnowledgeWriteRls(
2600
+ db,
2601
+ { ...input, scope: placeholderScope },
2602
+ async (scopedDb) => {
2603
+ try {
2604
+ const [claim, evidence] = await Promise.all([
2605
+ scopedDb
2606
+ .select()
2607
+ .from(schema.knowledgeClaims)
2608
+ .where(eq(schema.knowledgeClaims.id, input.claimId))
2609
+ .limit(1),
2610
+ scopedDb
2611
+ .select()
2612
+ .from(schema.knowledgeClaimEvidence)
2613
+ .where(eq(schema.knowledgeClaimEvidence.id, input.evidenceId))
2614
+ .limit(1),
2615
+ ]).then(([claims, evidenceRows]) => [claims[0], evidenceRows[0]] as const);
2616
+ if (
2617
+ !claim ||
2618
+ !evidence ||
2619
+ evidence.claimId !== claim.id ||
2620
+ evidence.polarity !== "supports"
2621
+ ) {
2622
+ throw new ScopedKnowledgeInvalidOperationError(
2623
+ "Knowledge change proposals require exact supporting claim evidence",
2624
+ );
2625
+ }
2626
+ const inputHash = scopedKnowledgeInputHash({
2627
+ claimId: input.claimId,
2628
+ evidenceId: input.evidenceId,
2629
+ targetKind: input.targetKind,
2630
+ targetScope: input.targetScope,
2631
+ targetKey,
2632
+ contentHash,
2633
+ actor: input.actor,
2634
+ });
2635
+ const [created] = await scopedDb
2636
+ .insert(schema.knowledgeChangeProposals)
2637
+ .values({
2638
+ accountId: input.accountId,
2639
+ scopeKind: claim.scopeKind,
2640
+ scopeWorkspaceId: claim.scopeWorkspaceId,
2641
+ scopeSubjectId: claim.scopeSubjectId,
2642
+ scopeKey: claim.scopeKey,
2643
+ targetKind: input.targetKind,
2644
+ targetScope: input.targetScope,
2645
+ targetKey,
2646
+ content,
2647
+ contentHash,
2648
+ claimId: claim.id,
2649
+ evidenceId: evidence.id,
2650
+ operationId: input.operationId,
2651
+ inputHash,
2652
+ ...actorColumns(input.actor),
2653
+ })
2654
+ .onConflictDoNothing()
2655
+ .returning();
2656
+ const row =
2657
+ created ??
2658
+ (
2659
+ await scopedDb
2660
+ .select()
2661
+ .from(schema.knowledgeChangeProposals)
2662
+ .where(
2663
+ and(
2664
+ eq(schema.knowledgeChangeProposals.accountId, input.accountId),
2665
+ eq(schema.knowledgeChangeProposals.operationId, input.operationId),
2666
+ ),
2667
+ )
2668
+ .limit(1)
2669
+ )[0];
2670
+ if (!row || row.inputHash !== inputHash) {
2671
+ throw new ScopedKnowledgeConflictError(
2672
+ "Change-proposal operation id was replayed with different input",
2673
+ );
2674
+ }
2675
+ return proposalFromRow(row);
2676
+ } catch (error) {
2677
+ if (error instanceof Error && error.name.startsWith("ScopedKnowledge")) throw error;
2678
+ translatePersistenceError(error, "Knowledge change proposal conflicted");
2679
+ }
2680
+ },
2681
+ );
2682
+ }
2683
+
2684
+ type EligibleRow = {
2685
+ claim_id: string;
2686
+ claim_account_id: string;
2687
+ claim_scope_kind: string;
2688
+ claim_scope_workspace_id: string | null;
2689
+ claim_scope_subject_id: string | null;
2690
+ claim_scope_key: string;
2691
+ fact_id: string;
2692
+ origin: KnowledgeClaimOrigin;
2693
+ confidence_bps: number;
2694
+ effective_at: Date | string;
2695
+ expires_at: Date | string | null;
2696
+ extraction_method: string;
2697
+ model_provider: string | null;
2698
+ model_name: string | null;
2699
+ model_version: string | null;
2700
+ claim_created_at: Date | string;
2701
+ subject_entity_id: string;
2702
+ predicate_key: string;
2703
+ object_kind: KnowledgeFactObjectKind;
2704
+ object_entity_id: string | null;
2705
+ object_value: unknown | null;
2706
+ object_hash: string;
2707
+ fact_created_at: Date | string;
2708
+ supporting_evidence_count: number | string;
2709
+ };
2710
+
2711
+ async function eligibleKnowledgeClaimRows(
2712
+ db: Database,
2713
+ input: KnowledgeReadContext & { limit: number },
2714
+ claimId: string | null,
2715
+ ): Promise<EligibleRow[]> {
2716
+ if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100) {
2717
+ throw new ScopedKnowledgeInvalidOperationError("limit must be between 1 and 100");
2718
+ }
2719
+ const agentOnly = input.surface === "agent";
2720
+ return await withKnowledgeReadRls(db, input, async (scopedDb) => {
2721
+ return (await scopedDb.execute(sql`
2722
+ SELECT
2723
+ claim.id AS claim_id,
2724
+ claim.account_id AS claim_account_id,
2725
+ claim.scope_kind AS claim_scope_kind,
2726
+ claim.scope_workspace_id AS claim_scope_workspace_id,
2727
+ claim.scope_subject_id AS claim_scope_subject_id,
2728
+ claim.scope_key AS claim_scope_key,
2729
+ claim.fact_id,
2730
+ claim.origin,
2731
+ claim.confidence_bps,
2732
+ claim.effective_at,
2733
+ claim.expires_at,
2734
+ claim.extraction_method,
2735
+ claim.model_provider,
2736
+ claim.model_name,
2737
+ claim.model_version,
2738
+ claim.created_at AS claim_created_at,
2739
+ fact.subject_entity_id,
2740
+ fact.predicate_key,
2741
+ fact.object_kind,
2742
+ fact.object_entity_id,
2743
+ fact.object_value,
2744
+ fact.object_hash,
2745
+ fact.created_at AS fact_created_at,
2746
+ (
2747
+ SELECT count(*)::int
2748
+ FROM knowledge_claim_evidence support
2749
+ WHERE support.claim_id = claim.id AND support.polarity = 'supports'
2750
+ ) AS supporting_evidence_count
2751
+ FROM knowledge_claims claim
2752
+ JOIN knowledge_facts fact ON fact.id = claim.fact_id
2753
+ AND fact.account_id = claim.account_id
2754
+ AND fact.scope_key = claim.scope_key
2755
+ JOIN LATERAL (
2756
+ SELECT review.state
2757
+ FROM knowledge_claim_reviews review
2758
+ WHERE review.claim_id = claim.id
2759
+ ORDER BY review.review_revision DESC
2760
+ LIMIT 1
2761
+ ) latest_review ON latest_review.state = 'approved'
2762
+ WHERE claim.effective_at <= transaction_timestamp()
2763
+ AND (claim.expires_at IS NULL OR claim.expires_at > transaction_timestamp())
2764
+ AND (${claimId}::uuid IS NULL OR claim.id = ${claimId}::uuid)
2765
+ AND EXISTS (
2766
+ SELECT 1 FROM knowledge_claim_evidence support
2767
+ WHERE support.claim_id = claim.id AND support.polarity = 'supports'
2768
+ )
2769
+ AND NOT EXISTS (
2770
+ SELECT 1
2771
+ FROM knowledge_claim_evidence support
2772
+ LEFT JOIN knowledge_document_versions version
2773
+ ON version.id = support.document_version_id
2774
+ AND version.account_id = support.account_id
2775
+ AND version.scope_key = support.scope_key
2776
+ LEFT JOIN knowledge_source_objects object
2777
+ ON object.id = version.object_id
2778
+ AND object.account_id = version.account_id
2779
+ AND object.scope_key = version.scope_key
2780
+ LEFT JOIN knowledge_sources source
2781
+ ON source.id = version.source_id
2782
+ AND source.account_id = version.account_id
2783
+ AND source.scope_key = version.scope_key
2784
+ LEFT JOIN knowledge_providers provider
2785
+ ON provider.id = source.provider_id
2786
+ AND provider.account_id = source.account_id
2787
+ AND provider.scope_key = source.scope_key
2788
+ LEFT JOIN knowledge_source_acl_versions evidence_acl
2789
+ ON evidence_acl.id = version.acl_version_id
2790
+ AND evidence_acl.account_id = version.account_id
2791
+ AND evidence_acl.source_id = version.source_id
2792
+ AND evidence_acl.generation = version.acl_generation
2793
+ LEFT JOIN knowledge_source_acl_versions current_acl
2794
+ ON current_acl.account_id = source.account_id
2795
+ AND current_acl.source_id = source.id
2796
+ AND current_acl.generation = source.current_acl_generation
2797
+ LEFT JOIN documents document
2798
+ ON document.id = version.document_id
2799
+ AND document.account_id = version.account_id
2800
+ AND document.workspace_id = version.scope_workspace_id
2801
+ LEFT JOIN document_chunks chunk
2802
+ ON chunk.id = support.document_chunk_id
2803
+ AND chunk.account_id = support.account_id
2804
+ AND chunk.workspace_id = support.scope_workspace_id
2805
+ AND chunk.document_id = version.document_id
2806
+ WHERE support.claim_id = claim.id
2807
+ AND support.polarity = 'supports'
2808
+ AND (
2809
+ version.id IS NULL
2810
+ OR object.id IS NULL OR object.lifecycle_state <> 'active'
2811
+ OR source.id IS NULL OR source.lifecycle_state <> 'active'
2812
+ OR provider.id IS NULL OR provider.lifecycle_state <> 'active'
2813
+ OR evidence_acl.id IS NULL
2814
+ OR current_acl.id IS NULL
2815
+ OR (${agentOnly} AND (NOT evidence_acl.agent_access OR NOT current_acl.agent_access))
2816
+ OR (
2817
+ version.document_id IS NOT NULL
2818
+ AND (
2819
+ document.id IS NULL
2820
+ OR document.status <> 'ready'
2821
+ OR (
2822
+ document.visibility = 'private'
2823
+ AND document.created_by IS DISTINCT FROM ${input.initiatingSubjectId}
2824
+ )
2825
+ OR (${agentOnly} AND NOT document.agent_access)
2826
+ )
2827
+ )
2828
+ OR (support.document_chunk_id IS NOT NULL AND chunk.id IS NULL)
2829
+ )
2830
+ )
2831
+ ORDER BY claim.created_at DESC, claim.id DESC
2832
+ LIMIT ${input.limit}
2833
+ `)) as unknown as EligibleRow[];
2834
+ });
2835
+ }
2836
+
2837
+ function eligibleFromRow(row: EligibleRow): EligibleKnowledgeClaim {
2838
+ const scopeRow: ScopedRow = {
2839
+ scopeKind: row.claim_scope_kind,
2840
+ scopeWorkspaceId: row.claim_scope_workspace_id,
2841
+ scopeSubjectId: row.claim_scope_subject_id,
2842
+ scopeKey: row.claim_scope_key,
2843
+ };
2844
+ return {
2845
+ claim: {
2846
+ id: row.claim_id,
2847
+ accountId: row.claim_account_id,
2848
+ scope: scopeFromRow(scopeRow),
2849
+ factId: row.fact_id,
2850
+ origin: row.origin,
2851
+ confidenceBps: Number(row.confidence_bps),
2852
+ effectiveAt: iso(row.effective_at),
2853
+ expiresAt: optionalIso(row.expires_at),
2854
+ extractionMethod: row.extraction_method,
2855
+ modelProvider: row.model_provider,
2856
+ modelName: row.model_name,
2857
+ modelVersion: row.model_version,
2858
+ createdAt: iso(row.claim_created_at),
2859
+ },
2860
+ fact: {
2861
+ id: row.fact_id,
2862
+ accountId: row.claim_account_id,
2863
+ scope: scopeFromRow(scopeRow),
2864
+ subjectEntityId: row.subject_entity_id,
2865
+ predicateKey: row.predicate_key,
2866
+ objectKind: row.object_kind,
2867
+ objectEntityId: row.object_entity_id,
2868
+ objectValue: row.object_value,
2869
+ objectHash: row.object_hash,
2870
+ createdAt: iso(row.fact_created_at),
2871
+ },
2872
+ reviewState: "approved",
2873
+ supportingEvidenceCount: Number(row.supporting_evidence_count),
2874
+ };
2875
+ }
2876
+
2877
+ export async function listEligibleKnowledgeClaims(
2878
+ db: Database,
2879
+ input: KnowledgeReadContext & { limit?: number | undefined },
2880
+ ): Promise<EligibleKnowledgeClaim[]> {
2881
+ const rows = await eligibleKnowledgeClaimRows(db, { ...input, limit: input.limit ?? 50 }, null);
2882
+ return rows.map(eligibleFromRow);
2883
+ }
2884
+
2885
+ export async function getEligibleKnowledgeClaim(
2886
+ db: Database,
2887
+ input: KnowledgeReadContext & { claimId: string },
2888
+ ): Promise<EligibleKnowledgeClaim | null> {
2889
+ const rows = await eligibleKnowledgeClaimRows(db, { ...input, limit: 1 }, input.claimId);
2890
+ return rows[0] ? eligibleFromRow(rows[0]) : null;
2891
+ }