@checkstack/ai-backend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/drizzle/0000_productive_jackpot.sql +26 -0
  3. package/drizzle/0001_puzzling_purple_man.sql +26 -0
  4. package/drizzle/0002_sparkling_paper_doll.sql +15 -0
  5. package/drizzle/0003_married_senator_kelly.sql +1 -0
  6. package/drizzle/0004_crazy_miek.sql +2 -0
  7. package/drizzle/0005_tearful_randall_flagg.sql +1 -0
  8. package/drizzle/meta/0000_snapshot.json +232 -0
  9. package/drizzle/meta/0001_snapshot.json +434 -0
  10. package/drizzle/meta/0002_snapshot.json +551 -0
  11. package/drizzle/meta/0003_snapshot.json +557 -0
  12. package/drizzle/meta/0004_snapshot.json +573 -0
  13. package/drizzle/meta/0005_snapshot.json +574 -0
  14. package/drizzle/meta/_journal.json +48 -0
  15. package/drizzle.config.ts +7 -0
  16. package/package.json +42 -0
  17. package/src/agent-runner.test.ts +262 -0
  18. package/src/agent-runner.ts +262 -0
  19. package/src/chat/agent-loop.test.ts +119 -0
  20. package/src/chat/agent-loop.ts +73 -0
  21. package/src/chat/auto-apply.test.ts +237 -0
  22. package/src/chat/chat-handler.ts +111 -0
  23. package/src/chat/chat-service.streamturn.test.ts +417 -0
  24. package/src/chat/chat-service.test.ts +250 -0
  25. package/src/chat/chat-service.ts +923 -0
  26. package/src/chat/classifier-service.ts +64 -0
  27. package/src/chat/classifier.logic.test.ts +92 -0
  28. package/src/chat/classifier.logic.ts +71 -0
  29. package/src/chat/conversation-store.it.test.ts +203 -0
  30. package/src/chat/conversation-store.test.ts +248 -0
  31. package/src/chat/conversation-store.ts +237 -0
  32. package/src/chat/decision.logic.test.ts +45 -0
  33. package/src/chat/decision.logic.ts +54 -0
  34. package/src/chat/llm-provider.test.ts +63 -0
  35. package/src/chat/llm-provider.ts +67 -0
  36. package/src/chat/model-error.logic.test.ts +60 -0
  37. package/src/chat/model-error.logic.ts +65 -0
  38. package/src/chat/normalize-messages.logic.test.ts +101 -0
  39. package/src/chat/normalize-messages.logic.ts +65 -0
  40. package/src/chat/permission-mode.logic.test.ts +70 -0
  41. package/src/chat/permission-mode.logic.ts +45 -0
  42. package/src/chat/read-invoker.ts +72 -0
  43. package/src/chat/replay.test.ts +174 -0
  44. package/src/chat/scrub-content.test.ts +183 -0
  45. package/src/chat/scrub-content.ts +154 -0
  46. package/src/chat/sdk-tools.test.ts +168 -0
  47. package/src/chat/sdk-tools.ts +181 -0
  48. package/src/chat/title-service.test.ts +146 -0
  49. package/src/chat/title-service.ts +111 -0
  50. package/src/chat/title.logic.test.ts +98 -0
  51. package/src/chat/title.logic.ts +102 -0
  52. package/src/extension-points.ts +41 -0
  53. package/src/generated/docs-index.ts +3020 -0
  54. package/src/hardening/handler-authz.test.ts +282 -0
  55. package/src/hardening/no-secret-leak.test.ts +303 -0
  56. package/src/hooks.ts +33 -0
  57. package/src/index.ts +542 -0
  58. package/src/mcp/connection-registry.test.ts +25 -0
  59. package/src/mcp/connection-registry.ts +54 -0
  60. package/src/mcp/mcp-conformance.it.test.ts +128 -0
  61. package/src/mcp/server.test.ts +285 -0
  62. package/src/mcp/server.ts +300 -0
  63. package/src/mcp/tool-invoker.ts +65 -0
  64. package/src/openai-provider.test.ts +64 -0
  65. package/src/openai-provider.ts +146 -0
  66. package/src/projection.test.ts +97 -0
  67. package/src/projection.ts +132 -0
  68. package/src/propose-apply/args-hash.test.ts +26 -0
  69. package/src/propose-apply/args-hash.ts +30 -0
  70. package/src/propose-apply/service.test.ts +423 -0
  71. package/src/propose-apply/service.ts +419 -0
  72. package/src/propose-apply/store.test.ts +136 -0
  73. package/src/propose-apply/store.ts +224 -0
  74. package/src/propose-apply/token.test.ts +52 -0
  75. package/src/propose-apply/token.ts +71 -0
  76. package/src/rate-limit/spend-ledger.it.test.ts +224 -0
  77. package/src/rate-limit/spend-ledger.test.ts +176 -0
  78. package/src/rate-limit/spend-ledger.ts +162 -0
  79. package/src/rate-limit/tool-budget.it.test.ts +173 -0
  80. package/src/rate-limit/tool-budget.test.ts +58 -0
  81. package/src/rate-limit/tool-budget.ts +107 -0
  82. package/src/registry-wiring.test.ts +131 -0
  83. package/src/registry-wiring.ts +68 -0
  84. package/src/resolver.test.ts +156 -0
  85. package/src/resolver.ts +78 -0
  86. package/src/router.test.ts +78 -0
  87. package/src/router.ts +345 -0
  88. package/src/schema.ts +284 -0
  89. package/src/serializer.test.ts +88 -0
  90. package/src/serializer.ts +42 -0
  91. package/src/tool-registry.ts +58 -0
  92. package/src/tools/composite-tools.ts +24 -0
  93. package/src/tools/docs-tools.test.ts +150 -0
  94. package/src/tools/docs-tools.ts +115 -0
  95. package/src/tools/probe-url.test.ts +51 -0
  96. package/src/tools/probe-url.ts +146 -0
  97. package/src/tools/rank-docs.test.ts +153 -0
  98. package/src/tools/rank-docs.ts +209 -0
  99. package/src/tools/script-context-extract.test.ts +93 -0
  100. package/src/tools/script-context-extract.ts +283 -0
  101. package/src/tools/ssrf-guard.test.ts +69 -0
  102. package/src/tools/ssrf-guard.ts +108 -0
  103. package/src/tools/tool-set.e2e.test.ts +64 -0
  104. package/src/user-rpc-client.test.ts +45 -0
  105. package/src/user-rpc-client.ts +60 -0
  106. package/tsconfig.json +26 -0
package/src/router.ts ADDED
@@ -0,0 +1,345 @@
1
+ import { implement, ORPCError } from "@orpc/server";
2
+ import {
3
+ autoAuthMiddleware,
4
+ correlationMiddleware,
5
+ type RpcContext,
6
+ } from "@checkstack/backend-api";
7
+ import { aiContract } from "@checkstack/ai-common";
8
+ import type { AiToolResolver } from "./resolver";
9
+ import { serializeTools } from "./serializer";
10
+ import {
11
+ ProposeApplyError,
12
+ type ProposeApplyService,
13
+ type ProposeApplyErrorCode,
14
+ } from "./propose-apply/service";
15
+ import {
16
+ createUserScopedRpcClient,
17
+ forwardableAuthHeadersFrom,
18
+ } from "./user-rpc-client";
19
+
20
+ import type { AiConversationStore } from "./chat/conversation-store";
21
+ import type {
22
+ AiConversationRow,
23
+ AiMessageRow,
24
+ } from "./schema";
25
+
26
+ /** A selectable AI integration's non-secret model UX metadata. */
27
+ export interface ChatIntegrationInfo {
28
+ connectionId: string;
29
+ name: string;
30
+ defaultModel: string;
31
+ availableModels?: string[];
32
+ }
33
+
34
+ /** Lists selectable AI integrations (non-secret model UX metadata) for chat. */
35
+ export interface ChatIntegrationLister {
36
+ list(): Promise<ChatIntegrationInfo[]>;
37
+ }
38
+
39
+ /**
40
+ * Coerce a caller-supplied model id against an integration's `availableModels`
41
+ * allowlist (untrusted wire input). Returns the requested model when allowed,
42
+ * otherwise the connection's `defaultModel`. Returns `undefined` only when the
43
+ * integration cannot be resolved (no model can be validated). Uses only the
44
+ * non-secret model metadata, never the credential.
45
+ */
46
+ export async function coerceConversationModel({
47
+ integrations,
48
+ integrationId,
49
+ model,
50
+ }: {
51
+ integrations: ChatIntegrationLister;
52
+ integrationId: string | undefined;
53
+ model: string | undefined;
54
+ }): Promise<string | undefined> {
55
+ if (model === undefined) return undefined;
56
+ if (!integrationId) {
57
+ // No integration to validate against: drop the unvalidated model id rather
58
+ // than persist an arbitrary one.
59
+ return undefined;
60
+ }
61
+ const list = await integrations.list();
62
+ const info = list.find((i) => i.connectionId === integrationId);
63
+ if (!info) return undefined;
64
+ const allow = info.availableModels;
65
+ if (allow && allow.length > 0 && !allow.includes(model)) {
66
+ return info.defaultModel;
67
+ }
68
+ return model;
69
+ }
70
+
71
+ interface AiRouterDeps {
72
+ resolver: AiToolResolver;
73
+ proposeApply: ProposeApplyService;
74
+ conversations: AiConversationStore;
75
+ integrations: ChatIntegrationLister;
76
+ /** Loopback base URL for the user-scoped RPC client (re-enters `/api`). */
77
+ internalUrl: string;
78
+ }
79
+
80
+ /** Serialize a conversation row for the wire (no secret fields exist on it). */
81
+ function toConversationDto(row: AiConversationRow) {
82
+ return {
83
+ id: row.id,
84
+ title: row.title,
85
+ integrationId: row.integrationId,
86
+ model: row.model,
87
+ permissionMode: row.permissionMode,
88
+ createdAt: row.createdAt,
89
+ updatedAt: row.updatedAt,
90
+ };
91
+ }
92
+
93
+ function toMessageDto(row: AiMessageRow) {
94
+ return {
95
+ id: row.id,
96
+ conversationId: row.conversationId,
97
+ role: row.role,
98
+ content: row.content,
99
+ toolCalls: row.toolCalls ?? null,
100
+ createdAt: row.createdAt,
101
+ };
102
+ }
103
+
104
+ /** Map a propose/apply error to the closest oRPC error code. */
105
+ function toOrpcError(error: ProposeApplyError): ORPCError<string, unknown> {
106
+ const mapping: Record<ProposeApplyErrorCode, string> = {
107
+ forbidden: "FORBIDDEN",
108
+ not_found: "NOT_FOUND",
109
+ not_proposable: "BAD_REQUEST",
110
+ invalid_token: "BAD_REQUEST",
111
+ expired: "CONFLICT", // 409 — token no longer valid (TTL elapsed)
112
+ consumed: "CONFLICT", // 409 — single-use violated
113
+ execute_failed: "BAD_REQUEST",
114
+ };
115
+ return new ORPCError(mapping[error.code], { message: error.message });
116
+ }
117
+
118
+ /**
119
+ * AI router (Phase 1 read-only introspection + Phase 3 propose/apply).
120
+ *
121
+ * `autoAuthMiddleware` enforces the contract's access gates, so the router is
122
+ * the single enforcement point even though the resolver / propose-apply service
123
+ * also re-check per-tool authorization. The model is treated as an untrusted
124
+ * caller throughout.
125
+ */
126
+ export function createAiRouter({
127
+ resolver,
128
+ proposeApply,
129
+ conversations,
130
+ integrations,
131
+ internalUrl,
132
+ }: AiRouterDeps) {
133
+ const os = implement(aiContract)
134
+ .$context<RpcContext>()
135
+ .use(correlationMiddleware)
136
+ .use(autoAuthMiddleware);
137
+
138
+ return os.router({
139
+ listTools: os.listTools.handler(async ({ context }) => {
140
+ const principal = context.user;
141
+ if (!principal) {
142
+ return { tools: [] };
143
+ }
144
+ const tools = resolver.resolveTools(principal);
145
+ return { tools: serializeTools({ tools }) };
146
+ }),
147
+
148
+ proposeTool: os.proposeTool.handler(async ({ context, input }) => {
149
+ const principal = context.user;
150
+ if (!principal) {
151
+ throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
152
+ }
153
+ // USER-SCOPED RPC client: the tool's dry-run re-enters `/api` as THIS
154
+ // user (forwarding the request's own cookie/bearer), so handler authz +
155
+ // per-resource/team scope apply - never the trusted service client.
156
+ const rpcClient = createUserScopedRpcClient({
157
+ internalUrl,
158
+ forwardHeaders: forwardableAuthHeadersFrom(context.requestHeaders),
159
+ });
160
+ try {
161
+ const proposal = await proposeApply.propose({
162
+ principal,
163
+ toolName: input.toolName,
164
+ input: input.input,
165
+ transport: "chat",
166
+ rpcClient,
167
+ });
168
+ return {
169
+ token: proposal.token,
170
+ summary: proposal.summary,
171
+ payload: proposal.payload,
172
+ toolCallId: proposal.toolCallId,
173
+ expiresAt: proposal.expiresAt,
174
+ };
175
+ } catch (error) {
176
+ if (error instanceof ProposeApplyError) throw toOrpcError(error);
177
+ throw error;
178
+ }
179
+ }),
180
+
181
+ applyTool: os.applyTool.handler(async ({ context, input }) => {
182
+ const principal = context.user;
183
+ if (!principal) {
184
+ throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
185
+ }
186
+ // USER-SCOPED RPC client (see proposeTool): the tool's commit runs as
187
+ // THIS user, so handler authz + per-resource/team scope apply at apply
188
+ // time exactly as a direct UI/RPC call would.
189
+ const rpcClient = createUserScopedRpcClient({
190
+ internalUrl,
191
+ forwardHeaders: forwardableAuthHeadersFrom(context.requestHeaders),
192
+ });
193
+ try {
194
+ const applied = await proposeApply.apply({
195
+ principal,
196
+ token: input.token,
197
+ transport: "chat",
198
+ rpcClient,
199
+ });
200
+ return { toolCallId: applied.toolCallId, result: applied.result };
201
+ } catch (error) {
202
+ if (error instanceof ProposeApplyError) throw toOrpcError(error);
203
+ throw error;
204
+ }
205
+ }),
206
+
207
+ listChatIntegrations: os.listChatIntegrations.handler(
208
+ async ({ context }) => {
209
+ const principal = context.user;
210
+ if (!principal) {
211
+ throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
212
+ }
213
+ const integrationList = await integrations.list();
214
+ return { integrations: integrationList };
215
+ },
216
+ ),
217
+
218
+ listConversations: os.listConversations.handler(async ({ context }) => {
219
+ const principal = context.user;
220
+ if (!principal || principal.type !== "user") {
221
+ throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
222
+ }
223
+ const rows = await conversations.listConversations({
224
+ userId: principal.id,
225
+ });
226
+ return { conversations: rows.map((r) => toConversationDto(r)) };
227
+ }),
228
+
229
+ createConversation: os.createConversation.handler(
230
+ async ({ context, input }) => {
231
+ const principal = context.user;
232
+ if (!principal || principal.type !== "user") {
233
+ throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
234
+ }
235
+ // The model id is untrusted wire input — coerce it against the chosen
236
+ // integration's allowlist before persisting (defeats model/cost-control
237
+ // bypass via a hand-crafted create call).
238
+ const model = await coerceConversationModel({
239
+ integrations,
240
+ integrationId: input.integrationId,
241
+ model: input.model,
242
+ });
243
+ const row = await conversations.createConversation({
244
+ userId: principal.id,
245
+ title: input.title,
246
+ integrationId: input.integrationId,
247
+ model,
248
+ permissionMode: input.permissionMode,
249
+ });
250
+ return toConversationDto(row);
251
+ },
252
+ ),
253
+
254
+ getConversation: os.getConversation.handler(async ({ context, input }) => {
255
+ const principal = context.user;
256
+ if (!principal || principal.type !== "user") {
257
+ throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
258
+ }
259
+ const conversation = await conversations.getConversation({
260
+ id: input.id,
261
+ userId: principal.id,
262
+ });
263
+ if (!conversation) {
264
+ throw new ORPCError("NOT_FOUND", { message: "Conversation not found" });
265
+ }
266
+ const messages = await conversations.listMessages({
267
+ conversationId: input.id,
268
+ });
269
+ return {
270
+ conversation: toConversationDto(conversation),
271
+ messages: messages.map((m) => toMessageDto(m)),
272
+ };
273
+ }),
274
+
275
+ updateConversation: os.updateConversation.handler(
276
+ async ({ context, input }) => {
277
+ const principal = context.user;
278
+ if (!principal || principal.type !== "user") {
279
+ throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
280
+ }
281
+ // Coerce the (untrusted) model id against the conversation's integration
282
+ // allowlist. Fetch the owned conversation first to read its integration
283
+ // id (also enforces ownership before any write).
284
+ let model = input.model;
285
+ if (model !== undefined) {
286
+ const existing = await conversations.getConversation({
287
+ id: input.id,
288
+ userId: principal.id,
289
+ });
290
+ if (!existing) {
291
+ throw new ORPCError("NOT_FOUND", {
292
+ message: "Conversation not found",
293
+ });
294
+ }
295
+ model = await coerceConversationModel({
296
+ integrations,
297
+ integrationId: existing.integrationId ?? undefined,
298
+ model,
299
+ });
300
+ }
301
+ const row = await conversations.updateConversation({
302
+ id: input.id,
303
+ userId: principal.id,
304
+ title: input.title,
305
+ model,
306
+ permissionMode: input.permissionMode,
307
+ });
308
+ if (!row) {
309
+ throw new ORPCError("NOT_FOUND", {
310
+ message: "Conversation not found",
311
+ });
312
+ }
313
+ return toConversationDto(row);
314
+ },
315
+ ),
316
+
317
+ archiveConversation: os.archiveConversation.handler(
318
+ async ({ context, input }) => {
319
+ const principal = context.user;
320
+ if (!principal || principal.type !== "user") {
321
+ throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
322
+ }
323
+ const archived = await conversations.archiveConversation({
324
+ id: input.id,
325
+ userId: principal.id,
326
+ });
327
+ return { archived };
328
+ },
329
+ ),
330
+
331
+ deleteConversation: os.deleteConversation.handler(
332
+ async ({ context, input }) => {
333
+ const principal = context.user;
334
+ if (!principal || principal.type !== "user") {
335
+ throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
336
+ }
337
+ const deleted = await conversations.deleteConversation({
338
+ id: input.id,
339
+ userId: principal.id,
340
+ });
341
+ return { deleted };
342
+ },
343
+ ),
344
+ });
345
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,284 @@
1
+ import {
2
+ pgTable,
3
+ text,
4
+ jsonb,
5
+ timestamp,
6
+ index,
7
+ integer,
8
+ } from "drizzle-orm/pg-core";
9
+
10
+ /**
11
+ * AI platform — Phase 3 data model (Drizzle).
12
+ *
13
+ * Phase 3 introduces ONE durable table, `ai_tool_calls`, which serves two
14
+ * purposes at once (decision §4, §13.4):
15
+ *
16
+ * 1. An append-only **audit log** for every AI tool invocation across both
17
+ * transports (chat + MCP), regardless of effect.
18
+ * 2. The **propose/apply token store**. A `proposed` row IS the token: the
19
+ * opaque token handed to the caller is `propose:<rowId>.<nonce>`; `apply`
20
+ * looks the row up by id, checks the nonce + TTL + status, then transitions
21
+ * it to `applied` in a single atomic UPDATE (single-use even under
22
+ * concurrent applies). There is no separate ephemeral table.
23
+ *
24
+ * State & scale (.claude/rules/state-and-scale.md): this is a shared-Postgres
25
+ * table, so every pod reads/writes the same audit + token store. A token
26
+ * proposed on pod A is consumable on pod B; an expired token is rejected on any
27
+ * pod. No proposal/audit state is pod-local.
28
+ *
29
+ * Phase 4 adds the chat surface: `ai_conversations` + `ai_messages` (durable,
30
+ * continuable from any pod — state-and-scale §9) and the `ai_message_role` enum.
31
+ * `ai_tool_calls.conversationId` now carries the deferred FK to
32
+ * `ai_conversations` (set null on delete, so audit history outlives a deleted
33
+ * chat).
34
+ */
35
+
36
+ import { pgEnum } from "drizzle-orm/pg-core";
37
+
38
+ /** Which transport drove the tool call. */
39
+ export const aiTransportEnum = pgEnum("ai_transport", [
40
+ "chat",
41
+ "mcp",
42
+ "automation",
43
+ ]);
44
+
45
+ /**
46
+ * Per-conversation permission mode (Phase 4). Durable (not pod-local) so a read
47
+ * returns the same answer on every pod. Governs the `mutate` tool branch only;
48
+ * destructive tools always require a human apply regardless of this value.
49
+ */
50
+ export const aiPermissionModeEnum = pgEnum("ai_permission_mode", [
51
+ "approve",
52
+ "auto",
53
+ ]);
54
+
55
+ /** Effect classification mirrored from the `AiTool` descriptor. */
56
+ export const aiToolEffectEnum = pgEnum("ai_tool_effect", [
57
+ "read",
58
+ "mutate",
59
+ "destructive",
60
+ ]);
61
+
62
+ /** Role of a persisted chat message (AI-SDK roles). */
63
+ export const aiMessageRoleEnum = pgEnum("ai_message_role", [
64
+ "system",
65
+ "user",
66
+ "assistant",
67
+ "tool",
68
+ ]);
69
+
70
+ /** Lifecycle of an `ai_tool_calls` row. */
71
+ export const aiToolCallStatusEnum = pgEnum("ai_tool_call_status", [
72
+ "proposed", // dry-run done, token issued, awaiting apply
73
+ "applied", // apply consumed the proposal token and committed
74
+ "executed", // read tool ran directly (no proposal step)
75
+ "failed", // execute / apply threw
76
+ "expired", // proposal token TTL elapsed before apply
77
+ "rejected", // human declined the confirm card / apply never called
78
+ ]);
79
+
80
+ /**
81
+ * A durable chat conversation, continuable from any pod (state-and-scale §9).
82
+ * Owned by a single RealUser; no FK to the auth plugin's user table
83
+ * (cross-plugin tables are not FK-linked in this codebase) — ownership is
84
+ * enforced at the handler via the session principal.
85
+ */
86
+ export const aiConversations = pgTable(
87
+ "ai_conversations",
88
+ {
89
+ id: text("id")
90
+ .primaryKey()
91
+ .$defaultFn(() => crypto.randomUUID()),
92
+ /** Owning real user id (chat is RealUser-only). */
93
+ userId: text("user_id").notNull(),
94
+ title: text("title"),
95
+ /** Qualified integration connection id used for this conversation. */
96
+ integrationId: text("integration_id"),
97
+ /** Model id selected for this conversation (defaults to the connection's). */
98
+ model: text("model"),
99
+ /**
100
+ * Per-conversation permission mode (Phase 4). Durable + shared-Postgres so a
101
+ * turn handled on any pod reads the SAME mode. Governs the `mutate` tool
102
+ * branch only (`auto` auto-applies a mutate proposal server-side; `approve`
103
+ * surfaces a confirm card). Reads always run and destructive tools always
104
+ * require a human apply, regardless of this column. Safe-by-default `approve`.
105
+ */
106
+ permissionMode: aiPermissionModeEnum("permission_mode")
107
+ .notNull()
108
+ .default("approve"),
109
+ createdAt: timestamp("created_at").defaultNow().notNull(),
110
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
111
+ /**
112
+ * Soft-delete marker (Phase 7). The user-facing "Delete" action ARCHIVES a
113
+ * conversation by stamping this column rather than hard-deleting the row, so
114
+ * the transcript is retained for later abuse introspection. `listConversations`
115
+ * filters `archivedAt IS NULL`, so archived chats disappear from the sidebar
116
+ * but the rows (and their messages) live on. Null = active.
117
+ */
118
+ archivedAt: timestamp("archived_at"),
119
+ },
120
+ (t) => ({
121
+ userIdx: index("ai_conversations_user_idx").on(t.userId, t.updatedAt),
122
+ }),
123
+ );
124
+
125
+ /** Append-only message log for a conversation. */
126
+ export const aiMessages = pgTable(
127
+ "ai_messages",
128
+ {
129
+ id: text("id")
130
+ .primaryKey()
131
+ .$defaultFn(() => crypto.randomUUID()),
132
+ conversationId: text("conversation_id")
133
+ .notNull()
134
+ .references(() => aiConversations.id, { onDelete: "cascade" }),
135
+ role: aiMessageRoleEnum("role").notNull(),
136
+ /** AI-SDK message parts: text + tool-call / tool-result parts. Secrets are
137
+ * masked before persist (see `scrubContent`). */
138
+ content: jsonb("content").$type<Record<string, unknown>>().notNull(),
139
+ /** Tool calls emitted by an assistant turn (denormalized for fast render). */
140
+ toolCalls: jsonb("tool_calls").$type<Array<Record<string, unknown>>>(),
141
+ /**
142
+ * Tool-call REPLAY (additive, Phase 6): the canonical AI-SDK
143
+ * `ResponseMessage[]` (assistant tool-call parts + tool-result parts) the
144
+ * model produced this turn. Stored verbatim (after secret-scrubbing) so a
145
+ * RESUMED multi-turn conversation replays the full prior tool interaction to
146
+ * the model — not just the rendered text. Null for plain user/system rows
147
+ * and for legacy assistant rows written before this column existed (those
148
+ * fall back to text-only replay). Shared Postgres, so replay is identical on
149
+ * whichever pod handles the next turn (state-and-scale §9).
150
+ */
151
+ modelMessages: jsonb("model_messages").$type<Array<Record<string, unknown>>>(),
152
+ createdAt: timestamp("created_at").defaultNow().notNull(),
153
+ },
154
+ (t) => ({
155
+ convIdx: index("ai_messages_conversation_idx").on(
156
+ t.conversationId,
157
+ t.createdAt,
158
+ ),
159
+ }),
160
+ );
161
+
162
+ export const aiToolCalls = pgTable(
163
+ "ai_tool_calls",
164
+ {
165
+ id: text("id")
166
+ .primaryKey()
167
+ .$defaultFn(() => crypto.randomUUID()),
168
+ /** "user" | "application" — never "service" (services bypass the registry). */
169
+ principalKind: text("principal_kind").notNull(),
170
+ principalId: text("principal_id").notNull(),
171
+ transport: aiTransportEnum("transport").notNull(),
172
+ /** Optional link back to a chat turn (null for MCP). Phase 4 deferred FK. */
173
+ conversationId: text("conversation_id").references(
174
+ () => aiConversations.id,
175
+ { onDelete: "set null" },
176
+ ),
177
+ toolName: text("tool_name").notNull(),
178
+ effect: aiToolEffectEnum("effect").notNull(),
179
+ /** SHA-256 of the canonical-JSON args (never the raw args — may hold PII). */
180
+ argsHash: text("args_hash").notNull(),
181
+ status: aiToolCallStatusEnum("status").notNull(),
182
+ /** Propose/apply token nonce (random 32 bytes hex). Null for read tools. */
183
+ proposalNonce: text("proposal_nonce"),
184
+ /** Hard expiry of a `proposed` row (now + TTL, §13.4). */
185
+ proposalExpiresAt: timestamp("proposal_expires_at"),
186
+ /**
187
+ * WHO applied the proposal (P3 review item 1). The proposer is recorded in
188
+ * `principalKind`/`principalId`; these two columns record the principal that
189
+ * actually consumed the token at `apply` time. They are normally identical,
190
+ * but the propose/apply security invariant (single-use, 256-bit secret
191
+ * token, live authz re-check) holds regardless of caller identity, so a
192
+ * cross-principal apply is RECORDED rather than rejected — the audit log must
193
+ * never silently attribute an apply to the wrong principal. Null until apply.
194
+ */
195
+ appliedByKind: text("applied_by_kind"),
196
+ appliedById: text("applied_by_id"),
197
+ /** dryRun preview / execute result snapshot (masked). */
198
+ resultSnapshot: jsonb("result_snapshot").$type<Record<string, unknown>>(),
199
+ /** The validated, ready-to-apply payload captured at propose time. */
200
+ proposedPayload: jsonb("proposed_payload").$type<Record<string, unknown>>(),
201
+ error: text("error"),
202
+ proposedAt: timestamp("proposed_at"),
203
+ appliedAt: timestamp("applied_at"),
204
+ createdAt: timestamp("created_at").defaultNow().notNull(),
205
+ },
206
+ (t) => ({
207
+ // Per-principal budget counter window scan + audit listing.
208
+ principalCreatedIdx: index("ai_tool_calls_principal_created_idx").on(
209
+ t.principalKind,
210
+ t.principalId,
211
+ t.createdAt,
212
+ ),
213
+ // Proposal-token lookup at apply time + the TTL prune sweep.
214
+ statusExpiresIdx: index("ai_tool_calls_status_expires_idx").on(
215
+ t.status,
216
+ t.proposalExpiresAt,
217
+ ),
218
+ convIdx: index("ai_tool_calls_conversation_idx").on(t.conversationId),
219
+ }),
220
+ );
221
+
222
+ /**
223
+ * Per-integration LLM SPEND LEDGER (Phase 6, the locked-off spend-cap knob).
224
+ *
225
+ * An append-only token-usage ledger: ONE row per completed chat turn, keyed by
226
+ * the integration connection AND the principal, carrying the AI-SDK token usage
227
+ * (`inputTokens` + `outputTokens` -> `totalTokens`) for that turn. The optional
228
+ * per-integration spend cap is a ROLLING-WINDOW SUM over this table, mirroring
229
+ * the per-principal tool rate-limit budget exactly:
230
+ *
231
+ * - State lives in shared Postgres, so the cap is COUNTED ACROSS ALL PODS. An
232
+ * in-memory per-pod token counter would let N pods each allow the cap = N x
233
+ * the intended spend — a leak a single-process test can never catch
234
+ * (state-and-scale §14.5). Every pod sums the SAME table.
235
+ * - Token-count (not USD) is deliberate: it is deterministic and provider-
236
+ * agnostic. OpenAI-compatible endpoints span OpenAI, Azure, OpenRouter,
237
+ * Ollama, vLLM, LM Studio — there is no single price table, and self-hosted
238
+ * models have no per-token price at all. Tokens are the one unit every
239
+ * provider reports via the AI SDK's `usage`.
240
+ * - The cap is OFF by default: no cap is enforced unless the connection
241
+ * configures `spendCap`.
242
+ */
243
+ export const aiSpend = pgTable(
244
+ "ai_spend",
245
+ {
246
+ id: text("id")
247
+ .primaryKey()
248
+ .$defaultFn(() => crypto.randomUUID()),
249
+ /** Qualified integration connection id the spend was incurred against. */
250
+ integrationId: text("integration_id").notNull(),
251
+ /** "user" | "application" — the principal that incurred the spend. */
252
+ principalKind: text("principal_kind").notNull(),
253
+ principalId: text("principal_id").notNull(),
254
+ /** Optional link back to the conversation (audit; null-safe). */
255
+ conversationId: text("conversation_id"),
256
+ /** Model id the turn ran against. */
257
+ model: text("model"),
258
+ /** Prompt (input) tokens for the turn. */
259
+ inputTokens: integer("input_tokens").notNull().default(0),
260
+ /** Completion (output) tokens for the turn. */
261
+ outputTokens: integer("output_tokens").notNull().default(0),
262
+ /** input + output, persisted so the window SUM is a single indexed column. */
263
+ totalTokens: integer("total_tokens").notNull().default(0),
264
+ createdAt: timestamp("created_at").defaultNow().notNull(),
265
+ },
266
+ (t) => ({
267
+ // Rolling-window SUM scan: filter by integration + principal + createdAt.
268
+ windowIdx: index("ai_spend_integration_principal_created_idx").on(
269
+ t.integrationId,
270
+ t.principalKind,
271
+ t.principalId,
272
+ t.createdAt,
273
+ ),
274
+ }),
275
+ );
276
+
277
+ export type AiSpendRow = typeof aiSpend.$inferSelect;
278
+ export type AiSpendInsert = typeof aiSpend.$inferInsert;
279
+ export type AiToolCallRow = typeof aiToolCalls.$inferSelect;
280
+ export type AiToolCallInsert = typeof aiToolCalls.$inferInsert;
281
+ export type AiConversationRow = typeof aiConversations.$inferSelect;
282
+ export type AiConversationInsert = typeof aiConversations.$inferInsert;
283
+ export type AiMessageRow = typeof aiMessages.$inferSelect;
284
+ export type AiMessageInsert = typeof aiMessages.$inferInsert;