@vantageos/backend-doctor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +88 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +94 -0
  4. package/dist/contract.d.ts +18 -0
  5. package/dist/contract.js +29 -0
  6. package/dist/coverage-cli.d.ts +2 -0
  7. package/dist/coverage-cli.js +17 -0
  8. package/dist/coverage.d.ts +32 -0
  9. package/dist/coverage.js +77 -0
  10. package/dist/detectors/oracle-axis1.d.ts +86 -0
  11. package/dist/detectors/oracle-axis1.js +221 -0
  12. package/dist/detectors/oracle-axis2.d.ts +22 -0
  13. package/dist/detectors/oracle-axis2.js +149 -0
  14. package/dist/detectors/predicates.d.ts +19 -0
  15. package/dist/detectors/predicates.js +334 -0
  16. package/dist/detectors/shared.d.ts +56 -0
  17. package/dist/detectors/shared.js +103 -0
  18. package/dist/detectors/source/_scan.d.ts +43 -0
  19. package/dist/detectors/source/_scan.js +82 -0
  20. package/dist/detectors/source/backfill.d.ts +27 -0
  21. package/dist/detectors/source/backfill.js +211 -0
  22. package/dist/detectors/source/contract.d.ts +33 -0
  23. package/dist/detectors/source/contract.js +385 -0
  24. package/dist/detectors/source/identity.d.ts +47 -0
  25. package/dist/detectors/source/identity.js +410 -0
  26. package/dist/detectors/source/jobs.d.ts +10 -0
  27. package/dist/detectors/source/jobs.js +209 -0
  28. package/dist/detectors/source/lifecycle.d.ts +6 -0
  29. package/dist/detectors/source/lifecycle.js +147 -0
  30. package/dist/detectors/source/readbudget.d.ts +6 -0
  31. package/dist/detectors/source/readbudget.js +133 -0
  32. package/dist/detectors/source/refusal.d.ts +6 -0
  33. package/dist/detectors/source/refusal.js +125 -0
  34. package/dist/detectors/source/security.d.ts +37 -0
  35. package/dist/detectors/source/security.js +392 -0
  36. package/dist/detectors/source/writesafety.d.ts +14 -0
  37. package/dist/detectors/source/writesafety.js +345 -0
  38. package/dist/detectors/standard/_doc.d.ts +66 -0
  39. package/dist/detectors/standard/_doc.js +152 -0
  40. package/dist/detectors/standard/r41-frontier.d.ts +58 -0
  41. package/dist/detectors/standard/r41-frontier.js +176 -0
  42. package/dist/detectors/standard/r46-returns-validator.d.ts +61 -0
  43. package/dist/detectors/standard/r46-returns-validator.js +474 -0
  44. package/dist/detectors/standard/standard-doc.d.ts +12 -0
  45. package/dist/detectors/standard/standard-doc.js +271 -0
  46. package/dist/detectors/structural.d.ts +14 -0
  47. package/dist/detectors/structural.js +83 -0
  48. package/dist/engine-types.d.ts +10 -0
  49. package/dist/engine-types.js +1 -0
  50. package/dist/not-checkable.d.ts +22 -0
  51. package/dist/not-checkable.js +37 -0
  52. package/dist/oracle.d.ts +33 -0
  53. package/dist/oracle.js +133 -0
  54. package/dist/registry.d.ts +11 -0
  55. package/dist/registry.js +140 -0
  56. package/dist/report.d.ts +35 -0
  57. package/dist/report.js +138 -0
  58. package/dist/severity.d.ts +30 -0
  59. package/dist/severity.js +121 -0
  60. package/dist/tally.d.ts +57 -0
  61. package/dist/tally.js +152 -0
  62. package/dist/trigger.d.ts +47 -0
  63. package/dist/trigger.js +143 -0
  64. package/fixtures/backend-standard-51ec7af1.md +648 -0
  65. package/fixtures/backend-standard-a9d7877b.md +629 -0
  66. package/fixtures/mcp-app-standard-a74a042b.md +279 -0
  67. package/fixtures/mcp-extension-standard-a74a042b.md +259 -0
  68. package/fixtures/mcp-server-standard-a74a042b.md +434 -0
  69. package/fixtures/mcp-standard-a74a042b.md +354 -0
  70. package/fixtures/vp-1200-green/.pin +1 -0
  71. package/fixtures/vp-1200-green/convex/migrations.ts +350 -0
  72. package/fixtures/vp-1200-green/convex/schema.ts +1243 -0
  73. package/fixtures/vp-1200-red/.pin +1 -0
  74. package/fixtures/vp-1200-red/convex/migrations.ts +239 -0
  75. package/fixtures/vp-1200-red/convex/schema.ts +1243 -0
  76. package/fixtures/vp-by-tool-day158.csv +190 -0
  77. package/fixtures/vp-convex/.github/workflows/npm-publish.yml +163 -0
  78. package/fixtures/vp-convex/.pin +1 -0
  79. package/fixtures/vp-convex/bun.lock +783 -0
  80. package/fixtures/vp-convex/convex/auth.config.ts +8 -0
  81. package/fixtures/vp-convex/convex/briefingNotes.ts +321 -0
  82. package/fixtures/vp-convex/convex/convex.config.ts +14 -0
  83. package/fixtures/vp-convex/convex/credentials.ts +591 -0
  84. package/fixtures/vp-convex/convex/crons.ts +72 -0
  85. package/fixtures/vp-convex/convex/dashboard.ts +199 -0
  86. package/fixtures/vp-convex/convex/gumroadWebhook.ts +815 -0
  87. package/fixtures/vp-convex/convex/issues.ts +544 -0
  88. package/fixtures/vp-convex/convex/kb.ts +305 -0
  89. package/fixtures/vp-convex/convex/kbMutations.ts +192 -0
  90. package/fixtures/vp-convex/convex/kbShared.ts +56 -0
  91. package/fixtures/vp-convex/convex/licenses.ts +443 -0
  92. package/fixtures/vp-convex/convex/memories.ts +406 -0
  93. package/fixtures/vp-convex/convex/memoriesScoped.ts +220 -0
  94. package/fixtures/vp-convex/convex/messages.ts +971 -0
  95. package/fixtures/vp-convex/convex/migrations.ts +239 -0
  96. package/fixtures/vp-convex/convex/missionTemplates.ts +369 -0
  97. package/fixtures/vp-convex/convex/oauth.ts +1608 -0
  98. package/fixtures/vp-convex/convex/oauthMigrations.ts +96 -0
  99. package/fixtures/vp-convex/convex/okfBundleNode.ts +1031 -0
  100. package/fixtures/vp-convex/convex/recurringTasks.ts +386 -0
  101. package/fixtures/vp-convex/convex/schema.ts +1229 -0
  102. package/fixtures/vp-convex/convex/tasks.ts +2782 -0
  103. package/fixtures/vp-r31-green/.pin +1 -0
  104. package/fixtures/vp-r31-green/convex/migrations.ts +350 -0
  105. package/fixtures/vp-r31-red/.pin +1 -0
  106. package/fixtures/vp-r31-red/convex/migrations.ts +286 -0
  107. package/fixtures/vr-by-tool-day159.csv +89 -0
  108. package/package.json +39 -0
@@ -0,0 +1,971 @@
1
+ import { ConvexError, v } from "convex/values";
2
+ // convex-strict-mode-doc-type-import-needed-when-refactoring-list-query-from-early-return-to-accumulator-post-filter
3
+ import type { Doc } from "./_generated/dataModel";
4
+ import { mutation, query } from "./_generated/server";
5
+ import { requireScope, withOrgScope } from "./lib/auth";
6
+ import { requireId } from "./lib/ids";
7
+ import { creatorValidator } from "./schema";
8
+ import { computeStaleInProgress } from "./lib/taskClosureGate";
9
+
10
+ const staleInProgressValidator = v.array(
11
+ v.object({
12
+ taskId: v.id("tasks"),
13
+ title: v.string(),
14
+ age: v.number(),
15
+ }),
16
+ );
17
+
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+ // sendMessage — send a message to one, many, or all orchestrators
20
+ // channel: "broadcast" | "tau" | "pi,phi" (comma-separated for multi)
21
+ // Creates one message row + one receipt per recipient.
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+
24
+ // Broadcast resolves dynamically from the profiles table.
25
+ // Any orchestrator with a profile receives broadcasts.
26
+ // No hardcoded list — new orchestrators are included automatically after calling update_profile.
27
+
28
+ export const sendMessage = mutation({
29
+ args: {
30
+ from: creatorValidator,
31
+ fromInstanceId: v.optional(v.string()),
32
+ channel: v.string(),
33
+ content: v.string(),
34
+ sessionDay: v.optional(v.number()),
35
+ tenantId: v.optional(v.string()),
36
+ },
37
+ returns: v.id("messages"),
38
+ handler: async (ctx, args) => {
39
+ const messageId = await ctx.db.insert("messages", {
40
+ from: args.from,
41
+ fromInstanceId: args.fromInstanceId,
42
+ channel: args.channel,
43
+ content: args.content,
44
+ sessionDay: args.sessionDay,
45
+ tenantId: args.tenantId,
46
+ createdAt: Date.now(),
47
+ });
48
+
49
+ // Resolve recipients — channel can be a role or instanceId
50
+ // If channel contains "-" (e.g. "pi-vps"), treat as instance-level
51
+ //
52
+ // Bounce contract (task k17dr97dwpe07n9zfgzzypkfm18bv6ws): a channel that
53
+ // resolves to ZERO real recipients — unknown role, non-existent instance,
54
+ // reserved non-target word, empty string, or an unknown comma-list part
55
+ // — is refused with an actionable ConvexError instead of silently
56
+ // succeeding with no receipts written. The recipient set is DERIVED from
57
+ // the org (the `profiles` table), never a hardcoded denylist — mirrors
58
+ // the broadcast branch below.
59
+ let recipients: string[];
60
+ if (args.channel === "broadcast") {
61
+ // Dynamic: get all registered orchestrators from profiles
62
+ const profiles = await ctx.db.query("profiles").collect();
63
+ const orchestratorIds = [
64
+ ...new Set(profiles.map((p) => p.orchestratorId)),
65
+ ];
66
+
67
+ // Cross-tenant leak fix (mission fix-broadcast-org-scoped-v1, T1):
68
+ // bound the broadcast fan-out to the EMITTER's own tenant, derived
69
+ // from withOrgScope(ctx) — never from the client-supplied
70
+ // args.tenantId (unauthenticated/self-declared, see T0 audit
71
+ // analysis/broadcast-org-scope-audit-t0-day157.md). Resolved
72
+ // FAIL-CLOSED (no allowNoIdentityMaster) — consistent with the
73
+ // Day-156 SEC-AUDIT doctrine. The MCP server forwards its
74
+ // service-account identity, which resolves to master via the
75
+ // CLERK_SERVICE_ACCOUNT_USER_ID carve-out (lib/auth.ts:111-121),
76
+ // so legitimate internal broadcasts still resolve to master. An
77
+ // anonymous/no-identity caller resolves to isMaster=false with an
78
+ // empty allowedOrchestrators, so it falls into the client branch
79
+ // below and yields zero recipients — the existing zero-recipient
80
+ // bounce fires (no fail-open path to the internal fleet).
81
+ const scope = await withOrgScope(ctx);
82
+
83
+ // convex-reviewer CRITICAL (mission fix-broadcast-org-scoped-v1, T1
84
+ // REVISE): scope.isMaster is OVERLOADED — it is also true for a
85
+ // CLIENT org whose client_org_mapping row carries the ["*"] read
86
+ // sentinel (lib/auth.ts:182: isMaster =
87
+ // allowedOrchestrators.includes("*")). Gating the fleet-wide
88
+ // master branch on isMaster alone would let a client
89
+ // (mis)configured with ["*"] fan out to the entire internal
90
+ // fleet + other tenants. The true internal master (service
91
+ // account / Laurent, lib/auth.ts:77-89 and :123-135) is the ONLY
92
+ // case with orgSlug === null — a client's isMaster=true always
93
+ // carries a set orgSlug (lib/auth.ts:177-183). So the
94
+ // fleet-wide branch is gated on BOTH isMaster AND orgSlug===null;
95
+ // this discriminant is applied locally here, not in lib/auth.ts
96
+ // (shared type, out of scope for this fix).
97
+ if (scope.isMaster && scope.orgSlug === null) {
98
+ // True internal/master emitter: exclude every orchestrator bound
99
+ // to any client tenant — active OR inactive — so an internal
100
+ // broadcast never reaches a client orchestrator, the exact leak
101
+ // reported in the T0 audit. Inactive mappings are included in
102
+ // the exclusion set deliberately: an inactive client_org_mapping
103
+ // row still identifies that orchestratorId as CLIENT-bound, not
104
+ // internal — dropping the isActive filter here would let a
105
+ // merely-disabled client's orchestrator silently rejoin the
106
+ // internal broadcast pool, which is itself a leak class. This
107
+ // is independent of whether that inactive org could ever
108
+ // authenticate (it can't — withOrgScope throws Forbidden on
109
+ // inactive orgs); the exclusion is about the identity of the
110
+ // orchestratorId, not the org's ability to log in.
111
+ const mappings = await ctx.db.query("client_org_mapping").collect();
112
+ const clientBound = new Set<string>();
113
+ for (const mapping of mappings) {
114
+ for (const orchestratorId of mapping.allowedOrchestrators) {
115
+ if (orchestratorId !== "*") clientBound.add(orchestratorId);
116
+ }
117
+ }
118
+ recipients = orchestratorIds.filter(
119
+ (o) => o !== args.from && !clientBound.has(o),
120
+ );
121
+ } else {
122
+ // Client-scoped emitter (including a client-org isMaster=true
123
+ // with orgSlug set — the ["*"] read-sentinel case): recipients
124
+ // bounded to this org's own allowedOrchestrators — never
125
+ // another tenant, never the internal fleet. A ["*"]
126
+ // allowedOrchestrators list never matches a real
127
+ // orchestratorId (the literal string "*" is not a
128
+ // registered orchestrator), so this yields zero recipients
129
+ // and the bounce below fires — fail-closed, not a leak.
130
+ const allowed = new Set(scope.allowedOrchestrators);
131
+ recipients = orchestratorIds.filter(
132
+ (o) => o !== args.from && allowed.has(o),
133
+ );
134
+ }
135
+
136
+ // Zero-recipient bounce contract (task k17dr97dwpe07n9zfgzzypkfm18bv6ws)
137
+ // extends to the tenant-scoped broadcast: an anonymous/no-identity
138
+ // caller resolves to isMaster=false with allowedOrchestrators=[],
139
+ // so it would otherwise silently "succeed" with zero receipts
140
+ // written. Fail-closed here means an explicit refusal, never a
141
+ // fall-through to the internal fleet.
142
+ if (recipients.length === 0) {
143
+ throw new ConvexError(
144
+ `recipient error / message non livré : "broadcast" ne correspond à aucun destinataire de l'organisation pour cet émetteur.`,
145
+ );
146
+ }
147
+ } else {
148
+ const profiles = await ctx.db.query("profiles").collect();
149
+ const knownRoles = new Set(profiles.map((p) => p.orchestratorId));
150
+ const knownInstances = new Set(
151
+ profiles
152
+ .map((p) => p.instanceId)
153
+ .filter((id): id is string => id !== undefined),
154
+ );
155
+
156
+ const rawParts = args.channel
157
+ .split(",")
158
+ .map((s) => s.trim())
159
+ .filter((s) => s.length > 0);
160
+
161
+ const bounce = () => {
162
+ throw new ConvexError(
163
+ `recipient error / message non livré : "${args.channel}" ne correspond à aucun destinataire de l'organisation. Formes valides : <role existant> | <instance> | broadcast | liste "eta,pi".`,
164
+ );
165
+ };
166
+
167
+ if (rawParts.length === 0) {
168
+ bounce();
169
+ }
170
+ for (const part of rawParts) {
171
+ if (part === args.from) continue; // sender excluding itself never needs to resolve
172
+ const isKnown = knownRoles.has(part) || knownInstances.has(part);
173
+ if (!isKnown) {
174
+ bounce();
175
+ }
176
+ }
177
+
178
+ recipients = rawParts.filter((s) => s !== args.from);
179
+ if (recipients.length === 0) {
180
+ bounce();
181
+ }
182
+ }
183
+
184
+ for (const recipient of recipients) {
185
+ // Determine if this is an instance target or role target
186
+ const isInstance = recipient.includes("-");
187
+ const role = isInstance ? recipient.split("-")[0] : recipient;
188
+
189
+ await ctx.db.insert("messageReceipts", {
190
+ messageId,
191
+ recipient: role,
192
+ recipientInstanceId: isInstance ? recipient : undefined,
193
+ tenantId: args.tenantId,
194
+ readAt: undefined,
195
+ });
196
+ }
197
+
198
+ return messageId;
199
+ },
200
+ });
201
+
202
+ // ─────────────────────────────────────────────────────────────────────────────
203
+ // checkNewMessages — get unread messages for a recipient
204
+ // Returns messages with their receipt IDs (for marking as read).
205
+ // ─────────────────────────────────────────────────────────────────────────────
206
+
207
+ export const checkNewMessages = query({
208
+ args: {
209
+ recipient: creatorValidator,
210
+ recipientInstanceId: v.optional(v.string()),
211
+ tenantId: v.optional(v.string()),
212
+ since: v.optional(v.number()), // Unix ms — only return receipts with _creationTime > since
213
+ },
214
+ returns: v.array(
215
+ v.object({
216
+ receiptId: v.id("messageReceipts"),
217
+ messageId: v.id("messages"),
218
+ from: creatorValidator,
219
+ fromInstanceId: v.optional(v.string()),
220
+ channel: v.optional(v.string()),
221
+ content: v.string(),
222
+ createdAt: v.number(),
223
+ }),
224
+ ),
225
+ // FROZEN CONTRACT — do not change this return shape. checkNewMessages is
226
+ // intentionally left as a bare array for vp-mcp <2.12.0 callers ("no
227
+ // break" — see mcp-server/CHANGELOG.md:40). The MCP server only calls
228
+ // checkNewMessagesEnvelope in practice (mcp-server/src/tools.ts:2776);
229
+ // staleInProgress (Day 130) is delivered there, not here. Protected by
230
+ // convex/__tests__/staleInProgress.test.ts
231
+ // "checkNewMessages frozen contract — returns a bare array".
232
+ // tenantId filtering: when provided, only returns messages for that tenant.
233
+ // When omitted, returns all messages (backward-compatible single-tenant mode).
234
+ // This is intentional — omitting tenantId = admin/legacy access, not a bypass.
235
+ handler: async (ctx, args) => {
236
+ let receipts;
237
+
238
+ if (args.recipientInstanceId !== undefined) {
239
+ // Instance-level: get messages targeted at this specific instance
240
+ // PLUS role-level messages (recipientInstanceId === undefined)
241
+ const instanceReceipts = await ctx.db
242
+ .query("messageReceipts")
243
+ .withIndex("by_instance_unread", (q) =>
244
+ q.eq("recipientInstanceId", args.recipientInstanceId!),
245
+ )
246
+ .filter((q) => {
247
+ const base = q.eq(q.field("readAt"), undefined);
248
+ return args.since !== undefined
249
+ ? q.and(base, q.gt(q.field("_creationTime"), args.since))
250
+ : base;
251
+ })
252
+ .take(100);
253
+
254
+ const roleReceipts = await ctx.db
255
+ .query("messageReceipts")
256
+ .withIndex("by_recipient_unread", (q) =>
257
+ q.eq("recipient", args.recipient),
258
+ )
259
+ .filter((q) => {
260
+ const base = q.and(
261
+ q.eq(q.field("readAt"), undefined),
262
+ q.eq(q.field("recipientInstanceId"), undefined),
263
+ );
264
+ return args.since !== undefined
265
+ ? q.and(base, q.gt(q.field("_creationTime"), args.since))
266
+ : base;
267
+ })
268
+ .take(100);
269
+
270
+ // Merge and deduplicate by receiptId
271
+ const seen = new Set<string>();
272
+ receipts = [];
273
+ for (const r of [...instanceReceipts, ...roleReceipts]) {
274
+ if (!seen.has(r._id)) {
275
+ seen.add(r._id);
276
+ receipts.push(r);
277
+ }
278
+ }
279
+
280
+ // No tenant+instance index — JS filter is acceptable for instance-targeted messages (low volume)
281
+ if (args.tenantId !== undefined) {
282
+ receipts = receipts.filter((r) => r.tenantId === args.tenantId);
283
+ }
284
+ } else {
285
+ // Role-level: get all unread for this role
286
+ if (args.tenantId !== undefined) {
287
+ receipts = await ctx.db
288
+ .query("messageReceipts")
289
+ .withIndex("by_tenant_recipient_unread", (q) =>
290
+ q
291
+ .eq("tenantId", args.tenantId)
292
+ .eq("recipient", args.recipient)
293
+ .eq("readAt", undefined),
294
+ )
295
+ .filter((q) =>
296
+ args.since !== undefined
297
+ ? q.gt(q.field("_creationTime"), args.since)
298
+ : true,
299
+ )
300
+ .take(100);
301
+ } else {
302
+ receipts = await ctx.db
303
+ .query("messageReceipts")
304
+ .withIndex("by_recipient_unread", (q) =>
305
+ q.eq("recipient", args.recipient),
306
+ )
307
+ .filter((q) => {
308
+ const base = q.eq(q.field("readAt"), undefined);
309
+ return args.since !== undefined
310
+ ? q.and(base, q.gt(q.field("_creationTime"), args.since))
311
+ : base;
312
+ })
313
+ .take(100);
314
+ }
315
+ }
316
+
317
+ const results = [];
318
+ for (const receipt of receipts) {
319
+ const message = await ctx.db.get(receipt.messageId);
320
+ if (message !== null) {
321
+ results.push({
322
+ receiptId: receipt._id,
323
+ messageId: receipt.messageId,
324
+ from: message.from,
325
+ fromInstanceId: message.fromInstanceId,
326
+ channel: message.channel,
327
+ content: message.content,
328
+ createdAt: message.createdAt,
329
+ });
330
+ }
331
+ }
332
+
333
+ return results;
334
+ },
335
+ });
336
+
337
+ // ─────────────────────────────────────────────────────────────────────────────
338
+ // checkNewMessagesEnvelope — envelope-guarded variant of checkNewMessages.
339
+ //
340
+ // Day 102 — Pi BLOCKER task k1702xaahb: 36 unread messages = 53 KB of content
341
+ // exceeded the Claude Code tool-response cap and crashed the cron. This variant
342
+ // adds (a) a `limit` arg (default 20, clamp [1,50]), (b) a `maxBytes` arg
343
+ // (default 40_000, clamp [1_000, 60_000]) summing JSON.stringify(projected)
344
+ // per included row, (c) returns `{messages, truncated, nextSince}` so callers
345
+ // can resume via `since=nextSince` on the next tick.
346
+ //
347
+ // The legacy `checkNewMessages` is intentionally left intact for vp-mcp
348
+ // <2.12.0 consumers. New mcp-server >=2.12.0 calls THIS variant.
349
+ // ─────────────────────────────────────────────────────────────────────────────
350
+
351
+ export const checkNewMessagesEnvelope = query({
352
+ args: {
353
+ recipient: creatorValidator,
354
+ recipientInstanceId: v.optional(v.string()),
355
+ tenantId: v.optional(v.string()),
356
+ since: v.optional(v.number()),
357
+ limit: v.optional(v.number()),
358
+ maxBytes: v.optional(v.number()),
359
+ },
360
+ returns: v.object({
361
+ messages: v.array(
362
+ v.object({
363
+ receiptId: v.id("messageReceipts"),
364
+ messageId: v.id("messages"),
365
+ from: creatorValidator,
366
+ fromInstanceId: v.optional(v.string()),
367
+ channel: v.optional(v.string()),
368
+ content: v.string(),
369
+ createdAt: v.number(),
370
+ }),
371
+ ),
372
+ truncated: v.boolean(),
373
+ nextSince: v.union(v.number(), v.null()),
374
+ staleInProgress: staleInProgressValidator,
375
+ }),
376
+ handler: async (ctx, args) => {
377
+ const limit = Math.min(Math.max(args.limit ?? 20, 1), 50);
378
+ const maxBytes = Math.min(Math.max(args.maxBytes ?? 40_000, 1_000), 60_000);
379
+ const takeBudget = limit + 1;
380
+
381
+ let receipts: Doc<"messageReceipts">[];
382
+
383
+ if (args.recipientInstanceId !== undefined) {
384
+ const instanceReceipts = await ctx.db
385
+ .query("messageReceipts")
386
+ .withIndex("by_instance_unread", (q) =>
387
+ q.eq("recipientInstanceId", args.recipientInstanceId!),
388
+ )
389
+ .filter((q) => {
390
+ const base = q.eq(q.field("readAt"), undefined);
391
+ return args.since !== undefined
392
+ ? q.and(base, q.gt(q.field("_creationTime"), args.since))
393
+ : base;
394
+ })
395
+ .take(takeBudget);
396
+
397
+ const roleReceipts = await ctx.db
398
+ .query("messageReceipts")
399
+ .withIndex("by_recipient_unread", (q) =>
400
+ q.eq("recipient", args.recipient),
401
+ )
402
+ .filter((q) => {
403
+ const base = q.and(
404
+ q.eq(q.field("readAt"), undefined),
405
+ q.eq(q.field("recipientInstanceId"), undefined),
406
+ );
407
+ return args.since !== undefined
408
+ ? q.and(base, q.gt(q.field("_creationTime"), args.since))
409
+ : base;
410
+ })
411
+ .take(takeBudget);
412
+
413
+ const seen = new Set<string>();
414
+ receipts = [];
415
+ for (const r of [...instanceReceipts, ...roleReceipts]) {
416
+ if (!seen.has(r._id)) {
417
+ seen.add(r._id);
418
+ receipts.push(r);
419
+ }
420
+ }
421
+ if (args.tenantId !== undefined) {
422
+ receipts = receipts.filter((r) => r.tenantId === args.tenantId);
423
+ }
424
+ } else if (args.tenantId !== undefined) {
425
+ receipts = await ctx.db
426
+ .query("messageReceipts")
427
+ .withIndex("by_tenant_recipient_unread", (q) =>
428
+ q
429
+ .eq("tenantId", args.tenantId!)
430
+ .eq("recipient", args.recipient)
431
+ .eq("readAt", undefined),
432
+ )
433
+ .filter((q) =>
434
+ args.since !== undefined
435
+ ? q.gt(q.field("_creationTime"), args.since)
436
+ : true,
437
+ )
438
+ .take(takeBudget);
439
+ } else {
440
+ receipts = await ctx.db
441
+ .query("messageReceipts")
442
+ .withIndex("by_recipient_unread", (q) =>
443
+ q.eq("recipient", args.recipient),
444
+ )
445
+ .filter((q) => {
446
+ const base = q.eq(q.field("readAt"), undefined);
447
+ return args.since !== undefined
448
+ ? q.and(base, q.gt(q.field("_creationTime"), args.since))
449
+ : base;
450
+ })
451
+ .take(takeBudget);
452
+ }
453
+
454
+ receipts.sort((a, b) => a._creationTime - b._creationTime);
455
+
456
+ const messages: Array<{
457
+ receiptId: Doc<"messageReceipts">["_id"];
458
+ messageId: Doc<"messages">["_id"];
459
+ from: Doc<"messages">["from"];
460
+ fromInstanceId?: string;
461
+ channel?: string;
462
+ content: string;
463
+ createdAt: number;
464
+ }> = [];
465
+ let bytes = 0;
466
+ let truncated = false;
467
+ let lastIncludedReceiptCreationTime: number | null = null;
468
+
469
+ for (const receipt of receipts) {
470
+ if (messages.length >= limit) {
471
+ truncated = true;
472
+ break;
473
+ }
474
+ const message = await ctx.db.get(receipt.messageId);
475
+ if (message === null) continue;
476
+ const projected = {
477
+ receiptId: receipt._id,
478
+ messageId: receipt.messageId,
479
+ from: message.from,
480
+ fromInstanceId: message.fromInstanceId,
481
+ channel: message.channel,
482
+ content: message.content,
483
+ createdAt: message.createdAt,
484
+ };
485
+ const projectedBytes = JSON.stringify(projected).length;
486
+ if (messages.length > 0 && bytes + projectedBytes > maxBytes) {
487
+ truncated = true;
488
+ break;
489
+ }
490
+ messages.push(projected);
491
+ bytes += projectedBytes;
492
+ lastIncludedReceiptCreationTime = receipt._creationTime;
493
+ }
494
+
495
+ const nextSince = truncated ? lastIncludedReceiptCreationTime : null;
496
+
497
+ // Day 130 closure gate — surface the recipient's own overdue
498
+ // in_progress work on every check_messages call, no extra cron. This
499
+ // is the ONLY path staleInProgress is delivered on: mcp-server calls
500
+ // exclusively checkNewMessagesEnvelope (tools.ts:2776); the legacy
501
+ // checkNewMessages array contract stays frozen (see its own comment).
502
+ const now = Date.now();
503
+ const staleInProgress = await computeStaleInProgress(
504
+ ctx,
505
+ args.recipient,
506
+ now,
507
+ );
508
+
509
+ return {
510
+ messages,
511
+ truncated,
512
+ nextSince,
513
+ staleInProgress,
514
+ };
515
+ },
516
+ });
517
+
518
+ // ─────────────────────────────────────────────────────────────────────────────
519
+ // markAsRead — mark one or more receipts as read
520
+ // ─────────────────────────────────────────────────────────────────────────────
521
+
522
+ export const markAsRead = mutation({
523
+ args: {
524
+ // Accept raw strings (not v.id("messageReceipts")) so we can normalize
525
+ // each element ourselves and throw an actionable ConvexError instead of
526
+ // letting the v.id() validator reject before the handler runs. Convex
527
+ // prod REDACTS non-ConvexError validator messages before they reach the
528
+ // client (issue #1064) — only an explicitly-thrown ConvexError's .data
529
+ // payload survives the wire.
530
+ receiptIds: v.array(v.string()),
531
+ // k179nrp3apj700pm0h1ckewm2h8b3nz7 — ownership gate. When provided, every
532
+ // resolved receipt MUST belong to this recipient or the whole call is
533
+ // rejected (RBAC_DENIED). Optional only to stay behavior-preserving for
534
+ // legacy/system callers that predate the MCP-layer guard (mirrors the
535
+ // deleteMessage callerOrchestrator pattern above); the MCP tool now
536
+ // ALWAYS passes it (tools.ts mark_as_read), closing the cross-owner hole
537
+ // where any caller could mark another orchestrator's mail read.
538
+ callerOrchestrator: v.optional(creatorValidator),
539
+ },
540
+ returns: v.number(),
541
+ handler: async (ctx, args) => {
542
+ const normalizedIds = args.receiptIds.map((raw, index) =>
543
+ requireId(
544
+ ctx,
545
+ "messageReceipts",
546
+ raw,
547
+ `receiptIds[${index}]`,
548
+ "Use the full 32-char receiptId returned by check_messages.",
549
+ ),
550
+ );
551
+
552
+ const now = Date.now();
553
+ let count = 0;
554
+ for (const receiptId of normalizedIds) {
555
+ const receipt = await ctx.db.get(receiptId);
556
+ if (receipt === null) continue;
557
+ if (
558
+ args.callerOrchestrator !== undefined &&
559
+ receipt.recipient !== args.callerOrchestrator
560
+ ) {
561
+ throw new ConvexError(
562
+ `RBAC_DENIED: ${args.callerOrchestrator} is not the recipient of receipt ${receiptId} — mark_as_read denied`,
563
+ );
564
+ }
565
+ if (receipt.readAt === undefined) {
566
+ await ctx.db.patch(receiptId, { readAt: now });
567
+ count++;
568
+ }
569
+ }
570
+ return count;
571
+ },
572
+ });
573
+
574
+ // ─────────────────────────────────────────────────────────────────────────────
575
+ // deleteMessage — delete a message and cascade-delete its receipts
576
+ // RBAC: only the sender or "system" may delete. Pass callerOrchestrator=undefined
577
+ // to bypass the check (server-to-server / admin use).
578
+ // ─────────────────────────────────────────────────────────────────────────────
579
+
580
+ export const deleteMessage = mutation({
581
+ args: {
582
+ messageId: v.id("messages"),
583
+ callerOrchestrator: v.optional(creatorValidator),
584
+ },
585
+ returns: v.object({ deleted: v.boolean(), receiptsDeleted: v.number() }),
586
+ handler: async (ctx, args) => {
587
+ const message = await ctx.db.get(args.messageId);
588
+ if (!message) throw new Error("Message not found");
589
+
590
+ // RBAC: callerOrchestrator is required and must match message.from
591
+ if (args.callerOrchestrator === undefined) {
592
+ throw new Error(
593
+ "Unauthorized: callerOrchestrator is required to delete a message — omitting it is refused, not exempted",
594
+ );
595
+ }
596
+ if (
597
+ args.callerOrchestrator !== "system" &&
598
+ message.from !== args.callerOrchestrator
599
+ ) {
600
+ throw new Error(
601
+ `Unauthorized: only ${message.from} (sender) or system can delete this message`,
602
+ );
603
+ }
604
+
605
+ // Cascade: delete all receipts for this message
606
+ const receipts = await ctx.db
607
+ .query("messageReceipts")
608
+ .withIndex("by_message", (q) => q.eq("messageId", args.messageId))
609
+ .collect();
610
+
611
+ for (const receipt of receipts) {
612
+ await ctx.db.delete(receipt._id);
613
+ }
614
+
615
+ // Delete the message
616
+ await ctx.db.delete(args.messageId);
617
+
618
+ return { deleted: true, receiptsDeleted: receipts.length };
619
+ },
620
+ });
621
+
622
+ // ─────────────────────────────────────────────────────────────────────────────
623
+ // listMessages — get messages for a day or from a sender (history/replay)
624
+ // ─────────────────────────────────────────────────────────────────────────────
625
+
626
+ // PR #635 wide-scan-cap pattern (see convex/tasks.ts TASK_LIST_SCAN_CAP,
627
+ // convex/profiles.ts PROFILES_LIST_SCAN_CAP, lot 1 mission k574p02m). When
628
+ // paginating via `createdBefore`, the post-take filter only finds rows
629
+ // older than the cursor if the FETCH is wide enough to include them —
630
+ // mission k574p02m DEFECT 2, lot 2.
631
+ export const MESSAGES_LIST_SCAN_CAP = 2000;
632
+
633
+ export const listMessages = query({
634
+ args: {
635
+ fields: v.optional(v.union(v.literal("lite"), v.literal("full"))), // v2.4.12 accept (no-op for now) — closes ArgumentValidationError from MCP wrappers passing fields
636
+ sessionDay: v.optional(v.number()),
637
+ from: v.optional(creatorValidator),
638
+ limit: v.optional(v.number()),
639
+ // S3.3 B8 follow-up batch 2 — cursor paging anchor (newest-first).
640
+ createdBefore: v.optional(v.number()),
641
+ },
642
+ returns: v.array(
643
+ v.object({
644
+ _id: v.id("messages"),
645
+ _creationTime: v.number(),
646
+ from: creatorValidator,
647
+ fromInstanceId: v.optional(v.string()),
648
+ channel: v.optional(v.string()),
649
+ to: v.optional(creatorValidator),
650
+ content: v.string(),
651
+ sessionDay: v.optional(v.number()),
652
+ createdAt: v.number(),
653
+ // Day 98 Cat A k17e611z4 fix — issues #655, #644, #643. Multi-tenant
654
+ // rows carry tenantId; returns shape must declare it or Convex
655
+ // emits ReturnsValidationError "extra field tenantId".
656
+ tenantId: v.optional(v.string()),
657
+ }),
658
+ ),
659
+ handler: async (ctx, args) => {
660
+ // ── Identity-derived tenant scope ────────────────────────────────────────
661
+ // m977mqck: no-identity callers (MCP server / CLI) → isMaster=true, all rows.
662
+ // m9748paff: Clerk callers are fail-CLOSED — scoped to their own tenantId.
663
+ // k179fk0c: same per-tool tenancy doctrine as tasks.list.
664
+ const scope = await withOrgScope(ctx);
665
+ requireScope(scope, "view-own-tasks");
666
+
667
+ const limit = args.limit ?? 100;
668
+ const before = args.createdBefore;
669
+ const needsWideScan = before !== undefined;
670
+ const fetchCap = needsWideScan ? MESSAGES_LIST_SCAN_CAP + 1 : limit;
671
+
672
+ let rows: Doc<"messages">[];
673
+
674
+ // Defense-in-depth (#776 Eta follow-up): degenerate !isMaster && orgSlug===null
675
+ // is unreachable per withOrgScope invariant but explicit guard prevents regression.
676
+ if (!scope.isMaster && scope.orgSlug === null) return [];
677
+
678
+ if (!scope.isMaster && scope.orgSlug !== null) {
679
+ // ── Clerk (non-master) path — tenant-scoped index ─────────────────────
680
+ // Push tenantId equality BEFORE .take(limit) using by_tenant_created so
681
+ // fleet (null-tenant) traffic cannot crowd out tenant rows in the window.
682
+ // sessionDay and full-scan branches are intentionally merged here: Clerk
683
+ // callers have no business querying a specific sessionDay of fleet traffic,
684
+ // so we always use the tenant-scoped index and apply `from` as a post-index
685
+ // .filter() (acceptable: within a single tenant, volume is low compared to
686
+ // the full cross-tenant table). (task k176wgsrhha0fr0dxxahctvhw588q5a1,
687
+ // Eta completeness edge from PR #775 verdict jn7563v34)
688
+ const orgSlug = scope.orgSlug;
689
+ rows = await ctx.db
690
+ .query("messages")
691
+ .withIndex("by_tenant_created", (q) => q.eq("tenantId", orgSlug))
692
+ .order("desc")
693
+ .filter((q) =>
694
+ args.from !== undefined ? q.eq(q.field("from"), args.from) : true,
695
+ )
696
+ .take(fetchCap);
697
+
698
+ // Belt-and-suspenders: ensure no cross-tenant row leaks through.
699
+ rows = rows.filter((r) => r.tenantId === orgSlug);
700
+ } else {
701
+ // ── Master path — existing index logic, unchanged ──────────────────────
702
+ if (args.sessionDay !== undefined) {
703
+ const sessionDay = args.sessionDay;
704
+ rows = await ctx.db
705
+ .query("messages")
706
+ .withIndex("by_day", (q) => q.eq("sessionDay", sessionDay))
707
+ .order("asc")
708
+ .take(fetchCap);
709
+ } else if (args.from !== undefined) {
710
+ const from = args.from;
711
+ rows = await ctx.db
712
+ .query("messages")
713
+ .withIndex("by_from", (q) => q.eq("from", from))
714
+ .order("desc")
715
+ .take(fetchCap);
716
+ } else {
717
+ rows = await ctx.db.query("messages").order("desc").take(fetchCap);
718
+ }
719
+ }
720
+
721
+ // S3.3 B8 follow-up batch 2 — drop rows newer-or-equal to anchor.
722
+ if (before !== undefined) {
723
+ rows = rows.filter((r) => r._creationTime < before);
724
+ }
725
+
726
+ return rows.slice(0, limit);
727
+ },
728
+ });
729
+
730
+ // ─────────────────────────────────────────────────────────────────────────────
731
+ // getUnreadCount — count unread receipts for a recipient role
732
+ // ─────────────────────────────────────────────────────────────────────────────
733
+
734
+ export const getUnreadCount = query({
735
+ args: { orchestratorId: creatorValidator },
736
+ returns: v.number(),
737
+ handler: async (ctx, { orchestratorId }) => {
738
+ const receipts = await ctx.db
739
+ .query("messageReceipts")
740
+ .withIndex("by_recipient_unread", (q) =>
741
+ q.eq("recipient", orchestratorId),
742
+ )
743
+ .filter((q) => q.eq(q.field("readAt"), undefined))
744
+ .take(500);
745
+ return receipts.length;
746
+ },
747
+ });
748
+
749
+ // ─────────────────────────────────────────────────────────────────────────────
750
+ // listBroadcastStatus — show who read a broadcast message and who didn't
751
+ // ─────────────────────────────────────────────────────────────────────────────
752
+
753
+ export const listBroadcastStatus = query({
754
+ args: {
755
+ fields: v.optional(v.union(v.literal("lite"), v.literal("full"))), // v2.4.12 accept (no-op for now) — closes ArgumentValidationError from MCP wrappers passing fields
756
+ messageId: v.id("messages"),
757
+ // Fix for the "list_broadcast_status returns Server Error" incident: the
758
+ // MCP wrapper (mcp-server/src/tools.ts) always sent `limit` even when the
759
+ // caller omitted it, but this arg list previously did not declare it,
760
+ // so Convex rejected EVERY call with ArgumentValidationError. `limit` is
761
+ // now declared AND applied (see `truncated` below — a capped list never
762
+ // looks complete).
763
+ limit: v.optional(v.number()),
764
+ },
765
+ returns: v.object({
766
+ messageId: v.id("messages"),
767
+ from: creatorValidator,
768
+ channel: v.optional(v.string()),
769
+ createdAt: v.number(),
770
+ receipts: v.array(
771
+ v.object({
772
+ recipient: v.string(),
773
+ recipientInstanceId: v.optional(v.string()),
774
+ read: v.boolean(),
775
+ readAt: v.optional(v.number()),
776
+ }),
777
+ ),
778
+ // True when `limit` truncated the receipts list — "I showed you N of M"
779
+ // must never render identically to "there are N".
780
+ truncated: v.boolean(),
781
+ }),
782
+ handler: async (ctx, { messageId, limit }) => {
783
+ const message = await ctx.db.get(messageId);
784
+ if (!message) throw new Error("Message not found");
785
+
786
+ // Deterministic order from the `by_message` index — no Date.now() here,
787
+ // queries must stay reproducible for reactivity.
788
+ const receipts = await ctx.db
789
+ .query("messageReceipts")
790
+ .withIndex("by_message", (q) => q.eq("messageId", messageId))
791
+ .collect();
792
+
793
+ const mapped = receipts.map((r) => ({
794
+ recipient: r.recipient,
795
+ recipientInstanceId: r.recipientInstanceId,
796
+ read: r.readAt !== undefined,
797
+ readAt: r.readAt,
798
+ }));
799
+
800
+ const truncated = limit !== undefined && mapped.length > limit;
801
+ const page = limit !== undefined ? mapped.slice(0, limit) : mapped;
802
+
803
+ return {
804
+ messageId,
805
+ from: message.from,
806
+ channel: message.channel,
807
+ createdAt: message.createdAt,
808
+ receipts: page,
809
+ truncated,
810
+ };
811
+ },
812
+ });
813
+
814
+ // ─────────────────────────────────────────────────────────────────────────────
815
+ // listByChannel — list recent messages for a channel (or all if unspecified)
816
+ // ─────────────────────────────────────────────────────────────────────────────
817
+
818
+ export const listByChannel = query({
819
+ args: {
820
+ channel: v.optional(v.string()),
821
+ limit: v.optional(v.number()),
822
+ },
823
+ returns: v.array(
824
+ v.object({
825
+ _id: v.id("messages"),
826
+ _creationTime: v.number(),
827
+ from: creatorValidator,
828
+ fromInstanceId: v.optional(v.string()),
829
+ channel: v.string(),
830
+ content: v.string(),
831
+ sessionDay: v.optional(v.number()),
832
+ createdAt: v.number(),
833
+ }),
834
+ ),
835
+ handler: async (ctx, { channel, limit }) => {
836
+ const take = limit ?? 100;
837
+ const scope = await withOrgScope(ctx);
838
+
839
+ // Fail-closed channel scoping: messages carry no orgId/tenantId column
840
+ // (schema.ts), so channel-name proximity to the caller's own scope is the
841
+ // only generic (non-hardcoded) signal available. A non-master, org-scoped
842
+ // caller may only read: "broadcast" (universally shared), a channel
843
+ // exactly matching one of its allowedOrchestrators, or one prefixed with
844
+ // its own "team/<orgSlug>/" convention. Anything else is denied.
845
+ const isChannelAllowed = (ch: string): boolean => {
846
+ if (scope.isMaster) return true;
847
+ if (ch === "broadcast") return true;
848
+ if (scope.orgSlug !== null && ch.startsWith(`team/${scope.orgSlug}`)) {
849
+ return true;
850
+ }
851
+ return scope.allowedOrchestrators.includes(ch);
852
+ };
853
+
854
+ if (channel !== undefined) {
855
+ if (!isChannelAllowed(channel)) return [];
856
+ return await ctx.db
857
+ .query("messages")
858
+ .withIndex("by_channel", (q) => q.eq("channel", channel))
859
+ .order("desc")
860
+ .take(take);
861
+ }
862
+
863
+ if (scope.isMaster) {
864
+ return await ctx.db.query("messages").order("desc").take(take);
865
+ }
866
+
867
+ const rows = await ctx.db.query("messages").order("desc").take(take);
868
+ return rows.filter((r) => isChannelAllowed(r.channel));
869
+ },
870
+ });
871
+
872
+ // ─────────────────────────────────────────────────────────────────────────────
873
+ // Day 100 — Phase 2 get_by_id surface fix (task k172735brsw6bc3j2dkkkfxqrx88kkjq)
874
+ // Single-row read by Convex doc ID. Returns null on miss (MCP layer reshapes
875
+ // to scope-aware "not found"). No validator on returns — schema for messages
876
+ // rows varies by send variant; raw row is fine for read-by-id.
877
+ // ─────────────────────────────────────────────────────────────────────────────
878
+
879
+ export const getById = query({
880
+ // A wrong-table but well-formed 32-char id passes the `v.id("messages")`
881
+ // validator's format check yet fails table membership, and that rejection
882
+ // happens BEFORE the handler, so a wrong-table ID is rejected with a message
883
+ // Convex redacts in prod (`Server Error`, `error.data` undefined — measured).
884
+ // Narrowing inside the handler via requireId() throws a ConvexError whose
885
+ // payload survives redaction. Same contract as PR #1069 (markAsRead) and
886
+ // #1072 (tasks:getById), on a read.
887
+ args: { messageId: v.string() },
888
+ handler: async (ctx, args) => {
889
+ const messageId = requireId(
890
+ ctx,
891
+ "messages",
892
+ args.messageId,
893
+ "messageId",
894
+ "Use the full 32-char messageId returned by list_messages or checkNewMessages.",
895
+ );
896
+ return await ctx.db.get(messageId);
897
+ },
898
+ });
899
+
900
+ // ─────────────────────────────────────────────────────────────────────────────
901
+ // Day 102 v2.11.0 — CRUD baseline PR-C-bis option B (mission k575kc1r).
902
+ // BM25 keyword search over message content via Convex native .searchIndex().
903
+ //
904
+ // Backed by the `search_content` searchIndex declared in schema.ts.
905
+ // Filter axes: from, channel, sessionDay, tenantId (matches messages.list()).
906
+ // ─────────────────────────────────────────────────────────────────────────────
907
+
908
+ export const searchMessagesByKeyword = query({
909
+ args: {
910
+ query: v.string(),
911
+ from: v.optional(creatorValidator),
912
+ channel: v.optional(v.string()),
913
+ sessionDay: v.optional(v.number()),
914
+ tenantId: v.optional(v.string()),
915
+ limit: v.optional(v.number()),
916
+ fields: v.optional(v.union(v.literal("lite"), v.literal("full"))),
917
+ },
918
+ handler: async (ctx, args) => {
919
+ // ── Identity-derived tenant scope ────────────────────────────────────────
920
+ // m977mqck: no-identity callers (MCP server / CLI) → isMaster=true, all rows.
921
+ // m9748paff: Clerk callers are fail-CLOSED — scoped to their own tenantId.
922
+ // k179fk0c: same per-tool tenancy doctrine as tasks.searchTasksByKeyword.
923
+ const scope = await withOrgScope(ctx);
924
+ requireScope(scope, "view-own-tasks");
925
+
926
+ // Defense-in-depth (#776 Eta follow-up): degenerate !isMaster && orgSlug===null
927
+ // is unreachable per withOrgScope invariant but explicit guard prevents regression.
928
+ if (!scope.isMaster && scope.orgSlug === null) return [];
929
+
930
+ const limit = Math.min(Math.max(args.limit ?? 20, 1), 200);
931
+ const lite = args.fields === "lite";
932
+
933
+ const results = await ctx.db
934
+ .query("messages")
935
+ .withSearchIndex("search_content", (q) => {
936
+ let qb = q.search("content", args.query);
937
+ if (args.from !== undefined) qb = qb.eq("from", args.from);
938
+ if (args.channel !== undefined) qb = qb.eq("channel", args.channel);
939
+ if (args.sessionDay !== undefined)
940
+ qb = qb.eq("sessionDay", args.sessionDay);
941
+ // For Clerk callers: push scope.orgSlug into the search index filter
942
+ // (primary isolation). For master callers: use caller-supplied tenantId
943
+ // if provided (backward-compatible narrow by tenant for admin queries).
944
+ if (!scope.isMaster && scope.orgSlug !== null) {
945
+ qb = qb.eq("tenantId", scope.orgSlug);
946
+ } else if (args.tenantId !== undefined) {
947
+ qb = qb.eq("tenantId", args.tenantId);
948
+ }
949
+ return qb;
950
+ })
951
+ .take(limit);
952
+
953
+ // Defense-in-depth: messages have no pilot/assignedTo so filterByOrgScope()
954
+ // does not fit. Enforce tenantId match inline for non-master scopes — the
955
+ // index .eq("tenantId", scope.orgSlug) above is the primary isolation; this
956
+ // is the belt-and-suspenders pass (mirrors briefingNotes pattern).
957
+ const filtered = !scope.isMaster
958
+ ? results.filter((r) => r.tenantId === scope.orgSlug)
959
+ : results;
960
+
961
+ if (!lite) return filtered;
962
+ return filtered.map((m) => ({
963
+ _id: m._id,
964
+ from: m.from,
965
+ channel: m.channel,
966
+ content: m.content,
967
+ sessionDay: m.sessionDay,
968
+ createdAt: m.createdAt,
969
+ }));
970
+ },
971
+ });