@opengeni/core 2.10.0-canary.0 → 2.10.0-canary.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/application/external-workspace-members.d.ts +16 -0
  2. package/dist/application/host-mcp-resolvers.d.ts +13 -0
  3. package/dist/application/new-session-drafts.d.ts +4 -2
  4. package/dist/chunk-5WZYFL45.js +266 -0
  5. package/dist/chunk-5WZYFL45.js.map +1 -0
  6. package/dist/domain/file-owner.d.ts +8 -0
  7. package/dist/domain/knowledge-files.d.ts +20 -0
  8. package/dist/domain/knowledge-messages.d.ts +39 -0
  9. package/dist/domain/knowledge-messages.test.d.ts +1 -0
  10. package/dist/domain/knowledge-search.d.ts +80 -0
  11. package/dist/domain/knowledge-search.test.d.ts +1 -0
  12. package/dist/domain/knowledge.d.ts +9 -0
  13. package/dist/domain/pr-review.d.ts +2 -2
  14. package/dist/domain/resources.d.ts +2 -2
  15. package/dist/domain/scheduled-tasks.d.ts +12 -2
  16. package/dist/domain/sessions.d.ts +6 -2
  17. package/dist/domain/skills.d.ts +16 -0
  18. package/dist/index.d.ts +6 -4
  19. package/dist/index.js +3948 -3939
  20. package/dist/index.js.map +1 -1
  21. package/dist/remote-mcp-credentials.d.ts +16 -1
  22. package/dist/remote-mcp-credentials.js +7 -215
  23. package/dist/remote-mcp-credentials.js.map +1 -1
  24. package/package.json +12 -12
  25. package/src/application/external-workspace-members.ts +89 -0
  26. package/src/application/host-mcp-resolvers.ts +85 -0
  27. package/src/application/new-session-drafts.ts +37 -3
  28. package/src/domain/file-owner.ts +52 -0
  29. package/src/domain/host-mcp-task-admission.ts +27 -17
  30. package/src/domain/knowledge-files.ts +39 -0
  31. package/src/domain/knowledge-messages.test.ts +91 -0
  32. package/src/domain/knowledge-messages.ts +105 -0
  33. package/src/domain/knowledge-search.test.ts +45 -0
  34. package/src/domain/knowledge-search.ts +55 -0
  35. package/src/domain/knowledge.ts +133 -0
  36. package/src/domain/product-integration-skill.gen.ts +7 -7
  37. package/src/domain/resources.ts +36 -25
  38. package/src/domain/scheduled-tasks.ts +362 -139
  39. package/src/domain/sessions.ts +117 -20
  40. package/src/domain/skills.ts +8 -0
  41. package/src/index.ts +9 -4
  42. package/src/remote-mcp-credentials.ts +76 -8
  43. package/dist/domain/company-brain-governed-writes.d.ts +0 -79
  44. package/dist/domain/governed-learning-activation.d.ts +0 -16
  45. package/dist/domain/governed-learning-evaluator.d.ts +0 -6
  46. package/dist/domain/remember.d.ts +0 -72
  47. package/src/domain/company-brain-governed-writes.ts +0 -346
  48. package/src/domain/governed-learning-activation.ts +0 -44
  49. package/src/domain/governed-learning-evaluator.ts +0 -49
  50. package/src/domain/remember.ts +0 -577
@@ -2,11 +2,13 @@ import { HTTPException } from "hono/http-exception";
2
2
  import type { Settings } from "@opengeni/config";
3
3
  import type { ToolRef, ScheduledTask, AccessGrant } from "@opengeni/contracts";
4
4
  import {
5
- HostMcpBindingDefinition,
5
+ hostMcpBindingMatchesSelection,
6
6
  HostMcpCreateSelections,
7
7
  } from "@opengeni/contracts/host-mcp-bindings";
8
8
  import {
9
9
  captureHostMcpTaskAuthorities,
10
+ getHostMcpBinding,
11
+ getHostMcpDelegation,
10
12
  HostMcpDelegationAuthorityError,
11
13
  inheritHostMcpTaskAuthoritiesFromAttempt,
12
14
  type Database,
@@ -55,23 +57,36 @@ export function prepareHostMcpTaskAdmission(input: {
55
57
  message: "Host task selection must match a selected configured server",
56
58
  });
57
59
  assertHostMcpAuthoritySourceAdmissionEnabled(input.settings, server.connectionRef);
58
- const { hostBinding, ...connectionRef } = server.connectionRef;
59
60
  return {
60
61
  delegationId: selection.delegationId,
61
62
  generation: selection.generation,
62
- bindingId: hostBinding.bindingId,
63
- bindingGeneration: hostBinding.generation,
64
- definition: HostMcpBindingDefinition.parse({
63
+ configured: structuredClone({
65
64
  serverId: selection.serverId,
66
65
  destinationUrl: server.url,
67
- connectionRef,
66
+ connectionRef: server.connectionRef,
68
67
  }),
69
68
  };
70
69
  });
71
70
  return async (tx, task) => {
72
71
  const owner = await reauthorize(tx);
73
72
  try {
74
- if (prepared.length) await captureHostMcpTaskAuthorities(tx, owner, task, prepared);
73
+ const selected = [];
74
+ for (const selection of prepared) {
75
+ const delegation = await getHostMcpDelegation(tx, owner, selection.delegationId);
76
+ const binding = delegation
77
+ ? await getHostMcpBinding(tx, owner, delegation.bindingId)
78
+ : null;
79
+ if (!binding || !hostMcpBindingMatchesSelection(binding, selection.configured))
80
+ throw new HostMcpDelegationAuthorityError("Host task selection changed");
81
+ selected.push({
82
+ delegationId: selection.delegationId,
83
+ generation: selection.generation,
84
+ bindingId: binding.id,
85
+ bindingGeneration: binding.generation,
86
+ definition: binding.definition,
87
+ });
88
+ }
89
+ if (selected.length) await captureHostMcpTaskAuthorities(tx, owner, task, selected);
75
90
  } catch (error) {
76
91
  if (error instanceof HostMcpDelegationAuthorityError)
77
92
  throw new HTTPException(403, { message: error.message });
@@ -94,17 +109,12 @@ export function prepareInheritedHostMcpTaskAdmission(
94
109
  )
95
110
  return [];
96
111
  assertHostMcpAuthoritySourceAdmissionEnabled(settings, server.connectionRef);
97
- const { hostBinding, ...connectionRef } = server.connectionRef;
98
112
  return [
99
- {
100
- bindingId: hostBinding.bindingId,
101
- bindingGeneration: hostBinding.generation,
102
- definition: HostMcpBindingDefinition.parse({
103
- serverId: server.id,
104
- destinationUrl: server.url,
105
- connectionRef,
106
- }),
107
- },
113
+ structuredClone({
114
+ serverId: server.id,
115
+ destinationUrl: server.url,
116
+ connectionRef: server.connectionRef,
117
+ }),
108
118
  ];
109
119
  });
110
120
  return (tx, task) => inheritHostMcpTaskAuthoritiesFromAttempt(tx, task, source, configured);
@@ -0,0 +1,39 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ completeKnowledgeFilePreparation,
4
+ inspectKnowledgeFilePreparation,
5
+ type KnowledgeContext,
6
+ } from "@opengeni/db";
7
+ import { retryWhileMissing } from "@opengeni/storage";
8
+ import type { ApiRouteDeps } from "../dependencies";
9
+
10
+ /** Existing original -> one source revision, through the ordinary task policy. */
11
+ export async function prepareKnowledgeFile(
12
+ deps: Pick<ApiRouteDeps, "db" | "objectStorage" | "getDocumentServices">,
13
+ context: KnowledgeContext,
14
+ fileId: string,
15
+ ) {
16
+ const inspected = await inspectKnowledgeFilePreparation(deps.db, context, fileId);
17
+ if (inspected.status !== "prepare") return inspected;
18
+ if (!deps.objectStorage) throw new Error("File storage is unavailable");
19
+ const object = await retryWhileMissing(() =>
20
+ deps.objectStorage!.getObjectBytes(inspected.file.objectKey),
21
+ );
22
+ if (!object) throw new Error("The original file is unavailable");
23
+ const sourceVersion = createHash("sha256").update(object.bytes).digest("hex");
24
+ if (
25
+ object.bytes.byteLength !== inspected.file.sizeBytes ||
26
+ (inspected.file.sha256 && inspected.file.sha256.toLowerCase() !== sourceVersion)
27
+ ) {
28
+ throw new Error("The original file no longer matches its retained metadata");
29
+ }
30
+ const parsed = await deps.getDocumentServices().parser.parse(object.bytes, inspected.file);
31
+ // Empty extraction is a visible failure, not a searchable-ready receipt.
32
+ if (!parsed.text.trim()) throw new Error("No searchable text could be extracted from this file");
33
+ return completeKnowledgeFilePreparation(deps.db, context, {
34
+ fileId,
35
+ title: inspected.file.filename,
36
+ sourceVersion,
37
+ content: parsed.text,
38
+ });
39
+ }
@@ -0,0 +1,91 @@
1
+ import { afterAll, beforeEach, expect, mock, test } from "bun:test";
2
+ const actor = {
3
+ kind: "agent",
4
+ sessionId: "session-a",
5
+ turnId: "turn-a",
6
+ attemptId: "attempt-a",
7
+ executionGeneration: 1,
8
+ } as const;
9
+ const context = { accountId: "account", workspaceId: "workspace", actor };
10
+ let event: any;
11
+ let existing: any;
12
+ const policy = mock(async () => ({ defaultScope: "personal", subjectId: "user-a" }));
13
+ const save = mock(async (_db: any, _context: any, request: any) => ({
14
+ entryId: request.entryId,
15
+ revisionId: "revision",
16
+ outcome: "pending",
17
+ }));
18
+ mock.module("@opengeni/db", () => ({
19
+ freezeAgentLearningPolicy: policy,
20
+ nestedPostgresSqlState: (error: any) => error.code,
21
+ getSessionTurn: async () => ({ sessionId: "session-a", triggerEventId: "message-a" }),
22
+ getSessionEvent: async () => event,
23
+ getKnowledgeEntry: async () => existing,
24
+ saveKnowledgeEntry: save,
25
+ }));
26
+ const { retainKnowledgeMessage } = await import("./knowledge-messages");
27
+ afterAll(() => mock.restore());
28
+ beforeEach(() => {
29
+ event = {
30
+ id: "message-a",
31
+ sessionId: "session-a",
32
+ type: "user.message",
33
+ payload: { text: "I confirm it is 21k.\nKeep this exact." },
34
+ occurredAt: "2026-09-11T12:00:00Z",
35
+ };
36
+ existing = null;
37
+ save.mockClear();
38
+ });
39
+ test("retains exact text with message identity and preserves the review receipt", async () => {
40
+ const result = await retainKnowledgeMessage({} as never, context);
41
+ expect(result).toMatchObject({ retained: true, outcome: "pending", messageId: "message-a" });
42
+ expect(save.mock.calls[0]?.[2].entry).toMatchObject({
43
+ content: event.payload.text,
44
+ source: {
45
+ kind: "conversation",
46
+ sessionId: "session-a",
47
+ externalId: "message-a",
48
+ capturedAt: event.occurredAt,
49
+ },
50
+ });
51
+ expect(save.mock.calls[0]?.[2].scope).toBeUndefined();
52
+ });
53
+ test("refuses foreign-session and non-user events without writing", async () => {
54
+ event.sessionId = "private-foreign";
55
+ expect(await retainKnowledgeMessage({} as never, context, "foreign-event")).toMatchObject({
56
+ retained: false,
57
+ });
58
+ event.sessionId = "session-a";
59
+ event.type = "assistant.message";
60
+ expect(await retainKnowledgeMessage({} as never, context)).toMatchObject({ retained: false });
61
+ expect(save).not.toHaveBeenCalled();
62
+ });
63
+ test("reuses a retained message without creating another review request", async () => {
64
+ existing = {
65
+ archived: false,
66
+ revision: {
67
+ id: "already-retained",
68
+ outcome: "pending",
69
+ entry: { content: event.payload.text },
70
+ },
71
+ };
72
+ expect(await retainKnowledgeMessage({} as never, context)).toMatchObject({
73
+ retained: true,
74
+ reused: true,
75
+ revisionId: "already-retained",
76
+ outcome: "pending",
77
+ });
78
+ expect(save).not.toHaveBeenCalled();
79
+ });
80
+ test("never revives rejected, archived, or edited retained content", async () => {
81
+ for (const revision of [
82
+ { outcome: "rejected", content: event.payload.text },
83
+ { outcome: "published", content: "edited" },
84
+ ]) {
85
+ existing = { archived: false, revision: { ...revision, entry: { content: revision.content } } };
86
+ expect(await retainKnowledgeMessage({} as never, context)).toMatchObject({ retained: false });
87
+ }
88
+ existing.archived = true;
89
+ expect(await retainKnowledgeMessage({} as never, context)).toMatchObject({ retained: false });
90
+ expect(save).not.toHaveBeenCalled();
91
+ });
@@ -0,0 +1,105 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ freezeAgentLearningPolicy,
4
+ getKnowledgeEntry,
5
+ getSessionEvent,
6
+ getSessionTurn,
7
+ nestedPostgresSqlState,
8
+ saveKnowledgeEntry,
9
+ type Database,
10
+ type KnowledgeContext,
11
+ } from "@opengeni/db";
12
+
13
+ function stableId(value: string) {
14
+ const hex = createHash("sha256").update(value).digest("hex");
15
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
16
+ }
17
+
18
+ /** Retain actual message bytes, never a model-authored reconstruction of a confirmation. */
19
+ export async function retainKnowledgeMessage(
20
+ db: Database,
21
+ context: KnowledgeContext,
22
+ messageId?: string,
23
+ ) {
24
+ if (context.actor.kind !== "agent") throw new Error("An exact agent attempt is required");
25
+ // Validates the live attempt and resolves its frozen personal/shared authoring scope.
26
+ const policy = await freezeAgentLearningPolicy(db, context);
27
+ const turn = await getSessionTurn(db, context.workspaceId, context.actor.turnId);
28
+ if (!turn || turn.sessionId !== context.actor.sessionId) throw new Error("Message unavailable");
29
+ const eventId = messageId ?? turn.triggerEventId;
30
+ const event = eventId ? await getSessionEvent(db, context.workspaceId, eventId) : null;
31
+ const payload = event?.payload;
32
+ const text =
33
+ payload && typeof payload === "object" && "text" in payload ? payload.text : undefined;
34
+ if (
35
+ !event ||
36
+ event.sessionId !== context.actor.sessionId ||
37
+ event.type !== "user.message" ||
38
+ typeof text !== "string" ||
39
+ !text.trim()
40
+ ) {
41
+ return {
42
+ retained: false,
43
+ reason:
44
+ "Choose a user message from this conversation. The current turn may have been triggered by a tool or scheduled event.",
45
+ };
46
+ }
47
+ const key = `${context.accountId}:${context.workspaceId}:${policy.defaultScope}:${policy.subjectId ?? ""}:${event.id}`;
48
+ const entryId = stableId(`knowledge-message:${key}`);
49
+ for (const view of ["published", "needs_review"] as const) {
50
+ const existing = await getKnowledgeEntry(db, context, entryId, { view });
51
+ if (!existing) continue;
52
+ if (
53
+ existing.archived ||
54
+ existing.revision.outcome === "rejected" ||
55
+ existing.revision.entry.content !== text
56
+ ) {
57
+ return {
58
+ retained: false,
59
+ reason:
60
+ "This retained message was edited, archived or rejected. Inspect its history; it has not been overwritten or revived.",
61
+ entryId,
62
+ };
63
+ }
64
+ return {
65
+ retained: true,
66
+ entryId,
67
+ revisionId: existing.revision.id,
68
+ outcome: existing.revision.outcome,
69
+ messageId: event.id,
70
+ reused: true,
71
+ };
72
+ }
73
+ let receipt;
74
+ try {
75
+ receipt = await saveKnowledgeEntry(db, context, {
76
+ operationId: crypto.randomUUID(),
77
+ entryId,
78
+ expectedVersion: 0,
79
+ entry: {
80
+ kind: "source",
81
+ title: `Chat: ${text.replace(/\s+/g, " ").slice(0, 100)}`,
82
+ content: text,
83
+ source: {
84
+ kind: "conversation",
85
+ sessionId: event.sessionId,
86
+ externalId: event.id,
87
+ capturedAt: event.occurredAt,
88
+ retention: "full_text",
89
+ },
90
+ },
91
+ });
92
+ } catch (error) {
93
+ // An inaccessible archived/rejected source or a concurrent creation must not
94
+ // be overwritten. The stable entry ID makes retries converge without a new copy.
95
+ if (nestedPostgresSqlState(error) === "40001")
96
+ return {
97
+ retained: false,
98
+ entryId,
99
+ reason:
100
+ "This message already has a retained source or changed concurrently. Inspect available Knowledge before retrying; no source was overwritten or revived.",
101
+ };
102
+ throw error;
103
+ }
104
+ return { retained: true, ...receipt, messageId: event.id };
105
+ }
@@ -0,0 +1,45 @@
1
+ import { afterAll, expect, mock, test } from "bun:test";
2
+ const list = mock(
3
+ async (_db: unknown, _context: unknown, _request: unknown, _embedding?: unknown) => ({
4
+ entries: [],
5
+ nextCursor: null,
6
+ }),
7
+ );
8
+ mock.module("@opengeni/db", () => ({ listKnowledgeEntries: list }));
9
+ const { searchKnowledgeEntries } = await import("./knowledge-search");
10
+ afterAll(() => mock.restore());
11
+ const unavailable = () => {
12
+ throw new Error("embedding unavailable");
13
+ };
14
+ test("hybrid fallback keeps lexical recall and passes the exact scope and cursor", async () => {
15
+ const result = await searchKnowledgeEntries(
16
+ {} as never,
17
+ {} as never,
18
+ {
19
+ query: "Acme renewal renew contract expiration renewal date",
20
+ scope: "workspace",
21
+ cursor: "page-2",
22
+ },
23
+ unavailable,
24
+ );
25
+ expect(result.searchMode).toBe("keyword");
26
+ expect(list.mock.calls.at(-1)?.[2]).toMatchObject({
27
+ query: '"Acme" OR "renewal" OR "renew" OR "contract" OR "expiration" OR "date"',
28
+ mode: "keyword",
29
+ scope: "workspace",
30
+ cursor: "page-2",
31
+ });
32
+ });
33
+ test("keyword and explicit search syntax remain unchanged", async () => {
34
+ for (const query of ['"Acme renewal"', "Acme -expired", "Acme OR renewal"]) {
35
+ await searchKnowledgeEntries({} as never, {} as never, { query }, unavailable);
36
+ expect(list.mock.calls.at(-1)?.[2]).toMatchObject({ query });
37
+ }
38
+ await searchKnowledgeEntries(
39
+ {} as never,
40
+ {} as never,
41
+ { query: "Acme renewal", mode: "keyword" },
42
+ unavailable,
43
+ );
44
+ expect(list.mock.calls.at(-1)?.[2]).toMatchObject({ query: "Acme renewal", mode: "keyword" });
45
+ });
@@ -0,0 +1,55 @@
1
+ import { KnowledgeEntryListRequest } from "@opengeni/contracts";
2
+ import { listKnowledgeEntries, type Database, type KnowledgeContext } from "@opengeni/db";
3
+ import type { DocumentEmbedder } from "@opengeni/documents";
4
+ import type { z } from "zod";
5
+
6
+ /** Shared search path for HTTP and both first-party retrieval servers. */
7
+ export async function searchKnowledgeEntries(
8
+ db: Database,
9
+ context: KnowledgeContext,
10
+ input: z.input<typeof KnowledgeEntryListRequest>,
11
+ embedder: () => DocumentEmbedder,
12
+ ) {
13
+ const request = KnowledgeEntryListRequest.parse(input);
14
+ if (!request.query || request.mode === "keyword") {
15
+ return {
16
+ ...(await listKnowledgeEntries(db, context, request)),
17
+ searchMode: "keyword" as const,
18
+ };
19
+ }
20
+ // Hybrid queries are natural language, not a conjunction of every synonym.
21
+ // Keep lexical recall useful when embeddings or vector indexing are unavailable.
22
+ // Explicit keyword syntax and keyword-mode queries retain their exact semantics.
23
+ const lexicalQuery =
24
+ /^[\p{L}\p{N}\s]+$/u.test(request.query) && !/\bOR\b/i.test(request.query)
25
+ ? [...new Set(request.query.trim().split(/\s+/u))].map((term) => `"${term}"`).join(" OR ")
26
+ : request.query;
27
+ const lexicalRequest = KnowledgeEntryListRequest.safeParse({ ...request, query: lexicalQuery });
28
+ const retrievalRequest =
29
+ request.mode === "hybrid" && lexicalRequest.success ? lexicalRequest.data : request;
30
+ let embedding: { model: string; values: number[] };
31
+ try {
32
+ const provider = embedder();
33
+ const values = await provider.embedQuery(request.query);
34
+ if (
35
+ values.length !== provider.dimensions ||
36
+ values.some((value) => !Number.isFinite(value)) ||
37
+ !values.some((value) => value !== 0)
38
+ ) {
39
+ throw new Error("Knowledge query embedding is unavailable");
40
+ }
41
+ embedding = { model: provider.model, values };
42
+ } catch (error) {
43
+ // Exact vector requests cannot silently change meaning. Hybrid search can
44
+ // continue using the transactional keyword index while a provider is down.
45
+ if (request.mode === "vector") throw error;
46
+ return {
47
+ ...(await listKnowledgeEntries(db, context, { ...retrievalRequest, mode: "keyword" })),
48
+ searchMode: "keyword" as const,
49
+ };
50
+ }
51
+ return {
52
+ ...(await listKnowledgeEntries(db, context, retrievalRequest, embedding)),
53
+ searchMode: request.mode,
54
+ };
55
+ }
@@ -0,0 +1,133 @@
1
+ import type { AccessGrant, KnowledgeEntryScope, Permission } from "@opengeni/contracts";
2
+ import type { KnowledgeContext } from "@opengeni/db";
3
+ import { hasPermission, type AccessGrantAuthorization } from "../access";
4
+ import { requireLiveAgentAttemptAuthorization } from "../session-authorization";
5
+ import type { ApiRouteDeps } from "../dependencies";
6
+ import { HTTPException } from "hono/http-exception";
7
+
8
+ /** A delegated MCP gateway can retrieve shared Knowledge. Its subject label
9
+ * does not establish personal ownership or human review authority. */
10
+ export async function knowledgeContextForGateway(
11
+ deps: Pick<ApiRouteDeps, "db">,
12
+ grant: AccessGrant,
13
+ ): Promise<KnowledgeContext> {
14
+ if (!hasPermission(grant.permissions, "documents:search"))
15
+ throw new HTTPException(403, { message: "Missing permission: documents:search" });
16
+ if (grant.principalKind === "agent_attempt") {
17
+ const sessionId = grant.metadata?.sessionId;
18
+ if (typeof sessionId !== "string")
19
+ throw new HTTPException(403, { message: "Knowledge attempt unavailable" });
20
+ const attempt = await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId);
21
+ return {
22
+ accountId: grant.accountId,
23
+ workspaceId: grant.workspaceId,
24
+ actor: {
25
+ kind: "agent",
26
+ sessionId: attempt.callerSessionId,
27
+ turnId: attempt.turnId,
28
+ attemptId: attempt.attemptId,
29
+ executionGeneration: attempt.executionGeneration,
30
+ },
31
+ };
32
+ }
33
+ return {
34
+ accountId: grant.accountId,
35
+ workspaceId: grant.workspaceId,
36
+ actor: {
37
+ kind: "service",
38
+ principalKind: "mcp_gateway",
39
+ subjectId: grant.subjectId,
40
+ writeScopes: [],
41
+ review: false,
42
+ settingsScopes: [],
43
+ },
44
+ };
45
+ }
46
+
47
+ /** One host boundary for HTTP, SDK-through-HTTP and first-party Knowledge tools. */
48
+ export async function knowledgeContextForAccess(
49
+ deps: Pick<ApiRouteDeps, "db">,
50
+ access: AccessGrantAuthorization,
51
+ permission: Permission,
52
+ ): Promise<KnowledgeContext> {
53
+ const { grant } = access;
54
+ if (!hasPermission(grant.permissions, permission)) {
55
+ throw new HTTPException(403, { message: `Missing permission: ${permission}` });
56
+ }
57
+ if (grant.principalKind === "agent_attempt") {
58
+ const sessionId = grant.metadata?.sessionId;
59
+ if (typeof sessionId !== "string")
60
+ throw new HTTPException(403, { message: "Knowledge attempt unavailable" });
61
+ const attempt = await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId);
62
+ return {
63
+ accountId: grant.accountId,
64
+ workspaceId: grant.workspaceId,
65
+ actor: {
66
+ kind: "agent",
67
+ sessionId: attempt.callerSessionId,
68
+ turnId: attempt.turnId,
69
+ attemptId: attempt.attemptId,
70
+ executionGeneration: attempt.executionGeneration,
71
+ },
72
+ };
73
+ }
74
+ if (
75
+ grant.principalKind === "service" ||
76
+ grant.principalKind === "api_key" ||
77
+ grant.principalKind === "configured_key"
78
+ ) {
79
+ const writeScopes: Array<"workspace" | "organization"> = hasPermission(
80
+ grant.permissions,
81
+ "documents:manage",
82
+ )
83
+ ? ["workspace"]
84
+ : [];
85
+ if (writeScopes.length && access.accountGrant?.permissions.includes("account:admin"))
86
+ writeScopes.push("organization");
87
+ return {
88
+ accountId: grant.accountId,
89
+ workspaceId: grant.workspaceId,
90
+ actor: {
91
+ kind: "service",
92
+ principalKind: grant.principalKind,
93
+ subjectId: grant.subjectId,
94
+ writeScopes,
95
+ review: false,
96
+ settingsScopes: [],
97
+ },
98
+ };
99
+ }
100
+ if (
101
+ grant.principalKind !== "human_session" ||
102
+ !access.contextIntegrity ||
103
+ access.authenticatedSubjectId !== grant.subjectId ||
104
+ grant.serviceInitiator ||
105
+ grant.serviceInitiatorContext ||
106
+ grant.metadata?.attemptId !== undefined ||
107
+ grant.metadata?.turnId !== undefined
108
+ ) {
109
+ throw new HTTPException(403, {
110
+ message: "Knowledge management requires an authenticated human or agent attempt",
111
+ });
112
+ }
113
+ const canWrite = hasPermission(grant.permissions, "documents:manage");
114
+ const writeScopes: KnowledgeEntryScope[] = canWrite ? ["workspace", "personal"] : [];
115
+ // Organization authority does not follow from the workspace-admin wildcard.
116
+ if (canWrite && access.accountGrant?.permissions.includes("account:admin"))
117
+ writeScopes.push("organization");
118
+ return {
119
+ accountId: grant.accountId,
120
+ workspaceId: grant.workspaceId,
121
+ actor: {
122
+ kind: "human",
123
+ principalKind: "human_session",
124
+ subjectId: access.authenticatedSubjectId,
125
+ writeScopes,
126
+ review: canWrite,
127
+ settingsScopes: [
128
+ "personal",
129
+ ...(hasPermission(grant.permissions, "workspace:admin") ? ["workspace" as const] : []),
130
+ ],
131
+ },
132
+ };
133
+ }