@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
@@ -0,0 +1,419 @@
1
+ import { extractErrorMessage } from "@checkstack/common";
2
+ import type { AuthUser, EventBus, RpcClient } from "@checkstack/backend-api";
3
+ import type { AiFieldDiff } from "@checkstack/ai-common";
4
+ import type { AiToolResolver } from "../resolver";
5
+ import type { RegisteredAiTool } from "../tool-registry";
6
+ import type { AiToolRegistry } from "../tool-registry";
7
+ import { aiHooks } from "../hooks";
8
+ import { hashToolArgs } from "./args-hash";
9
+ import type { AiToolCallStore, AuditPrincipal } from "./store";
10
+ import {
11
+ formatProposalToken,
12
+ nonceMatches,
13
+ parseProposalToken,
14
+ } from "./token";
15
+
16
+ /** Errors a transport maps to an appropriate status code. */
17
+ export type ProposeApplyErrorCode =
18
+ | "forbidden" // resolver/authz gate refused
19
+ | "not_found" // unknown tool / token row
20
+ | "not_proposable" // tool has no dryRun (read tool or misconfigured mutate)
21
+ | "invalid_token" // malformed token / nonce mismatch
22
+ | "expired" // TTL elapsed (or row already swept to expired)
23
+ | "consumed" // already applied/rejected — single-use violated
24
+ | "execute_failed"; // apply's execute threw
25
+
26
+ export class ProposeApplyError extends Error {
27
+ constructor(
28
+ public readonly code: ProposeApplyErrorCode,
29
+ message: string,
30
+ ) {
31
+ super(message);
32
+ this.name = "ProposeApplyError";
33
+ }
34
+ }
35
+
36
+ export interface ProposeResult {
37
+ /** Opaque proposal token (`propose:<rowId>.<nonce>`). */
38
+ token: string;
39
+ /** Human/model-facing one-line summary of what `apply` will do. */
40
+ summary: string;
41
+ /** The validated, ready-to-apply payload (for the chat confirm card). */
42
+ payload: unknown;
43
+ /** Optional before -> after diff for an update (shown on the card). */
44
+ diff?: AiFieldDiff[];
45
+ /** Audit row id (== the rowId inside the token). */
46
+ toolCallId: string;
47
+ /** Hard expiry of the proposal. */
48
+ expiresAt: Date;
49
+ }
50
+
51
+ export interface ApplyResult {
52
+ toolCallId: string;
53
+ result: unknown;
54
+ }
55
+
56
+ /**
57
+ * Read-only description of a proposal, resolved from its token WITHOUT consuming
58
+ * it. Powers the post-confirm-card acknowledgment turn (the model reacting to a
59
+ * human apply/decline): the caller needs the tool name + stored summary + the
60
+ * owning conversation + current status, but must NOT apply anything. The nonce
61
+ * is verified so a guessed/forged token reveals nothing.
62
+ */
63
+ export interface ProposalDescription {
64
+ rowId: string;
65
+ toolName: string;
66
+ /** Lifecycle status: proposed | applied | rejected | expired | failed. */
67
+ status: string;
68
+ /** The chat conversation the proposal was created in (if any). */
69
+ conversationId?: string;
70
+ /** The one-line summary captured at propose time (resultSnapshot.summary). */
71
+ summary?: string;
72
+ }
73
+
74
+ interface ProposeApplyDeps {
75
+ registry: AiToolRegistry;
76
+ resolver: AiToolResolver;
77
+ store: AiToolCallStore;
78
+ /** Optional bus; when present, `ai.toolCalled` is emitted (best-effort). */
79
+ eventBus?: EventBus;
80
+ }
81
+
82
+ function auditPrincipalOf(principal: AuthUser): AuditPrincipal {
83
+ // Services bypass the registry entirely; the resolver gate below also refuses
84
+ // them, but we guard here so the audit row never records a "service" kind.
85
+ if (principal.type === "service") {
86
+ throw new ProposeApplyError(
87
+ "forbidden",
88
+ "Service principals cannot drive AI tools.",
89
+ );
90
+ }
91
+ return { kind: principal.type, id: principal.id };
92
+ }
93
+
94
+ /**
95
+ * The required access rules the principal does NOT hold, so a `forbidden` error
96
+ * can name the missing permission (the assistant relays it to the user). Returns
97
+ * `[]` for the `"*"` admin escape. Never includes anything the principal has.
98
+ */
99
+ function missingRules({
100
+ principal,
101
+ tool,
102
+ }: {
103
+ principal: AuthUser;
104
+ tool: RegisteredAiTool;
105
+ }): string[] {
106
+ const have =
107
+ "accessRules" in principal ? (principal.accessRules ?? []) : [];
108
+ if (have.includes("*")) return [];
109
+ return tool.requiredAccessRules.filter((rule) => !have.includes(rule));
110
+ }
111
+
112
+ /** A `forbidden` message that names the missing rule(s) when known. */
113
+ function forbiddenMessage({
114
+ principal,
115
+ tool,
116
+ }: {
117
+ principal: AuthUser;
118
+ tool: RegisteredAiTool;
119
+ }): string {
120
+ const missing = missingRules({ principal, tool });
121
+ return missing.length > 0
122
+ ? `Forbidden: ${tool.name} (missing permission: ${missing.join(", ")})`
123
+ : `Forbidden: ${tool.name}`;
124
+ }
125
+
126
+ function emitToolCalled({
127
+ eventBus,
128
+ principal,
129
+ transport,
130
+ tool,
131
+ status,
132
+ }: {
133
+ eventBus?: EventBus;
134
+ principal: AuditPrincipal;
135
+ transport: "chat" | "mcp" | "automation";
136
+ tool: RegisteredAiTool;
137
+ status: "proposed" | "applied" | "failed";
138
+ }): void {
139
+ if (!eventBus) return;
140
+ // Best-effort, fire-and-forget: an audit/notification failure must never
141
+ // block or fail the tool call itself.
142
+ void eventBus.emit(aiHooks.toolCalled, {
143
+ principalKind: principal.kind,
144
+ principalId: principal.id,
145
+ transport,
146
+ toolName: tool.name,
147
+ effect: tool.effect,
148
+ status,
149
+ });
150
+ }
151
+
152
+ /**
153
+ * The transport-agnostic two-step propose -> apply service (§8, §13.4).
154
+ *
155
+ * `propose` runs the mutating tool's `dryRun` (the mature validateDefinition /
156
+ * renderConfig pattern), persists a `proposed` audit row, and returns a token.
157
+ * `apply` parses + validates the token, re-checks authorization (rules may have
158
+ * changed since propose), and commits via `execute` — atomic single-use.
159
+ *
160
+ * Read-effect tools are NOT proposable: they run directly via the transport,
161
+ * never through this gate.
162
+ */
163
+ export function createProposeApplyService({
164
+ registry,
165
+ resolver,
166
+ store,
167
+ eventBus,
168
+ }: ProposeApplyDeps) {
169
+ return {
170
+ async propose({
171
+ principal,
172
+ toolName,
173
+ input,
174
+ transport,
175
+ conversationId,
176
+ rpcClient,
177
+ }: {
178
+ principal: AuthUser;
179
+ toolName: string;
180
+ input: unknown;
181
+ transport: "chat" | "mcp";
182
+ conversationId?: string;
183
+ /** USER-scoped client (bound to the originating user) for the dry-run. */
184
+ rpcClient: RpcClient;
185
+ }): Promise<ProposeResult> {
186
+ const auditPrincipal = auditPrincipalOf(principal);
187
+ const tool = registry.getTool(toolName);
188
+ if (!tool) {
189
+ throw new ProposeApplyError("not_found", `Unknown tool: ${toolName}`);
190
+ }
191
+ // Authz gate (decision 5) — the same predicate the handler enforces.
192
+ if (!resolver.isAllowed({ principal, tool })) {
193
+ throw new ProposeApplyError(
194
+ "forbidden",
195
+ forbiddenMessage({ principal, tool }),
196
+ );
197
+ }
198
+ // Only mutate/destructive tools with a dryRun are proposable.
199
+ if (tool.effect === "read" || !tool.dryRun) {
200
+ throw new ProposeApplyError(
201
+ "not_proposable",
202
+ `Tool "${toolName}" is not a proposable mutating tool.`,
203
+ );
204
+ }
205
+
206
+ // Validate the input against the tool's own schema BEFORE dry-running so
207
+ // a malformed call is rejected without side effects.
208
+ const parsed = tool.input.safeParse(input);
209
+ if (!parsed.success) {
210
+ throw new ProposeApplyError(
211
+ "execute_failed",
212
+ `Invalid arguments for ${toolName}: ${parsed.error.message}`,
213
+ );
214
+ }
215
+
216
+ const argsHash = hashToolArgs(parsed.data);
217
+ const preview = await tool.dryRun({
218
+ input: parsed.data,
219
+ principal,
220
+ rpcClient,
221
+ });
222
+
223
+ const { row, nonce } = await store.createProposal({
224
+ principal: auditPrincipal,
225
+ transport,
226
+ conversationId,
227
+ toolName,
228
+ effect: tool.effect,
229
+ argsHash,
230
+ proposedPayload: preview.payload as Record<string, unknown>,
231
+ resultSnapshot: { summary: preview.summary },
232
+ });
233
+
234
+ emitToolCalled({
235
+ eventBus,
236
+ principal: auditPrincipal,
237
+ transport,
238
+ tool,
239
+ status: "proposed",
240
+ });
241
+
242
+ return {
243
+ token: formatProposalToken({ rowId: row.id, nonce }),
244
+ summary: preview.summary,
245
+ payload: preview.payload,
246
+ diff: preview.diff,
247
+ toolCallId: row.id,
248
+ expiresAt: row.proposalExpiresAt ?? new Date(),
249
+ };
250
+ },
251
+
252
+ /**
253
+ * Resolve a proposal token to a read-only description WITHOUT consuming it.
254
+ * Returns undefined for a malformed token, an unknown row, or a nonce
255
+ * mismatch (so a forged token leaks nothing). Does NOT check TTL/status -
256
+ * the caller inspects `status` itself (an applied proposal is expected here).
257
+ */
258
+ async describeProposal({
259
+ token,
260
+ }: {
261
+ token: string;
262
+ }): Promise<ProposalDescription | undefined> {
263
+ const parsedToken = parseProposalToken(token);
264
+ if (!parsedToken) return undefined;
265
+ const row = await store.getProposal(parsedToken.rowId);
266
+ if (!row || !row.proposalNonce) return undefined;
267
+ if (
268
+ !nonceMatches({
269
+ candidate: parsedToken.nonce,
270
+ stored: row.proposalNonce,
271
+ })
272
+ ) {
273
+ return undefined;
274
+ }
275
+ const snapshot = row.resultSnapshot as { summary?: unknown } | null;
276
+ const summary =
277
+ snapshot && typeof snapshot.summary === "string"
278
+ ? snapshot.summary
279
+ : undefined;
280
+ return {
281
+ rowId: row.id,
282
+ toolName: row.toolName,
283
+ status: row.status,
284
+ conversationId: row.conversationId ?? undefined,
285
+ summary,
286
+ };
287
+ },
288
+
289
+ async apply({
290
+ principal,
291
+ token,
292
+ rpcClient,
293
+ }: {
294
+ principal: AuthUser;
295
+ token: string;
296
+ transport?: "chat" | "mcp";
297
+ /** USER-scoped client (bound to the originating user) for the commit. */
298
+ rpcClient: RpcClient;
299
+ }): Promise<ApplyResult> {
300
+ const auditPrincipal = auditPrincipalOf(principal);
301
+
302
+ const parsedToken = parseProposalToken(token);
303
+ if (!parsedToken) {
304
+ throw new ProposeApplyError("invalid_token", "Malformed proposal token.");
305
+ }
306
+
307
+ // Fetch first to validate the nonce + status + TTL with precise errors
308
+ // (the atomic consume below is the single-use authority, but we want a
309
+ // constant-time nonce compare and clear 410/409 distinctions).
310
+ const existing = await store.getProposal(parsedToken.rowId);
311
+ if (!existing || !existing.proposalNonce) {
312
+ throw new ProposeApplyError("not_found", "Unknown proposal token.");
313
+ }
314
+ if (
315
+ !nonceMatches({
316
+ candidate: parsedToken.nonce,
317
+ stored: existing.proposalNonce,
318
+ })
319
+ ) {
320
+ // Constant-time mismatch — treat as invalid, never reveal which part.
321
+ throw new ProposeApplyError("invalid_token", "Invalid proposal token.");
322
+ }
323
+ if (existing.status !== "proposed") {
324
+ // Already applied / rejected / expired — single-use.
325
+ throw new ProposeApplyError(
326
+ existing.status === "expired" ? "expired" : "consumed",
327
+ `Proposal is no longer applicable (status: ${existing.status}).`,
328
+ );
329
+ }
330
+ if (
331
+ existing.proposalExpiresAt &&
332
+ existing.proposalExpiresAt.getTime() <= Date.now()
333
+ ) {
334
+ throw new ProposeApplyError("expired", "Proposal token has expired.");
335
+ }
336
+
337
+ const tool = registry.getTool(existing.toolName);
338
+ if (!tool) {
339
+ throw new ProposeApplyError(
340
+ "not_found",
341
+ `Tool "${existing.toolName}" is no longer registered.`,
342
+ );
343
+ }
344
+ // Re-check authz at apply time — the principal's rules may have changed
345
+ // since propose (narrowing runs live, §6.3).
346
+ if (!resolver.isAllowed({ principal, tool })) {
347
+ throw new ProposeApplyError(
348
+ "forbidden",
349
+ forbiddenMessage({ principal, tool }),
350
+ );
351
+ }
352
+
353
+ // Atomic single-use consume: only one caller wins the proposed -> applied
354
+ // transition. A concurrent second apply gets `undefined`. The applier
355
+ // principal is stamped into the row so the audit records WHO applied,
356
+ // even if it differs from the proposer (P3 review item 1).
357
+ const consumed = await store.consumeProposal({
358
+ rowId: parsedToken.rowId,
359
+ applier: auditPrincipal,
360
+ });
361
+ if (!consumed) {
362
+ throw new ProposeApplyError(
363
+ "consumed",
364
+ "Proposal token was already consumed.",
365
+ );
366
+ }
367
+
368
+ // Belt-and-suspenders (P3 review item 2): re-parse the SERVER-STORED
369
+ // payload against the tool's own input schema before executing. `apply`
370
+ // executes ONLY the server-stored payload captured at propose time — never
371
+ // any caller-supplied arguments — so this guards against a payload that no
372
+ // longer satisfies the (possibly evolved) schema.
373
+ const repared = tool.input.safeParse(consumed.proposedPayload);
374
+ if (!repared.success) {
375
+ emitToolCalled({
376
+ eventBus,
377
+ principal: auditPrincipal,
378
+ transport: consumed.transport,
379
+ tool,
380
+ status: "failed",
381
+ });
382
+ throw new ProposeApplyError(
383
+ "execute_failed",
384
+ `Stored proposal payload for ${tool.name} no longer matches its input schema: ${repared.error.message}`,
385
+ );
386
+ }
387
+
388
+ try {
389
+ const result = await tool.execute({
390
+ input: repared.data,
391
+ principal,
392
+ rpcClient,
393
+ });
394
+ emitToolCalled({
395
+ eventBus,
396
+ principal: auditPrincipal,
397
+ transport: consumed.transport,
398
+ tool,
399
+ status: "applied",
400
+ });
401
+ return { toolCallId: consumed.id, result };
402
+ } catch (error) {
403
+ emitToolCalled({
404
+ eventBus,
405
+ principal: auditPrincipal,
406
+ transport: consumed.transport,
407
+ tool,
408
+ status: "failed",
409
+ });
410
+ throw new ProposeApplyError(
411
+ "execute_failed",
412
+ extractErrorMessage(error, "Apply failed during execute."),
413
+ );
414
+ }
415
+ },
416
+ };
417
+ }
418
+
419
+ export type ProposeApplyService = ReturnType<typeof createProposeApplyService>;
@@ -0,0 +1,136 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import { createAiToolCallStore, PROPOSAL_TTL_MS } from "./store";
3
+ import type { AiToolCallRow } from "../schema";
4
+
5
+ function row(over: Partial<AiToolCallRow> = {}): AiToolCallRow {
6
+ return {
7
+ id: "r1",
8
+ principalKind: "user",
9
+ principalId: "u1",
10
+ transport: "chat",
11
+ conversationId: null,
12
+ toolName: "demo.mutate",
13
+ effect: "mutate",
14
+ argsHash: "h".repeat(64),
15
+ status: "proposed",
16
+ proposalNonce: "n".repeat(64),
17
+ proposalExpiresAt: new Date("2026-06-01T00:10:00Z"),
18
+ resultSnapshot: null,
19
+ proposedPayload: { value: "x" },
20
+ error: null,
21
+ proposedAt: new Date("2026-06-01T00:00:00Z"),
22
+ appliedAt: null,
23
+ appliedByKind: null,
24
+ appliedById: null,
25
+ createdAt: new Date("2026-06-01T00:00:00Z"),
26
+ ...over,
27
+ };
28
+ }
29
+
30
+ describe("createAiToolCallStore", () => {
31
+ test("createProposal inserts a proposed row with nonce + TTL", async () => {
32
+ const now = new Date("2026-06-01T00:00:00Z");
33
+ const values = mock((_v: Record<string, unknown>) => ({
34
+ returning: mock(() => Promise.resolve([row()])),
35
+ }));
36
+ const db = { insert: mock(() => ({ values })) };
37
+ const store = createAiToolCallStore({ db: db as never });
38
+
39
+ const { row: created, nonce } = await store.createProposal({
40
+ principal: { kind: "user", id: "u1" },
41
+ transport: "chat",
42
+ toolName: "demo.mutate",
43
+ effect: "mutate",
44
+ argsHash: "h".repeat(64),
45
+ proposedPayload: { value: "x" },
46
+ now,
47
+ });
48
+
49
+ const inserted = values.mock.calls[0]?.[0] as {
50
+ status: string;
51
+ proposalNonce: string;
52
+ proposalExpiresAt: Date;
53
+ };
54
+ expect(inserted.status).toBe("proposed");
55
+ expect(inserted.proposalNonce).toMatch(/^[0-9a-f]{64}$/);
56
+ expect(inserted.proposalExpiresAt.getTime()).toBe(now.getTime() + PROPOSAL_TTL_MS);
57
+ expect(nonce).toBe(inserted.proposalNonce);
58
+ expect(created.id).toBe("r1");
59
+ });
60
+
61
+ test("recordExecuted writes an executed read row (no nonce)", async () => {
62
+ const values = mock((_v: Record<string, unknown>) => ({
63
+ returning: mock(() =>
64
+ Promise.resolve([row({ status: "executed", effect: "read", proposalNonce: null })]),
65
+ ),
66
+ }));
67
+ const db = { insert: mock(() => ({ values })) };
68
+ const store = createAiToolCallStore({ db: db as never });
69
+
70
+ await store.recordExecuted({
71
+ principal: { kind: "user", id: "u1" },
72
+ transport: "mcp",
73
+ toolName: "incident.list",
74
+ argsHash: "h".repeat(64),
75
+ });
76
+
77
+ const inserted = values.mock.calls[0]?.[0] as {
78
+ status: string;
79
+ effect: string;
80
+ proposalNonce?: string;
81
+ };
82
+ expect(inserted.status).toBe("executed");
83
+ expect(inserted.effect).toBe("read");
84
+ expect(inserted.proposalNonce).toBeUndefined();
85
+ });
86
+
87
+ test("consumeProposal issues an UPDATE ... WHERE status='proposed' (atomic single-use) and stamps the applier", async () => {
88
+ const where = mock(() => ({
89
+ returning: mock(() =>
90
+ Promise.resolve([
91
+ row({ status: "applied", appliedByKind: "user", appliedById: "u2" }),
92
+ ]),
93
+ ),
94
+ }));
95
+ const set = mock(
96
+ (_v: {
97
+ status: string;
98
+ appliedAt: Date;
99
+ appliedByKind: string;
100
+ appliedById: string;
101
+ }) => ({ where }),
102
+ );
103
+ const db = { update: mock(() => ({ set })) };
104
+ const store = createAiToolCallStore({ db: db as never });
105
+
106
+ const consumed = await store.consumeProposal({
107
+ rowId: "r1",
108
+ applier: { kind: "user", id: "u2" },
109
+ });
110
+ expect(consumed?.status).toBe("applied");
111
+ // The set transitions to applied with an appliedAt timestamp + applier.
112
+ const setArg = set.mock.calls[0]?.[0];
113
+ expect(setArg?.status).toBe("applied");
114
+ expect(setArg?.appliedAt).toBeInstanceOf(Date);
115
+ // P3 review item 1: the actual applying principal is recorded.
116
+ expect(setArg?.appliedByKind).toBe("user");
117
+ expect(setArg?.appliedById).toBe("u2");
118
+ // The atomic guard (id + status + TTL) is expressed in the WHERE clause.
119
+ expect(where).toHaveBeenCalledTimes(1);
120
+ });
121
+
122
+ test("consumeProposal returns undefined when no row matches (already consumed)", async () => {
123
+ const where = mock(() => ({
124
+ returning: mock(() => Promise.resolve([])),
125
+ }));
126
+ const set = mock(() => ({ where }));
127
+ const db = { update: mock(() => ({ set })) };
128
+ const store = createAiToolCallStore({ db: db as never });
129
+ expect(
130
+ await store.consumeProposal({
131
+ rowId: "r1",
132
+ applier: { kind: "user", id: "u2" },
133
+ }),
134
+ ).toBeUndefined();
135
+ });
136
+ });