@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.
- package/dist/{chunk-XOXEQ7HG.js → chunk-BNGEN5QZ.js} +85 -1
- package/dist/chunk-BNGEN5QZ.js.map +1 -0
- package/dist/{chunk-SZP6KRLC.js → chunk-CYGFLLMN.js} +2698 -1918
- package/dist/chunk-CYGFLLMN.js.map +1 -0
- package/dist/index.d.ts +108 -1
- package/dist/index.js +5940 -2907
- package/dist/index.js.map +1 -1
- package/dist/provision-roles.js +1 -1
- package/dist/runtime-posture.d.ts +4 -4
- package/dist/schema.d.ts +1147 -101
- package/dist/schema.js +37 -1
- package/dist/scoped-knowledge-schema.d.ts +5341 -0
- package/dist/scoped-knowledge.d.ts +208 -0
- package/dist/session-control.d.ts +1 -0
- package/dist/session-queue-commands.d.ts +2 -1
- package/drizzle/0153_mcp_personal_connection_delegations.sql +181 -0
- package/drizzle/0154_scoped_knowledge_foundation.sql +2292 -0
- package/drizzle/0155_connector_action_policies.sql +218 -0
- package/package.json +3 -3
- package/src/connection-token-resolver.ts +3 -0
- package/src/index.ts +1211 -7
- package/src/provision-roles.ts +48 -0
- package/src/runtime-posture.ts +36 -0
- package/src/schema.ts +228 -2
- package/src/scoped-knowledge-schema.ts +603 -0
- package/src/scoped-knowledge.ts +2891 -0
- package/src/session-control.ts +3 -0
- package/src/session-queue-commands.ts +48 -0
- package/dist/chunk-SZP6KRLC.js.map +0 -1
- package/dist/chunk-XOXEQ7HG.js.map +0 -1
|
@@ -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
|
+
}
|