@extrovert.dev/mcp 0.1.0-pre.3

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +313 -0
  3. package/dist/bin.d.ts +13 -0
  4. package/dist/bin.d.ts.map +1 -0
  5. package/dist/bin.js +89 -0
  6. package/dist/bin.js.map +1 -0
  7. package/dist/client.d.ts +956 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +1354 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/config.d.ts +51 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +53 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/contract.d.ts +62 -0
  16. package/dist/contract.d.ts.map +1 -0
  17. package/dist/contract.js +78 -0
  18. package/dist/contract.js.map +1 -0
  19. package/dist/extract.d.ts +25 -0
  20. package/dist/extract.d.ts.map +1 -0
  21. package/dist/extract.js +131 -0
  22. package/dist/extract.js.map +1 -0
  23. package/dist/fixtures.d.ts +676 -0
  24. package/dist/fixtures.d.ts.map +1 -0
  25. package/dist/fixtures.js +2685 -0
  26. package/dist/fixtures.js.map +1 -0
  27. package/dist/http.d.ts +18 -0
  28. package/dist/http.d.ts.map +1 -0
  29. package/dist/http.js +124 -0
  30. package/dist/http.js.map +1 -0
  31. package/dist/index.d.ts +17 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +18 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/server.d.ts +20 -0
  36. package/dist/server.d.ts.map +1 -0
  37. package/dist/server.js +36 -0
  38. package/dist/server.js.map +1 -0
  39. package/dist/stdio.d.ts +8 -0
  40. package/dist/stdio.d.ts.map +1 -0
  41. package/dist/stdio.js +22 -0
  42. package/dist/stdio.js.map +1 -0
  43. package/dist/tools.d.ts +27 -0
  44. package/dist/tools.d.ts.map +1 -0
  45. package/dist/tools.js +2752 -0
  46. package/dist/tools.js.map +1 -0
  47. package/dist/types.d.ts +1037 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/dist/types.js +83 -0
  50. package/dist/types.js.map +1 -0
  51. package/package.json +72 -0
@@ -0,0 +1,2685 @@
1
+ /**
2
+ * Offline fixture store.
3
+ *
4
+ * Every method here maps 1:1 onto a REST call in `client.ts`. Live MCP sessions
5
+ * use the Go REST API by default; this store is used for tests and offline demos
6
+ * when `EXTROVERT_MOCK=1`.
7
+ *
8
+ * The store is intentionally stateful within a process: creating an inbox,
9
+ * sending mail, and waiting for a reply all mutate the same in-memory data, so
10
+ * the example agent flow (redeem -> create_inbox -> send -> wait_for_email)
11
+ * produces coherent results offline.
12
+ */
13
+ import { extractSignals } from "./extract.js";
14
+ let seq = 1000;
15
+ function nextId(prefix) {
16
+ seq += 1;
17
+ return `${prefix}_${seq.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
18
+ }
19
+ /**
20
+ * The FIXED org/project the mock agent key is bound to. There is no mutable
21
+ * project selector — these are the offline mirror of the values the live key
22
+ * resolves from its stored binding (surfaced by whoami / enroll, stamped onto
23
+ * project-layer rules).
24
+ */
25
+ const MOCK_ORG_ID = "org_mock";
26
+ // Matches the SDK fixture + docs convention ("prj_…"); a shared cross-tool test
27
+ // can hardcode the same expected mock project id for both MCP and SDK output.
28
+ const MOCK_PROJECT_ID = "prj_mock";
29
+ /** Effective per-inbox recipient cap returned by the offline API fixture. */
30
+ const DEFAULT_DAILY_SEND_LIMIT = 75;
31
+ /**
32
+ * The recipient the mock seeds an active org-scope suppression for, so the
33
+ * suppression reads (check/list/revoke) and the `recipient_suppressed`
34
+ * send-rejection path have deterministic data offline.
35
+ */
36
+ export const SEEDED_SUPPRESSED_RECIPIENT = "unsubscribed@example.com";
37
+ /**
38
+ * The recipient whose delivery ALWAYS fails at the mock's outbound-provider boundary, so the
39
+ * `approved -> failed` edge and its terminal `send_failed` nudge are drivable
40
+ * offline. Without it the mock could only ever demonstrate the happy path, and a
41
+ * drain loop that never sees `send_failed` is a drain loop nobody proved
42
+ * terminates on failure — which is exactly how the missing terminal nudges
43
+ * survived unnoticed.
44
+ */
45
+ export const SEEDED_SEND_FAILURE_RECIPIENT = "bounce@example.com";
46
+ /** The scrubbed error the mock reports for {@link SEEDED_SEND_FAILURE_RECIPIENT}. */
47
+ const MOCK_SEND_FAILURE_ERROR = "delivery rejected by the recipient's mail server (550 mailbox unavailable)";
48
+ /**
49
+ * The mock mirror of `reviewloop.AllowedAgentActions` — the verbs that ARE legal
50
+ * from a given state. A `wrong_state` / `terminal` 409 carries these so the agent
51
+ * is told what to do instead of being left to guess; from a terminal state the
52
+ * reads are the only honest answer.
53
+ */
54
+ function allowedAgentActions(state) {
55
+ const actions = [];
56
+ // submit_revision targets needs_review WITH a revision bump — including the
57
+ // needs_review self-edge, which is legal precisely because a redraft bumps the
58
+ // revision and rewrites the draft.
59
+ if (["needs_review", "in_review", "chatting", "rejected", "stale", "stalled"].includes(state)) {
60
+ actions.push("submit_revision");
61
+ }
62
+ // post_review_chat from an AGENT actor: in_review/chatting always, and
63
+ // needs_review because an agent question does not open the draft.
64
+ if (["needs_review", "in_review", "chatting"].includes(state))
65
+ actions.push("post_review_chat");
66
+ if (["needs_review", "in_review", "chatting", "stale"].includes(state))
67
+ actions.push("restamp_review");
68
+ if (["needs_review", "in_review", "chatting", "stale", "stalled", "rejected", "failed"].includes(state)) {
69
+ actions.push("cancel_review");
70
+ }
71
+ return [...actions, "get_review", "list_review_events"];
72
+ }
73
+ /** Terminal states: nothing will ever move these rows (409 `terminal`). */
74
+ const TERMINAL_REVIEW_STATES = ["sent", "auto_sent", "cancelled"];
75
+ /**
76
+ * `closed` — the DEFINITIVE "am I done?" answer. `failed` is included even though
77
+ * it is not formally terminal: the console cannot re-approve it, so an agent told
78
+ * `closed:false` would wait forever on a row nobody is going to move.
79
+ */
80
+ function reviewClosed(state) {
81
+ return TERMINAL_REVIEW_STATES.includes(state) || state === "failed";
82
+ }
83
+ /**
84
+ * Enforce the project_id assertion contract (mock): a request `project_id` is an
85
+ * ASSERTION, never a selector. The mock binds every key to {@link MOCK_PROJECT_ID},
86
+ * so a non-matching assertion is a 403 — mirroring the SDK MockBackend and the
87
+ * real server (assertProjectMatch). Offline parity prevents the bug from only
88
+ * surfacing in production.
89
+ */
90
+ function assertProjectMatch(projectId) {
91
+ if (projectId !== undefined && projectId !== MOCK_PROJECT_ID) {
92
+ throw new ForbiddenError(`project_id "${projectId}" does not match the key's bound project.`);
93
+ }
94
+ }
95
+ /**
96
+ * Apply a metadata patch with merge-null-clear semantics (mirrors the server):
97
+ * a top-level `null` clears ALL metadata; otherwise the patch merges into the
98
+ * current object and a key whose value is `null` deletes that key.
99
+ */
100
+ function applyMetadataPatch(current, patch) {
101
+ if (patch === undefined)
102
+ return current;
103
+ if (patch === null)
104
+ return {};
105
+ const next = { ...current };
106
+ for (const [key, value] of Object.entries(patch)) {
107
+ if (value === null)
108
+ delete next[key];
109
+ else
110
+ next[key] = value;
111
+ }
112
+ return next;
113
+ }
114
+ function shortLabel() {
115
+ return Math.random().toString(36).slice(2, 5);
116
+ }
117
+ /** ruleSnapshotJSON renders a rule's restorable fields for an undo before/after column. */
118
+ function ruleSnapshotJSON(r) {
119
+ return JSON.stringify({
120
+ id: r.id,
121
+ lineage_id: r.lineage_id,
122
+ rev: r.rev,
123
+ scope: r.scope,
124
+ category_id: r.category_id,
125
+ rule_text: r.rule_text,
126
+ kind: r.kind,
127
+ priority: r.priority,
128
+ status: r.status,
129
+ });
130
+ }
131
+ /** Join the source MIME alternatives without turning a missing part into "null". */
132
+ function sourceMessageBody(message) {
133
+ return [message.text, message.html]
134
+ .filter((part) => typeof part === "string" && part.length > 0)
135
+ .join("\n");
136
+ }
137
+ export class FixtureStore {
138
+ inboxes = new Map();
139
+ messages = new Map(); // inbox_id -> messages
140
+ /** message id -> stored attachments (mock mirror of the real MIME parts). */
141
+ attachments = new Map();
142
+ /** webhook id -> registered webhook (mock mirror of extrovert_webhooks). */
143
+ webhooks = new Map();
144
+ /** entry id -> contact-list entry (mock mirror of extrovert_contact_lists). */
145
+ contactLists = new Map();
146
+ /** domain name -> onboarded domain (mock mirror of extrovert_domains). */
147
+ domains = new Map();
148
+ /** job id -> async job status (mock mirror of extrovert_jobs; currently only
149
+ * the domain-offboard teardown enqueues one). */
150
+ jobs = new Map();
151
+ /** suppression id -> recipient opt-out row (mock mirror of extrovert_suppressions). */
152
+ suppressions = new Map();
153
+ /** review id -> review request (mock mirror of extrovert_review_requests). */
154
+ reviews = new Map();
155
+ /** review id -> append-only thread turns (mock mirror of the turn log). */
156
+ reviewTurns = new Map();
157
+ /**
158
+ * review id -> reviewer hand-back count (M8 Slice B circuit breaker (a)). The wire
159
+ * Review shape doesn't carry hop_count, so the mock tracks it here to surface the
160
+ * max_hops breaker on the decision context + reviewer_decide.
161
+ */
162
+ reviewHopCounts = new Map();
163
+ /**
164
+ * review id -> durable nudges for that review (mock mirror of
165
+ * extrovert_review_nudges), oldest-first with a per-review monotonic seq. The
166
+ * authoritative liveness queue (spec §4.5) the agent drains/acks.
167
+ */
168
+ reviewEvents = new Map();
169
+ /** review id -> the agent's last-acked seq (the per-(agent, review) cursor). */
170
+ reviewEventCursors = new Map();
171
+ /** review id -> the thread a queued reply/forward delivers into (materialized at submit). */
172
+ reviewThreads = new Map();
173
+ /** review id -> the opaque parent message id a queued reply threads to. */
174
+ reviewParents = new Map();
175
+ /** category id -> category (mock mirror of extrovert_categories, D9/D10). */
176
+ categories = new Map();
177
+ /** rule id -> writing rule (mock mirror of extrovert_writing_rules, D2/D11). */
178
+ rules = new Map();
179
+ /** udo id -> change/undo audit row (mock mirror of extrovert_rule_undo_log). */
180
+ ruleAudit = new Map();
181
+ /** human_email -> mock self-signup state (in-memory OTP). */
182
+ signups = new Map();
183
+ /** "<scope>:<client_id>" -> the created resource id, mirroring the server's
184
+ * idempotency replay (a repeat with the same key returns the first result). */
185
+ idempotency = new Map();
186
+ agentId = "agt_demo7";
187
+ /**
188
+ * The ceiling tier of the session's key (redesign §3.1). Default `project`
189
+ * (legacy bare `pk_agent_` behavior). Drives the bare-vs-wildcard list ceiling:
190
+ * an `org` key must pick a breadth (`breadth_required`); a non-org key cannot use
191
+ * the org wildcard (`forbidden_scope`). Mirrors the live choke-point so the
192
+ * isolation contract is exercised offline.
193
+ */
194
+ keyTier;
195
+ /**
196
+ * The org's review policy, mirrored offline so the mock enforces the SAME tree
197
+ * the server does.
198
+ *
199
+ * The default is `require_review` — deliberately, and matching the column
200
+ * default every real account gets. A mock that defaulted to `allow_direct` would
201
+ * teach every offline agent that a bare send just sends, which is precisely the
202
+ * lie that let the wire bug live for months: the mock passed while the real API
203
+ * refused. Override it with `EXTROVERT_MOCK_REVIEW_POLICY=allow_direct` when a
204
+ * test needs a delivered message rather than a queued review.
205
+ */
206
+ reviewPolicy;
207
+ /**
208
+ * The `front_run_next` nudge keys already enqueued, so N identical retries
209
+ * against a terminal review collapse to ONE row (the server dedupes on a
210
+ * deterministic key over reason + review + terminal state + parent revision).
211
+ */
212
+ frontRunKeys = new Set();
213
+ constructor(opts = {}) {
214
+ this.keyTier = opts.keyTier ?? "project";
215
+ this.reviewPolicy = opts.reviewPolicy ?? "require_review";
216
+ this.seed();
217
+ }
218
+ /** The resolved review policy for this account (mock mirror of the inbox read). */
219
+ effectiveReviewPolicy() {
220
+ return this.reviewPolicy;
221
+ }
222
+ /**
223
+ * Persist a review and recompute the derived `closed` flag from its state. Every
224
+ * mutation goes through here so `closed` can never drift from `state` — an agent
225
+ * polling `closed` after a crash is trusting exactly this.
226
+ */
227
+ commitReview(review) {
228
+ review.closed = reviewClosed(review.state);
229
+ this.reviews.set(review.id, review);
230
+ return review;
231
+ }
232
+ /**
233
+ * The mock mirror of the submit-time D3 gate. A resolved-review send REQUIRES an
234
+ * intent; a bare send/reply/forward has none by construction, so under anything
235
+ * but an explicitly-asserted direct mode on an `allow_direct` account it is
236
+ * refused — nothing sent, nothing queued.
237
+ */
238
+ resolvedModeIsReview(mode) {
239
+ return !(this.reviewPolicy === "allow_direct" && mode === "direct");
240
+ }
241
+ /**
242
+ * The SUBMIT-TIME pre-flight: contact lists and list-unsubscribe suppression,
243
+ * run against the resolved recipient set BEFORE the intent gate.
244
+ *
245
+ * The order matters and mirrors the server exactly. A blocked or suppressed
246
+ * recipient is a fact about the message that no amount of intent will fix, so
247
+ * answering `intent_required` first would send the agent off to add an intent
248
+ * and retry straight into the same wall. Running it before the review is created
249
+ * also means no human is ever handed a draft that could never have been sent.
250
+ */
251
+ preflight(fromAddress, recipients) {
252
+ this.enforceSendPolicy(fromAddress, recipients);
253
+ this.enforceSuppression(recipients);
254
+ }
255
+ /**
256
+ * Reject a BARE send/reply/forward under a policy that requires review. The
257
+ * error mirrors the server's 422 `intent_required` remediation, including the
258
+ * `retry_with` example, so an offline agent recovers exactly the way it would
259
+ * against the live API.
260
+ */
261
+ requireDirectSendAllowed(verb) {
262
+ if (this.reviewPolicy === "allow_direct")
263
+ return;
264
+ throw new IntentRequiredError(`This inbox requires human review before sending (review policy: ${this.reviewPolicy}, from the account default; ` +
265
+ "no per-inbox override). Nothing was sent and nothing was queued. Retry the SAME request with an `intent` " +
266
+ 'object added: {"intent":{"summary":"<one sentence: who you are writing to, what you want, and why now>"}}. ' +
267
+ "That summary is the first thing the human reviewer reads; 8-200 characters. On success you get a queued " +
268
+ "review id (rr_…); then monitor it with wait_for_review_event / list_review_events until you receive a `sent` " +
269
+ "or `send_failed` event.", [
270
+ {
271
+ field: "intent.summary",
272
+ code: "required",
273
+ detail: "One sentence for the human reviewer: who / what / why. 8-200 chars.",
274
+ },
275
+ {
276
+ field: "policy",
277
+ code: "review_policy",
278
+ detail: `${this.reviewPolicy} (source: account default; inbox override: none)`,
279
+ },
280
+ {
281
+ field: "retry_with",
282
+ code: "example",
283
+ detail: '{"intent":{"summary":"Follow up with vp@acme.com on the Q3 pilot; 2 prior touches"}}',
284
+ },
285
+ { field: "verb", code: verb, detail: "the request that was refused" },
286
+ ]);
287
+ }
288
+ // ---- self-signup + auth (Slice E) -------------------------------------
289
+ signUp(input) {
290
+ const email = input.human_email.trim().toLowerCase();
291
+ const existing = this.signups.get(email);
292
+ const customerId = existing?.customerId ?? nextId("cus");
293
+ const agentId = existing?.agentId ?? nextId("agt");
294
+ const address = existing?.address ?? `${input.username ?? "agent" + shortLabel()}@smtp.extrovert.dev`;
295
+ const otp = String(Math.floor(100000 + Math.random() * 900000));
296
+ this.signups.set(email, { customerId, agentId, address, otp, verified: false });
297
+ const keyPrefix = "pk_agent_" + nextId("").split("_")[1];
298
+ return {
299
+ customer_id: customerId,
300
+ agent_id: agentId,
301
+ agent_key: `${keyPrefix}_${Math.random().toString(36).slice(2)}`,
302
+ key_prefix: keyPrefix,
303
+ scopes: ["mailbox:read"],
304
+ address,
305
+ verified: false,
306
+ otp_sent_to: email,
307
+ otp_expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
308
+ message: "A verification code was sent to your email. Call verify with it.",
309
+ };
310
+ }
311
+ verify(otp) {
312
+ for (const [, s] of this.signups) {
313
+ if (!s.verified && s.otp === otp.trim()) {
314
+ s.verified = true;
315
+ const keyPrefix = "pk_agent_" + nextId("").split("_")[1];
316
+ return {
317
+ agent_id: s.agentId,
318
+ agent_key: `${keyPrefix}_${Math.random().toString(36).slice(2)}`,
319
+ key_prefix: keyPrefix,
320
+ scopes: ["mailbox:create", "mailbox:read", "mailbox:send"],
321
+ verified: true,
322
+ message: "Verified. Use the new agent_key (full scopes).",
323
+ };
324
+ }
325
+ }
326
+ throw new NotFoundError("verification code invalid or expired");
327
+ }
328
+ whoami() {
329
+ return {
330
+ customer_id: "cus_pn_mock",
331
+ org_id: MOCK_ORG_ID,
332
+ project_id: MOCK_PROJECT_ID,
333
+ agent_id: this.agentId,
334
+ key_id: "pkey_mock",
335
+ scopes: ["mailbox:create", "mailbox:read", "mailbox:send"],
336
+ };
337
+ }
338
+ // ---- enrollment -------------------------------------------------------
339
+ redeemEnrollment(token, agentHandle) {
340
+ const keyPrefix = "pk_agent_" + nextId("").split("_")[1];
341
+ const result = {
342
+ agent_id: this.agentId,
343
+ agent_key: `${keyPrefix}_${Math.random().toString(36).slice(2)}${Math.random()
344
+ .toString(36)
345
+ .slice(2)}`,
346
+ scopes: ["mailbox:create", "mailbox:read", "mailbox:send"],
347
+ org_id: MOCK_ORG_ID,
348
+ project_id: MOCK_PROJECT_ID,
349
+ };
350
+ // token is validated server-side in production; offline we accept any.
351
+ void token;
352
+ void agentHandle;
353
+ return result;
354
+ }
355
+ // ---- inboxes ----------------------------------------------------------
356
+ createInbox(opts) {
357
+ // A project_id assertion must match the key's bound project (403 on mismatch),
358
+ // mirroring the SDK mock + the real server.
359
+ assertProjectMatch(opts.projectId);
360
+ // Idempotency replay: a repeat with the same client id returns the first inbox.
361
+ const idemKey = opts.clientId?.trim() ? `inbox.create:${opts.clientId.trim()}` : "";
362
+ if (idemKey) {
363
+ const existingId = this.idempotency.get(idemKey);
364
+ const existing = existingId ? this.inboxes.get(existingId) : undefined;
365
+ if (existing)
366
+ return existing;
367
+ }
368
+ const username = (opts.username ?? `agent${this.inboxes.size + 1}`).toLowerCase();
369
+ const domain = opts.domain ?? `${shortLabel()}.smtp.extrovert.dev`;
370
+ const inbox = {
371
+ object: "inbox",
372
+ // Mint the canonical opaque inbox id with the LIVE prefix (`pmbx_…`,
373
+ // Appendix A); the public contract is "treat it as opaque."
374
+ id: nextId("pmbx"),
375
+ org_id: MOCK_ORG_ID,
376
+ project_id: MOCK_PROJECT_ID,
377
+ address: `${username}@${domain}`,
378
+ domain,
379
+ onboarding_mode: "shared",
380
+ status: "live",
381
+ agent_id: this.agentId,
382
+ created_at: new Date().toISOString(),
383
+ sender_verified: true,
384
+ daily_send_limit: DEFAULT_DAILY_SEND_LIMIT,
385
+ // Metadata is always an object on a read (`{}` when none is set); a create
386
+ // patch drops any `null` values per the merge-null-clear semantics.
387
+ metadata: applyMetadataPatch({}, opts.metadata),
388
+ };
389
+ if (opts.displayName)
390
+ inbox.display_name = opts.displayName;
391
+ if (opts.inboundWebhookUrl)
392
+ inbox.webhook_url = opts.inboundWebhookUrl;
393
+ this.inboxes.set(inbox.id, inbox);
394
+ this.messages.set(inbox.id, []);
395
+ if (idemKey)
396
+ this.idempotency.set(idemKey, inbox.id);
397
+ return inbox;
398
+ }
399
+ listInboxes(opts = {}) {
400
+ const o = typeof opts === "number" ? { limit: opts } : opts;
401
+ const limit = o.limit ?? 20;
402
+ // Enforce the same bare-vs-wildcard ceiling the live choke-point applies
403
+ // (redesign §4.1), so the isolation contract is exercised offline.
404
+ const projectSegment = o.wildcard ? "-" : o.project;
405
+ if (projectSegment === undefined) {
406
+ // Bare list. An org key must pick a breadth; project/inbox keys default to
407
+ // their (implicit) project — exactly today's behavior.
408
+ if (this.keyTier === "org") {
409
+ throw new BreadthRequiredError("An org-tier key must pick a list breadth: pass a project id or wildcard=true (the org subtree).");
410
+ }
411
+ }
412
+ else if (projectSegment === "-") {
413
+ // The org wildcard is reserved for org keys (a non-org key is 403).
414
+ if (this.keyTier !== "org") {
415
+ throw new ForbiddenError("Only an org-tier key may list the org subtree (/v1/projects/-/inboxes).");
416
+ }
417
+ }
418
+ else if (projectSegment !== MOCK_PROJECT_ID) {
419
+ // A concrete project outside the key's ceiling is a 404 (no existence leak).
420
+ throw new NotFoundError(`project "${projectSegment}" is outside this key's ceiling.`);
421
+ }
422
+ const items = [...this.inboxes.values()]
423
+ .sort((a, b) => b.created_at.localeCompare(a.created_at))
424
+ .slice(0, limit);
425
+ return { items, total: this.inboxes.size };
426
+ }
427
+ getInbox(idOrAddress) {
428
+ const inbox = this.resolveInbox(idOrAddress);
429
+ if (!inbox)
430
+ return undefined;
431
+ // Only the SINGLE-inbox read carries the policy — the list path omits it
432
+ // because the value is identical for every inbox in the org.
433
+ return { ...inbox, effective_review_policy: this.reviewPolicy };
434
+ }
435
+ /** Update an inbox's settings in place (mirrors PATCH /v1/inboxes/{inbox_id}). */
436
+ updateInbox(idOrAddress, opts) {
437
+ // A project_id assertion must match the key's bound project (403 on mismatch),
438
+ // mirroring the SDK mock + the real server.
439
+ assertProjectMatch(opts.projectId);
440
+ const inbox = this.resolveInbox(idOrAddress);
441
+ if (!inbox)
442
+ return undefined;
443
+ if (opts.displayName !== undefined) {
444
+ inbox.display_name = opts.displayName || undefined;
445
+ }
446
+ if (opts.inboundWebhookUrl !== undefined) {
447
+ inbox.webhook_url = opts.inboundWebhookUrl || undefined;
448
+ }
449
+ if (opts.dailySendLimit !== undefined) {
450
+ if (!Number.isInteger(opts.dailySendLimit) ||
451
+ opts.dailySendLimit < 1 ||
452
+ opts.dailySendLimit > 10_000) {
453
+ throw new Error("daily_send_limit must be an integer from 1 through 10000");
454
+ }
455
+ inbox.daily_send_limit = opts.dailySendLimit;
456
+ }
457
+ if (opts.metadata !== undefined) {
458
+ // Shallow merge: object merges (null value deletes a key); top-level null
459
+ // clears all. Omitting `metadata` (handled above) leaves it unchanged.
460
+ inbox.metadata = applyMetadataPatch(inbox.metadata ?? {}, opts.metadata);
461
+ }
462
+ return inbox;
463
+ }
464
+ getCredentials(idOrAddress) {
465
+ const inbox = this.resolveInbox(idOrAddress);
466
+ if (!inbox)
467
+ throw new NotFoundError(`Inbox not found: ${idOrAddress}`);
468
+ return {
469
+ address: inbox.address,
470
+ username: inbox.address,
471
+ // Deterministic offline stand-in; the live API returns the real password.
472
+ password: `fixture-pw-${inbox.id}`,
473
+ imap: { host: "smtp.extrovert.dev", port: 993, security: "tls" },
474
+ smtp: { host: "smtp.extrovert.dev", port: 587, security: "starttls" },
475
+ };
476
+ }
477
+ deleteInbox(idOrAddress) {
478
+ const inbox = this.resolveInbox(idOrAddress);
479
+ if (!inbox)
480
+ return undefined;
481
+ this.inboxes.delete(inbox.id);
482
+ this.messages.delete(inbox.id);
483
+ return { id: inbox.id, deleted: true };
484
+ }
485
+ // ---- messages ---------------------------------------------------------
486
+ /**
487
+ * A BARE send — no mode/intent/category_id. Policy-gated exactly like the live
488
+ * endpoint: only an `allow_direct` account delivers here; under `require_review`
489
+ * (the default) this is 422 `intent_required` with the remediation attached, and
490
+ * nothing is sent or queued.
491
+ */
492
+ sendEmail(opts) {
493
+ const inbox = this.requireInbox(opts.inbox);
494
+ this.preflight(inbox.address, [...opts.to, ...(opts.cc ?? []), ...(opts.bcc ?? [])]);
495
+ this.requireDirectSendAllowed("send");
496
+ const msg = this.deliverSend(opts);
497
+ return { status: "sent", message_id: msg.id, review_id: this.recordDirectReview("send", msg, opts.subject, opts.to) };
498
+ }
499
+ /**
500
+ * The actual mock delivery, shared by the bare direct path and the review
501
+ * loop's approve/auto-send dispatch. Policy is enforced by the CALLERS, never
502
+ * here: an approved review has already passed the human and must deliver.
503
+ */
504
+ deliverSend(opts) {
505
+ const inbox = this.requireInbox(opts.inbox);
506
+ const recipients = [...opts.to, ...(opts.cc ?? []), ...(opts.bcc ?? [])];
507
+ // Contact lists (Slice 3): reject a block-listed recipient, or any recipient
508
+ // outside the allowlist when allowlist mode is active.
509
+ this.enforceSendPolicy(inbox.address, recipients);
510
+ // Suppression (list-unsubscribe): reject the WHOLE send if any recipient has an
511
+ // active org-scope opt-out, naming exactly the suppressed addresses to drop.
512
+ this.enforceSuppression(recipients);
513
+ const msg = this.appendMessage(inbox.id, {
514
+ direction: "outbound",
515
+ fromName: inbox.display_name,
516
+ fromEmail: inbox.address,
517
+ to: opts.to,
518
+ cc: opts.cc,
519
+ subject: opts.subject,
520
+ text: opts.text,
521
+ html: opts.html,
522
+ threadId: nextId("thr"),
523
+ ageMinutes: 0,
524
+ attachments: opts.attachments,
525
+ });
526
+ // Offline affordance: queue a believable inbound reply so wait_for_email
527
+ // and read_messages return something coherent in the demo flow.
528
+ this.queueAutoReply(inbox, msg);
529
+ return msg;
530
+ }
531
+ /**
532
+ * Thread-aware reply (mock). Resolves the parent by message_id or the latest
533
+ * message in thread_id, derives recipients/subject server-side, and returns the
534
+ * canonical `{message_id, thread_id}` — matching the real API contract.
535
+ */
536
+ replyEmail(opts) {
537
+ this.requireInbox(opts.inbox);
538
+ this.requireDirectSendAllowed("reply");
539
+ return this.deliverReply(opts);
540
+ }
541
+ /** The mock reply delivery, shared by the direct path and approval dispatch. */
542
+ deliverReply(opts) {
543
+ const inbox = this.requireInbox(opts.inbox);
544
+ const all = this.messages.get(inbox.id) ?? [];
545
+ let parent;
546
+ let threadId = opts.threadId;
547
+ if (opts.messageId) {
548
+ parent = all.find((m) => m.id === opts.messageId);
549
+ if (!parent)
550
+ throw new NotFoundError(`Message not found: ${opts.messageId}`);
551
+ threadId = parent.thread_id;
552
+ }
553
+ else if (opts.threadId) {
554
+ parent = all.filter((m) => m.thread_id === opts.threadId).at(-1);
555
+ }
556
+ else {
557
+ throw new NotFoundError("reply requires thread_id or message_id");
558
+ }
559
+ const to = [];
560
+ if (parent) {
561
+ to.push(parent.from.email);
562
+ if (opts.replyAll)
563
+ for (const a of parent.to)
564
+ if (a.email !== inbox.address)
565
+ to.push(a.email);
566
+ }
567
+ const msg = this.appendMessage(inbox.id, {
568
+ direction: "outbound",
569
+ fromName: inbox.display_name,
570
+ fromEmail: inbox.address,
571
+ to: to.length ? to : ["reply@example.com"],
572
+ cc: opts.cc,
573
+ subject: parent ? reSubject(parent.subject) : "Re:",
574
+ text: opts.text ?? "",
575
+ html: opts.html,
576
+ threadId: threadId ?? nextId("thr"),
577
+ ageMinutes: 0,
578
+ attachments: opts.attachments,
579
+ });
580
+ return { message_id: msg.id, thread_id: msg.thread_id };
581
+ }
582
+ /**
583
+ * Forward an existing message to new recipients (mock). Policy-gated like send
584
+ * and reply: a forward quotes an entire received thread to arbitrary NEW
585
+ * recipients, so leaving it ungated would make it the documented bypass.
586
+ */
587
+ forwardEmail(opts) {
588
+ const inbox = this.requireInbox(opts.inbox);
589
+ this.preflight(inbox.address, [...opts.to, ...(opts.cc ?? []), ...(opts.bcc ?? [])]);
590
+ this.requireDirectSendAllowed("forward");
591
+ return this.deliverForward(opts);
592
+ }
593
+ /** The mock forward delivery, shared by the direct path and approval dispatch. */
594
+ deliverForward(opts) {
595
+ const inbox = this.requireInbox(opts.inbox);
596
+ const parent = (this.messages.get(inbox.id) ?? []).find((m) => m.id === opts.messageId);
597
+ if (!parent)
598
+ throw new NotFoundError(`Message not found: ${opts.messageId}`);
599
+ const msg = this.appendMessage(inbox.id, {
600
+ direction: "outbound",
601
+ fromName: inbox.display_name,
602
+ fromEmail: inbox.address,
603
+ to: opts.to,
604
+ cc: opts.cc,
605
+ subject: fwdSubject(parent.subject),
606
+ text: forwardBody(opts.text, parent),
607
+ threadId: parent.thread_id,
608
+ ageMinutes: 0,
609
+ });
610
+ return { message_id: msg.id, thread_id: msg.thread_id };
611
+ }
612
+ // ---- Review Loop (HITL) -----------------------------------------------
613
+ /**
614
+ * Submit a new message for review (mock). Mirrors the server's deterministic
615
+ * routing: a `direct` mode is sent immediately (`kind:"sent"`); otherwise the
616
+ * message is parked in `needs_review` (`kind:"queued_for_review"`). Intent is
617
+ * required when the resolved mode is review (D3).
618
+ */
619
+ submitForReview(input) {
620
+ const inbox = this.requireInbox(input.inbox);
621
+ // The POLICY resolves the mode, not the caller: an asserted `direct` under
622
+ // require_review is downgraded to review, which is what makes the policy
623
+ // binding rather than advisory.
624
+ const asserted = input.mode === "direct" ? "direct" : "review";
625
+ // Pre-flight BEFORE the intent gate — see preflight()'s note on the ordering.
626
+ this.preflight(inbox.address, [...input.to, ...(input.cc ?? []), ...(input.bcc ?? [])]);
627
+ const isReview = this.resolvedModeIsReview(asserted);
628
+ if (isReview && !input.intent?.summary?.trim()) {
629
+ throw new IntentRequiredError("intent summary is required when the resolved mode is review", [
630
+ {
631
+ field: "intent.summary",
632
+ code: "required",
633
+ detail: "One sentence for the human reviewer: who / what / why. 8-200 chars.",
634
+ },
635
+ {
636
+ field: "policy",
637
+ code: "review_policy",
638
+ detail: `${this.reviewPolicy} (source: account default; inbox override: none)`,
639
+ },
640
+ ]);
641
+ }
642
+ if (!isReview) {
643
+ const sent = this.deliverSend({
644
+ inbox: input.inbox,
645
+ to: input.to,
646
+ subject: input.subject ?? "",
647
+ text: input.text,
648
+ html: input.html,
649
+ cc: input.cc,
650
+ bcc: input.bcc,
651
+ reply_to: input.reply_to,
652
+ headers: input.headers,
653
+ attachments: input.attachments,
654
+ });
655
+ const reviewId = this.recordDirectReview("send", sent, input.subject ?? "", input.to);
656
+ return { kind: "sent", message: { id: sent.id, thread_id: sent.thread_id }, review: { id: reviewId } };
657
+ }
658
+ const review = this.createReviewRecord({
659
+ kind: "send",
660
+ fromAddress: inbox.address,
661
+ subject: input.subject ?? "",
662
+ text: input.text,
663
+ html: input.html,
664
+ to: input.to,
665
+ cc: input.cc,
666
+ bcc: input.bcc,
667
+ intent: input.intent,
668
+ categoryId: input.category_id,
669
+ });
670
+ return { kind: "queued_for_review", review: { id: review.id, state: review.state, effective_mode: review.effective_mode } };
671
+ }
672
+ /** Submit an in-thread reply for review (mock). Same routing as submitForReview. */
673
+ submitReplyForReview(input) {
674
+ const inbox = this.requireInbox(input.inbox);
675
+ const asserted = input.mode === "direct" ? "direct" : "review";
676
+ const isReview = this.resolvedModeIsReview(asserted);
677
+ if (isReview && !input.intent?.summary?.trim()) {
678
+ throw new IntentRequiredError("intent summary is required when the resolved mode is review");
679
+ }
680
+ if (!isReview) {
681
+ const res = this.deliverReply({
682
+ inbox: input.inbox,
683
+ threadId: input.thread_id,
684
+ messageId: input.message_id,
685
+ text: input.text,
686
+ html: input.html,
687
+ cc: input.cc,
688
+ bcc: input.bcc,
689
+ replyTo: input.reply_to,
690
+ replyAll: input.reply_all,
691
+ attachments: input.attachments,
692
+ });
693
+ return { kind: "sent", message: { id: res.message_id, thread_id: res.thread_id } };
694
+ }
695
+ // The reply's envelope is materialized AT SUBMIT — subject and recipients are
696
+ // derived here, not at approval — so the human reviews a message that actually
697
+ // has a subject line and recipients. A queued reply used to store neither.
698
+ const parent = this.resolveReplyParent(inbox.id, input.thread_id, input.message_id);
699
+ const to = parent ? [parent.from.email] : [];
700
+ if (parent && input.reply_all) {
701
+ for (const participant of parent.to) {
702
+ if (participant.email !== inbox.address && !to.includes(participant.email))
703
+ to.push(participant.email);
704
+ }
705
+ }
706
+ const review = this.createReviewRecord({
707
+ kind: "reply",
708
+ fromAddress: inbox.address,
709
+ subject: parent ? reSubject(parent.subject) : "Re:",
710
+ text: input.text,
711
+ html: input.html,
712
+ to,
713
+ cc: input.cc,
714
+ intent: input.intent,
715
+ categoryId: input.category_id,
716
+ replyThreadId: parent?.thread_id ?? input.thread_id,
717
+ replyParentId: parent?.id ?? input.message_id,
718
+ });
719
+ return { kind: "queued_for_review", review: { id: review.id, state: review.state, effective_mode: review.effective_mode } };
720
+ }
721
+ /**
722
+ * Submit a forward for review (mock). Same routing as submitForReview, with the
723
+ * forward's subject + quoted body MATERIALIZED here so the human reviews the
724
+ * exact bytes that go out rather than a body re-derived from the live parent at
725
+ * approval time (which would silently discard the reviewer's edit).
726
+ */
727
+ submitForwardForReview(input) {
728
+ const inbox = this.requireInbox(input.inbox);
729
+ this.preflight(inbox.address, [...input.to, ...(input.cc ?? []), ...(input.bcc ?? [])]);
730
+ const asserted = input.mode === "direct" ? "direct" : "review";
731
+ const isReview = this.resolvedModeIsReview(asserted);
732
+ if (isReview && !input.intent?.summary?.trim()) {
733
+ throw new IntentRequiredError("intent summary is required when the resolved mode is review");
734
+ }
735
+ if (!isReview) {
736
+ const res = this.deliverForward({
737
+ inbox: input.inbox,
738
+ messageId: input.message_id,
739
+ to: input.to,
740
+ cc: input.cc,
741
+ bcc: input.bcc,
742
+ text: input.text,
743
+ html: input.html,
744
+ });
745
+ return { kind: "sent", message: { id: res.message_id, thread_id: res.thread_id } };
746
+ }
747
+ const parent = (this.messages.get(inbox.id) ?? []).find((m) => m.id === input.message_id);
748
+ if (!parent)
749
+ throw new NotFoundError(`Message not found: ${input.message_id}`);
750
+ const review = this.createReviewRecord({
751
+ kind: "forward",
752
+ fromAddress: inbox.address,
753
+ subject: fwdSubject(parent.subject),
754
+ text: forwardBody(input.text, parent),
755
+ to: input.to,
756
+ cc: input.cc,
757
+ bcc: input.bcc,
758
+ intent: input.intent,
759
+ categoryId: input.category_id,
760
+ // A forward is NOT threaded to its parent — the new recipients were never in
761
+ // that conversation — but the delivered forward still answers with the
762
+ // parent's thread id, matching the direct forward path.
763
+ replyThreadId: parent.thread_id,
764
+ });
765
+ return { kind: "queued_for_review", review: { id: review.id, state: review.state, effective_mode: review.effective_mode } };
766
+ }
767
+ /** Resolve a reply's parent by message id, else the latest message in a thread. */
768
+ resolveReplyParent(inboxId, threadId, messageId) {
769
+ const all = this.messages.get(inboxId) ?? [];
770
+ if (messageId) {
771
+ const parent = all.find((m) => m.id === messageId);
772
+ if (!parent)
773
+ throw new NotFoundError(`Message not found: ${messageId}`);
774
+ return parent;
775
+ }
776
+ if (threadId)
777
+ return all.filter((m) => m.thread_id === threadId).at(-1);
778
+ throw new NotFoundError("reply requires thread_id or message_id");
779
+ }
780
+ /**
781
+ * Record the review row that governed a DIRECT (policy-permitted) send, park it
782
+ * in the terminal `auto_sent` state with `send_path: agent_direct`, and emit its
783
+ * one terminal `sent` nudge.
784
+ *
785
+ * The direct path used to hand back no handle at all, which meant an agent that
786
+ * crashed between the request and the response could never ask what became of
787
+ * the message. Leaving the dominant path handle-less would have entrenched
788
+ * exactly the crash-recovery hole the review loop exists to close.
789
+ */
790
+ recordDirectReview(kind, msg, subject, to) {
791
+ const review = this.createReviewRecord({
792
+ kind,
793
+ fromAddress: msg.from.email,
794
+ subject,
795
+ text: msg.text ?? "",
796
+ to,
797
+ mode: "direct",
798
+ });
799
+ review.state = "auto_sent";
800
+ review.version += 1;
801
+ review.sent_message_id = msg.id;
802
+ review.send_path = "agent_direct";
803
+ review.sent_at = new Date().toISOString();
804
+ review.updated_at = review.sent_at;
805
+ this.commitReview(review);
806
+ this.enqueueTerminalNudge(review, "sent");
807
+ return review.id;
808
+ }
809
+ /**
810
+ * Enqueue the ONE terminal nudge a finished review is allowed to produce.
811
+ *
812
+ * The invariant a drain loop is written against: every review that reaches
813
+ * `sent`, `auto_sent`, `failed` or `cancelled` emits exactly one terminal nudge,
814
+ * and it is the last and highest-`seq` nudge that review will ever produce. The
815
+ * payload carries everything the agent needs to stop — including WHY a send
816
+ * failed, which was previously stored and exposed on no surface at all.
817
+ */
818
+ enqueueTerminalNudge(review, reason) {
819
+ if (reason === "cancelled") {
820
+ this.enqueueReviewEvent(review.id, "cancelled", { state: "cancelled" });
821
+ return;
822
+ }
823
+ if (reason === "send_failed") {
824
+ this.enqueueReviewEvent(review.id, "send_failed", {
825
+ state: "failed",
826
+ error: review.send_error ?? "",
827
+ from_state: "approved",
828
+ // A failed review is NOT retryable: the row is absorbing. The only close-out
829
+ // is cancel_review; the message itself must be composed and submitted anew.
830
+ agent_retryable: false,
831
+ next_action: "compose_and_submit_a_new_message",
832
+ });
833
+ return;
834
+ }
835
+ // ONE `sent` reason covers both delivery flavors: `payload.state` distinguishes
836
+ // a human-reviewed `sent` from a `auto_sent`, and the agent's action — record
837
+ // the message id, ack, stop polling — is identical either way.
838
+ this.enqueueReviewEvent(review.id, "sent", {
839
+ state: review.state,
840
+ message_id: review.sent_message_id ?? "",
841
+ send_path: review.send_path ?? "",
842
+ decision: review.sent_body_text || review.sent_subject || review.diff_unified ? "edited" : "approved",
843
+ sent_at: review.sent_at ?? "",
844
+ });
845
+ }
846
+ /**
847
+ * Best-effort `front_run_next` nudge for an agent that tried to mutate a review
848
+ * somebody else already finished. It is enqueued OUTSIDE any transition (there
849
+ * is none) and deduped on a deterministic key, so a retry loop hitting the same
850
+ * 409 with the same parent_revision collapses N identical nudges to ONE row.
851
+ */
852
+ enqueueFrontRunNudge(review, parentRevision) {
853
+ const key = `front_run_next:${review.id}:${review.state}:${parentRevision}`;
854
+ if (this.frontRunKeys.has(key))
855
+ return;
856
+ this.frontRunKeys.add(key);
857
+ this.enqueueReviewEvent(review.id, "front_run_next", {
858
+ state: review.state,
859
+ sent_message_id: review.sent_message_id ?? "",
860
+ your_parent_revision: parentRevision,
861
+ current_revision: review.revision,
862
+ diff_available: !!review.diff_unified,
863
+ });
864
+ }
865
+ /**
866
+ * The mock mirror of the 409 taxonomy for a review-mutating verb. A terminal row
867
+ * and a wrong-phase row are DIFFERENT errors on purpose: one must never be
868
+ * retried, the other needs a different verb. Collapsing them to a single
869
+ * "conflict" is what made a skill's one 409 handler retry a sent message forever.
870
+ */
871
+ assertMutable(review, verb, parentRevision) {
872
+ if (TERMINAL_REVIEW_STATES.includes(review.state)) {
873
+ this.enqueueFrontRunNudge(review, parentRevision);
874
+ throw new TerminalError(`${verb} is not legal on a ${review.state} review: it is terminal and nothing will ever succeed. ` +
875
+ "STOP retrying — a front_run_next nudge is waiting for you.", review);
876
+ }
877
+ }
878
+ /** List review requests (mock), newest-first, with optional state/category/inbox filters. */
879
+ listReviews(input = {}) {
880
+ let items = [...this.reviews.values()].sort((a, b) => b.created_at.localeCompare(a.created_at));
881
+ if (input.state !== undefined) {
882
+ const states = Array.isArray(input.state) ? input.state : [input.state];
883
+ items = items.filter((r) => states.includes(r.state));
884
+ }
885
+ if (input.category_id)
886
+ items = items.filter((r) => r.category_id === input.category_id);
887
+ if (input.inbox) {
888
+ const inbox = input.inbox.toLowerCase();
889
+ items = items.filter((r) => r.from_address.toLowerCase() === inbox);
890
+ }
891
+ return { items, total: items.length };
892
+ }
893
+ /** Get one review request (mock). */
894
+ getReview(id) {
895
+ const review = this.reviews.get(id);
896
+ if (!review)
897
+ throw new NotFoundError(`Review not found: ${id}`);
898
+ return review;
899
+ }
900
+ /** Get a review's append-only thread turns (mock). */
901
+ getReviewTurns(id) {
902
+ if (!this.reviews.has(id))
903
+ throw new NotFoundError(`Review not found: ${id}`);
904
+ const items = this.reviewTurns.get(id) ?? [];
905
+ return { items, total: items.length };
906
+ }
907
+ /**
908
+ * Get the human's assembled feedback for a review (mock; M5): the diff + the
909
+ * human comments/rejection turns + the decision (derived from state) + the rules
910
+ * born from this review. Mirrors the server's $0-LLM assembly.
911
+ */
912
+ getReviewFeedback(id) {
913
+ const review = this.reviews.get(id);
914
+ if (!review)
915
+ throw new NotFoundError(`Review not found: ${id}`);
916
+ const turns = this.reviewTurns.get(id) ?? [];
917
+ const comments = turns
918
+ .filter((t) => (t.turn_type === "human_comment" ||
919
+ t.turn_type === "human_reject" ||
920
+ t.turn_type === "human_question") &&
921
+ (t.body ?? "").trim() !== "")
922
+ .map((t) => ({
923
+ turn_id: t.id,
924
+ actor_kind: t.actor_kind,
925
+ body: t.body ?? "",
926
+ actor_id: t.actor_id,
927
+ created_at: t.created_at,
928
+ }));
929
+ let diffJson;
930
+ for (const t of turns) {
931
+ if ((t.turn_type === "human_edit" || t.turn_type === "system_diff") && t.diff_json) {
932
+ diffJson = t.diff_json;
933
+ }
934
+ }
935
+ const edited = !!review.sent_body_text || !!review.sent_subject || !!review.diff_unified;
936
+ let decision;
937
+ if (review.state === "sent" || review.state === "auto_sent" || review.state === "approved") {
938
+ decision = edited ? "edited" : "approved";
939
+ }
940
+ else if (review.state === "rejected") {
941
+ decision = "rejected";
942
+ }
943
+ else {
944
+ decision = review.state;
945
+ }
946
+ const newRules = [...this.rules.values()]
947
+ .filter((r) => r.source_review_id === id)
948
+ .map((r) => r.id);
949
+ return {
950
+ review_id: id,
951
+ decision,
952
+ diff_unified: review.diff_unified,
953
+ diff_json: diffJson,
954
+ comments,
955
+ new_rules: newRules,
956
+ };
957
+ }
958
+ /**
959
+ * Post a chat turn on a review's thread (mock; M5): append an agent_question turn,
960
+ * flip in_review -> chatting on the first turn, enqueue a feedback_added event.
961
+ * Idempotent-key dedup is the transport's concern (replayed there); the mock just
962
+ * appends one turn per call.
963
+ */
964
+ postReviewChat(input) {
965
+ const review = this.reviews.get(input.id);
966
+ if (!review)
967
+ throw new NotFoundError(`Review not found: ${input.id}`);
968
+ this.assertMutable(review, "post_review_chat", review.revision);
969
+ // needs_review is legal for an AGENT actor: an agent asking a question does not
970
+ // open the draft or assign a reviewer, so it stays in the queue. (A HUMAN
971
+ // comment on a needs_review draft DOES open it — that asymmetry is the point.)
972
+ if (!["needs_review", "in_review", "chatting"].includes(review.state)) {
973
+ throw new WrongStateError(`post_review_chat is not legal while the draft is in '${review.state}'.`, review);
974
+ }
975
+ const turns = this.reviewTurns.get(input.id) ?? [];
976
+ turns.push({
977
+ id: nextId("turn"),
978
+ seq: turns.length + 1,
979
+ turn_type: "agent_question",
980
+ actor_kind: "agent",
981
+ actor_id: this.agentId,
982
+ body: input.text,
983
+ created_at: new Date().toISOString(),
984
+ });
985
+ this.reviewTurns.set(input.id, turns);
986
+ // in_review -> chatting only. A question posted on a needs_review draft leaves
987
+ // it in needs_review: an agent must not be able to pull a draft out of the
988
+ // human queue (or claim a reviewer) just by asking a question.
989
+ if (review.state === "in_review") {
990
+ review.state = "chatting";
991
+ review.version += 1;
992
+ review.updated_at = new Date().toISOString();
993
+ this.commitReview(review);
994
+ }
995
+ this.enqueueReviewEvent(input.id, "feedback_added", { actor: "agent" });
996
+ return review;
997
+ }
998
+ /**
999
+ * Post a new agent draft under a parent_revision CAS (mock; M5). A mismatch is a
1000
+ * 409 STALE with NO mutation (D17); a clean CAS re-renders the draft in place
1001
+ * (revision++), returns to needs_review, and enqueues a redraft_requested event.
1002
+ */
1003
+ submitRevision(input) {
1004
+ const review = this.reviews.get(input.id);
1005
+ if (!review)
1006
+ throw new NotFoundError(`Review not found: ${input.id}`);
1007
+ // TERMINAL first: nothing will ever move this row, so this must never be
1008
+ // retried — a distinct code from the stale CAS below, which MUST be.
1009
+ this.assertMutable(review, "submit_revision", input.parent_revision);
1010
+ // needs_review is a LEGAL source: the reviewer-reject, born-stale and
1011
+ // recheck_category paths all hand the composer a needs_review draft and nudge
1012
+ // it to redraft. A blanket self-edge ban used to deadlock exactly those loops.
1013
+ if (!["needs_review", "in_review", "chatting", "rejected", "stale", "stalled"].includes(review.state)) {
1014
+ throw new WrongStateError(`submit_revision is not legal while the draft is in '${review.state}'. Read the allowed_action hints and ` +
1015
+ "pick a legal verb — do NOT retry this one.", review);
1016
+ }
1017
+ if (review.revision !== input.parent_revision) {
1018
+ // STALE, not wrong_state: the draft is still live and the retry IS the fix —
1019
+ // re-read, re-apply your edit on top of theirs, resubmit with the new
1020
+ // parent_revision. Bounded (<=3): the human always wins (D17).
1021
+ throw new StaleError("parent_revision is stale; re-read the review and retry", review);
1022
+ }
1023
+ if (input.version !== undefined && review.version !== input.version) {
1024
+ throw new StaleError("version is stale; re-read the review and retry", review);
1025
+ }
1026
+ review.revision += 1;
1027
+ review.version += 1;
1028
+ review.state = "needs_review";
1029
+ if (input.subject !== undefined)
1030
+ review.proposed_subject = input.subject;
1031
+ // `text` is canonical; `body` is the deprecated alias. Both-but-different is a
1032
+ // caller bug the server rejects rather than guessing which bytes to relay.
1033
+ if (input.text !== undefined && input.body !== undefined && input.text !== input.body) {
1034
+ throw new ConflictingAliasError("`body` is a deprecated alias for `text`; send one or the other");
1035
+ }
1036
+ const newText = input.text ?? input.body;
1037
+ if (newText !== undefined)
1038
+ review.proposed_body_text = newText;
1039
+ if (input.html !== undefined)
1040
+ review.proposed_body_html = input.html;
1041
+ review.updated_at = new Date().toISOString();
1042
+ this.commitReview(review);
1043
+ const turns = this.reviewTurns.get(input.id) ?? [];
1044
+ turns.push({
1045
+ id: nextId("turn"),
1046
+ seq: turns.length + 1,
1047
+ turn_type: "agent_draft",
1048
+ actor_kind: "agent",
1049
+ actor_id: this.agentId,
1050
+ body: newText ?? review.proposed_body_text,
1051
+ revision: review.revision,
1052
+ created_at: new Date().toISOString(),
1053
+ });
1054
+ this.reviewTurns.set(input.id, turns);
1055
+ this.enqueueReviewEvent(input.id, "redraft_requested");
1056
+ return review;
1057
+ }
1058
+ /** Withdraw a pending review (mock; M5) to the terminal cancelled state. */
1059
+ cancelReview(id) {
1060
+ const review = this.reviews.get(id);
1061
+ if (!review)
1062
+ throw new NotFoundError(`Review not found: ${id}`);
1063
+ this.assertMutable(review, "cancel_review", review.revision);
1064
+ if (review.state === "approved") {
1065
+ throw new WrongStateError("cancel_review is not legal while the draft is in 'approved': it has been approved and is being delivered. " +
1066
+ "Wait for a `sent` or `send_failed` review event.", review);
1067
+ }
1068
+ review.state = "cancelled";
1069
+ review.version += 1;
1070
+ review.updated_at = new Date().toISOString();
1071
+ this.commitReview(review);
1072
+ this.enqueueTerminalNudge(review, "cancelled");
1073
+ return review;
1074
+ }
1075
+ /**
1076
+ * Re-stamp a draft's rules-version WITHOUT redrafting (mock; D19/§8 $0 escape valve).
1077
+ * Advances the version the draft is current against; no revision bump, no body change.
1078
+ * A terminal/approved draft 409s; against_version < 0 is invalid.
1079
+ */
1080
+ restampReview(input) {
1081
+ const review = this.reviews.get(input.id);
1082
+ if (!review)
1083
+ throw new NotFoundError(`Review not found: ${input.id}`);
1084
+ this.assertMutable(review, "restamp_review", review.revision);
1085
+ if (!["needs_review", "in_review", "chatting", "stale"].includes(review.state)) {
1086
+ throw new WrongStateError(`restamp_review applies only to a draft still sitting in the human queue (got '${review.state}').`, review);
1087
+ }
1088
+ if (input.against_version < 0) {
1089
+ throw new ConflictError("against_version must be >= 0");
1090
+ }
1091
+ review.version += 1;
1092
+ review.updated_at = new Date().toISOString();
1093
+ this.commitReview(review);
1094
+ return review;
1095
+ }
1096
+ // ---- BYO review-agent decision plane (M8 Slice B; D5/§9) ---------------
1097
+ //
1098
+ // The mock single-agent store acts as BOTH composer and reviewer, so it surfaces
1099
+ // the reviewer decision surface + the two circuit breakers without modeling the link
1100
+ // table. hop_count is tracked in a side map (the wire Review shape doesn't carry it);
1101
+ // the breakers use the schema defaults (max_hops=3, review_deadline_s=86400). A draft
1102
+ // is "reviewer-held" iff in_review/chatting (the queue states a reviewer can decide).
1103
+ /** Get the reviewer's decision context for a review (mock; §9). */
1104
+ getReviewDecisionContext(id) {
1105
+ const review = this.reviews.get(id);
1106
+ if (!review)
1107
+ throw new NotFoundError(`Review not found: ${id}`);
1108
+ const turns = this.reviewTurns.get(id) ?? [];
1109
+ const hopCount = this.reviewHopCounts.get(id) ?? 0;
1110
+ const maxHops = 3;
1111
+ const deadlineMs = new Date(review.created_at).getTime() + 86400 * 1000;
1112
+ const deadline = new Date(deadlineMs).toISOString();
1113
+ const deadlinePassed = Date.now() >= deadlineMs;
1114
+ const hopsExhausted = hopCount >= maxHops;
1115
+ const forceToHuman = hopsExhausted || deadlinePassed;
1116
+ return {
1117
+ review,
1118
+ turns,
1119
+ hop_count: hopCount,
1120
+ max_hops: maxHops,
1121
+ review_deadline: deadline,
1122
+ deadline_passed: deadlinePassed,
1123
+ hops_exhausted: hopsExhausted,
1124
+ force_to_human: forceToHuman,
1125
+ force_reason: hopsExhausted
1126
+ ? "max_hops_reached"
1127
+ : deadlinePassed
1128
+ ? "review_deadline_passed"
1129
+ : undefined,
1130
+ };
1131
+ }
1132
+ /**
1133
+ * Submit a reviewer decision (mock; §9). approve/edit → the platform "sends" with the
1134
+ * composer's creds (kind=sent, send_path=reviewer_approved); reject → back to the
1135
+ * composer (needs_review, hop_count++) UNLESS a breaker forces the human; escalate →
1136
+ * the human queue. revision/version are the CAS (409 STALE on mismatch, NO mutation).
1137
+ */
1138
+ reviewerDecide(input) {
1139
+ const review = this.reviews.get(input.id);
1140
+ if (!review)
1141
+ throw new NotFoundError(`Review not found: ${input.id}`);
1142
+ if (review.state === "sent" || review.state === "auto_sent" || review.state === "cancelled") {
1143
+ throw new ConflictError(`cannot decide a terminal review (${review.state})`);
1144
+ }
1145
+ // needs_review is a legal source: a reviewer (or a human on the console) may
1146
+ // approve/reject straight from the QUEUE without opening the draft first —
1147
+ // needs_review -> approved and needs_review -> rejected are both real edges.
1148
+ if (!["needs_review", "in_review", "chatting"].includes(review.state)) {
1149
+ throw new WrongStateError(`reviewer_decide is not legal while the draft is in '${review.state}'.`, review);
1150
+ }
1151
+ if (review.revision !== input.revision) {
1152
+ throw new ConflictError("revision is stale; re-read the decision context and retry");
1153
+ }
1154
+ if (input.version !== undefined && review.version !== input.version) {
1155
+ throw new ConflictError("version is stale; re-read the decision context and retry");
1156
+ }
1157
+ const hopCount = this.reviewHopCounts.get(input.id) ?? 0;
1158
+ const deadlineMs = new Date(review.created_at).getTime() + 86400 * 1000;
1159
+ const breakerTripped = hopCount >= 3 || Date.now() >= deadlineMs;
1160
+ if (input.action === "approve" || input.action === "edit") {
1161
+ if (input.action === "edit") {
1162
+ if (input.subject !== undefined)
1163
+ review.sent_subject = input.subject;
1164
+ if (input.body !== undefined)
1165
+ review.sent_body_text = input.body;
1166
+ }
1167
+ review.version += 1;
1168
+ review.updated_at = new Date().toISOString();
1169
+ // The approval DISPATCH can fail at the mail boundary, and that failure is
1170
+ // the case a real agent most needs to hear about: before the terminal nudges
1171
+ // existed the composer was never told, because the delivery happened on the
1172
+ // REVIEWER's call, not its own.
1173
+ if (review.proposed_to.some((r) => r.toLowerCase() === SEEDED_SEND_FAILURE_RECIPIENT)) {
1174
+ review.state = "failed";
1175
+ review.send_error = MOCK_SEND_FAILURE_ERROR;
1176
+ this.commitReview(review);
1177
+ this.enqueueTerminalNudge(review, "send_failed");
1178
+ return { kind: "sent_to_human", review, sent: false, sent_to_human: false };
1179
+ }
1180
+ review.state = "sent";
1181
+ review.sent_message_id = nextId("msg");
1182
+ review.send_path = "reviewer_approved";
1183
+ review.sent_at = review.updated_at;
1184
+ this.commitReview(review);
1185
+ // The nudge targets the COMPOSER, not the reviewer: the reviewer already
1186
+ // knows what it decided; the composing agent is the one still waiting.
1187
+ this.enqueueTerminalNudge(review, "sent");
1188
+ return { kind: "sent", review, sent: true, message_id: review.sent_message_id, sent_to_human: false };
1189
+ }
1190
+ // reject / escalate → the human queue (needs_review). A reject within budget bumps
1191
+ // hop_count and goes back to the composer; a tripped breaker FORCES the human.
1192
+ review.state = "needs_review";
1193
+ review.version += 1;
1194
+ if (input.feedback !== undefined)
1195
+ review.decision_feedback = input.feedback;
1196
+ review.updated_at = new Date().toISOString();
1197
+ this.commitReview(review);
1198
+ let forcedByBreaker;
1199
+ if (input.action === "reject" && !breakerTripped) {
1200
+ this.reviewHopCounts.set(input.id, hopCount + 1);
1201
+ }
1202
+ else if (input.action === "reject" && breakerTripped) {
1203
+ forcedByBreaker = hopCount >= 3 ? "max_hops_reached" : "review_deadline_passed";
1204
+ }
1205
+ return { kind: "sent_to_human", review, sent: false, sent_to_human: true, forced_by_breaker: forcedByBreaker };
1206
+ }
1207
+ // ---- Category registry (Review Loop, D9/D10) --------------------------
1208
+ /**
1209
+ * Browse the registry (mock), newest-first, excluding merged/soft-deleted
1210
+ * (merged_into set). `match` is a pure lexical filter (every token must appear in
1211
+ * name+description) — NO LLM, mirroring the server.
1212
+ */
1213
+ listCategories(match) {
1214
+ let items = [...this.categories.values()]
1215
+ .filter((c) => !c.merged_into)
1216
+ .sort((a, b) => b.created_at.localeCompare(a.created_at));
1217
+ const tokens = (match ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
1218
+ if (tokens.length) {
1219
+ items = items.filter((c) => {
1220
+ const hay = `${c.name} ${c.description}`.toLowerCase();
1221
+ return tokens.every((t) => hay.includes(t));
1222
+ });
1223
+ }
1224
+ return { items, total: items.length };
1225
+ }
1226
+ /** Get one category (mock); throws NotFound on an unknown id. */
1227
+ getCategory(id) {
1228
+ const cat = this.categories.get(id);
1229
+ if (!cat)
1230
+ throw new NotFoundError(`Category not found: ${id}`);
1231
+ return cat;
1232
+ }
1233
+ /** Propose a category (mock): stands immediately, author_kind=agent. */
1234
+ proposeCategory(input) {
1235
+ const name = input.name.trim();
1236
+ if (!name)
1237
+ throw new IntentRequiredError("name is required");
1238
+ const now = new Date().toISOString();
1239
+ const cat = {
1240
+ id: nextId("cat"),
1241
+ name,
1242
+ description: (input.description ?? "").trim(),
1243
+ scope: input.scope ?? "org_shared",
1244
+ state: "supervised",
1245
+ created_by_agent_id: this.agentId,
1246
+ author_kind: "agent",
1247
+ rule_high_water: 0,
1248
+ rules_version: 0,
1249
+ created_at: now,
1250
+ updated_at: now,
1251
+ };
1252
+ this.categories.set(cat.id, cat);
1253
+ return cat;
1254
+ }
1255
+ /** Rename / re-describe a category (mock) — metadata only (D10). */
1256
+ updateCategory(input) {
1257
+ const cat = this.categories.get(input.id);
1258
+ if (!cat)
1259
+ throw new NotFoundError(`Category not found: ${input.id}`);
1260
+ if (input.name !== undefined)
1261
+ cat.name = input.name.trim();
1262
+ if (input.description !== undefined)
1263
+ cat.description = input.description.trim();
1264
+ cat.updated_at = new Date().toISOString();
1265
+ this.categories.set(cat.id, cat);
1266
+ return cat;
1267
+ }
1268
+ // ---- Graduation + risk dial (Review Loop, D16/D6/D17) — agent READ + PROPOSE --
1269
+ /** The mock account-default risk dial (mirrors the server defaults). */
1270
+ accountDial() {
1271
+ return {
1272
+ min_confidence: 0.7,
1273
+ first_contact_gate: true,
1274
+ drift_demote_after: 3,
1275
+ canary_rate: 0.05,
1276
+ graduate_min_approvals: 20,
1277
+ graduate_min_age_hours: 24,
1278
+ auto_send_cap_per_day: 50,
1279
+ };
1280
+ }
1281
+ /**
1282
+ * Read the effective risk dial (mock): the account default + every category with
1283
+ * an inherited (null override) effective dial. The mock category carries no risk-
1284
+ * dial overrides, so every category inherits — effective == account.
1285
+ */
1286
+ getRiskDial() {
1287
+ const account = this.accountDial();
1288
+ const categories = [...this.categories.values()]
1289
+ .filter((c) => !c.merged_into)
1290
+ .map((c) => ({
1291
+ category_id: c.id,
1292
+ min_confidence: null,
1293
+ first_contact_gate: null,
1294
+ drift_demote_after: null,
1295
+ canary_rate: null,
1296
+ graduate_min_approvals: null,
1297
+ graduate_min_age_hours: null,
1298
+ effective: { ...account },
1299
+ }));
1300
+ return { account, categories };
1301
+ }
1302
+ /** The next rung up the graduation ladder ("" if none). */
1303
+ nextGraduationState(state) {
1304
+ if (state === "supervised")
1305
+ return "auto_notify";
1306
+ if (state === "auto_notify")
1307
+ return "auto_silent";
1308
+ return "";
1309
+ }
1310
+ /**
1311
+ * Read a category's graduation gate status (mock). The mock category has no
1312
+ * counters, so it reports zero clean approvals / zero drift against the account
1313
+ * defaults; can_graduate is true for supervised→auto_notify (no maturity gate).
1314
+ */
1315
+ getGraduationStatus(categoryId) {
1316
+ const cat = this.categories.get(categoryId);
1317
+ if (!cat)
1318
+ throw new NotFoundError(`Category not found: ${categoryId}`);
1319
+ const dial = this.accountDial();
1320
+ const next = this.nextGraduationState(cat.state);
1321
+ const cleanApprovals = 0;
1322
+ const driftCount = 0;
1323
+ const approvalsMet = cleanApprovals >= dial.graduate_min_approvals;
1324
+ const ageMet = false; // a fresh mock category is younger than min_age_hours.
1325
+ const maturityMet = approvalsMet && ageMet;
1326
+ const canGraduate = next === "auto_notify" ? true : next === "auto_silent" ? maturityMet : false;
1327
+ return {
1328
+ category_id: cat.id,
1329
+ state: cat.state,
1330
+ next_state: next,
1331
+ never_graduate: false,
1332
+ clean_approval_count: cleanApprovals,
1333
+ graduate_min_approvals: dial.graduate_min_approvals,
1334
+ approvals_met: approvalsMet,
1335
+ age_hours: 0,
1336
+ graduate_min_age_hours: dial.graduate_min_age_hours,
1337
+ age_met: ageMet,
1338
+ maturity_gate_met: maturityMet,
1339
+ drift_count: driftCount,
1340
+ drift_demote_after: dial.drift_demote_after,
1341
+ can_graduate: canGraduate,
1342
+ };
1343
+ }
1344
+ /**
1345
+ * Propose graduating a category (mock): records nothing mutating and returns the
1346
+ * current gate status. It does NOT flip the bit (D16) — the category state is
1347
+ * unchanged.
1348
+ */
1349
+ proposeGraduation(categoryId, _evidence) {
1350
+ return this.getGraduationStatus(categoryId);
1351
+ }
1352
+ /**
1353
+ * Read the D19/§8 backlog-reconciliation status (mock): counts the QUEUED drafts in a
1354
+ * category that are stale vs current-enough against the current rules-version. The
1355
+ * mock has no per-draft composed_* stamps, so every queued draft reads current-enough;
1356
+ * the contract shape is exercised (the integer-compare is covered by the Go tests).
1357
+ */
1358
+ getBacklogStatus(categoryId) {
1359
+ const cat = this.categories.get(categoryId);
1360
+ if (!cat)
1361
+ throw new NotFoundError(`Category not found: ${categoryId}`);
1362
+ let queued = 0;
1363
+ for (const review of this.reviews.values()) {
1364
+ if (review.category_id === categoryId &&
1365
+ (review.state === "needs_review" || review.state === "in_review" || review.state === "chatting")) {
1366
+ queued += 1;
1367
+ }
1368
+ }
1369
+ return {
1370
+ category_id: cat.id,
1371
+ state: cat.state,
1372
+ queued,
1373
+ current_enough: queued,
1374
+ stale: 0,
1375
+ current_category_rules_version: cat.rules_version,
1376
+ current_house_style_version: 0,
1377
+ staleness_tolerance: 0,
1378
+ };
1379
+ }
1380
+ /**
1381
+ * Read the demand-driven pacing state (mock — M7 Slice B/§8): the cursor + effective
1382
+ * window/ceiling/interval + each queued draft's classification. The mock has no cursor
1383
+ * (nothing reviewed) and no composed_* stamps, so every queued draft reads in-window-
1384
+ * fresh until the window fills, then ahead; the contract shape is exercised (the
1385
+ * cursor/staleness mechanics are covered by the Go tests).
1386
+ */
1387
+ getPacingState(categoryId) {
1388
+ const cat = this.categories.get(categoryId);
1389
+ if (!cat)
1390
+ throw new NotFoundError(`Category not found: ${categoryId}`);
1391
+ const lookaheadWindow = 3;
1392
+ const queuedReviews = [];
1393
+ for (const review of this.reviews.values()) {
1394
+ if (review.category_id === categoryId &&
1395
+ (review.state === "needs_review" || review.state === "in_review" || review.state === "chatting")) {
1396
+ queuedReviews.push({ id: review.id });
1397
+ }
1398
+ }
1399
+ const items = queuedReviews.map((r, i) => ({
1400
+ review_id: r.id,
1401
+ state: i < lookaheadWindow ? "in_window_fresh" : "ahead",
1402
+ }));
1403
+ return {
1404
+ category_id: cat.id,
1405
+ cursor_advanced_count: 0,
1406
+ lookahead_window: lookaheadWindow,
1407
+ rework_batch_max: 10,
1408
+ nudge_min_interval_ms: 5000,
1409
+ queued: queuedReviews.length,
1410
+ in_window: Math.min(queuedReviews.length, lookaheadWindow),
1411
+ redrafting: 0,
1412
+ items,
1413
+ };
1414
+ }
1415
+ // ---- Writing rules + house-style + precedence ladder + audit/undo ------
1416
+ /** rank a rule for the §7 ladder (mock mirror of the server's deterministic order). */
1417
+ ruleRank(r) {
1418
+ const hard = r.kind === "hard" ? 1 : 0;
1419
+ const spec = r.scope_agent_id ? 2 : r.scope === "category" ? 1 : 0;
1420
+ const human = r.author_kind === "human" ? 1 : 0;
1421
+ return [hard, spec, human, r.rev, r.priority];
1422
+ }
1423
+ /**
1424
+ * Get the ORDERED active rule set (mock). Applies the §7 precedence ladder and the
1425
+ * category-before-general concatenation, mirroring the server (NO LLM).
1426
+ */
1427
+ getRules(input = {}) {
1428
+ const active = [...this.rules.values()].filter((r) => r.status === "active");
1429
+ const byRank = (a, b) => {
1430
+ const ra = this.ruleRank(a);
1431
+ const rb = this.ruleRank(b);
1432
+ for (let i = 0; i < ra.length; i += 1) {
1433
+ const da = ra[i] ?? 0;
1434
+ const db = rb[i] ?? 0;
1435
+ if (da !== db)
1436
+ return db - da;
1437
+ }
1438
+ return a.id < b.id ? -1 : 1;
1439
+ };
1440
+ let general = [];
1441
+ let category = [];
1442
+ if (!input.scope || input.scope === "general") {
1443
+ general = active.filter((r) => r.scope === "general").sort(byRank);
1444
+ }
1445
+ if (input.category_id && (!input.scope || input.scope === "category")) {
1446
+ category = active.filter((r) => r.scope === "category" && r.category_id === input.category_id).sort(byRank);
1447
+ }
1448
+ const items = [...category, ...general];
1449
+ return { items, total: items.length };
1450
+ }
1451
+ /** Save / edit a rule (mock) — append-only by supersession (D11). */
1452
+ saveRule(input) {
1453
+ const text = input.rule_text.trim();
1454
+ if (!text)
1455
+ throw new IntentRequiredError("rule_text is required");
1456
+ const categoryId = (input.category_id ?? "").trim();
1457
+ const scope = input.scope ?? (categoryId ? "category" : "general");
1458
+ if (scope === "general" && categoryId)
1459
+ throw new ConflictError("scope=general forbids a category_id");
1460
+ if (scope === "category" && !categoryId)
1461
+ throw new ConflictError("scope=category requires a category_id");
1462
+ const now = new Date().toISOString();
1463
+ if (input.supersedes_id) {
1464
+ const prior = this.rules.get(input.supersedes_id);
1465
+ if (!prior)
1466
+ throw new NotFoundError(`Rule not found: ${input.supersedes_id}`);
1467
+ prior.status = "superseded";
1468
+ this.rules.set(prior.id, prior);
1469
+ const next = {
1470
+ ...prior,
1471
+ id: nextId("rule"),
1472
+ rev: prior.rev + 1,
1473
+ rule_text: text,
1474
+ kind: input.kind ?? prior.kind,
1475
+ priority: input.priority ?? prior.priority,
1476
+ status: "active",
1477
+ supersedes_id: prior.id,
1478
+ author_kind: "agent",
1479
+ created_at: now,
1480
+ updated_at: now,
1481
+ };
1482
+ this.rules.set(next.id, next);
1483
+ this.recordRuleAudit("supersede", next.id, ruleSnapshotJSON(prior), ruleSnapshotJSON(next));
1484
+ return next;
1485
+ }
1486
+ const rule = {
1487
+ id: nextId("rule"),
1488
+ // Agent-plane saves are ALWAYS project-layer, bound to the key's project;
1489
+ // creating org-layer/house-style rules is console/admin-only in v1.
1490
+ rule_layer: "project",
1491
+ org_id: MOCK_ORG_ID,
1492
+ project_id: MOCK_PROJECT_ID,
1493
+ lineage_id: nextId("rln"),
1494
+ rev: 1,
1495
+ scope,
1496
+ category_id: scope === "category" ? categoryId : undefined,
1497
+ scope_agent_id: input.scope_agent_id || undefined,
1498
+ rule_text: text,
1499
+ kind: input.kind ?? "soft",
1500
+ priority: input.priority ?? 0,
1501
+ status: "active",
1502
+ author_kind: "agent",
1503
+ created_at: now,
1504
+ updated_at: now,
1505
+ };
1506
+ this.rules.set(rule.id, rule);
1507
+ this.recordRuleAudit("create", rule.id, "{}", ruleSnapshotJSON(rule));
1508
+ return rule;
1509
+ }
1510
+ /** Promote a rule between the category and general layers (mock, via supersession). */
1511
+ promoteRule(id, toScope) {
1512
+ const prior = this.rules.get(id);
1513
+ if (!prior)
1514
+ throw new NotFoundError(`Rule not found: ${id}`);
1515
+ if (prior.scope === toScope)
1516
+ return prior;
1517
+ if (toScope === "category" && !prior.category_id)
1518
+ throw new ConflictError("promote to category needs a category");
1519
+ prior.status = "superseded";
1520
+ this.rules.set(prior.id, prior);
1521
+ const now = new Date().toISOString();
1522
+ const next = {
1523
+ ...prior,
1524
+ id: nextId("rule"),
1525
+ lineage_id: nextId("rln"),
1526
+ rev: 1,
1527
+ scope: toScope,
1528
+ category_id: toScope === "category" ? prior.category_id : undefined,
1529
+ status: "active",
1530
+ supersedes_id: prior.id,
1531
+ author_kind: "agent",
1532
+ created_at: now,
1533
+ updated_at: now,
1534
+ };
1535
+ this.rules.set(next.id, next);
1536
+ this.recordRuleAudit("supersede", next.id, ruleSnapshotJSON(prior), ruleSnapshotJSON(next));
1537
+ return next;
1538
+ }
1539
+ /** Retire a rule (mock) — soft delete; history survives. */
1540
+ retireRule(id) {
1541
+ const rule = this.rules.get(id);
1542
+ if (!rule)
1543
+ throw new NotFoundError(`Rule not found: ${id}`);
1544
+ if (rule.status === "retired")
1545
+ return rule;
1546
+ rule.status = "retired";
1547
+ rule.updated_at = new Date().toISOString();
1548
+ this.rules.set(rule.id, rule);
1549
+ this.recordRuleAudit("retire", rule.id, ruleSnapshotJSON(rule), ruleSnapshotJSON(rule));
1550
+ return rule;
1551
+ }
1552
+ /** Read the rule/category change audit log (mock). */
1553
+ getRuleAudit(input = {}) {
1554
+ let items = [...this.ruleAudit.values()].sort((a, b) => b.created_at.localeCompare(a.created_at));
1555
+ if (input.entity_kind)
1556
+ items = items.filter((e) => e.entity_kind === input.entity_kind);
1557
+ if (input.entity_id)
1558
+ items = items.filter((e) => e.entity_id === input.entity_id);
1559
+ return { items, total: items.length };
1560
+ }
1561
+ /** Undo a rule change (mock) — restore the prior version; idempotent (re-undo 409). */
1562
+ undoRuleChange(udoId) {
1563
+ const entry = this.ruleAudit.get(udoId);
1564
+ if (!entry)
1565
+ throw new NotFoundError(`Audit row not found: ${udoId}`);
1566
+ if (entry.entity_kind !== "rule")
1567
+ throw new ConflictError("only rule entities are restorable here");
1568
+ if (entry.undone)
1569
+ throw new ConflictError("already undone");
1570
+ const head = this.rules.get(entry.entity_id);
1571
+ if (!head)
1572
+ throw new NotFoundError(`Rule head not found: ${entry.entity_id}`);
1573
+ entry.undone = true;
1574
+ this.ruleAudit.set(entry.id, entry);
1575
+ const before = JSON.parse(entry.before_json ?? "{}");
1576
+ head.status = "superseded";
1577
+ this.rules.set(head.id, head);
1578
+ const now = new Date().toISOString();
1579
+ const restored = {
1580
+ ...head,
1581
+ id: nextId("rule"),
1582
+ rev: head.rev + 1,
1583
+ rule_text: before.rule_text ?? head.rule_text,
1584
+ status: before.rule_text ? "active" : "retired",
1585
+ supersedes_id: head.id,
1586
+ created_at: now,
1587
+ updated_at: now,
1588
+ };
1589
+ this.rules.set(restored.id, restored);
1590
+ this.recordRuleAudit("restore", restored.id, ruleSnapshotJSON(head), ruleSnapshotJSON(restored));
1591
+ return restored;
1592
+ }
1593
+ /** recordRuleAudit appends one change/undo audit row (mock). */
1594
+ recordRuleAudit(action, entityId, before, after) {
1595
+ const entry = {
1596
+ id: nextId("udo"),
1597
+ entity_kind: "rule",
1598
+ entity_id: entityId,
1599
+ action,
1600
+ actor_kind: "agent",
1601
+ actor_id: `agent:${this.agentId}`,
1602
+ before_json: before,
1603
+ after_json: after,
1604
+ undone: false,
1605
+ created_at: new Date().toISOString(),
1606
+ };
1607
+ this.ruleAudit.set(entry.id, entry);
1608
+ }
1609
+ /**
1610
+ * enqueueReviewEvent appends a durable nudge for a review with the next
1611
+ * per-review monotonic seq (mock mirror of the server's enqueue-on-transition).
1612
+ * Used by the seed + (in a fuller mock) by transition handlers.
1613
+ */
1614
+ enqueueReviewEvent(reviewId, reason, payload) {
1615
+ const list = this.reviewEvents.get(reviewId) ?? [];
1616
+ const last = list[list.length - 1];
1617
+ const seqNo = last ? last.seq + 1 : 1;
1618
+ const review = this.reviews.get(reviewId);
1619
+ const ev = {
1620
+ seq: seqNo,
1621
+ id: nextId("ndg"),
1622
+ reason,
1623
+ review_id: reviewId,
1624
+ category_id: review?.category_id,
1625
+ payload,
1626
+ created_at: new Date().toISOString(),
1627
+ };
1628
+ list.push(ev);
1629
+ this.reviewEvents.set(reviewId, list);
1630
+ return ev;
1631
+ }
1632
+ /** Drain the next un-acked review events (mock), FIFO per review + cursors. */
1633
+ listReviewEvents(input = {}) {
1634
+ const want = input.review_id?.trim();
1635
+ const events = [];
1636
+ const cursors = [];
1637
+ const touched = new Set();
1638
+ for (const [reviewId, list] of this.reviewEvents) {
1639
+ if (want && reviewId !== want)
1640
+ continue;
1641
+ const acked = this.reviewEventCursors.get(reviewId) ?? 0;
1642
+ for (const ev of list) {
1643
+ if (ev.seq > acked)
1644
+ events.push(ev);
1645
+ }
1646
+ if (list.some((ev) => ev.seq > acked))
1647
+ touched.add(reviewId);
1648
+ }
1649
+ // Stable FIFO order: by review id, then seq (best-effort across reviews).
1650
+ events.sort((a, b) => (a.review_id ?? "").localeCompare(b.review_id ?? "") || a.seq - b.seq);
1651
+ const limited = input.limit && input.limit > 0 ? events.slice(0, input.limit) : events;
1652
+ for (const reviewId of touched) {
1653
+ cursors.push({ review_id: reviewId, last_acked_seq: this.reviewEventCursors.get(reviewId) ?? 0 });
1654
+ }
1655
+ return { events: limited, cursors };
1656
+ }
1657
+ /**
1658
+ * Long-poll for a review event (mock). Offline there is nothing to wait FOR, so
1659
+ * it returns the immediate drain (empty when caught up) — matching the server's
1660
+ * "empty on timeout" contract.
1661
+ */
1662
+ waitForReviewEvent(input = {}) {
1663
+ return this.listReviewEvents({ review_id: input.review_id, limit: input.limit });
1664
+ }
1665
+ /** Ack review events (mock): advance per-review cursors monotonically. */
1666
+ ackReviewEvent(input) {
1667
+ const cursors = [];
1668
+ for (const a of input.acks ?? []) {
1669
+ const reviewId = a.review_id?.trim();
1670
+ if (!reviewId)
1671
+ continue;
1672
+ const prev = this.reviewEventCursors.get(reviewId) ?? 0;
1673
+ const next = Math.max(prev, a.through_seq); // monotonic (exactly-once effect)
1674
+ this.reviewEventCursors.set(reviewId, next);
1675
+ cursors.push({ review_id: reviewId, last_acked_seq: next });
1676
+ }
1677
+ return { cursors };
1678
+ }
1679
+ /**
1680
+ * createReviewRecord mints a needs_review row + the intent (agent_note) and
1681
+ * initial-draft (agent_draft) turns, mirroring the server's submit-time writes.
1682
+ */
1683
+ createReviewRecord(opts) {
1684
+ const id = nextId("rr");
1685
+ const now = new Date().toISOString();
1686
+ const review = {
1687
+ id,
1688
+ state: "needs_review",
1689
+ mode: opts.mode ?? "review",
1690
+ effective_mode: opts.mode ?? "review",
1691
+ kind: opts.kind,
1692
+ from_address: opts.fromAddress,
1693
+ agent_id: this.agentId,
1694
+ category_id: opts.categoryId,
1695
+ intent_summary: opts.intent?.summary ?? "",
1696
+ intent_meta: opts.intent?.meta,
1697
+ revision: 0,
1698
+ version: 0,
1699
+ proposed_subject: opts.subject,
1700
+ proposed_body_text: opts.text,
1701
+ proposed_body_html: opts.html,
1702
+ proposed_to: opts.to,
1703
+ proposed_cc: opts.cc,
1704
+ proposed_bcc: opts.bcc,
1705
+ created_at: now,
1706
+ updated_at: now,
1707
+ };
1708
+ if (opts.replyThreadId)
1709
+ this.reviewThreads.set(id, opts.replyThreadId);
1710
+ if (opts.replyParentId)
1711
+ this.reviewParents.set(id, opts.replyParentId);
1712
+ this.commitReview(review);
1713
+ const turns = [];
1714
+ if (opts.intent?.summary?.trim()) {
1715
+ turns.push({
1716
+ id: nextId("turn"),
1717
+ seq: turns.length + 1,
1718
+ turn_type: "agent_note",
1719
+ actor_kind: "agent",
1720
+ actor_id: this.agentId,
1721
+ body: opts.intent.summary,
1722
+ metadata: { kind: "intent" },
1723
+ created_at: now,
1724
+ });
1725
+ }
1726
+ turns.push({
1727
+ id: nextId("turn"),
1728
+ seq: turns.length + 1,
1729
+ turn_type: "agent_draft",
1730
+ actor_kind: "agent",
1731
+ actor_id: this.agentId,
1732
+ body: opts.text,
1733
+ revision: 0,
1734
+ created_at: now,
1735
+ });
1736
+ this.reviewTurns.set(id, turns);
1737
+ return review;
1738
+ }
1739
+ listMessages(opts) {
1740
+ const inbox = this.requireInbox(opts.inbox);
1741
+ let items = [...(this.messages.get(inbox.id) ?? [])].sort((a, b) => b.date.localeCompare(a.date));
1742
+ if (opts.unreadOnly)
1743
+ items = items.filter((m) => !m.seen);
1744
+ if (opts.from) {
1745
+ const q = opts.from.toLowerCase();
1746
+ items = items.filter((m) => m.from.email.toLowerCase().includes(q));
1747
+ }
1748
+ if (opts.to) {
1749
+ const q = opts.to.toLowerCase();
1750
+ items = items.filter((m) => m.to.some((a) => a.email.toLowerCase().includes(q)));
1751
+ }
1752
+ if (opts.subject) {
1753
+ const q = opts.subject.toLowerCase();
1754
+ items = items.filter((m) => m.subject.toLowerCase().includes(q));
1755
+ }
1756
+ const total = items.length;
1757
+ const offset = opts.offset ?? 0;
1758
+ const page = items.slice(offset, offset + (opts.limit ?? 20));
1759
+ const result = { items: page, total };
1760
+ if (offset + page.length < total)
1761
+ result.next_cursor = String(offset + page.length);
1762
+ return result;
1763
+ }
1764
+ /** Fetch a single message by id across all inboxes (mirrors GET /v1/messages/{id}). */
1765
+ getMessage(id) {
1766
+ for (const msgs of this.messages.values()) {
1767
+ const found = msgs.find((m) => m.id === id);
1768
+ if (found)
1769
+ return found;
1770
+ }
1771
+ throw new NotFoundError(`Message not found: ${id}`);
1772
+ }
1773
+ /** Toggle the \Seen flag for a message by id (mirrors PATCH .../messages/{id}). */
1774
+ markRead(id, read) {
1775
+ const msg = this.getMessage(id);
1776
+ msg.seen = read;
1777
+ return msg;
1778
+ }
1779
+ listThreads(opts) {
1780
+ const inbox = this.requireInbox(opts.inbox);
1781
+ const byThread = new Map();
1782
+ for (const m of this.messages.get(inbox.id) ?? []) {
1783
+ const arr = byThread.get(m.thread_id) ?? [];
1784
+ arr.push(m);
1785
+ byThread.set(m.thread_id, arr);
1786
+ }
1787
+ const threads = [...byThread.entries()].map(([id, msgs]) => this.toThread(inbox.address, id, msgs));
1788
+ threads.sort((a, b) => b.last_message_at.localeCompare(a.last_message_at));
1789
+ return { items: threads.slice(0, opts.limit ?? 20), total: threads.length };
1790
+ }
1791
+ /** Fetch one thread (with its messages, oldest-first) by id under an inbox. */
1792
+ getThread(idOrAddress, threadId) {
1793
+ const inbox = this.requireInbox(idOrAddress);
1794
+ const msgs = (this.messages.get(inbox.id) ?? [])
1795
+ .filter((m) => m.thread_id === threadId)
1796
+ .sort((a, b) => a.date.localeCompare(b.date));
1797
+ if (msgs.length === 0)
1798
+ throw new NotFoundError(`Thread not found: ${threadId}`);
1799
+ return { ...this.toThread(inbox.address, threadId, msgs), messages: msgs };
1800
+ }
1801
+ /**
1802
+ * Delete a message by id (mirrors DELETE .../messages/{id}). The mock moves the
1803
+ * message to a Trash folder (soft delete) or removes it outright when expunge is
1804
+ * set or it already lives in Trash. Throws NotFoundError when absent.
1805
+ */
1806
+ deleteMessage(idOrAddress, id, expunge) {
1807
+ const inbox = this.requireInbox(idOrAddress);
1808
+ const msgs = this.messages.get(inbox.id) ?? [];
1809
+ const msg = msgs.find((m) => m.id === id);
1810
+ if (!msg)
1811
+ throw new NotFoundError(`Message not found: ${id}`);
1812
+ const hard = expunge || msg.folder === "Trash";
1813
+ if (hard) {
1814
+ this.messages.set(inbox.id, msgs.filter((m) => m.id !== id));
1815
+ }
1816
+ else {
1817
+ msg.folder = "Trash";
1818
+ }
1819
+ return { id, deleted: true, expunged: hard, count: 1 };
1820
+ }
1821
+ /**
1822
+ * Delete every message in a thread by id (mirrors DELETE .../threads/{id}).
1823
+ * Moves them to Trash (soft) or removes them (expunge / already in Trash).
1824
+ */
1825
+ deleteThread(idOrAddress, threadId, expunge) {
1826
+ const inbox = this.requireInbox(idOrAddress);
1827
+ const msgs = this.messages.get(inbox.id) ?? [];
1828
+ const inThread = msgs.filter((m) => m.thread_id === threadId);
1829
+ if (inThread.length === 0)
1830
+ throw new NotFoundError(`Thread not found: ${threadId}`);
1831
+ const hard = expunge;
1832
+ if (hard) {
1833
+ this.messages.set(inbox.id, msgs.filter((m) => m.thread_id !== threadId));
1834
+ }
1835
+ else {
1836
+ for (const m of inThread)
1837
+ m.folder = "Trash";
1838
+ }
1839
+ return { id: threadId, deleted: true, expunged: hard, count: inThread.length };
1840
+ }
1841
+ /**
1842
+ * Batch mark read/unread and/or move folder for a list of message ids under one
1843
+ * inbox (mirrors PATCH .../messages/batch). Ids not present in the inbox are
1844
+ * reported in `failed`; the rest in `updated`.
1845
+ */
1846
+ batchUpdateMessages(idOrAddress, ids, read, folder) {
1847
+ const inbox = this.requireInbox(idOrAddress);
1848
+ const msgs = this.messages.get(inbox.id) ?? [];
1849
+ const byId = new Map(msgs.map((m) => [m.id, m]));
1850
+ const updated = [];
1851
+ const failed = [];
1852
+ for (const id of ids) {
1853
+ const m = byId.get(id);
1854
+ if (!m) {
1855
+ failed.push(id);
1856
+ continue;
1857
+ }
1858
+ if (read !== undefined)
1859
+ m.seen = read;
1860
+ if (folder)
1861
+ m.folder = folder;
1862
+ updated.push(id);
1863
+ }
1864
+ return { updated, failed };
1865
+ }
1866
+ /** Build the canonical Thread wire shape (snippet, participant strings). */
1867
+ toThread(inboxAddr, id, msgs) {
1868
+ const sorted = [...msgs].sort((a, b) => a.date.localeCompare(b.date));
1869
+ const last = sorted.at(-1);
1870
+ const participants = dedupeAddresses(sorted.flatMap((m) => [m.from, ...m.to])).map((a) => a.name ? `${a.name} <${a.email}>` : a.email);
1871
+ return {
1872
+ id,
1873
+ inbox_id: inboxAddr,
1874
+ subject: normalizeThreadSubject(sorted[0]?.subject ?? "(no subject)"),
1875
+ message_count: sorted.length,
1876
+ participants,
1877
+ last_message_at: last.date,
1878
+ snippet: (last.text ?? last.html ?? "").slice(0, 140),
1879
+ };
1880
+ }
1881
+ search(opts) {
1882
+ const q = opts.query.toLowerCase();
1883
+ const scope = opts.inbox ? [this.requireInbox(opts.inbox).id] : [...this.inboxes.keys()];
1884
+ const hits = [];
1885
+ for (const inboxId of scope) {
1886
+ for (const m of this.messages.get(inboxId) ?? []) {
1887
+ if (m.subject.toLowerCase().includes(q) ||
1888
+ sourceMessageBody(m).toLowerCase().includes(q) ||
1889
+ m.from.email.toLowerCase().includes(q)) {
1890
+ hits.push(m);
1891
+ }
1892
+ }
1893
+ }
1894
+ hits.sort((a, b) => b.date.localeCompare(a.date));
1895
+ return { items: hits.slice(0, opts.limit ?? 20), total: hits.length };
1896
+ }
1897
+ /**
1898
+ * Offline `wait_for_email`. Resolves as soon as a matching inbound message is
1899
+ * present. Because `sendEmail` queues an auto-reply ~1.2s out, the demo flow
1900
+ * resolves quickly; otherwise it resolves against an already-seeded OTP mail.
1901
+ */
1902
+ async waitForEmail(opts) {
1903
+ const started = Date.now();
1904
+ const inbox = this.requireInbox(opts.inbox);
1905
+ const pollMs = opts.pollMs ?? 250;
1906
+ // Go regexp matching is case-sensitive. Support the documented leading
1907
+ // inline flag for the common explicit-insensitive case in offline fixtures.
1908
+ const re = opts.regex ? compileFixtureRegex(opts.regex) : undefined;
1909
+ const matches = (m) => {
1910
+ if (m.direction !== "inbound")
1911
+ return false;
1912
+ if (m.seen)
1913
+ return false;
1914
+ if (opts.from && !m.from.email.toLowerCase().includes(opts.from.toLowerCase()))
1915
+ return false;
1916
+ if (opts.subject && !m.subject.toLowerCase().includes(opts.subject.toLowerCase()))
1917
+ return false;
1918
+ if (re && !re.test(`${m.subject}\n${sourceMessageBody(m)}`))
1919
+ return false;
1920
+ return true;
1921
+ };
1922
+ for (;;) {
1923
+ const candidate = (this.messages.get(inbox.id) ?? []).find(matches);
1924
+ if (candidate) {
1925
+ candidate.seen = true;
1926
+ const signals = extractSignals(sourceMessageBody(candidate), opts.linkHint);
1927
+ const result = {
1928
+ matched: true,
1929
+ message: candidate,
1930
+ waited_ms: Date.now() - started,
1931
+ };
1932
+ if (signals.otp_code)
1933
+ result.otp_code = signals.otp_code;
1934
+ if (signals.verification_link)
1935
+ result.verification_link = signals.verification_link;
1936
+ return result;
1937
+ }
1938
+ if (Date.now() - started >= opts.timeoutMs) {
1939
+ return { matched: false, waited_ms: Date.now() - started };
1940
+ }
1941
+ await delay(pollMs);
1942
+ }
1943
+ }
1944
+ // ---- internals --------------------------------------------------------
1945
+ resolveInbox(idOrAddress) {
1946
+ if (this.inboxes.has(idOrAddress))
1947
+ return this.inboxes.get(idOrAddress);
1948
+ const key = idOrAddress.toLowerCase();
1949
+ return [...this.inboxes.values()].find((i) => i.address.toLowerCase() === key);
1950
+ }
1951
+ requireInbox(idOrAddress) {
1952
+ const inbox = this.resolveInbox(idOrAddress);
1953
+ if (!inbox) {
1954
+ throw new NotFoundError(`No inbox matches "${idOrAddress}". Create one with create_inbox.`);
1955
+ }
1956
+ return inbox;
1957
+ }
1958
+ appendMessage(inboxId, opts) {
1959
+ const date = new Date(Date.now() - opts.ageMinutes * 60_000).toISOString();
1960
+ const inboxAddr = this.inboxes.get(inboxId)?.address ?? inboxId;
1961
+ const msg = {
1962
+ id: nextId("msg"),
1963
+ thread_id: opts.threadId,
1964
+ inbox: inboxAddr,
1965
+ direction: opts.direction,
1966
+ from: opts.fromName ? { name: opts.fromName, email: opts.fromEmail } : { email: opts.fromEmail },
1967
+ to: opts.to.map((email) => ({ email })),
1968
+ subject: opts.subject,
1969
+ text: opts.text,
1970
+ html: opts.html ?? null,
1971
+ extracted_text: opts.text?.trim() || null,
1972
+ extracted_html: opts.html?.trim() || null,
1973
+ date,
1974
+ message_id: `<${nextId("mid")}@smtp.extrovert.dev>`,
1975
+ seen: opts.direction === "outbound",
1976
+ folder: opts.direction === "inbound" ? "INBOX" : "Sent",
1977
+ };
1978
+ if (opts.cc)
1979
+ msg.cc = opts.cc.map((email) => ({ email }));
1980
+ const arr = this.messages.get(inboxId) ?? [];
1981
+ arr.push(msg);
1982
+ this.messages.set(inboxId, arr);
1983
+ if (opts.attachments && opts.attachments.length > 0) {
1984
+ this.attachments.set(msg.id, opts.attachments.map((a, i) => ({
1985
+ meta: {
1986
+ id: `att_${i + 1}_${msg.id}`,
1987
+ filename: a.filename,
1988
+ content_type: a.content_type || "application/octet-stream",
1989
+ size: base64ByteLength(a.content_base64),
1990
+ },
1991
+ content_base64: a.content_base64,
1992
+ })));
1993
+ }
1994
+ return msg;
1995
+ }
1996
+ /** List a message's attachment metadata (mirrors the list endpoint). */
1997
+ listAttachments(messageId) {
1998
+ this.getMessage(messageId); // throws NotFoundError if absent
1999
+ const items = (this.attachments.get(messageId) ?? []).map((a) => a.meta);
2000
+ return { items, total: items.length };
2001
+ }
2002
+ /** Fetch one attachment's bytes + metadata (mirrors the download endpoint). */
2003
+ getAttachment(messageId, attachmentId) {
2004
+ const stored = (this.attachments.get(messageId) ?? []).find((a) => a.meta.id === attachmentId);
2005
+ if (!stored)
2006
+ throw new NotFoundError(`Attachment not found: ${attachmentId}`);
2007
+ return {
2008
+ filename: stored.meta.filename,
2009
+ content_type: stored.meta.content_type,
2010
+ content_base64: stored.content_base64,
2011
+ };
2012
+ }
2013
+ // ---- webhooks ---------------------------------------------------------
2014
+ /** Register a webhook; returns the row WITH the one-time signing secret. */
2015
+ registerWebhook(input) {
2016
+ // Idempotency replay: a repeat with the same client id returns the first row.
2017
+ const idemKey = input.clientId?.trim() ? `webhook.create:${input.clientId.trim()}` : "";
2018
+ if (idemKey) {
2019
+ const existingId = this.idempotency.get(idemKey);
2020
+ const existing = existingId ? this.webhooks.get(existingId) : undefined;
2021
+ if (existing)
2022
+ return { ...existing };
2023
+ }
2024
+ const id = nextId("whk");
2025
+ const secret = `whsec_${shortLabel()}${Math.random().toString(36).slice(2, 14)}`;
2026
+ const webhook = {
2027
+ id,
2028
+ url: input.url,
2029
+ events: input.events && input.events.length ? input.events : ["message.received"],
2030
+ inbox: input.inbox ?? null,
2031
+ agent_id: this.agentId,
2032
+ secret,
2033
+ secret_prefix: secret.slice(0, "whsec_".length + 6),
2034
+ active: true,
2035
+ created_at: new Date().toISOString(),
2036
+ };
2037
+ this.webhooks.set(id, webhook);
2038
+ if (idemKey)
2039
+ this.idempotency.set(idemKey, id);
2040
+ return { ...webhook };
2041
+ }
2042
+ /** List webhooks (secret redacted). */
2043
+ listWebhooks() {
2044
+ const items = [...this.webhooks.values()].map((w) => redactWebhookSecret(w));
2045
+ return { items, total: items.length };
2046
+ }
2047
+ /** Get one webhook (secret redacted). Throws NotFoundError when absent. */
2048
+ getWebhook(id) {
2049
+ const w = this.webhooks.get(id);
2050
+ if (!w)
2051
+ throw new NotFoundError(`Webhook not found: ${id}`);
2052
+ return redactWebhookSecret(w);
2053
+ }
2054
+ /**
2055
+ * Update a webhook in place (secret redacted in the response). Every field is
2056
+ * optional; an unset field leaves the stored value untouched (PATCH semantics).
2057
+ * Throws NotFoundError when absent.
2058
+ */
2059
+ updateWebhook(id, input) {
2060
+ const w = this.webhooks.get(id);
2061
+ if (!w)
2062
+ throw new NotFoundError(`Webhook not found: ${id}`);
2063
+ if (input.url !== undefined)
2064
+ w.url = input.url;
2065
+ if (input.events !== undefined) {
2066
+ w.events = input.events.length ? input.events : ["message.received"];
2067
+ }
2068
+ if (input.inbox !== undefined)
2069
+ w.inbox = input.inbox === "" ? null : input.inbox;
2070
+ if (input.active !== undefined)
2071
+ w.active = input.active;
2072
+ this.webhooks.set(id, w);
2073
+ return redactWebhookSecret(w);
2074
+ }
2075
+ /** Delete a webhook. Throws NotFoundError when absent. */
2076
+ deleteWebhook(id) {
2077
+ if (!this.webhooks.delete(id))
2078
+ throw new NotFoundError(`Webhook not found: ${id}`);
2079
+ return { id, deleted: true };
2080
+ }
2081
+ // ---- contact allow/block lists (Slice 3) ------------------------------
2082
+ /** Add one allow/block entry scoped to an inbox. */
2083
+ addContactListEntry(inbox, input) {
2084
+ this.requireInbox(inbox);
2085
+ const pattern = normalizeContactPattern(input.pattern ?? "");
2086
+ if (!pattern)
2087
+ throw new NotFoundError("pattern is required (address or domain)");
2088
+ const entry = {
2089
+ id: nextId("lst"),
2090
+ inbox: this.requireInbox(inbox).address,
2091
+ kind: input.kind,
2092
+ direction: input.direction ?? "send",
2093
+ pattern,
2094
+ created_at: new Date().toISOString(),
2095
+ };
2096
+ this.contactLists.set(entry.id, entry);
2097
+ return { ...entry };
2098
+ }
2099
+ /** List the entries governing an inbox (inbox-specific + account-wide). */
2100
+ listContactListEntries(inbox) {
2101
+ const address = this.requireInbox(inbox).address;
2102
+ const items = [...this.contactLists.values()].filter((e) => e.inbox === null || e.inbox === address);
2103
+ return { items, total: items.length };
2104
+ }
2105
+ /** Delete a contact-list entry by id. Throws NotFoundError when absent. */
2106
+ deleteContactListEntry(_inbox, id) {
2107
+ if (!this.contactLists.delete(id))
2108
+ throw new NotFoundError(`Contact list entry not found: ${id}`);
2109
+ return { id, deleted: true };
2110
+ }
2111
+ // ---- domains (Slice 5) ------------------------------------------------
2112
+ /** Onboard a domain. Mirrors the server's per-mode record set + status. */
2113
+ onboardDomain(input) {
2114
+ const name = input.domain.trim().toLowerCase();
2115
+ const mode = input.mode ?? "ns_delegated";
2116
+ // A project_id assertion must match the key's bound project (403 on mismatch),
2117
+ // mirroring the SDK mock + the real server. `scope` is accepted offline (the
2118
+ // live API binds visibility to the key's project); the mock does not otherwise
2119
+ // model cross-project isolation.
2120
+ void input.scope;
2121
+ assertProjectMatch(input.project_id);
2122
+ const existing = this.domains.get(name);
2123
+ if (existing)
2124
+ return { ...existing };
2125
+ const domain = {
2126
+ id: nextId("dom"),
2127
+ domain: name,
2128
+ mode,
2129
+ verification_status: mode === "shared" ? "verified" : mode === "manual" ? "pending" : "verifying",
2130
+ dkim_status: mode === "shared" ? "configured" : mode === "manual" ? "pending" : "configured",
2131
+ shared: mode === "shared",
2132
+ created_at: new Date().toISOString(),
2133
+ records: mode === "manual" || mode === "ns_delegated" ? domainRecordSet(name) : undefined,
2134
+ delegation_ns: mode === "ns_delegated" ? domainDelegationNS(name) : undefined,
2135
+ instruction: mode === "shared"
2136
+ ? "Shared domain ready. No DNS changes required."
2137
+ : "Add the records, then trigger verification.",
2138
+ };
2139
+ this.domains.set(name, domain);
2140
+ return { ...domain };
2141
+ }
2142
+ /** List onboarded domains (records omitted on the summary, mirroring the server). */
2143
+ listDomains() {
2144
+ const items = [...this.domains.values()].map((d) => domainSummary(d));
2145
+ return { items, total: items.length };
2146
+ }
2147
+ /** Get one domain's detail + the records to set, inline. Throws NotFoundError when absent. */
2148
+ getDomain(domain) {
2149
+ const d = this.domains.get(domain.trim().toLowerCase());
2150
+ if (!d)
2151
+ throw new NotFoundError(`Domain not found: ${domain}`);
2152
+ return { ...d };
2153
+ }
2154
+ /** Trigger/refresh verification; returns the (re-read) detail. Throws when absent. */
2155
+ verifyDomain(domain) {
2156
+ const d = this.domains.get(domain.trim().toLowerCase());
2157
+ if (!d)
2158
+ throw new NotFoundError(`Domain not found: ${domain}`);
2159
+ return { ...d };
2160
+ }
2161
+ /**
2162
+ * Offboard (remove) a domain. Throws NotFoundError when absent. Mirrors the live
2163
+ * API's async contract: it returns an accepted teardown job (there is no job
2164
+ * runner in-fixture, so the row is removed synchronously and a synthetic
2165
+ * succeeded job is reported).
2166
+ */
2167
+ offboardDomain(domain) {
2168
+ const name = domain.trim().toLowerCase();
2169
+ if (!this.domains.delete(name))
2170
+ throw new NotFoundError(`Domain not found: ${domain}`);
2171
+ const jobId = `job-offboard-${name}`;
2172
+ const ts = new Date().toISOString();
2173
+ this.jobs.set(jobId, {
2174
+ object: "job",
2175
+ id: jobId,
2176
+ type: "domain_offboard",
2177
+ status: "succeeded",
2178
+ created_at: ts,
2179
+ updated_at: ts,
2180
+ finished_at: ts,
2181
+ });
2182
+ return { domain: name, job_id: jobId, status: "succeeded", status_url: `/v1/jobs/${jobId}` };
2183
+ }
2184
+ /** Get one async job's poll status (mirrors `GET /v1/jobs/{job_id}`). Throws NotFoundError when absent. */
2185
+ getJob(jobId) {
2186
+ const job = this.jobs.get(jobId);
2187
+ if (!job)
2188
+ throw new NotFoundError(`Job not found: ${jobId}`);
2189
+ return { ...job };
2190
+ }
2191
+ // ---- suppressions (recipient opt-outs / list-unsubscribe) --------------
2192
+ /**
2193
+ * Pre-check whether the caller's org suppresses a recipient (mirrors
2194
+ * `GET /v1/suppressions?recipient=…`): `{recipient, suppressed, rows}` over the
2195
+ * active (non-revoked) org rows for that canonicalized recipient.
2196
+ */
2197
+ precheckSuppression(recipient) {
2198
+ const canonical = canonicalRecipient(recipient);
2199
+ const rows = [...this.suppressions.values()]
2200
+ .filter((s) => !s.revoked && s.recipient === canonical)
2201
+ .map((s) => ({ ...s }));
2202
+ return { recipient: canonical, suppressed: rows.length > 0, rows };
2203
+ }
2204
+ /** Offline deliverability rollup (mirrors `GET /v1/reputation`): healthy, no data. */
2205
+ getReputation() {
2206
+ return {
2207
+ object: "reputation",
2208
+ org_id: MOCK_ORG_ID,
2209
+ status: "unknown",
2210
+ sending_status: "unknown",
2211
+ providers: [],
2212
+ metrics: { sends: 0, bounces: 0, complaints: 0, bounce_rate: 0, complaint_rate: 0 },
2213
+ open_findings: 0,
2214
+ };
2215
+ }
2216
+ /** Offline findings list (mirrors `GET /v1/reputation/findings`): empty. */
2217
+ listDeliverabilityFindings(_input = {}) {
2218
+ return { items: [], total: 0 };
2219
+ }
2220
+ /** List the caller's own org suppression rows (mirrors the paged `GET /v1/suppressions`). */
2221
+ listSuppressions(input) {
2222
+ let rows = [...this.suppressions.values()];
2223
+ if (input.scope)
2224
+ rows = rows.filter((s) => s.scope === input.scope);
2225
+ if (!input.include_revoked)
2226
+ rows = rows.filter((s) => !s.revoked);
2227
+ rows.sort((a, b) => b.created_at.localeCompare(a.created_at));
2228
+ const total = rows.length;
2229
+ const offset = input.cursor ? Math.max(0, Number.parseInt(input.cursor, 10) || 0) : 0;
2230
+ const limit = input.limit ?? 50;
2231
+ const items = rows.slice(offset, offset + limit).map((s) => ({ ...s }));
2232
+ const page = { items, total };
2233
+ if (offset + items.length < total)
2234
+ page.next_cursor = String(offset + items.length);
2235
+ return page;
2236
+ }
2237
+ /**
2238
+ * Revoke one org-scope suppression row (mirrors `POST /v1/suppressions/{id}/revoke`);
2239
+ * a reason is required. Throws NotFoundError when the id is unknown or not the
2240
+ * caller's own org row (global/shared rows are platform-operator only → 404).
2241
+ */
2242
+ revokeSuppression(id, reason) {
2243
+ const row = this.suppressions.get(id);
2244
+ if (!row || row.scope !== "org" || row.revoked) {
2245
+ throw new NotFoundError(`Suppression not found: ${id}`);
2246
+ }
2247
+ row.revoked = true;
2248
+ row.revoked_at = new Date().toISOString();
2249
+ row.revoked_by = "agent:" + this.agentId;
2250
+ row.revoke_reason = reason;
2251
+ this.suppressions.set(id, row);
2252
+ return { ...row };
2253
+ }
2254
+ /**
2255
+ * Reject the WHOLE send if ANY recipient has an active org-scope suppression,
2256
+ * naming exactly the suppressed addresses (never the scope/origin) so the caller
2257
+ * can drop them and retry — mirroring the live `recipient_suppressed` (422) path.
2258
+ */
2259
+ enforceSuppression(recipients) {
2260
+ const active = new Set([...this.suppressions.values()].filter((s) => !s.revoked).map((s) => s.recipient));
2261
+ if (active.size === 0)
2262
+ return;
2263
+ const hit = [];
2264
+ for (const rcpt of recipients) {
2265
+ const canonical = canonicalRecipient(rcpt);
2266
+ if (canonical && active.has(canonical) && !hit.includes(canonical))
2267
+ hit.push(canonical);
2268
+ }
2269
+ if (hit.length > 0)
2270
+ throw new SuppressedError(hit);
2271
+ }
2272
+ /**
2273
+ * Enforce the send-direction contact lists for an inbox: reject a block-listed
2274
+ * recipient, or any recipient outside the allowlist when allowlist mode is on.
2275
+ */
2276
+ enforceSendPolicy(from, recipients) {
2277
+ const entries = [...this.contactLists.values()].filter((e) => e.direction === "send" && (e.inbox === null || e.inbox === from));
2278
+ if (entries.length === 0)
2279
+ return;
2280
+ const blocks = entries.filter((e) => e.kind === "block");
2281
+ const allows = entries.filter((e) => e.kind === "allow");
2282
+ for (const rcpt of recipients) {
2283
+ const addr = normalizeContactPattern(rcpt);
2284
+ if (!addr)
2285
+ continue;
2286
+ if (blocks.some((b) => contactEntryMatches(b.pattern, addr))) {
2287
+ throw new BlockedError(`${rcpt} is block-listed`);
2288
+ }
2289
+ if (allows.length > 0 && !allows.some((a) => contactEntryMatches(a.pattern, addr))) {
2290
+ throw new BlockedError(`${rcpt} is not on the allow list`);
2291
+ }
2292
+ }
2293
+ }
2294
+ /** Drop a believable inbound OTP reply into the thread shortly after a send. */
2295
+ queueAutoReply(inbox, original) {
2296
+ const code = String(Math.floor(100000 + Math.random() * 900000));
2297
+ setTimeout(() => {
2298
+ if (!this.inboxes.has(inbox.id))
2299
+ return;
2300
+ this.appendMessage(inbox.id, {
2301
+ direction: "inbound",
2302
+ fromName: "Acme Security",
2303
+ fromEmail: "no-reply@acme.example",
2304
+ to: [inbox.address],
2305
+ subject: `Your verification code`,
2306
+ text: `Hello,\n\nYour Acme verification code is: ${code}\n\nIt expires in 10 minutes. If you did not request this, ignore this email.`,
2307
+ html: `<p>Hello,</p><p>Your Acme verification code is: <b>${code}</b></p><p><a href="https://acme.example/verify?token=${code}abc&u=${encodeURIComponent(inbox.address)}">Verify your email</a></p>`,
2308
+ threadId: original.thread_id,
2309
+ ageMinutes: 0,
2310
+ });
2311
+ }, 1200).unref?.();
2312
+ }
2313
+ seed() {
2314
+ const inbox = this.createInbox({
2315
+ username: "agent7",
2316
+ domain: "smtp.extrovert.dev",
2317
+ displayName: "Extrovert Demo Agent",
2318
+ });
2319
+ // Seed one active org-scope suppression so the recipient opt-out surface
2320
+ // (check_suppression / list_suppressions / revoke_suppression) and the
2321
+ // recipient_suppressed send-rejection path have deterministic offline data.
2322
+ const supId = nextId("sup");
2323
+ this.suppressions.set(supId, {
2324
+ id: supId,
2325
+ recipient: SEEDED_SUPPRESSED_RECIPIENT,
2326
+ recipient_raw: SEEDED_SUPPRESSED_RECIPIENT,
2327
+ scope: "org",
2328
+ source: "manual",
2329
+ reactivation_count: 0,
2330
+ created_at: new Date().toISOString(),
2331
+ revoked: false,
2332
+ });
2333
+ const seeds = [
2334
+ {
2335
+ fromName: "Stripe",
2336
+ fromEmail: "verify@stripe.com",
2337
+ subject: "Confirm your email address",
2338
+ text: "Welcome! Your confirmation code is 481920. Or click the button below to verify.",
2339
+ html: '<p>Your confirmation code is <b>481920</b>.</p><p><a href="https://dashboard.stripe.com/verify?code=481920&id=evt_9">Confirm email</a></p>',
2340
+ ageMinutes: 3,
2341
+ },
2342
+ {
2343
+ fromName: "GitHub",
2344
+ fromEmail: "noreply@github.com",
2345
+ subject: "[GitHub] Please verify your device",
2346
+ text: "A sign-in attempt requires verification. Your authentication code is GH-204815.",
2347
+ ageMinutes: 41,
2348
+ },
2349
+ {
2350
+ fromName: "HTML Sender",
2351
+ fromEmail: "html-only@example.test",
2352
+ subject: "HTML-only verification",
2353
+ text: null,
2354
+ html: '<p>Your HTMLONLY verification code is <strong>731942</strong>.</p><p><a href="https://example.test/verify?token=htmlonly">Verify</a></p>',
2355
+ ageMinutes: 90,
2356
+ },
2357
+ {
2358
+ fromName: "Linear",
2359
+ fromEmail: "notifications@linear.app",
2360
+ subject: "You were assigned POS-128",
2361
+ text: "Keith assigned you an issue: 'Wire wait_for_email into the MCP server'. Due Friday.",
2362
+ ageMinutes: 220,
2363
+ },
2364
+ ];
2365
+ for (const s of seeds) {
2366
+ const msg = this.appendMessage(inbox.id, {
2367
+ direction: "inbound",
2368
+ fromName: s.fromName,
2369
+ fromEmail: s.fromEmail,
2370
+ to: [inbox.address],
2371
+ subject: s.subject,
2372
+ text: s.text,
2373
+ html: s.html,
2374
+ threadId: nextId("thr"),
2375
+ ageMinutes: s.ageMinutes,
2376
+ });
2377
+ msg.seen = false;
2378
+ }
2379
+ // Seed a review with one pending durable nudge so the realtime drain/ack
2380
+ // surface (list_review_events / wait_for_review_event / ack_review_event) has a
2381
+ // deterministic event to exercise offline: a human rejected a draft with
2382
+ // feedback before the agent connected, leaving work on the authoritative queue.
2383
+ const seededReview = this.createReviewRecord({
2384
+ kind: "send",
2385
+ fromAddress: inbox.address,
2386
+ subject: "Re-engage cold lead at Acme",
2387
+ text: "Let me know your thoughts.",
2388
+ to: ["vp@acme.com"],
2389
+ intent: { summary: "re-engage cold lead", meta: { goal: "book_meeting" } },
2390
+ });
2391
+ this.enqueueReviewEvent(seededReview.id, "rejected", {
2392
+ decision: "rejected",
2393
+ comment: "be more pushy, we need MRR",
2394
+ });
2395
+ // Seed a review already OPEN for review (a reviewer opened it) so the M5 chat
2396
+ // surface (post_review_chat / submit_revision / get_review_feedback) has a
2397
+ // chattable draft offline. A human comment turn gives get_review_feedback data.
2398
+ const inReviewReview = this.createReviewRecord({
2399
+ kind: "send",
2400
+ fromAddress: inbox.address,
2401
+ subject: "Pilot proposal",
2402
+ text: "Here is the Q3 pilot proposal.",
2403
+ to: ["vp@acme.com"],
2404
+ intent: { summary: "send pilot proposal", meta: { goal: "book_meeting" } },
2405
+ });
2406
+ inReviewReview.state = "in_review";
2407
+ this.commitReview(inReviewReview);
2408
+ const seededTurns = this.reviewTurns.get(inReviewReview.id) ?? [];
2409
+ seededTurns.push({
2410
+ id: nextId("turn"),
2411
+ seq: seededTurns.length + 1,
2412
+ turn_type: "human_comment",
2413
+ actor_kind: "human",
2414
+ actor_id: "user_demo",
2415
+ body: "tighten the opening line",
2416
+ created_at: new Date().toISOString(),
2417
+ });
2418
+ this.reviewTurns.set(inReviewReview.id, seededTurns);
2419
+ }
2420
+ }
2421
+ /** Drop the one-time signing secret from a stored webhook for read responses. */
2422
+ function redactWebhookSecret(w) {
2423
+ const { secret: _omit, ...rest } = w;
2424
+ return rest;
2425
+ }
2426
+ /** The MX/SPF/DMARC/DKIM record set a customer must set (mirrors the Go manualRecordSet). */
2427
+ function domainRecordSet(domain) {
2428
+ const dkimSuffix = domain.replace(/\./g, "-");
2429
+ return [
2430
+ { name: domain, type: "MX", value: "smtp.extrovert.dev", priority: 10, ttl: 3600 },
2431
+ { name: domain, type: "TXT", value: "v=spf1 include:spf.protection.outlook.com -all", ttl: 3600 },
2432
+ { name: `_dmarc.${domain}`, type: "TXT", value: "v=DMARC1; p=none; rua=mailto:dmarc@smtp.extrovert.dev", ttl: 3600 },
2433
+ { name: `selector1._domainkey.${domain}`, type: "CNAME", value: `selector1-${dkimSuffix}._domainkey.azurecomm.net`, ttl: 3600 },
2434
+ { name: `selector2._domainkey.${domain}`, type: "CNAME", value: `selector2-${dkimSuffix}._domainkey.azurecomm.net`, ttl: 3600 },
2435
+ ];
2436
+ }
2437
+ /** The single NS delegation (one row per public nameserver) for ns_delegated mode. */
2438
+ function domainDelegationNS(domain) {
2439
+ return [
2440
+ { name: domain, type: "NS", value: "ns1.extrovert.dev", ttl: 300 },
2441
+ { name: domain, type: "NS", value: "ns2.extrovert.dev", ttl: 300 },
2442
+ ];
2443
+ }
2444
+ /** The list-view summary: status fields without the inline record set. */
2445
+ function domainSummary(d) {
2446
+ const { records: _r, delegation_ns: _d, instruction: _i, ...rest } = d;
2447
+ return rest;
2448
+ }
2449
+ /** Thrown when an inbox/thread cannot be resolved (maps to API 404). */
2450
+ export class NotFoundError extends Error {
2451
+ constructor(message) {
2452
+ super(message);
2453
+ this.name = "NotFoundError";
2454
+ }
2455
+ }
2456
+ /** Thrown when a send is rejected by an inbox's contact lists (maps to API 403). */
2457
+ export class BlockedError extends Error {
2458
+ constructor(message) {
2459
+ super(message);
2460
+ this.name = "BlockedError";
2461
+ }
2462
+ }
2463
+ /**
2464
+ * Thrown when a send is rejected because one or more recipients have opted out
2465
+ * (list-unsubscribe / suppression; maps to API 422 `recipient_suppressed`). The
2466
+ * message names exactly the suppressed addresses so the agent can drop them and
2467
+ * retry; `recipients` carries the same list machine-readably.
2468
+ */
2469
+ export class SuppressedError extends Error {
2470
+ recipients;
2471
+ constructor(recipients) {
2472
+ super(`recipient(s) suppressed (opted out): ${recipients.join(", ")}`);
2473
+ this.name = "SuppressedError";
2474
+ this.recipients = recipients;
2475
+ }
2476
+ }
2477
+ /** Thrown when a project_id assertion does not match the key's bound project (maps to API 403). */
2478
+ export class ForbiddenError extends Error {
2479
+ constructor(message) {
2480
+ super(message);
2481
+ this.name = "ForbiddenError";
2482
+ }
2483
+ }
2484
+ /**
2485
+ * Thrown when an org-tier key issues a bare list that needs an explicit breadth
2486
+ * pick (maps to API 400 `breadth_required`; redesign §4.1). The message names the
2487
+ * next call (a project id or the org wildcard).
2488
+ */
2489
+ export class BreadthRequiredError extends Error {
2490
+ constructor(message) {
2491
+ super(message);
2492
+ this.name = "BreadthRequiredError";
2493
+ }
2494
+ }
2495
+ /**
2496
+ * Thrown when a send/reply/forward is refused because the account's review policy
2497
+ * requires an intent (maps to API 422 `intent_required`; D3).
2498
+ *
2499
+ * `problemErrors` carries the SAME `{field, code, detail}` hints the live problem
2500
+ * body does — including the `retry_with` example — because the offline error is
2501
+ * useless as practice if it is less actionable than the real one.
2502
+ */
2503
+ export class IntentRequiredError extends Error {
2504
+ problemErrors;
2505
+ constructor(message, problemErrors) {
2506
+ super(message);
2507
+ this.name = "IntentRequiredError";
2508
+ this.problemErrors = problemErrors;
2509
+ }
2510
+ }
2511
+ /** Thrown on a conflicting/idempotent-replay mutation (maps to API 409). */
2512
+ export class ConflictError extends Error {
2513
+ constructor(message) {
2514
+ super(message);
2515
+ this.name = "ConflictError";
2516
+ }
2517
+ }
2518
+ /**
2519
+ * Thrown when `text` and its deprecated `body` alias disagree (maps to API 400
2520
+ * `conflicting_alias`). There is no safe guess: picking a winner would relay the
2521
+ * wrong bytes from the customer's own domain to a real recipient.
2522
+ */
2523
+ export class ConflictingAliasError extends Error {
2524
+ problemErrors;
2525
+ constructor(message) {
2526
+ super(message);
2527
+ this.name = "ConflictingAliasError";
2528
+ this.problemErrors = [{ field: "text", code: "conflicting_alias", detail: message }];
2529
+ }
2530
+ }
2531
+ /**
2532
+ * Base for the review-loop 409s that carry the RECOVERY FACTS as problem fields:
2533
+ * the current state / revision / version, plus one `allowed_action` per legal verb.
2534
+ * A stale-CAS retry therefore needs no extra `get_review`, and a wrong-state agent
2535
+ * is told what IS legal instead of retrying the same verb forever.
2536
+ */
2537
+ export class ReviewConflictError extends ConflictError {
2538
+ problemErrors;
2539
+ constructor(message, review) {
2540
+ super(message);
2541
+ this.problemErrors = [
2542
+ { field: "state", code: review.state, detail: "the draft's current state" },
2543
+ { field: "revision", code: String(review.revision), detail: "current revision — use as parent_revision" },
2544
+ { field: "version", code: String(review.version), detail: "current row version" },
2545
+ ...(review.sent_message_id
2546
+ ? [{ field: "sent_message_id", code: review.sent_message_id, detail: "the message that already went out" }]
2547
+ : []),
2548
+ ...allowedAgentActions(review.state).map((a) => ({
2549
+ field: "allowed_action",
2550
+ code: a,
2551
+ detail: "legal from the current state",
2552
+ })),
2553
+ ];
2554
+ }
2555
+ }
2556
+ /**
2557
+ * 409 `stale` — the `(revision[,version])` you named is no longer current and
2558
+ * NOTHING was mutated. The ONE 409 worth retrying: re-read, re-apply your edit on
2559
+ * top of the other party's, resubmit with the new parent_revision. Bounded (<=3).
2560
+ */
2561
+ export class StaleError extends ReviewConflictError {
2562
+ constructor(message, review) {
2563
+ super(message, review);
2564
+ this.name = "StaleError";
2565
+ }
2566
+ }
2567
+ /**
2568
+ * 409 `wrong_state` — this VERB is illegal from the current state, but the draft
2569
+ * is still live. NEVER retry the same verb; read the `allowed_action` hints and
2570
+ * pick a legal one.
2571
+ */
2572
+ export class WrongStateError extends ReviewConflictError {
2573
+ constructor(message, review) {
2574
+ super(message, review);
2575
+ this.name = "WrongStateError";
2576
+ }
2577
+ }
2578
+ /**
2579
+ * 409 `terminal` — sent / auto_sent / cancelled. Nothing will ever succeed on this
2580
+ * review. STOP; a `front_run_next` nudge is waiting in the drain.
2581
+ */
2582
+ export class TerminalError extends ReviewConflictError {
2583
+ constructor(message, review) {
2584
+ super(message, review);
2585
+ this.name = "TerminalError";
2586
+ }
2587
+ }
2588
+ /**
2589
+ * Canonicalize a recipient address for suppression matching: pull the address out
2590
+ * of a "Name <addr>" form, NFC-normalize, trim, and lower-case (mirrors the Go
2591
+ * canonicalization closely enough for the offline mock).
2592
+ */
2593
+ function canonicalRecipient(raw) {
2594
+ let value = raw.trim();
2595
+ const m = value.match(/<([^>]+)>/);
2596
+ if (m && m[1])
2597
+ value = m[1];
2598
+ return value.normalize("NFC").trim().toLowerCase();
2599
+ }
2600
+ /**
2601
+ * Normalize a contact pattern or recipient: lower-case/trim, pull the address
2602
+ * out of a "Name <addr>" form, and strip a leading "@" from a domain pattern.
2603
+ */
2604
+ function normalizeContactPattern(raw) {
2605
+ let value = raw.trim();
2606
+ const m = value.match(/<([^>]+)>/);
2607
+ if (m && m[1])
2608
+ value = m[1];
2609
+ value = value.trim().toLowerCase();
2610
+ return value.startsWith("@") ? value.slice(1) : value;
2611
+ }
2612
+ /**
2613
+ * Match a normalized pattern against a normalized recipient address. A pattern
2614
+ * with an "@" is a full-address match; a bare domain matches any address in it.
2615
+ */
2616
+ function contactEntryMatches(pattern, addr) {
2617
+ if (!pattern || !addr)
2618
+ return false;
2619
+ if (pattern.includes("@"))
2620
+ return pattern === addr;
2621
+ const at = addr.lastIndexOf("@");
2622
+ const domain = at >= 0 ? addr.slice(at + 1) : addr;
2623
+ return domain === pattern;
2624
+ }
2625
+ function reSubject(subject) {
2626
+ return /^re:/i.test(subject) ? subject : `Re: ${normalizeThreadSubject(subject)}`;
2627
+ }
2628
+ function fwdSubject(subject) {
2629
+ return /^fwd?:/i.test(subject) ? subject : `Fwd: ${normalizeThreadSubject(subject)}`;
2630
+ }
2631
+ /**
2632
+ * The forwarded body: the agent's optional note, then the quoted parent. Built at
2633
+ * SUBMIT time (not at approval) so the human reviews the exact bytes that go out —
2634
+ * re-deriving it from the live parent at approval would silently discard the
2635
+ * reviewer's edit.
2636
+ */
2637
+ function forwardBody(note, parent) {
2638
+ return `${note ?? ""}\n\n---------- Forwarded message ----------\nFrom: ${fmtFrom(parent)}\nSubject: ${parent.subject}\n\n${parent.text}`;
2639
+ }
2640
+ /** Strip leading Re:/Fwd: prefixes for a thread's display subject. */
2641
+ function normalizeThreadSubject(subject) {
2642
+ let s = subject.trim();
2643
+ for (;;) {
2644
+ const next = s.replace(/^(re|fwd|fw)\s*:\s*/i, "");
2645
+ if (next === s)
2646
+ break;
2647
+ s = next;
2648
+ }
2649
+ return s.trim();
2650
+ }
2651
+ function fmtFrom(m) {
2652
+ return m.from.name ? `${m.from.name} <${m.from.email}>` : m.from.email;
2653
+ }
2654
+ function dedupeAddresses(addresses) {
2655
+ const seen = new Set();
2656
+ const out = [];
2657
+ for (const a of addresses) {
2658
+ const key = a.email.toLowerCase();
2659
+ if (seen.has(key))
2660
+ continue;
2661
+ seen.add(key);
2662
+ out.push(a);
2663
+ }
2664
+ return out;
2665
+ }
2666
+ function delay(ms) {
2667
+ return new Promise((resolve) => {
2668
+ const t = setTimeout(resolve, ms);
2669
+ t.unref?.();
2670
+ });
2671
+ }
2672
+ function compileFixtureRegex(pattern) {
2673
+ const explicitInsensitive = pattern.startsWith("(?i)");
2674
+ const source = explicitInsensitive ? pattern.slice(4) : pattern;
2675
+ return new RegExp(source, explicitInsensitive ? "i" : undefined);
2676
+ }
2677
+ /** Decoded byte length of a base64 string (mirrors the Go-reported size). */
2678
+ function base64ByteLength(b64) {
2679
+ const clean = b64.replace(/[\r\n\s]/g, "");
2680
+ if (clean.length === 0)
2681
+ return 0;
2682
+ const padding = clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0;
2683
+ return Math.floor((clean.length * 3) / 4) - padding;
2684
+ }
2685
+ //# sourceMappingURL=fixtures.js.map