@frockbot/plugin-audit 0.3.4 → 0.3.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-audit",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -25,11 +25,11 @@
25
25
  "typecheck": "vue-tsc --noEmit -p tsconfig.json"
26
26
  },
27
27
  "dependencies": {
28
- "@frockbot/client-core": "0.3.4",
29
- "@frockbot/client-ui": "0.3.4",
30
- "@frockbot/kernel-contracts": "0.3.4",
31
- "@frockbot/plugin-shell": "0.3.4",
32
- "@frockbot/secret-shapes": "0.3.4",
28
+ "@frockbot/client-core": "0.3.5",
29
+ "@frockbot/client-ui": "0.3.5",
30
+ "@frockbot/kernel-contracts": "0.3.5",
31
+ "@frockbot/plugin-shell": "0.3.5",
32
+ "@frockbot/secret-shapes": "0.3.5",
33
33
  "cordis": "4.0.0-rc.8",
34
34
  "vue": "3.5.41"
35
35
  },
package/src/bot.test.ts CHANGED
@@ -248,3 +248,55 @@ describe("the Bot Durable Object's outbox", () => {
248
248
  expect(await outbox.state()).toEqual({ pending: 0, truncated: false });
249
249
  });
250
250
  });
251
+
252
+ describe("what the row is allowed to claim", () => {
253
+ test("an approval-gated command is not `ok` before anybody approved it", async () => {
254
+ const entries = await auditEntriesFromStoredRunV1(
255
+ "foreman",
256
+ run([
257
+ call("tool:1:1:0", "machine_exec", {
258
+ command: "rm -rf /tmp/build",
259
+ machineId: "994dc2ee-1",
260
+ }),
261
+ result("tool:1:1:0", {
262
+ content:
263
+ 'Command "cmd-1" is waiting on the user\'s approval. Nothing has run.',
264
+ }),
265
+ ]),
266
+ );
267
+
268
+ // The approval ends the Turn before anything runs, and `isError` is false
269
+ // at queue time — so the row used to say `ok` for a command that had not
270
+ // run and might never run.
271
+ expect(entries[0]).toMatchObject({
272
+ toolName: "machine_exec",
273
+ target: "machine:994dc2ee-1",
274
+ outcome: "unknown",
275
+ });
276
+ });
277
+
278
+ test("a namespaced dynamic tool is recorded under the tool that ran", async () => {
279
+ const entries = await auditEntriesFromStoredRunV1(
280
+ "foreman",
281
+ run([
282
+ call("tool:1:1:0", "call_dynamic_tool", {
283
+ namespace: "frockbot",
284
+ toolName: "package_author",
285
+ input: { packageId: "acme", path: "src/index.ts" },
286
+ }),
287
+ result("tool:1:1:0", { content: "written" }),
288
+ ]),
289
+ );
290
+
291
+ // `package_author` produced no row at all before: it is a namespaced
292
+ // dynamic tool, so the journalled name is `call_dynamic_tool`, and the
293
+ // same hole hid every Composio and publisher call.
294
+ expect(entries).toHaveLength(1);
295
+ expect(entries[0]).toMatchObject({
296
+ toolName: "package_author",
297
+ kind: "file",
298
+ outcome: "ok",
299
+ });
300
+ expect(entries[0]?.preview).toContain("acme");
301
+ });
302
+ });
package/src/bot.ts CHANGED
@@ -21,7 +21,11 @@
21
21
  // It reads the *stored* run rather than the client projection, because the
22
22
  // client projection drops `call.input` and the argument digest needs the exact
23
23
  // arguments.
24
- import { auditKindForToolV1 } from "./classify.js";
24
+ import {
25
+ auditKindForToolV1,
26
+ dynamicToolInputV1,
27
+ resolveDynamicToolNameV1,
28
+ } from "./classify.js";
25
29
  import { auditArgumentDigestV1, auditPreviewV1 } from "./redact.js";
26
30
  import {
27
31
  AUDIT_MAX_OUTBOX_V1,
@@ -71,6 +75,17 @@ export function isSettledAuditRunV1(run: { status: string }): boolean {
71
75
  );
72
76
  }
73
77
 
78
+ /**
79
+ * A tool result that says the effect has not run yet.
80
+ *
81
+ * An approval-gated `machine_exec` answers the model at *queue* time, before
82
+ * anybody has approved anything, and `isError` is false — so the row said `ok`
83
+ * for a command that had not run and might never run. The durable log does not
84
+ * yet know how it ended, which is exactly what `unknown` means.
85
+ */
86
+ const AWAITING_APPROVAL =
87
+ /\bnothing has run\b|waiting on the user's approval|awaiting (?:your )?approval|queued for approval/i;
88
+
74
89
  function outcomeFor(
75
90
  result: { isError?: boolean; status?: string; content?: string } | undefined,
76
91
  ): AuditOutcomeV1 {
@@ -79,7 +94,9 @@ function outcomeFor(
79
94
  // silent classification the constitution's reconciliation rule forbids.
80
95
  if (!result) return "unknown";
81
96
  if (result.status === "interrupted") return "interrupted";
82
- if (result.isError !== true) return "ok";
97
+ if (result.isError !== true) {
98
+ return AWAITING_APPROVAL.test(result.content ?? "") ? "unknown" : "ok";
99
+ }
83
100
  // A tool that declined before doing anything is a refusal, which is a
84
101
  // materially different fact from an effect that ran and failed.
85
102
  return /\brefus|not allowed|denied|blocked while\b/i.test(
@@ -122,6 +139,11 @@ export async function auditEntriesFromStoredRunV1(
122
139
  if (!occurrenceId || !name) continue;
123
140
  const classification = auditKindForToolV1(name, event.input);
124
141
  if (!classification) continue;
142
+ // The row names the tool that ran, not the wrapper it was journalled
143
+ // under, and previews the arguments that tool was actually given.
144
+ const toolName = resolveDynamicToolNameV1(name, event.input);
145
+ const toolInput =
146
+ toolName === name ? event.input : dynamicToolInputV1(event.input);
125
147
  let coordinates: { turn: number; step: number; ordinal: number };
126
148
  try {
127
149
  coordinates = decodeAuditOccurrenceIdV1(occurrenceId);
@@ -146,9 +168,11 @@ export async function auditEntriesFromStoredRunV1(
146
168
  at,
147
169
  kind: classification.kind,
148
170
  target: classification.target,
149
- toolName: name,
171
+ toolName,
172
+ // The digest stays over the exact argument JSON the durable `tool/call`
173
+ // event holds, so a row written months ago still reproduces.
150
174
  argumentDigest: await auditArgumentDigestV1(event.input),
151
- preview: auditPreviewV1(classification.kind, name, event.input),
175
+ preview: auditPreviewV1(classification.kind, toolName, toolInput),
152
176
  outcome: outcomeFor(result),
153
177
  ...(result?.content === undefined
154
178
  ? {}
@@ -104,20 +104,56 @@ describe("the classifier table", () => {
104
104
  });
105
105
  });
106
106
 
107
- test("a call naming a registered machine is audited against that machine", () => {
107
+ test("a Computer tool is audited against the Computer, whatever it claims", () => {
108
+ // `machineId` is model-supplied. Deriving the target from it on every tool
109
+ // let a Bot run a command on the Computer and have the durable audit row
110
+ // say it ran on the User's laptop — the one field the row exists to be
111
+ // trusted on.
108
112
  expect(
109
113
  auditKindForToolV1("computer_exec", {
110
114
  command: "ls",
111
115
  machineId: "994dc2ee-1",
112
116
  }),
117
+ ).toEqual({ kind: "shell", target: "computer" });
118
+ // The machine's own verbs still name the machine they reached.
119
+ expect(
120
+ auditKindForToolV1("machine_exec", {
121
+ command: "ls",
122
+ machineId: "994dc2ee-1",
123
+ }),
113
124
  ).toEqual({ kind: "shell", target: "machine:994dc2ee-1" });
114
125
  // A malformed machine id is the Bot's own Computer, not a target the row
115
126
  // would be lying about.
116
127
  expect(
117
- auditKindForToolV1("computer_exec", { command: "ls", machineId: "../x" }),
128
+ auditKindForToolV1("machine_exec", { command: "ls", machineId: "../x" }),
118
129
  ).toEqual({ kind: "shell", target: "computer" });
119
130
  });
120
131
 
132
+ test("a namespaced dynamic tool is classified on the tool it resolves to", () => {
133
+ // `package_author` was dead: it is a namespaced dynamic tool, so the
134
+ // journalled name is `call_dynamic_tool` and no row was ever produced —
135
+ // the same hole hid every Composio and publisher call.
136
+ expect(
137
+ auditKindForToolV1("call_dynamic_tool", {
138
+ namespace: "frockbot",
139
+ toolName: "package_author",
140
+ input: { packageId: "acme" },
141
+ }),
142
+ ).toEqual({ kind: "file", target: "computer" });
143
+ // A wrapper that names nothing resolvable stays the wrapper, and the
144
+ // wrapper is not an audited effect.
145
+ expect(auditKindForToolV1("call_dynamic_tool", {})).toBeUndefined();
146
+ });
147
+
148
+ test("an MCP tool whose own name contains __ still names its server", () => {
149
+ // The slug capture was greedy: `mcp__gh__list__files` reported server
150
+ // `gh__list`, a target no Connection can resolve to a host.
151
+ expect(auditKindForToolV1("mcp__gh__list__files", {})).toEqual({
152
+ kind: "mcp",
153
+ target: "remote:gh",
154
+ });
155
+ });
156
+
121
157
  test("is pure: the same call always classifies the same way", () => {
122
158
  const first = auditKindForToolV1("computer_exec", { command: "ls" });
123
159
  const second = auditKindForToolV1("computer_exec", { command: "ls" });
package/src/classify.ts CHANGED
@@ -77,13 +77,56 @@ const MACHINE_SHELL_TOOL = "machine_exec";
77
77
  */
78
78
  const MACHINE_MESSAGES_PREFIX = "machine_messages_";
79
79
 
80
- const MCP_TOOL = /^mcp__([a-zA-Z0-9_]{1,64})__(.{1,96})$/;
80
+ // The slug capture is LAZY. Greedy, `mcp__gh__list__files` reported server
81
+ // `gh__list` — a target that names no Connection, so `resolveAuditTargetV1`
82
+ // could never resolve it to a host and the row filtered under a server nobody
83
+ // has. The slug is the first segment; everything after the second `__` is the
84
+ // remote tool's own name, `__` included.
85
+ const MCP_TOOL = /^mcp__([a-zA-Z0-9_]{1,64}?)__(.{1,96})$/;
81
86
  const MACHINE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
82
87
 
83
88
  function isObject(value: unknown): value is Record<string, unknown> {
84
89
  return typeof value === "object" && value !== null && !Array.isArray(value);
85
90
  }
86
91
 
92
+ /** The wrapper every namespaced Package tool is journalled under. */
93
+ const DYNAMIC_TOOL = "call_dynamic_tool";
94
+
95
+ /**
96
+ * The tool a call actually made.
97
+ *
98
+ * `package_author` was dead code here: it is a namespaced dynamic tool, so the
99
+ * journalled `tool/call` name is `call_dynamic_tool` and no row was ever
100
+ * produced for it — the same hole hid every Composio and publisher call. The
101
+ * wrapper's own input names the tool, exactly as the provider's presented name
102
+ * does, so the classifier reads it off the call and stays pure.
103
+ */
104
+ export function resolveDynamicToolNameV1(name: string, input: unknown): string {
105
+ if (name !== DYNAMIC_TOOL || !isObject(input)) return name;
106
+ const { namespace, toolName } = input;
107
+ if (typeof namespace !== "string" || typeof toolName !== "string") {
108
+ return name;
109
+ }
110
+ return namespace === "frockbot" ? toolName : `${namespace}/${toolName}`;
111
+ }
112
+
113
+ /** The arguments the wrapped tool was actually given. */
114
+ export function dynamicToolInputV1(input: unknown): unknown {
115
+ if (!isObject(input)) return input;
116
+ return Object.hasOwn(input, "input") ? input.input : input;
117
+ }
118
+
119
+ /** Whether a tool reaches the User's registered machine rather than the Computer. */
120
+ function isMachineToolV1(name: string): boolean {
121
+ return (
122
+ name === MACHINE_SHELL_TOOL ||
123
+ name.startsWith(MACHINE_MESSAGES_PREFIX) ||
124
+ name === "machine_read" ||
125
+ name === "machine_copy_to_computer" ||
126
+ name === "machine_copy_from_computer"
127
+ );
128
+ }
129
+
87
130
  /**
88
131
  * The registered Mac, when the call named one.
89
132
  *
@@ -114,7 +157,17 @@ export function auditKindForToolV1(
114
157
  name: string,
115
158
  input: unknown,
116
159
  ): AuditClassificationV1 | undefined {
117
- const onComputer = machineTarget(input) ?? AUDIT_TARGET_COMPUTER_V1;
160
+ // Only a tool that actually reaches a registered machine may be targeted at
161
+ // one. The target used to come off `machineId` for every tool, and
162
+ // `machineId` is model-supplied: a Bot could run a command on the Computer
163
+ // and have the audit row say it ran on the User's laptop. A Computer tool is
164
+ // audited against the Computer, whatever its arguments claim.
165
+ const resolved = resolveDynamicToolNameV1(name, input);
166
+ const onMachine = isMachineToolV1(resolved)
167
+ ? (machineTarget(input) ?? AUDIT_TARGET_COMPUTER_V1)
168
+ : AUDIT_TARGET_COMPUTER_V1;
169
+ const onComputer = onMachine;
170
+ name = resolved;
118
171
  if (name === "computer_exec") {
119
172
  // A background command outlives the Turn that launched it and is acted on
120
173
  // afterwards by the three `computer_process_*` tools, so it is a process
@@ -89,3 +89,58 @@ describe("the argument digest", () => {
89
89
  );
90
90
  });
91
91
  });
92
+
93
+ describe("secrets a preview must not carry", () => {
94
+ test("redacts an env-var assignment whose keyword sits behind an underscore", () => {
95
+ // `_` is a word character, so the old `\bsecret\b` anchor did not match
96
+ // inside `AWS_SECRET_ACCESS_KEY` and these landed verbatim in a durable
97
+ // table a person reads — the exact shape a leased credential takes in a
98
+ // `computer_exec` command.
99
+ for (const command of [
100
+ "AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEY aws s3 ls",
101
+ "GITHUB_TOKEN=abcdefghijklmnopqrst gh pr list",
102
+ "MYSQL_PASSWORD=hunter2hunter2 mysql -u root",
103
+ ]) {
104
+ const preview = auditPreviewV1("shell", "computer_exec", { command });
105
+ expect(preview).toContain("[redacted:credential-assignment]");
106
+ expect(preview).not.toContain("wJalrXUtnFEMIK");
107
+ expect(preview).not.toContain("abcdefghijklmnopqrst");
108
+ expect(preview).not.toContain("hunter2hunter2");
109
+ }
110
+ });
111
+
112
+ test("redacts credentials carried in a URL", () => {
113
+ const withUser = auditPreviewV1("browser", "computer_browser", {
114
+ action: "open",
115
+ url: "https://alice:s3cr3tpass@internal.example.com/reports",
116
+ });
117
+ expect(withUser).toContain("[redacted:url-credentials]");
118
+ expect(withUser).not.toContain("s3cr3tpass");
119
+
120
+ for (const [url, secret] of [
121
+ ["https://example.com/cb?token=abcdefghijklmnop", "abcdefghijklmnop"],
122
+ ["https://example.com/cb?code=4%2F0AeanS0abcdefgh", "AeanS0abcdefgh"],
123
+ ["https://example.com/f.zip?sig=aGVsbG93b3JsZA", "aGVsbG93b3JsZA"],
124
+ ] as const) {
125
+ const preview = auditPreviewV1("browser", "computer_browser", {
126
+ action: "open",
127
+ url,
128
+ });
129
+ // Which shape catches it does not matter; that it never reaches the
130
+ // durable table does.
131
+ expect(preview).toMatch(/\[redacted:/);
132
+ expect(preview).not.toContain(secret);
133
+ }
134
+ });
135
+
136
+ test("never previews the body of a memory or skill write", () => {
137
+ const preview = auditPreviewV1("file", "memory_write", {
138
+ path: "by-agent/scout/profile.md",
139
+ text: "Tim's home address is 12 Somewhere Street and his PIN is 4021.",
140
+ });
141
+ // The audit row says where something was written. What was written is the
142
+ // Workspace's business and the digest's.
143
+ expect(preview).toBe("by-agent/scout/profile.md");
144
+ expect(preview).not.toContain("Somewhere Street");
145
+ });
146
+ });
package/src/redact.ts CHANGED
@@ -43,7 +43,11 @@ const PREVIEW_FIELDS: Record<AuditKindV1, readonly string[]> = {
43
43
  shell: ["command", "machineId"],
44
44
  browser: ["action", "url", "role", "name", "label", "key"],
45
45
  process: ["action", "command", "processId", "machineId"],
46
- file: ["path", "root", "project", "packageId", "skill", "text"],
46
+ // No `text`: it previewed up to 200 characters of a `memory_write` or
47
+ // `skill_write` body into a durable table a person reads later. What was
48
+ // written is the Workspace's business and the digest's; the audit row says
49
+ // where it was written, which is the question an audit answers.
50
+ file: ["path", "root", "project", "packageId", "skill"],
47
51
  mcp: [],
48
52
  };
49
53
 
package/src/store.test.ts CHANGED
@@ -166,3 +166,102 @@ describe("the audit table", () => {
166
166
  expect(table.count()).toBe(0);
167
167
  });
168
168
  });
169
+
170
+ describe("a rebuild that fails", () => {
171
+ test("leaves the live table exactly as it was", async () => {
172
+ const table = store();
173
+ table.insert([entry(), entry({ occurrenceId: "tool:1:1:1" })]);
174
+ expect(table.count()).toBe(2);
175
+
176
+ // The rebuild used to `DELETE FROM` the live table before it fetched page
177
+ // one, so any source failure left a truncated table still reporting
178
+ // `ready` — a person would read an audit log with rows silently missing
179
+ // and nothing saying so.
180
+ await expect(
181
+ table.rebuild([
182
+ {
183
+ botId: "foreman",
184
+ page: async () => {
185
+ throw new Error("the Bot object is unreachable");
186
+ },
187
+ },
188
+ ]),
189
+ ).rejects.toThrow("unreachable");
190
+
191
+ expect(table.count()).toBe(2);
192
+ expect(table.state()).toBe("ready");
193
+ });
194
+
195
+ test("a rebuild that succeeds replaces the table wholesale", async () => {
196
+ const table = store();
197
+ table.insert([entry({ occurrenceId: "tool:9:9:9" })]);
198
+
199
+ const receipt = await table.rebuild([
200
+ {
201
+ botId: "foreman",
202
+ page: async (cursor?: string) =>
203
+ cursor === undefined
204
+ ? {
205
+ entries: [entry({ occurrenceId: "tool:1:1:0" })],
206
+ nextCursor: "p1",
207
+ }
208
+ : { entries: [entry({ occurrenceId: "tool:1:1:1" })] },
209
+ },
210
+ ]);
211
+
212
+ expect(receipt).toMatchObject({ entries: 2, bots: 1, indexState: "ready" });
213
+ expect(table.count()).toBe(2);
214
+ // The row the old table held and the sources no longer offer is gone: a
215
+ // rebuild is a replacement, not a merge.
216
+ expect(
217
+ table
218
+ .query({})
219
+ .entries.map((row) => row.occurrenceId)
220
+ .sort(),
221
+ ).toEqual(["tool:1:1:0", "tool:1:1:1"]);
222
+ });
223
+
224
+ test("refuses a second rebuild while one is running", async () => {
225
+ const table = store();
226
+ let release: (() => void) | undefined;
227
+ const gate = new Promise<void>((resolve) => {
228
+ release = resolve;
229
+ });
230
+ const first = table.rebuild([
231
+ {
232
+ botId: "foreman",
233
+ page: async () => {
234
+ await gate;
235
+ return { entries: [entry()] };
236
+ },
237
+ },
238
+ ]);
239
+
240
+ // `audit-rebuilding` was written and never read as a lock, so two
241
+ // concurrent rebuilds wiped each other.
242
+ await expect(
243
+ table.rebuild([
244
+ { botId: "foreman", page: async () => ({ entries: [] }) },
245
+ ]),
246
+ ).rejects.toThrow("already running");
247
+
248
+ release?.();
249
+ await first;
250
+ expect(table.state()).toBe("ready");
251
+ });
252
+ });
253
+
254
+ describe("retention", () => {
255
+ test("is enforced on a read too, not only when something is written", () => {
256
+ let now = Date.parse("2026-08-31T00:00:00.000Z");
257
+ const table = store({ maxAgeMs: 60_000, now: () => now });
258
+ table.insert([entry({ at: "2026-08-31T00:00:00.000Z" })]);
259
+ expect(table.count()).toBe(1);
260
+
261
+ // A Bot nobody has spoken to since kept every row past the age bound,
262
+ // because eviction only ever ran on insert. Retention is a promise about
263
+ // time.
264
+ now += 10 * 60_000;
265
+ expect(table.query({}).entries).toHaveLength(0);
266
+ });
267
+ });
package/src/store.ts CHANGED
@@ -42,6 +42,15 @@ export interface AuditSqlV1 {
42
42
  }
43
43
 
44
44
  const TABLE = "audit_entries";
45
+ /**
46
+ * Where a rebuild accumulates rows before it replaces the live table.
47
+ *
48
+ * A rebuild used to `DELETE FROM` the live table before it fetched page one,
49
+ * so any source failure left a truncated table still reporting `ready`. It
50
+ * fills this instead and swaps at the end, which makes a failed rebuild cost
51
+ * nothing.
52
+ */
53
+ const SHADOW_TABLE = "audit_entries_rebuild";
45
54
  const META_TABLE = "audit_meta";
46
55
  const TRUNCATED_KEY = "audit-truncated";
47
56
  const REBUILDING_KEY = "audit-rebuilding";
@@ -49,6 +58,26 @@ const REBUILDING_KEY = "audit-rebuilding";
49
58
  /** The page size a rebuild pulls from one Bot at a time. */
50
59
  export const AUDIT_REBUILD_PAGE_V1 = 32;
51
60
 
61
+ /**
62
+ * How long a rebuild holds the lock before another may take it.
63
+ *
64
+ * `REBUILDING_KEY` was written and never read as a lock, so two concurrent
65
+ * rebuilds wiped each other. It now records when the rebuild started, and a
66
+ * marker older than this is a rebuild whose isolate died — not a reason for
67
+ * the table to be unrebuildable for ever.
68
+ */
69
+ export const AUDIT_REBUILD_LOCK_MS = 10 * 60_000;
70
+
71
+ /** The columns both the live table and the rebuild's shadow carry. */
72
+ const AUDIT_COLUMNS =
73
+ "bot_id TEXT NOT NULL, run_id TEXT NOT NULL, occurrence_id TEXT NOT NULL, " +
74
+ "turn INTEGER NOT NULL, step INTEGER NOT NULL, ordinal INTEGER NOT NULL, " +
75
+ "effect_id TEXT NOT NULL, at TEXT NOT NULL, kind TEXT NOT NULL, " +
76
+ "target TEXT NOT NULL, tool_name TEXT NOT NULL, argument_digest TEXT NOT NULL, " +
77
+ "preview TEXT NOT NULL, outcome TEXT NOT NULL, exit_code INTEGER, " +
78
+ "duration_ms INTEGER, bytes_out INTEGER, " +
79
+ "PRIMARY KEY (bot_id, run_id, occurrence_id)";
80
+
52
81
  export interface AuditStoreOptionsV1 {
53
82
  sql: AuditSqlV1;
54
83
  /** Overridable so a test can drive eviction without twenty thousand rows. */
@@ -134,16 +163,7 @@ export class AuditStoreV1 {
134
163
  /** Creates the table if it is absent. Safe to call on every request. */
135
164
  open(): void {
136
165
  if (this.opened) return;
137
- this.sql.exec(
138
- `CREATE TABLE IF NOT EXISTS ${TABLE} (` +
139
- "bot_id TEXT NOT NULL, run_id TEXT NOT NULL, occurrence_id TEXT NOT NULL, " +
140
- "turn INTEGER NOT NULL, step INTEGER NOT NULL, ordinal INTEGER NOT NULL, " +
141
- "effect_id TEXT NOT NULL, at TEXT NOT NULL, kind TEXT NOT NULL, " +
142
- "target TEXT NOT NULL, tool_name TEXT NOT NULL, argument_digest TEXT NOT NULL, " +
143
- "preview TEXT NOT NULL, outcome TEXT NOT NULL, exit_code INTEGER, " +
144
- "duration_ms INTEGER, bytes_out INTEGER, " +
145
- "PRIMARY KEY (bot_id, run_id, occurrence_id))",
146
- );
166
+ this.sql.exec(`CREATE TABLE IF NOT EXISTS ${TABLE} (${AUDIT_COLUMNS})`);
147
167
  this.sql.exec(`CREATE INDEX IF NOT EXISTS ${TABLE}_at ON ${TABLE} (at)`);
148
168
  this.sql.exec(
149
169
  `CREATE INDEX IF NOT EXISTS ${TABLE}_bot_at ON ${TABLE} (bot_id, at)`,
@@ -204,11 +224,18 @@ export class AuditStoreV1 {
204
224
  */
205
225
  insert(entries: readonly AuditEntryV1[]): number {
206
226
  this.open();
227
+ const inserted = this.insertInto(TABLE, entries);
228
+ this.evict();
229
+ return inserted;
230
+ }
231
+
232
+ /** The insert half, named so a rebuild can aim it at its shadow table. */
233
+ private insertInto(table: string, entries: readonly AuditEntryV1[]): number {
207
234
  let inserted = 0;
208
235
  for (const entry of entries) {
209
236
  const existing = this.sql
210
237
  .exec<{ n: number }>(
211
- `SELECT count(*) AS n FROM ${TABLE} WHERE bot_id = ? AND run_id = ? AND occurrence_id = ?`,
238
+ `SELECT count(*) AS n FROM ${table} WHERE bot_id = ? AND run_id = ? AND occurrence_id = ?`,
212
239
  entry.botId,
213
240
  entry.runId,
214
241
  entry.occurrenceId,
@@ -216,7 +243,7 @@ export class AuditStoreV1 {
216
243
  .toArray();
217
244
  if (Number(existing[0]?.n ?? 0) > 0) continue;
218
245
  this.sql.exec(
219
- `INSERT INTO ${TABLE} (bot_id, run_id, occurrence_id, turn, step, ordinal, ` +
246
+ `INSERT INTO ${table} (bot_id, run_id, occurrence_id, turn, step, ordinal, ` +
220
247
  "effect_id, at, kind, target, tool_name, argument_digest, preview, outcome, " +
221
248
  "exit_code, duration_ms, bytes_out) " +
222
249
  "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
@@ -240,7 +267,6 @@ export class AuditStoreV1 {
240
267
  );
241
268
  inserted += 1;
242
269
  }
243
- this.evict();
244
270
  return inserted;
245
271
  }
246
272
 
@@ -333,6 +359,10 @@ export class AuditStoreV1 {
333
359
  limit?: number;
334
360
  }): { entries: AuditEntryV1[]; nextCursor?: string; total: number } {
335
361
  this.open();
362
+ // Retention is a promise about time, so it cannot be enforced only when
363
+ // something is written: a Bot nobody has spoken to for a year kept every
364
+ // row past the 180-day bound simply because no insert came to evict them.
365
+ this.evict();
336
366
  const limit = Math.min(Math.max(request.limit ?? 50, 1), 500);
337
367
  const offset = decodeAuditOffsetV1(request.before);
338
368
  const clauses: string[] = [];
@@ -391,17 +421,33 @@ export class AuditStoreV1 {
391
421
  sources: readonly AuditEntrySourceV1[],
392
422
  ): Promise<AuditRebuildOutcomeV1> {
393
423
  this.open();
394
- this.setMeta(REBUILDING_KEY, "1");
395
- this.sql.exec(`DELETE FROM ${TABLE}`);
396
- this.setMeta(TRUNCATED_KEY, undefined);
424
+ const held = this.meta(REBUILDING_KEY);
425
+ const startedAt = held === undefined ? undefined : Number(held);
426
+ if (
427
+ startedAt !== undefined &&
428
+ Number.isFinite(startedAt) &&
429
+ this.now() - startedAt < AUDIT_REBUILD_LOCK_MS
430
+ ) {
431
+ // The marker was written and never read as a lock, so two concurrent
432
+ // rebuilds wiped each other. It is a lock now.
433
+ throw new Error("an audit rebuild is already running");
434
+ }
435
+ this.setMeta(REBUILDING_KEY, String(this.now()));
397
436
  let entries = 0;
398
437
  try {
438
+ // Append, then swap. Nothing touches the live table until every source
439
+ // has answered, so a source that fails halfway costs a rebuild and not
440
+ // the table — where before the rows were deleted before page one was
441
+ // even fetched, and a failure left a truncated table reporting `ready`.
442
+ this.sql.exec(`DROP TABLE IF EXISTS ${SHADOW_TABLE}`);
443
+ this.sql.exec(`CREATE TABLE ${SHADOW_TABLE} (${AUDIT_COLUMNS})`);
399
444
  for (const source of sources) {
400
445
  let cursor: string | undefined;
401
446
  let pages = 0;
402
447
  do {
403
448
  const page = await source.page(cursor);
404
- entries += this.insert(
449
+ entries += this.insertInto(
450
+ SHADOW_TABLE,
405
451
  page.entries.filter((entry) => entry.botId === source.botId),
406
452
  );
407
453
  cursor = page.nextCursor;
@@ -410,6 +456,16 @@ export class AuditStoreV1 {
410
456
  // that never stops offering pages is a fault, not a large Bot.
411
457
  } while (cursor && pages < 10_000);
412
458
  }
459
+ this.sql.exec(`DROP TABLE ${TABLE}`);
460
+ this.sql.exec(`ALTER TABLE ${SHADOW_TABLE} RENAME TO ${TABLE}`);
461
+ // The indexes went with the table the rename replaced.
462
+ this.opened = false;
463
+ this.open();
464
+ this.setMeta(TRUNCATED_KEY, undefined);
465
+ this.evict();
466
+ } catch (error) {
467
+ this.sql.exec(`DROP TABLE IF EXISTS ${SHADOW_TABLE}`);
468
+ throw error;
413
469
  } finally {
414
470
  this.setMeta(REBUILDING_KEY, undefined);
415
471
  }
package/src/testing.ts CHANGED
@@ -65,11 +65,33 @@ function order(left: FakeRow, right: FakeRow): number {
65
65
  }
66
66
 
67
67
  export class FakeAuditSql implements AuditSqlV1 {
68
- private rows: FakeRow[] = [];
68
+ /**
69
+ * One row list per table, because a rebuild fills a shadow table and swaps
70
+ * it in: a fake with a single list could not tell the two apart and would
71
+ * report the swap as working whatever the store did.
72
+ */
73
+ private tables = new Map<string, FakeRow[]>([["audit_entries", []]]);
69
74
  private meta = new Map<string, string>();
70
75
  /** Every statement the module issued, for tests that assert on shape. */
71
76
  readonly statements: string[] = [];
72
77
 
78
+ /** The table one statement names, defaulting to the live one. */
79
+ private static table(sql: string): string {
80
+ return (
81
+ /(?:FROM|INTO|TABLE(?: IF NOT EXISTS)?(?: IF EXISTS)?) (audit_entries(?:_rebuild)?)/.exec(
82
+ sql,
83
+ )?.[1] ?? "audit_entries"
84
+ );
85
+ }
86
+
87
+ private rowsIn(table: string): FakeRow[] {
88
+ const rows = this.tables.get(table);
89
+ if (rows) return rows;
90
+ const created: FakeRow[] = [];
91
+ this.tables.set(table, created);
92
+ return created;
93
+ }
94
+
73
95
  exec<Row extends Record<string, AuditSqlValueV1>>(
74
96
  query: string,
75
97
  ...bindings: unknown[]
@@ -77,7 +99,23 @@ export class FakeAuditSql implements AuditSqlV1 {
77
99
  this.statements.push(query);
78
100
  const sql = query.replace(/\s+/g, " ").trim();
79
101
  const answer = (rows: unknown[]) => cursor(rows as Row[]);
102
+ const table = FakeAuditSql.table(sql);
80
103
 
104
+ const renamed = /^ALTER TABLE (\S+) RENAME TO (\S+)$/.exec(sql);
105
+ if (renamed) {
106
+ this.tables.set(renamed[2]!, this.rowsIn(renamed[1]!));
107
+ this.tables.delete(renamed[1]!);
108
+ return answer([]);
109
+ }
110
+ if (sql.startsWith("DROP TABLE")) {
111
+ this.tables.delete(table);
112
+ return answer([]);
113
+ }
114
+ if (sql.startsWith("CREATE TABLE")) {
115
+ if (!sql.includes("IF NOT EXISTS")) this.tables.set(table, []);
116
+ else this.rowsIn(table);
117
+ return answer([]);
118
+ }
81
119
  if (sql.startsWith("CREATE") || sql.startsWith("DROP")) return answer([]);
82
120
 
83
121
  if (sql.startsWith("SELECT value FROM audit_meta")) {
@@ -96,16 +134,16 @@ export class FakeAuditSql implements AuditSqlV1 {
96
134
  const row = Object.fromEntries(
97
135
  COLUMNS.map((column, index) => [column, bindings[index] ?? null]),
98
136
  ) as FakeRow;
99
- this.rows.push(row);
137
+ this.rowsIn(table).push(row);
100
138
  return answer([]);
101
139
  }
102
140
  if (sql.startsWith("SELECT count(*) AS n FROM audit_entries")) {
103
- return answer([{ n: this.filtered(sql, bindings).length }]);
141
+ return answer([{ n: this.filtered(table, sql, bindings).length }]);
104
142
  }
105
143
  if (sql.startsWith("SELECT bot_id, run_id, occurrence_id FROM")) {
106
144
  const limit = Number(bindings[0]);
107
145
  return answer(
108
- [...this.rows]
146
+ [...this.rowsIn(table)]
109
147
  .sort(order)
110
148
  .slice(0, limit)
111
149
  .map((row) => ({
@@ -116,7 +154,10 @@ export class FakeAuditSql implements AuditSqlV1 {
116
154
  );
117
155
  }
118
156
  if (sql.startsWith("DELETE FROM audit_entries WHERE at <")) {
119
- this.rows = this.rows.filter((row) => row.at >= String(bindings[0]));
157
+ this.tables.set(
158
+ table,
159
+ this.rowsIn(table).filter((row) => row.at >= String(bindings[0])),
160
+ );
120
161
  return answer([]);
121
162
  }
122
163
  if (
@@ -124,26 +165,32 @@ export class FakeAuditSql implements AuditSqlV1 {
124
165
  "DELETE FROM audit_entries WHERE bot_id = ? AND run_id = ? AND occurrence_id = ?",
125
166
  )
126
167
  ) {
127
- this.rows = this.rows.filter(
128
- (row) =>
129
- !(
130
- row.bot_id === bindings[0] &&
131
- row.run_id === bindings[1] &&
132
- row.occurrence_id === bindings[2]
133
- ),
168
+ this.tables.set(
169
+ table,
170
+ this.rowsIn(table).filter(
171
+ (row) =>
172
+ !(
173
+ row.bot_id === bindings[0] &&
174
+ row.run_id === bindings[1] &&
175
+ row.occurrence_id === bindings[2]
176
+ ),
177
+ ),
134
178
  );
135
179
  return answer([]);
136
180
  }
137
181
  if (sql.startsWith("DELETE FROM audit_entries WHERE bot_id = ?")) {
138
- this.rows = this.rows.filter((row) => row.bot_id !== bindings[0]);
182
+ this.tables.set(
183
+ table,
184
+ this.rowsIn(table).filter((row) => row.bot_id !== bindings[0]),
185
+ );
139
186
  return answer([]);
140
187
  }
141
188
  if (sql === "DELETE FROM audit_entries") {
142
- this.rows = [];
189
+ this.tables.set(table, []);
143
190
  return answer([]);
144
191
  }
145
192
  if (sql.startsWith("SELECT * FROM audit_entries")) {
146
- const descending = [...this.filtered(sql, bindings)].sort(
193
+ const descending = [...this.filtered(table, sql, bindings)].sort(
147
194
  (left, right) => -order(left, right),
148
195
  );
149
196
  if (!sql.includes("LIMIT")) return answer(descending);
@@ -155,11 +202,11 @@ export class FakeAuditSql implements AuditSqlV1 {
155
202
  }
156
203
 
157
204
  /** Applies the `WHERE bot_id/kind/target` clauses the store builds. */
158
- private filtered(sql: string, bindings: unknown[]): FakeRow[] {
205
+ private filtered(table: string, sql: string, bindings: unknown[]): FakeRow[] {
159
206
  const values = [...bindings];
160
207
  const where = /WHERE (.+?)(?: ORDER BY| LIMIT|$)/.exec(sql)?.[1] ?? "";
161
208
  if (where.startsWith("at <")) {
162
- return this.rows.filter((row) => row.at < String(values[0]));
209
+ return this.rowsIn(table).filter((row) => row.at < String(values[0]));
163
210
  }
164
211
  const predicates: Array<(row: FakeRow) => boolean> = [];
165
212
  for (const clause of where.split(" AND ").filter(Boolean)) {
@@ -168,7 +215,7 @@ export class FakeAuditSql implements AuditSqlV1 {
168
215
  const expected = String(values.shift());
169
216
  predicates.push((row) => String(row[column]) === expected);
170
217
  }
171
- return this.rows.filter((row) =>
218
+ return this.rowsIn(table).filter((row) =>
172
219
  predicates.every((predicate) => predicate(row)),
173
220
  );
174
221
  }
package/src/user.test.ts CHANGED
@@ -93,9 +93,25 @@ describe("the User Contribution", () => {
93
93
  await expect(
94
94
  audit.indexAuditEntries(Array.from({ length: 513 }, () => entry())),
95
95
  ).rejects.toThrow("bound");
96
- await expect(
97
- audit.indexAuditEntries([{ ...entry(), turn: 7 }]),
98
- ).rejects.toThrow("disagree");
96
+ });
97
+
98
+ test("quarantines one undecodable entry and indexes the rest", async () => {
99
+ const audit = contribution();
100
+
101
+ // The page carries one entry whose `turn` disagrees with its occurrence id
102
+ // — reachable in production through the tool-name length and slug mismatch
103
+ // between the classifier and the wire codec.
104
+ const receipt = await audit.indexAuditEntries([
105
+ entry(),
106
+ { ...entry(), turn: 7 },
107
+ ]);
108
+
109
+ // Before this, the whole page threw. The throw was swallowed at the Bot's
110
+ // drain, the outbox was never drained again, and every later entry was
111
+ // dropped at the 512 bound: one malformed row cost the Bot its whole audit
112
+ // trail, silently.
113
+ expect(receipt).toEqual({ indexed: 1, quarantined: 1 });
114
+ expect(audit.query({}).entries).toHaveLength(1);
99
115
  });
100
116
 
101
117
  test("counts host-journal discrepancies without writing them in", async () => {
package/src/user.ts CHANGED
@@ -115,17 +115,36 @@ export class AuditUserBackendContribution {
115
115
  /**
116
116
  * Idempotent on `(botId, runId, occurrenceId)`; a redelivered outbox page
117
117
  * inserts nothing the second time.
118
+ *
119
+ * ONE BAD ENTRY IS ONE BAD ENTRY. Decoding used to be `input.map(decode)`,
120
+ * so a single undecodable entry threw for the whole page. The throw was
121
+ * swallowed at the Bot's drain, the outbox was never drained again, and
122
+ * every later entry was dropped at the 512 bound — one malformed row cost
123
+ * the Bot its entire audit trail, silently. Entries are decoded one at a
124
+ * time now: the bad one is quarantined and counted, the rest are indexed,
125
+ * and the page is accepted so the outbox clears.
118
126
  */
119
- async indexAuditEntries(input: unknown): Promise<{ indexed: number }> {
127
+ async indexAuditEntries(
128
+ input: unknown,
129
+ ): Promise<{ indexed: number; quarantined: number }> {
120
130
  if (!Array.isArray(input)) {
121
131
  throw new AuditDecodeError("audit entries must be an array");
122
132
  }
123
133
  if (input.length > AUDIT_MAX_ENTRY_PAGE_V1) {
124
134
  throw new AuditDecodeError("audit entries exceed their bound");
125
135
  }
126
- const entries = input.map(decodeAuditEntryV1);
136
+ const entries: AuditEntryV1[] = [];
137
+ let quarantined = 0;
138
+ for (const candidate of input) {
139
+ try {
140
+ entries.push(decodeAuditEntryV1(candidate));
141
+ } catch {
142
+ quarantined += 1;
143
+ }
144
+ }
127
145
  return {
128
146
  indexed: this.store.insert(this.resolve(entries, await this.hosts())),
147
+ quarantined,
129
148
  };
130
149
  }
131
150