@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,1243 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ // Shared validators (reused across tables)
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+
8
+ export const memoryTypeValidator = v.union(
9
+ v.literal("user"),
10
+ v.literal("feedback"),
11
+ v.literal("project"),
12
+ v.literal("reference"),
13
+ v.literal("episode"),
14
+ );
15
+
16
+ // Open validator — any orchestrator name is accepted.
17
+ // Known defaults: pi, tau, phi, sigma, omega, zeta, eta, kappa, alpha, lambda,
18
+ // victor, proxima, theta, xi, epsilon, omicron, upsilon, system.
19
+ // New orchestrators can be added without schema changes (see issue #132).
20
+ export const creatorValidator = v.string();
21
+
22
+ // Day 130 follow-up #2 (Eta REVISE, PR #1089) — inforgeable automation
23
+ // signal. `createdBy` is a caller-supplied string on the PUBLIC
24
+ // `tasks.create` mutation, so it can be forged (e.g. `createdBy: "system"`)
25
+ // to escape the billing closure gate. `origin` is NOT accepted as an
26
+ // argument on any public mutation — only the internal webhook path
27
+ // (createOrUpdateReviewTask) writes it. The closure gate reads `origin`,
28
+ // never `createdBy`, to decide whether a task is automation-created.
29
+ export const taskOriginValidator = v.literal("automation");
30
+
31
+ export const relationTypeValidator = v.union(
32
+ v.literal("updates"),
33
+ v.literal("extends"),
34
+ v.literal("derives"),
35
+ );
36
+
37
+ export const severityValidator = v.union(
38
+ v.literal("critical"),
39
+ v.literal("major"),
40
+ v.literal("minor"),
41
+ );
42
+
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+ // Schema
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+
47
+ export default defineSchema({
48
+ // ── memories ────────────────────────────────────────────────────────────────
49
+ // Core memory store. Each row is one typed memory entry.
50
+ // Supermemory pattern: updates/extends/derives relations + isLatest flag.
51
+ // Mem0 pattern: typed memory (user/feedback/project/reference/episode).
52
+ // Episodic type includes structured episode metadata.
53
+ //
54
+ // Embedding + search is handled by @convex-dev/rag (component-owned schema).
55
+ // RAG entry key = memoryId string. Filters: namespace, type, isLatest.
56
+ memories: defineTable({
57
+ // Namespace: "global" | "orchestrator/pi" | "project/vantage-starter"
58
+ namespace: v.string(),
59
+
60
+ // Memory classification — drives retrieval strategy
61
+ type: memoryTypeValidator,
62
+
63
+ // Human-readable content (what the memory says)
64
+ content: v.string(),
65
+
66
+ // Which orchestrator (or system) created this memory
67
+ createdBy: creatorValidator,
68
+ instanceId: v.optional(v.string()), // which instance wrote this
69
+
70
+ // Graph relations to other memories (Supermemory pattern)
71
+ // "updates" supersedes target (sets target.isLatest = false)
72
+ // "extends" adds detail to target (both remain latest)
73
+ // "derives" is an inference drawn from target
74
+ relations: v.array(
75
+ v.object({
76
+ targetId: v.id("memories"),
77
+ type: relationTypeValidator,
78
+ }),
79
+ ),
80
+
81
+ // True = this is the current authoritative version.
82
+ // False = superseded by a newer "updates" relation.
83
+ // Search always filters isLatest=true by default.
84
+ isLatest: v.boolean(),
85
+
86
+ // Optional TTL hint (ISO string). Cron job handles actual expiry.
87
+ // Example: "2026-06-01T00:00:00Z"
88
+ ttl: v.optional(v.string()),
89
+
90
+ // Episodic memory payload — only present when type="episode"
91
+ episode: v.optional(
92
+ v.object({
93
+ context: v.string(), // Situation that triggered this episode
94
+ goal: v.string(), // What was being attempted
95
+ action: v.string(), // What was actually done
96
+ outcome: v.string(), // What happened
97
+ insight: v.string(), // The lesson extracted (procedural memory)
98
+ severity: severityValidator,
99
+ }),
100
+ ),
101
+
102
+ // Timestamp (ms since epoch)
103
+ createdAt: v.number(),
104
+ updatedAt: v.number(),
105
+ })
106
+ // Primary query patterns — all filtered to isLatest for active memory reads
107
+ .index("by_namespace", ["namespace", "isLatest"])
108
+ .index("by_type", ["type", "isLatest"])
109
+ .index("by_creator", ["createdBy", "isLatest"])
110
+ .index("by_namespace_type", ["namespace", "type", "isLatest"]),
111
+
112
+ // ── profiles ────────────────────────────────────────────────────────────────
113
+ // One row per INSTANCE. orchestratorId = role (pi/tau/phi).
114
+ // instanceId = unique running copy (pi-chromebook, pi-vps, tau-client-acme).
115
+ // Multiple instances can share the same role.
116
+ // static = stable identity facts (role, workspace, capabilities)
117
+ // dynamic = mutable session state (current task, last seen, session count)
118
+ profiles: defineTable({
119
+ // Role: "pi" | "tau" | "phi"
120
+ orchestratorId: v.string(),
121
+
122
+ // Unique instance identifier: "pi-chromebook" | "pi-vps" | "tau-vps-1"
123
+ instanceId: v.optional(v.string()),
124
+
125
+ name: v.string(),
126
+
127
+ // Static profile — stable facts, infrequently updated
128
+ static: v.object({
129
+ role: v.string(),
130
+ workspace: v.string(),
131
+ capabilities: v.array(v.string()),
132
+ }),
133
+
134
+ // Dynamic profile — updated each session
135
+ dynamic: v.object({
136
+ currentTask: v.optional(v.string()),
137
+ // Durable end-of-day index (close-day step 9) — distinct from
138
+ // `currentTask` (live session summary) so a next-morning
139
+ // daily-start write does not clobber it. mission k574p02m DEFECT 1.
140
+ endOfDayIndex: v.optional(v.string()),
141
+ lastSeen: v.number(), // ms since epoch
142
+ sessionCount: v.number(),
143
+ }),
144
+ })
145
+ .index("by_orchestrator", ["orchestratorId"])
146
+ .index("by_instance", ["instanceId"]),
147
+
148
+ // ── messages ──────────────────────────────────────────────────────────────
149
+ // Inter-orchestrator messaging. VantagePeers native (supersedes pre-VP legacy).
150
+ // One message row per send. Recipients tracked via messageReceipts table.
151
+ // channel: "broadcast" | "pi" | "tau" | "phi" | "pi,tau"
152
+ messages: defineTable({
153
+ from: creatorValidator,
154
+ fromInstanceId: v.optional(v.string()), // "pi-chromebook", "tau-vps-1"
155
+ tenantId: v.optional(v.string()),
156
+ channel: v.string(),
157
+ content: v.string(),
158
+ sessionDay: v.optional(v.number()),
159
+ createdAt: v.number(),
160
+ })
161
+ .index("by_day", ["sessionDay"])
162
+ .index("by_from", ["from", "createdAt"])
163
+ .index("by_channel", ["channel", "createdAt"])
164
+ .index("by_tenant_channel", ["tenantId", "channel", "createdAt"])
165
+ // Day 103 — tenant-scoped listing for Clerk callers (task k176wgsrhha0fr0dxxahctvhw588q5a1).
166
+ // by_tenant_created lets listMessages push tenantId BEFORE .take(limit) for
167
+ // non-master callers, preventing under-fill when fleet (null-tenant) traffic
168
+ // dominates the recent window. by_tenant_channel cannot be reused here because
169
+ // channel is a required equality field in that compound (can't skip to createdAt).
170
+ .index("by_tenant_created", ["tenantId", "createdAt"])
171
+ // Day 102 v2.11.0 — CRUD baseline PR-C-bis option B (mission k575kc1r):
172
+ // Convex native BM25 search on message body, with filterFields for the
173
+ // common audit narrowing axes (from, channel, sessionDay).
174
+ .searchIndex("search_content", {
175
+ searchField: "content",
176
+ filterFields: ["from", "channel", "sessionDay", "tenantId"],
177
+ }),
178
+
179
+ // ── messageReceipts ──────────────────────────────────────────────────────
180
+ // One row per recipient per message. Tracks read status.
181
+ // check_new_messages = query receipts where recipient=X AND readAt=undefined
182
+ messageReceipts: defineTable({
183
+ messageId: v.id("messages"),
184
+ recipient: creatorValidator, // role-level: "pi" | "tau" | "phi"
185
+ recipientInstanceId: v.optional(v.string()), // instance-level: "pi-vps"
186
+ tenantId: v.optional(v.string()),
187
+ readAt: v.optional(v.number()), // undefined = unread, ms epoch = read
188
+ })
189
+ .index("by_recipient_unread", ["recipient", "readAt"])
190
+ .index("by_instance_unread", ["recipientInstanceId", "readAt"])
191
+ .index("by_message", ["messageId"])
192
+ .index("by_tenant_recipient_unread", ["tenantId", "recipient", "readAt"]),
193
+
194
+ // ── missions ──────────────────────────────────────────────────────────────
195
+ missions: defineTable({
196
+ name: v.string(),
197
+ description: v.optional(v.string()),
198
+ project: v.string(),
199
+ status: v.union(
200
+ v.literal("brainstorm"),
201
+ v.literal("plan"),
202
+ v.literal("execute"),
203
+ v.literal("validate"),
204
+ v.literal("complete"),
205
+ v.literal("cancelled"),
206
+ ),
207
+ priority: v.union(
208
+ v.literal("urgent"),
209
+ v.literal("high"),
210
+ v.literal("medium"),
211
+ v.literal("low"),
212
+ ),
213
+ pilot: creatorValidator,
214
+ agents: v.array(v.string()),
215
+ brief: v.optional(v.string()),
216
+ startDate: v.optional(v.number()),
217
+ targetDate: v.optional(v.number()),
218
+ progress: v.optional(v.number()),
219
+ createdBy: creatorValidator,
220
+ createdAt: v.number(),
221
+ updatedAt: v.number(),
222
+ // Beta multi-tenant scope. null/undefined = master (internal Alpha).
223
+ // Set to Clerk org slug (e.g. "acme-hr") for client-scoped rows.
224
+ orgId: v.optional(v.string()),
225
+ // Terminal cancelled status (Day 157) — creator-only, mandatory reason.
226
+ // Never counted as done/complete; excluded from open/active aliases.
227
+ cancelledBy: v.optional(creatorValidator),
228
+ cancelReason: v.optional(v.string()),
229
+ })
230
+ .index("by_project", ["project", "status"])
231
+ .index("by_pilot", ["pilot", "status"])
232
+ .index("by_status", ["status", "createdAt"])
233
+ .index("by_priority", ["priority", "status"])
234
+ .index("by_orgId", ["orgId"]),
235
+
236
+ // ── tasks ──────────────────────────────────────────────────────────────────
237
+ tasks: defineTable({
238
+ title: v.string(),
239
+ description: v.optional(v.string()),
240
+ project: v.optional(v.string()), // "vantage-starter", "perfect-ai-agent", etc.
241
+ tags: v.optional(v.array(v.string())),
242
+ // Orchestrator or person assigned — open string (issue #132)
243
+ assignedTo: v.string(),
244
+ priority: v.union(
245
+ v.literal("urgent"),
246
+ v.literal("high"),
247
+ v.literal("medium"),
248
+ v.literal("low"),
249
+ ),
250
+ status: v.union(
251
+ v.literal("todo"),
252
+ v.literal("in_progress"),
253
+ v.literal("review"),
254
+ v.literal("blocked"),
255
+ v.literal("done"),
256
+ v.literal("cancelled"),
257
+ ),
258
+ completionNote: v.optional(v.string()), // what was actually done — written on complete/review
259
+ assignedToInstance: v.optional(v.string()), // instance-level assignment: "pi-vps", "tau-chromebook"
260
+ claimedByInstance: v.optional(v.string()), // which instance is working on this
261
+ dependsOn: v.optional(v.array(v.id("tasks"))), // tasks that must be done first
262
+ missionId: v.optional(v.id("missions")),
263
+ estimatedMinutes: v.optional(v.number()),
264
+ actualMinutes: v.optional(v.number()),
265
+ startedAt: v.optional(v.number()),
266
+ completedAt: v.optional(v.number()),
267
+ dueDate: v.optional(v.number()),
268
+ createdBy: creatorValidator,
269
+ createdAt: v.number(),
270
+ updatedAt: v.number(),
271
+ // Beta multi-tenant scope. null/undefined = master (internal Alpha).
272
+ // Set to Clerk org slug (e.g. "acme-hr") for client-scoped rows.
273
+ orgId: v.optional(v.string()),
274
+ // Day 130 follow-up #2 — inforgeable automation signal. ONLY the
275
+ // internal webhook mutation (createOrUpdateReviewTask) writes this;
276
+ // the public `tasks.create` mutation does not accept it as an arg.
277
+ // The billing closure gate reads this field, never `createdBy`
278
+ // (which is a caller-supplied, forgeable string on a public mutation).
279
+ origin: v.optional(taskOriginValidator),
280
+ // Terminal cancelled status (Day 157) — creator-only, mandatory reason.
281
+ // Never counted as done/complete; excluded from open/active aliases.
282
+ cancelledBy: v.optional(creatorValidator),
283
+ cancelReason: v.optional(v.string()),
284
+ // Day 159 — a block that names a commitment, not just a journal entry.
285
+ // blockedOnTaskId: the live task (assigned to someone else) this task
286
+ // waits on. blockTask validates existence/openness/other-assignee at
287
+ // call time; when the cited task reaches "done" every waiter here is
288
+ // swept back to "todo" (convex/tasks.ts unblockWaitersOn). Mutually
289
+ // exclusive with blockedOnNobodyReason in practice (blockTask sets
290
+ // exactly one), both kept optional so pre-existing "blocked" rows with
291
+ // neither field are valid (see migration list query
292
+ // convex/tasks.ts:listUnlinkedBlocked).
293
+ blockedOnTaskId: v.optional(v.id("tasks")),
294
+ // Explicit "# blocked-on-nobody: <reason>" marker text — required to
295
+ // block WITHOUT a blockedOnTaskId (nobody in the fleet owns the
296
+ // obstacle: operator decision, third-party outage). Forbids ANONYMOUS
297
+ // blocking without forbidding blocking itself.
298
+ blockedOnNobodyReason: v.optional(v.string()),
299
+ })
300
+ .index("by_assignee", ["assignedTo", "status"])
301
+ .index("by_project", ["project", "status"])
302
+ .index("by_priority", ["priority", "status"])
303
+ .index("by_status", ["status", "createdAt"])
304
+ .index("by_mission", ["missionId", "status"])
305
+ .index("by_instance", ["assignedToInstance", "status"])
306
+ .index("by_orgId", ["orgId"])
307
+ // Compound indexes added to close the silent-filter-drop defect in
308
+ // convex/tasks.ts `list`: when a caller supplies assignedTo/assignedToInstance
309
+ // TOGETHER with project, the query must apply BOTH filters via a matching
310
+ // index, never pick one and silently discard the other.
311
+ .index("by_assignee_project", ["assignedTo", "project", "status"])
312
+ .index("by_instance_project", ["assignedToInstance", "project", "status"])
313
+ // billingSummaryByProject (convex/tasks.ts) — the period MUST bound the
314
+ // query itself, not a post-hoc in-memory filter over a fixed-size scan
315
+ // (Day-131 live-defect fix: recent, billable work was silently invisible
316
+ // because the old handler capped the by_status(status, createdAt) scan
317
+ // BEFORE ever comparing completedAt against the requested window).
318
+ .index("by_status_completedAt", ["status", "completedAt"])
319
+ // Same fix, pushed one level further: when the caller also supplies a
320
+ // project filter, apply BOTH status+project+completedAt at the index
321
+ // level so a single-project billing query is never a post-hoc filter
322
+ // over a truncated cross-project scan (same disease, same fix).
323
+ .index("by_status_project_completedAt", ["status", "project", "completedAt"])
324
+ // Day-132 live-defect fix: `updatedSince` on `tasks.list` was filtered
325
+ // IN-MEMORY after a fixed-size widened scan, so narrowing the window
326
+ // never reduced the number of candidates fetched and the scan cap bit
327
+ // into normal usage on exactly two branches (measured in production):
328
+ // `assignedTo` alone, and `assignedTo` + `status`. These two compound
329
+ // indexes end in `updatedAt` so the bound lives in the query itself —
330
+ // no index added for missions/briefingNotes, whose branches were not
331
+ // measured to exceed the cap.
332
+ .index("by_assignee_updatedAt", ["assignedTo", "updatedAt"])
333
+ .index("by_assignee_status_updatedAt", ["assignedTo", "status", "updatedAt"])
334
+ // Day 159 — reciprocal unblock: find every task waiting on a given
335
+ // task so it can be swept back to "todo" the moment that task closes.
336
+ .index("by_blockedOnTaskId", ["blockedOnTaskId"])
337
+ // Day 102 v2.11.0 — CRUD baseline PR-C-bis option B (mission k575kc1r):
338
+ // Convex native BM25 search on task title, with filterFields for the
339
+ // common targeting axes (assignedTo, status, project, missionId).
340
+ .searchIndex("search_title", {
341
+ searchField: "title",
342
+ filterFields: ["assignedTo", "status", "project", "missionId", "orgId"],
343
+ }),
344
+
345
+ // ── diary ──────────────────────────────────────────────────────────────────
346
+ diary: defineTable({
347
+ date: v.string(), // "2026-03-25" ISO date
348
+ orchestrator: creatorValidator,
349
+ instanceId: v.optional(v.string()), // which instance wrote this
350
+ content: v.string(), // Full diary entry
351
+ highlights: v.optional(v.array(v.string())),
352
+ blockers: v.optional(v.array(v.string())),
353
+ // v2.4.8: auth-derived author captured at write time from oauthCtx.userId.
354
+ // Distinct from `orchestrator` (writer-intent label, client-supplied).
355
+ // Pre-v2.4.8 entries are backfilled with orchestrator as best-guess via
356
+ // migrations/diary_backfill_createdBy — NOT auth-verified.
357
+ createdBy: v.optional(creatorValidator),
358
+ createdAt: v.number(),
359
+ })
360
+ .index("by_orchestrator_date", ["orchestrator", "date"])
361
+ .index("by_date", ["date"]),
362
+ // by_createdBy_date intentionally omitted: createdBy filtering is handled
363
+ // as a universal post-take in-memory filter (mirrors tasks.ts:371-373 pattern).
364
+ // Adding an index pushdown optimization is deferred to a separate PR.
365
+
366
+ // ── briefingNotes ──────────────────────────────────────────────────────────
367
+ briefingNotes: defineTable({
368
+ title: v.string(),
369
+ topic: v.string(),
370
+ participants: v.array(v.string()), // ["pi", "laurent", "tau"]
371
+ content: v.string(), // Full briefing content
372
+ decisions: v.optional(v.array(v.string())),
373
+ linkedMemoryIds: v.optional(v.array(v.id("memories"))),
374
+ createdBy: creatorValidator,
375
+ createdAt: v.number(),
376
+ updatedAt: v.optional(v.number()), // set on first update
377
+ updatedBy: v.optional(creatorValidator), // orchestrator that last updated
378
+ // Beta multi-tenant scope. null/undefined = master (internal Alpha).
379
+ // Set to Clerk org slug (e.g. "acme-hr") for client-scoped rows.
380
+ orgId: v.optional(v.string()),
381
+ })
382
+ .index("by_topic", ["topic"])
383
+ .index("by_creator", ["createdBy", "createdAt"])
384
+ .index("by_orgId", ["orgId"])
385
+ // Day 102 v2.11.0 — CRUD baseline PR-C-bis option B (mission k575kc1r):
386
+ // Convex native BM25 search on briefing body, with filterFields for the
387
+ // common narrowing axes (topic, createdBy).
388
+ .searchIndex("search_content", {
389
+ searchField: "content",
390
+ filterFields: ["topic", "createdBy", "orgId"],
391
+ }),
392
+
393
+ // ── briefingNoteParticipants ──────────────────────────────────────────────
394
+ // Day 165 fix (task k175ga65p654z200ydj7s8qv5s8cnxfc): junction table so
395
+ // "is `identity` a participant on `noteId`" is an INDEX-RANGE predicate
396
+ // inside the get/list/search queries, never a post-query handler filter
397
+ // (R-11) and never an in-memory scan of `briefingNotes.participants`
398
+ // (Convex indexes can't range-query inside an array field's elements).
399
+ // One row per (noteId, participant) pair; kept in sync by create/update.
400
+ briefingNoteParticipants: defineTable({
401
+ noteId: v.id("briefingNotes"),
402
+ participant: v.string(),
403
+ })
404
+ .index("by_participant_note", ["participant", "noteId"])
405
+ .index("by_note", ["noteId"]),
406
+
407
+ // ── components ──────────────────────────────────────────────────────────
408
+ // Registry of agents, skills, hooks, plugins — backup + inventory.
409
+ // Content stores the full file so nothing is lost if filesystem is destroyed.
410
+ components: defineTable({
411
+ name: v.string(),
412
+ type: v.union(
413
+ v.literal("agent"),
414
+ v.literal("skill"),
415
+ v.literal("hook"),
416
+ v.literal("plugin"),
417
+ ),
418
+ team: v.optional(v.string()), // e.g. "marketing", "development"
419
+ content: v.string(), // full file content
420
+ version: v.optional(v.string()),
421
+ project: v.optional(v.string()),
422
+ createdBy: creatorValidator,
423
+ createdAt: v.number(),
424
+ updatedAt: v.number(),
425
+ })
426
+ .index("by_type", ["type"])
427
+ .index("by_team", ["team", "type"])
428
+ .index("by_name_type", ["name", "type"]),
429
+
430
+ // ── mandates ──────────────────────────────────────────────────────────────
431
+ // Cross-orchestrator service requests. One orchestrator requests a service from another.
432
+ // Budget (token allocation) is agreed upfront; cost is recorded on settle.
433
+ mandates: defineTable({
434
+ requestedBy: creatorValidator, // who needs the service
435
+ fulfilledBy: creatorValidator, // who will provide the service
436
+ service: v.string(), // description of what is needed
437
+ budget: v.number(), // token budget allocated
438
+ status: v.union(
439
+ v.literal("requested"),
440
+ v.literal("accepted"),
441
+ v.literal("in_progress"),
442
+ v.literal("delivered"),
443
+ v.literal("settled"),
444
+ ),
445
+ linkedTaskIds: v.optional(v.array(v.id("tasks"))), // tasks created to fulfill this mandate
446
+ tokensCost: v.optional(v.number()), // actual tokens consumed
447
+ createdAt: v.number(),
448
+ updatedAt: v.number(),
449
+ completedAt: v.optional(v.number()),
450
+ // AP2 authorization fields
451
+ spendingLimits: v.optional(
452
+ v.object({
453
+ maxPerTransaction: v.number(),
454
+ maxPerPeriod: v.number(),
455
+ periodDays: v.optional(v.number()), // default 30
456
+ }),
457
+ ),
458
+ approvedCategories: v.optional(v.array(v.string())), // e.g. ["seo", "content", "development"]
459
+ mandateDocument: v.optional(v.string()), // signed authorization text or reference
460
+ })
461
+ .index("by_requestedBy", ["requestedBy", "status"])
462
+ .index("by_fulfilledBy", ["fulfilledBy", "status"])
463
+ .index("by_status", ["status", "createdAt"]),
464
+
465
+ // ── issues ────────────────────────────────────────────────────────────────
466
+ // GitHub issues tracked in VantagePeers. Synced via webhook.
467
+ issues: defineTable({
468
+ repo: v.string(), // "myreeldream-ai/MyShortReel-beta"
469
+ issueNumber: v.number(),
470
+ title: v.string(),
471
+ body: v.string(), // truncated to 2000 chars
472
+ htmlUrl: v.string(),
473
+ labels: v.array(v.string()),
474
+ status: v.union(
475
+ v.literal("open"),
476
+ v.literal("in_progress"),
477
+ v.literal("fixed"),
478
+ v.literal("verified"),
479
+ v.literal("closed"),
480
+ ),
481
+ priority: v.union(
482
+ v.literal("urgent"),
483
+ v.literal("high"),
484
+ v.literal("medium"),
485
+ v.literal("low"),
486
+ ),
487
+ assignedOrchestrator: v.string(),
488
+ project: v.string(),
489
+ fixCommits: v.optional(v.array(v.string())),
490
+ fixedBy: v.optional(v.string()),
491
+ fixedAt: v.optional(v.number()),
492
+ verifiedBy: v.optional(v.string()),
493
+ verifiedAt: v.optional(v.number()),
494
+ linkedTaskIds: v.optional(v.array(v.string())),
495
+ githubCreatedAt: v.number(),
496
+ githubUpdatedAt: v.number(),
497
+ // External repo tracking (for Zeta contributions to third-party repos)
498
+ externalRepo: v.optional(v.string()), // "get-convex/better-auth"
499
+ externalIssueNumber: v.optional(v.number()),
500
+ externalIssueUrl: v.optional(v.string()),
501
+ prUrl: v.optional(v.string()),
502
+ prStatus: v.optional(
503
+ v.union(
504
+ v.literal("draft"),
505
+ v.literal("open"),
506
+ v.literal("merged"),
507
+ v.literal("closed"),
508
+ ),
509
+ ),
510
+ forkRepo: v.optional(v.string()), // "elpiarthera/better-auth"
511
+ })
512
+ .index("by_repo_number", ["repo", "issueNumber"])
513
+ .index("by_status", ["status"])
514
+ .index("by_project", ["project"])
515
+ .index("by_assigned", ["assignedOrchestrator", "status"])
516
+ .index("by_external_repo", ["externalRepo", "prStatus"]),
517
+
518
+ // ── githubRepoMapping ─────────────────────────────────────────────────────
519
+ // Maps GitHub repos to orchestrators. Used by webhook handler to route events.
520
+ //
521
+ // Day 98 (k173yr5n1) — auto-IRP cascade overhaul Mechanism (a):
522
+ // lastDeployedSHA + lastDeployedAt track the most recent successful Convex
523
+ // deploy of the repo. createDeployTaskWithDedup compares pr.mergedAt vs
524
+ // lastDeployedAt — if a deploy completed AFTER the PR merged, the PR is
525
+ // already shipped via a bundled chain and no per-PR Deploy task is spawned.
526
+ // Recorded via githubRepoMapping.recordDeployment mutation after each
527
+ // successful `npx convex deploy --yes`.
528
+ githubRepoMapping: defineTable({
529
+ repo: v.string(), // "myreeldream-ai/MyShortReel-beta"
530
+ orchestrator: v.string(), // "omega", "tau", "sigma", etc.
531
+ project: v.string(), // "myreeldream", "vantage-starter", etc.
532
+ active: v.boolean(),
533
+ lastDeployedSHA: v.optional(v.string()), // Day 98 — most recent prod-deployed commit OID
534
+ lastDeployedAt: v.optional(v.number()), // Day 98 — Unix ms timestamp of that deploy
535
+ }).index("by_repo", ["repo"]),
536
+
537
+ // ── businessUnits ─────────────────────────────────────────────────────────
538
+ // One row per ElPi Corp business unit. Tracks strategy, structure, and KPIs.
539
+ // managementFee: ElPi Corp takes this percentage of revenue (default 10%).
540
+ businessUnits: defineTable({
541
+ name: v.string(), // e.g. "VantagePeers", "VantageRegistry"
542
+ description: v.string(),
543
+ purpose: v.string(),
544
+ domain: v.optional(v.string()), // e.g. "vantagepeers.com"
545
+ orchestratorId: v.string(), // lead orchestrator — e.g. "sigma"
546
+ status: v.union(
547
+ v.literal("idea"),
548
+ v.literal("building"),
549
+ v.literal("live"),
550
+ v.literal("revenue"),
551
+ ),
552
+ businessModel: v.string(), // how it makes money
553
+ targetCustomers: v.string(),
554
+ services: v.array(v.string()), // what it offers
555
+ pricing: v.string(),
556
+ revenueProjections: v.object({
557
+ y1: v.number(),
558
+ y2: v.number(),
559
+ y3: v.number(),
560
+ }),
561
+ coreTeam: v.object({
562
+ agents: v.array(v.string()),
563
+ skills: v.array(v.string()),
564
+ hooks: v.array(v.string()),
565
+ plugins: v.array(v.string()),
566
+ }),
567
+ coreProcesses: v.array(v.string()),
568
+ dependencies: v.array(v.string()), // which BUs it consumes
569
+ kpis: v.array(v.string()),
570
+ managementFee: v.number(), // ElPi Corp % cut (default 10)
571
+ createdAt: v.number(),
572
+ updatedAt: v.number(),
573
+ })
574
+ .index("by_orchestrator", ["orchestratorId"])
575
+ .index("by_status", ["status"])
576
+ .index("by_name", ["name"]),
577
+
578
+ // ── recurringTasks ────────────────────────────────────────────────────────
579
+ // Templates for tasks that auto-create on a schedule (daily standup, weekly scan, etc.)
580
+ // Convex cron checks every 15 min and creates tasks when nextRunAt <= now.
581
+ recurringTasks: defineTable({
582
+ title: v.string(),
583
+ description: v.optional(v.string()),
584
+ // Orchestrator or person assigned — open string (issue #132)
585
+ assignedTo: v.string(),
586
+ priority: v.union(
587
+ v.literal("urgent"),
588
+ v.literal("high"),
589
+ v.literal("medium"),
590
+ v.literal("low"),
591
+ ),
592
+ project: v.optional(v.string()),
593
+ tags: v.optional(v.array(v.string())),
594
+ cronExpression: v.string(), // "0 9 * * *" = daily 9am, "0 9 * * 1" = Monday 9am
595
+ lastCreatedAt: v.optional(v.number()),
596
+ nextRunAt: v.number(),
597
+ active: v.boolean(),
598
+ createdBy: creatorValidator,
599
+ createdAt: v.number(),
600
+ updatedAt: v.number(),
601
+ })
602
+ .index("by_active", ["active", "nextRunAt"])
603
+ .index("by_assignee", ["assignedTo"]),
604
+
605
+ // ── fixPatterns ──────────────────────────────────────────────────────────────
606
+ // Knowledge base of fix patterns. Documents bugs, root causes, fix attempts
607
+ // (including failures), and validated fixes. Agents search this BEFORE fixing
608
+ // to avoid repeating mistakes. Semantic search via RAG on symptom + rootCause.
609
+ fixPatterns: defineTable({
610
+ symptom: v.string(), // What the bug looks like (searchable via RAG)
611
+ rootCause: v.string(), // Why it happens
612
+ validatedFix: v.optional(v.string()), // The fix that actually worked
613
+ files: v.optional(v.array(v.string())), // Files involved
614
+ tags: v.array(v.string()), // e.g. "react-hydration", "convex-subscription"
615
+ stack: v.array(v.string()), // e.g. "next.js", "convex", "clerk"
616
+ sourceProject: v.string(), // Origin project
617
+ linkedIssueIds: v.optional(v.array(v.string())), // VantagePeers issue IDs
618
+ createdBy: creatorValidator,
619
+ severity: severityValidator,
620
+ createdAt: v.number(),
621
+ updatedAt: v.number(),
622
+ })
623
+ .index("by_project", ["sourceProject"])
624
+ .index("by_severity", ["severity"])
625
+ .index("by_creator", ["createdBy"]),
626
+
627
+ // ── fixAttempts ──────────────────────────────────────────────────────────────
628
+ // Individual fix attempts for a pattern. Separate table to avoid unbounded
629
+ // array growth in fixPatterns documents (per Convex guidelines).
630
+ fixAttempts: defineTable({
631
+ patternId: v.id("fixPatterns"),
632
+ description: v.string(), // What was tried
633
+ commit: v.optional(v.string()), // Commit hash
634
+ worked: v.boolean(), // Did it fix the issue?
635
+ why: v.string(), // Why it worked or didn't
636
+ createdBy: creatorValidator,
637
+ createdAt: v.number(),
638
+ })
639
+ .index("by_pattern", ["patternId"])
640
+ .index("by_worked", ["patternId", "worked"]),
641
+
642
+ // ── missionTemplates ──────────────────────────────────────────────────────
643
+ // Reusable mission templates. Each template contains an ordered list of
644
+ // steps that get instantiated as tasks when a triggering event occurs.
645
+ // The "issue-resolution-v2" template drives the IRP (Issue Resolution Protocol).
646
+ missionTemplates: defineTable({
647
+ name: v.string(),
648
+ description: v.optional(v.string()),
649
+ brief: v.optional(v.string()), // Reused as the mission's brief on instantiation when the caller supplies none.
650
+ steps: v.array(
651
+ v.object({
652
+ title: v.string(),
653
+ description: v.string(),
654
+ tags: v.optional(v.array(v.string())),
655
+ assignedTo: v.optional(v.string()), // orchestrator role, e.g. "proxima"
656
+ assignedToInstance: v.optional(v.string()), // instance, e.g. "proxima-vps"
657
+ dependsOn: v.optional(v.array(v.number())), // indexes of required steps (0-based)
658
+ }),
659
+ ),
660
+ isDefault: v.boolean(),
661
+ createdBy: creatorValidator,
662
+ createdAt: v.number(),
663
+ updatedAt: v.number(),
664
+ // Soft-delete marker — mirrors memories.softDeleteMemory's audit-preserving
665
+ // PATCH-a-flag motif. Chose a timestamp (not a boolean like memories'
666
+ // `isLatest`) because it also records *when* the template was retired,
667
+ // which the memories table gets for free via `updatedAt` alongside the
668
+ // flag — a lone boolean here would lose that provenance. undefined = active.
669
+ deletedAt: v.optional(v.number()),
670
+ })
671
+ .index("by_name", ["name"])
672
+ .index("by_default", ["isDefault"]),
673
+
674
+ // ── monitoredDeployments ─────────────────────────────────────────────────
675
+ // Registry of Convex deployments to poll for errors.
676
+ // deployKeyEnvVar: name of the env var holding the admin deploy key.
677
+ monitoredDeployments: defineTable({
678
+ name: v.string(),
679
+ deploymentUrl: v.string(),
680
+ deployKeyEnvVar: v.string(),
681
+ githubRepo: v.string(),
682
+ orchestrator: v.string(),
683
+ active: v.boolean(),
684
+ lastCursor: v.optional(v.number()),
685
+ createdAt: v.number(),
686
+ })
687
+ .index("by_active", ["active"])
688
+ .index("by_name", ["name"]),
689
+
690
+ // ── issueStats ───────────────────────────────────────────────────────────
691
+ // Daily issue resolution metrics per repo.
692
+ issueStats: defineTable({
693
+ repo: v.string(),
694
+ date: v.string(), // YYYY-MM-DD
695
+ totalIssues: v.number(),
696
+ resolvedIssues: v.number(),
697
+ medianTimeToFirstResponse: v.optional(v.number()), // minutes
698
+ medianTimeToFix: v.optional(v.number()), // minutes
699
+ fastestResolution: v.optional(v.number()), // minutes
700
+ slowestResolution: v.optional(v.number()), // minutes
701
+ avgTimeToFix: v.optional(v.number()), // minutes
702
+ // Before/after VantageOS Team (pivot: 2026-04-01)
703
+ beforeVantageOS: v.optional(
704
+ v.object({
705
+ totalIssues: v.number(),
706
+ resolvedIssues: v.number(),
707
+ medianTimeToFix: v.optional(v.number()),
708
+ avgTimeToFix: v.optional(v.number()),
709
+ }),
710
+ ),
711
+ afterVantageOS: v.optional(
712
+ v.object({
713
+ totalIssues: v.number(),
714
+ resolvedIssues: v.number(),
715
+ medianTimeToFix: v.optional(v.number()),
716
+ avgTimeToFix: v.optional(v.number()),
717
+ }),
718
+ ),
719
+ issueDetails: v.optional(
720
+ v.array(
721
+ v.object({
722
+ number: v.number(),
723
+ title: v.string(),
724
+ timeToFirstResponse: v.optional(v.number()),
725
+ timeToFix: v.optional(v.number()),
726
+ status: v.string(),
727
+ }),
728
+ ),
729
+ ),
730
+ calculatedAt: v.number(),
731
+ })
732
+ .index("by_repo_date", ["repo", "date"])
733
+ .index("by_date", ["date"]),
734
+
735
+ // ── kbUploads ─────────────────────────────────────────────────────────────
736
+ // TOFU (trust-on-first-use) storage ownership binding table.
737
+ // Created by B5 M1 storageId org-binding defense-in-depth (mission k5779q).
738
+ //
739
+ // On first call to storeDocumentChunked for a given storageId, an entry is
740
+ // inserted binding that storageId to the calling org. Subsequent calls with
741
+ // the SAME storageId but a DIFFERENT orgId are rejected with
742
+ // AUTH_STORAGE_NOT_OWNED to close the cross-tenant attack vector identified
743
+ // by Eta iter-2 post-merge follow-up (PR #992, commit 16bb32d).
744
+ //
745
+ // Index: by_storageId — O(1) ownership lookup before ctx.storage.get().
746
+ kbUploads: defineTable({
747
+ storageId: v.id("_storage"),
748
+ orgId: v.string(),
749
+ createdAt: v.number(),
750
+ }).index("by_storageId", ["storageId"]),
751
+
752
+ // ── oauth_clients ─────────────────────────────────────────────────────────
753
+ // OAuth 2.0 Dynamic Client Registration (RFC 7591).
754
+ // One row per registered OAuth client (Claude.ai custom connector, Nadia,
755
+ // future Perello VIP clients). Each client is bound to a scopeProfile that
756
+ // limits the `from=` allowlist and namespace read/write prefixes.
757
+ //
758
+ // clientSecretHash = SHA-256 hex of the raw client secret (raw never stored).
759
+ // Admin onboarding returns the raw secret exactly ONCE to the provisioner.
760
+ oauth_clients: defineTable({
761
+ clientId: v.string(), // public identifier (UUID)
762
+ clientSecretHash: v.string(), // SHA-256 hex of raw secret
763
+ redirectUris: v.array(v.string()),
764
+ name: v.string(), // human label, e.g. "nadia-acme-hr"
765
+ scopeProfile: v.string(), // FK to oauth_scope_profiles.profileId
766
+ createdAt: v.number(),
767
+ revokedAt: v.optional(v.number()),
768
+ // RFC 7591 §2 token_endpoint_auth_method (D6 — confidential client validation).
769
+ // Absent => treat as confidential ("client_secret_basic") for backward
770
+ // compatibility; existing rows MUST be backfilled in S2 with explicit value
771
+ // ("client_secret_basic" for confidential; "none" for known public clients).
772
+ tokenEndpointAuthMethod: v.optional(v.string()),
773
+ })
774
+ .index("by_clientId", ["clientId"])
775
+ .index("by_scopeProfile", ["scopeProfile"]),
776
+
777
+ // ── oauth_authorization_codes ────────────────────────────────────────────
778
+ // Short-lived authorization codes (RFC 6749 §4.1). TTL ~10 min.
779
+ // Cleaned periodically by cron (processExpiredOauthCodes).
780
+ oauth_authorization_codes: defineTable({
781
+ code: v.string(), // opaque random value
782
+ clientId: v.string(),
783
+ redirectUri: v.string(),
784
+ codeChallenge: v.string(), // PKCE S256 (RFC 7636)
785
+ scope: v.string(),
786
+ userId: v.string(), // who the code was issued for (== scopeProfile owner by default)
787
+ expiresAt: v.number(),
788
+ }).index("by_code", ["code"]),
789
+
790
+ // ── oauth_access_tokens ──────────────────────────────────────────────────
791
+ // Issued access tokens. tokenHash = SHA-256 hex of the raw token (raw never
792
+ // stored). The HTTP bearer middleware hashes the incoming Authorization
793
+ // header and looks the token up by hash, then enforces fromAllowList +
794
+ // namespaceRead/Write prefixes on every MCP tool call.
795
+ oauth_access_tokens: defineTable({
796
+ tokenHash: v.string(),
797
+ clientId: v.string(),
798
+ userId: v.string(),
799
+ scopes: v.array(v.string()), // OAuth scopes (vantage:read, vantage:write)
800
+ scopeProfile: v.string(), // snapshot at issue time
801
+ fromAllowList: v.array(v.string()),
802
+ namespaceReadPrefixes: v.array(v.string()),
803
+ namespaceWritePrefixes: v.array(v.string()),
804
+ expiresAt: v.number(),
805
+ refreshTokenHash: v.optional(v.string()),
806
+ createdAt: v.number(),
807
+ revokedAt: v.optional(v.number()),
808
+ })
809
+ .index("by_tokenHash", ["tokenHash"])
810
+ .index("by_clientId", ["clientId"]),
811
+
812
+ // ── oauth_refresh_tokens ─────────────────────────────────────────────────
813
+ // Refresh tokens for renewing expired access tokens. Same hashing rules.
814
+ oauth_refresh_tokens: defineTable({
815
+ tokenHash: v.string(),
816
+ clientId: v.string(),
817
+ userId: v.string(),
818
+ scopeProfile: v.string(),
819
+ expiresAt: v.number(),
820
+ createdAt: v.number(),
821
+ revokedAt: v.optional(v.number()),
822
+ })
823
+ .index("by_tokenHash", ["tokenHash"])
824
+ .index("by_clientId", ["clientId"]),
825
+
826
+ // ── oauth_scope_profiles ─────────────────────────────────────────────────
827
+ // Reusable scope templates. An OAuth client references a profile by
828
+ // profileId; the access_token materialises the profile into the token row
829
+ // so scope changes on the profile don't retroactively grant or revoke.
830
+ //
831
+ // Seeded on first deploy via oauth.seedDefaultProfiles: master, nadia-acme-hr,
832
+ // client-generic (deny-by-default template).
833
+ oauth_scope_profiles: defineTable({
834
+ profileId: v.string(), // stable slug e.g. "nadia-acme-hr"
835
+ description: v.string(),
836
+ fromAllowList: v.array(v.string()),
837
+ namespaceReadPrefixes: v.array(v.string()),
838
+ namespaceWritePrefixes: v.array(v.string()),
839
+ createdAt: v.number(),
840
+ updatedAt: v.number(),
841
+ }).index("by_profileId", ["profileId"]),
842
+
843
+ // ── mcpTenants ────────────────────────────────────────────────────────────
844
+ // Registry of VIP tenants for HTTP MCP transport.
845
+ // Each tenant has a hashed bearer token and a target Convex deployment URL.
846
+ // The HTTP MCP server (Railway) looks up tenants by tokenHash on every request
847
+ // and proxies to the correct Convex deployment.
848
+ // tokenHash = SHA-256 hex of the raw bearer token (raw token never stored).
849
+ mcpTenants: defineTable({
850
+ tokenHash: v.string(), // SHA-256 hex of bearer token
851
+ tenantName: v.string(), // e.g. "perello-consulting-vip-1"
852
+ convexUrl: v.string(), // e.g. "https://xxxx.convex.cloud"
853
+ createdAt: v.number(),
854
+ enabledAt: v.optional(v.number()), // undefined = disabled
855
+ lastUsedAt: v.optional(v.number()),
856
+ revokedAt: v.optional(v.number()),
857
+ }).index("by_tokenHash", ["tokenHash"]),
858
+
859
+ // ── errorMonitorFilterRules ──────────────────────────────────────────────
860
+ // Runtime-configurable filter rules for the auto-IRP bot.
861
+ // Each rule matches (functionName, errorMessageRegex) and assigns a severity:
862
+ // "skip" → drop silently, no upsert / issue
863
+ // "log-only" → console.log only
864
+ // "create-issue" → normal flow (used to override a default skip)
865
+ //
866
+ // Defaults are seeded by errorMonitorFilters.seedDefaultRules. Sigma/Pi
867
+ // can add or disable rules without redeploying via addFilterRule /
868
+ // disableFilterRule — internalMutation (admin-only, invoked with a deploy
869
+ // key via `npx convex run`; converted from public to close a DoS-adjacent
870
+ // suppression surface, see errorMonitorFilters.ts).
871
+ //
872
+ // Linked: memory j573cwcs3znp0xsvtg34x435jh84b0eg, pattern m978zeg4b2e9nx67z2hg5rwgfs85hf7f.
873
+ errorMonitorFilterRules: defineTable({
874
+ functionName: v.string(), // exact match, e.g. "tasks:complete"
875
+ errorMessageRegex: v.string(), // RegExp source
876
+ regexFlags: v.optional(v.string()), // e.g. "i"
877
+ reason: v.string(), // human-readable why this is filtered
878
+ severity: v.union(
879
+ v.literal("skip"),
880
+ v.literal("log-only"),
881
+ v.literal("create-issue"),
882
+ ),
883
+ active: v.boolean(),
884
+ createdAt: v.number(),
885
+ // v1.0.1 observability — added in PR follow-up to PR #354 review.
886
+ // `lastMatchedAt` and `matchCount` are bumped from `pollDeploymentLogs`
887
+ // via a fire-and-forget mutation when a rule's severity is "skip" or
888
+ // "log-only" (the silenced classes), so operators can see which rules
889
+ // are actually firing in prod and which are dead weight.
890
+ lastMatchedAt: v.optional(v.number()), // Unix ms of last match
891
+ matchCount: v.optional(v.number()), // running counter, treat undefined as 0
892
+ // v1.0.1 precedence — higher `priority` = evaluated first. Stable sort
893
+ // by `_creationTime` for ties. Treat undefined as 0 (unprioritized).
894
+ priority: v.optional(v.number()),
895
+ })
896
+ .index("by_active", ["active"])
897
+ .index("by_function", ["functionName", "active"]),
898
+
899
+ // ── oauthClients ─────────────────────────────────────────────────────────
900
+ // OAuth 2.1 Dynamic Client Registration (RFC 7591) clients.
901
+ // Issued by the DCR /register endpoint without admin gating.
902
+ // clientSecret = 64-char hex (raw value — returned once on registration,
903
+ // stored here for PKCE / client_secret_post auth on the token endpoint).
904
+ oauthClients: defineTable({
905
+ clientId: v.string(), // crypto.randomUUID()
906
+ clientSecret: v.string(), // 64-char hex (raw — transmitted once)
907
+ clientName: v.string(),
908
+ redirectUris: v.array(v.string()),
909
+ createdAt: v.number(),
910
+ scope: v.optional(v.string()), // default "mcp:full"
911
+ }).index("by_clientId", ["clientId"]),
912
+
913
+ // ── oauthTokens ──────────────────────────────────────────────────────────
914
+ // Auth codes and access/refresh tokens for the DCR OAuth 2.1 flow.
915
+ // A single row covers both the auth-code phase (authCode set, accessToken
916
+ // absent) and the token phase (accessToken set, authCode consumed).
917
+ // Index on authCode supports code exchange; index on accessToken supports
918
+ // bearer validation; index on clientId supports revocation/listing.
919
+ oauthTokens: defineTable({
920
+ clientId: v.string(),
921
+ accessToken: v.string(), // crypto.randomUUID()
922
+ refreshToken: v.optional(v.string()),
923
+ scope: v.string(),
924
+ expiresAt: v.number(), // ms since epoch
925
+ authCode: v.optional(v.string()), // PKCE authorization code
926
+ codeChallenge: v.optional(v.string()),
927
+ codeChallengeMethod: v.optional(v.string()),
928
+ redirectUri: v.optional(v.string()),
929
+ used: v.optional(v.boolean()), // auth code single-use flag
930
+ createdAt: v.number(),
931
+ })
932
+ .index("by_accessToken", ["accessToken"])
933
+ .index("by_authCode", ["authCode"])
934
+ .index("by_clientId", ["clientId"])
935
+ .index("by_refreshToken", ["refreshToken"]),
936
+
937
+ // ── errorLogs ────────────────────────────────────────────────────────────
938
+ // Deduplicated log of detected function errors across monitored deployments.
939
+ // hash = simpleHash(functionName + ":" + errorMessage) for deduplication.
940
+ //
941
+ // Anti-flood additions (Day 76):
942
+ // issueCreated — true once the GH issue + IRP mission have been created.
943
+ // Prevents re-triggering if the errorLog is touched again.
944
+ // irpMissionId — ID of the auto-IRP mission spawned for this error, so
945
+ // the auto-resolver can cascade-close it.
946
+ // autoResolved — true once the auto-resolver has closed the mission.
947
+ // Prevents the resolver from double-processing.
948
+ // recurrenceThreshold — overrides the deployment-level default; the GH issue
949
+ // is NOT created until count >= this value.
950
+ errorLogs: defineTable({
951
+ hash: v.string(),
952
+ deployment: v.string(),
953
+ functionName: v.string(),
954
+ errorMessage: v.string(),
955
+ stackTrace: v.optional(v.string()),
956
+ firstSeen: v.number(),
957
+ lastSeen: v.number(),
958
+ count: v.number(),
959
+ issueNumber: v.optional(v.number()),
960
+ githubRepo: v.optional(v.string()),
961
+ // Day 76 anti-flood fields
962
+ issueCreated: v.optional(v.boolean()),
963
+ irpMissionId: v.optional(v.id("missions")),
964
+ autoResolved: v.optional(v.boolean()),
965
+ recurrenceThreshold: v.optional(v.number()),
966
+ // Day 128 fix (issue #1088 fabricated incident) — see
967
+ // convex/errorMonitorRecurrence.ts. Records the `count` value at the
968
+ // moment a 24h+ re-raise measurement window was armed. The RECURRING
969
+ // escalation label is only emitted once NEW occurrences counted after
970
+ // this baseline reach the effective threshold — never from group
971
+ // identity + a stale timestamp alone.
972
+ reRaiseBaselineCount: v.optional(v.number()),
973
+ })
974
+ .index("by_hash", ["hash"])
975
+ .index("by_deployment", ["deployment"])
976
+ .index("by_issue_created", ["issueCreated", "lastSeen"])
977
+ .index("by_issue_number", ["issueNumber"]),
978
+
979
+ // ── licenses ─────────────────────────────────────────────────────────────
980
+ // Open-core license registry. Raw license keys are NEVER stored — only the
981
+ // SHA-256 hex hash is persisted. The raw key is returned once on generate and
982
+ // is the customer's responsibility to store safely.
983
+ //
984
+ // Lifecycle: generated → activated (optional, sets activatedAt) → active until
985
+ // expiresAt, after which status moves to "expired" (handled by cron or on
986
+ // validate). Revocation sets status="revoked" immediately.
987
+ licenses: defineTable({
988
+ keyHash: v.string(), // sha256 hex of the raw license key
989
+ customerEmail: v.string(),
990
+ customerName: v.optional(v.string()),
991
+ productCode: v.string(), // e.g. "vantage-peers-self-host"
992
+ tier: v.string(), // e.g. "open-core-99-eur-yr"
993
+ purchasedAt: v.number(), // ms since epoch
994
+ activatedAt: v.optional(v.number()), // ms since epoch, set on first activation
995
+ expiresAt: v.number(), // purchasedAt + 365d (ms since epoch)
996
+ gumroadOrderId: v.optional(v.string()),
997
+ status: v.union(
998
+ v.literal("active"),
999
+ v.literal("trial"),
1000
+ v.literal("revoked"),
1001
+ v.literal("expired"),
1002
+ ),
1003
+ githubRepos: v.optional(v.array(v.string())),
1004
+ purchaseLocale: v.optional(v.union(v.literal("en"), v.literal("fr"))),
1005
+ // Onboarding email delivery tracking (set by gumroadWebhook handler)
1006
+ emailSent: v.optional(v.boolean()),
1007
+ // Customer segmentation — set at purchase time or enriched later.
1008
+ // Unset = unknown / backwards-compat for rows created before W3.
1009
+ customerType: v.optional(
1010
+ v.union(
1011
+ v.literal("early-dev-voie-3"),
1012
+ v.literal("smb-voie-2"),
1013
+ v.literal("pro-voie-3"),
1014
+ v.literal("partner-reseller"),
1015
+ v.literal("standard"),
1016
+ ),
1017
+ ),
1018
+ // True if the customer has expressed interest in reselling VantagePeers.
1019
+ resellerCandidate: v.optional(v.boolean()),
1020
+ })
1021
+ .index("by_keyHash", ["keyHash"])
1022
+ .index("by_customerEmail", ["customerEmail"])
1023
+ .index("by_gumroadOrderId", ["gumroadOrderId"]),
1024
+
1025
+ // ── iframeEmbedSessions ──────────────────────────────────────────────────
1026
+ // Session registry for VP Gen UI iframe embeds (SEP-1865 M3).
1027
+ // Each session is bound to an origin and carries optional tenantId + userId
1028
+ // for multi-tenant routing. Sessions expire via expiresAt; revoked flag
1029
+ // provides immediate invalidation without waiting for TTL.
1030
+ //
1031
+ // Mission : sigma-vantage-peers-mcp-gui-iframe-embed-v1 (k5730xct6rvrwkvxhy5t5js12d87jwfw).
1032
+ iframeEmbedSessions: defineTable({
1033
+ sessionId: v.string(),
1034
+ tenantId: v.optional(v.string()),
1035
+ origin: v.string(),
1036
+ userId: v.optional(v.string()),
1037
+ createdAt: v.number(),
1038
+ lastSeenAt: v.number(),
1039
+ expiresAt: v.number(),
1040
+ revoked: v.boolean(),
1041
+ })
1042
+ .index("by_session_id", ["sessionId"])
1043
+ .index("by_origin_expires", ["origin", "expiresAt"]),
1044
+
1045
+ // ── errorMonitorConfig ───────────────────────────────────────────────────
1046
+ // Singleton-ish dynamic configuration for the error-monitor subsystem.
1047
+ // Key-value store where `key` is a stable slug (e.g. "pendingAliasReleases")
1048
+ // and `value` is a string array.
1049
+ //
1050
+ // pendingAliasReleases — list of status aliases introduced in an in-flight
1051
+ // release that are not yet deployed to prod. The auto-IRP bot synthesises
1052
+ // ArgumentValidationError filter rules for each alias so that pre-deploy
1053
+ // smoke-test noise doesn't spawn GitHub issues. Remove aliases post-deploy.
1054
+ errorMonitorConfig: defineTable({
1055
+ key: v.string(), // e.g. "pendingAliasReleases"
1056
+ value: v.array(v.string()),
1057
+ updatedAt: v.number(),
1058
+ }).index("by_key", ["key"]),
1059
+
1060
+ // ── taskClosureConfig ────────────────────────────────────────────────────
1061
+ // Day 130 (k17dhcmzqafve1ayzvh833kf558ae019) — server-side closure gate
1062
+ // config. Key-value store, mirrors errorMonitorConfig pattern.
1063
+ // Doctrine: no-hardcoded-business-knowledge — billable project list and
1064
+ // stale-in-progress threshold live here, never as a code constant.
1065
+ //
1066
+ // Known keys:
1067
+ // "billableProjects" — string[] of `task.project` values that
1068
+ // require a machine-timestamped startedAt before closure (billing
1069
+ // source = actualMinutes derived from startedAt→completedAt).
1070
+ // Seeded with ["vantage-immo"] — add client projects here, not in code.
1071
+ // "staleInProgressThresholdMs" — number encoded as a single-element
1072
+ // string[] (["86400000"]) — age (ms) after which an in_progress task
1073
+ // is surfaced as staleInProgress in check_messages. Default 24h.
1074
+ taskClosureConfig: defineTable({
1075
+ key: v.string(),
1076
+ value: v.array(v.string()),
1077
+ updatedAt: v.number(),
1078
+ }).index("by_key", ["key"]),
1079
+
1080
+ // ── client_org_mapping ───────────────────────────────────────────────────
1081
+ // Dashboard Beta multi-tenant scope registry. One row per Clerk organisation
1082
+ // granted dashboard access. Provisioned manually by Pi / Laurent via Convex
1083
+ // dashboard or npx convex run after merge.
1084
+ //
1085
+ // clerkOrgSlug: the Clerk organisation slug (e.g. "acme-hr", "a-client-org").
1086
+ // Maps to `identity.organizationId ?? identity.organizationSlug` in Convex
1087
+ // auth helpers.
1088
+ // allowedOrchestrators: which orchestrator pilots/assignees this org can see.
1089
+ // ["*"] = master sentinel = all orchestrators (Laurent / internal use).
1090
+ // scopes: permission tokens granted to this org.
1091
+ // Known values: "view-own-tasks", "view-own-missions",
1092
+ // "view-orchestrator-summary", "view-stats-aggregated", "cross-tenant-read".
1093
+ // isActive: false = org is disabled (returns Forbidden) without deleting the row.
1094
+ //
1095
+ // Seed rows (post-merge, Sigma runs npx convex run):
1096
+ // acme-hr → allowedOrchestrators=["victor"], scopes=["view-own-tasks","view-own-missions","view-orchestrator-summary"]
1097
+ // <redacted-client> → allowedOrchestrators=["phi"], scopes=["view-own-tasks","view-own-missions"]
1098
+ client_org_mapping: defineTable({
1099
+ clerkOrgSlug: v.string(), // "acme-hr"
1100
+ allowedOrchestrators: v.array(v.string()), // ["victor"] or ["*"] for master sentinel
1101
+ scopes: v.array(v.string()), // ["view-own-tasks", "view-own-missions", ...]
1102
+ displayName: v.string(), // "<redacted-client>"
1103
+ isActive: v.boolean(),
1104
+ createdAt: v.number(),
1105
+ })
1106
+ .index("by_clerk_slug", ["clerkOrgSlug"])
1107
+ .index("by_isActive", ["isActive"]),
1108
+
1109
+ // ── userBearerTokens ─────────────────────────────────────────────────────
1110
+ // Bearer tokens issued to VP webapp users via Clerk JWT exchange
1111
+ // (POST /issueBearerFromClerk). Separate from the OAuth DCR flow in
1112
+ // oauth_access_tokens — these are user-grade tokens bound to a Clerk userId
1113
+ // rather than an OAuth client.
1114
+ //
1115
+ // tokenHash = SHA-256 hex of the raw token (raw value NEVER stored).
1116
+ // The raw bearer is returned once in the HTTP response payload and never
1117
+ // re-derivable from the database.
1118
+ //
1119
+ // TTL: 7 days (issuedAt + 604800000 ms).
1120
+ userBearerTokens: defineTable({
1121
+ tokenHash: v.string(), // SHA-256 hex of raw bearer
1122
+ clerkUserId: v.string(), // Clerk `sub` claim
1123
+ workspaceId: v.string(), // logical workspace identifier (clerkUserId-based slug)
1124
+ extId: v.string(), // Chrome extension ID that requested the token
1125
+ expiresAt: v.number(), // ms since epoch
1126
+ revoked: v.boolean(),
1127
+ createdAt: v.number(),
1128
+ lastUsedAt: v.optional(v.number()),
1129
+ })
1130
+ .index("by_token_hash", ["tokenHash"])
1131
+ .index("by_clerkUserId", ["clerkUserId"]),
1132
+
1133
+ // ── credentialsAuditLog ──────────────────────────────────────────────────
1134
+ // Append-only audit trail for every Bearer issuance via issueBearerFromClerk.
1135
+ // Never updated after insert — one row per successful (or failed) issuance.
1136
+ credentialsAuditLog: defineTable({
1137
+ clerkUserId: v.string(),
1138
+ workspaceId: v.string(),
1139
+ extId: v.string(),
1140
+ extVersion: v.optional(v.string()),
1141
+ issuedAt: v.number(),
1142
+ ip: v.optional(v.string()),
1143
+ userAgent: v.optional(v.string()),
1144
+ })
1145
+ .index("by_clerkUserId", ["clerkUserId"])
1146
+ .index("by_workspaceId", ["workspaceId"]),
1147
+
1148
+ // ── credentialsRateLimits ────────────────────────────────────────────────
1149
+ // Rolling rate-limit counter for issueBearerFromClerk.
1150
+ // key = "<clerkUserId>-issueBearer"
1151
+ // Resets automatically when windowStart + 60000 < now.
1152
+ credentialsRateLimits: defineTable({
1153
+ key: v.string(), // "<clerkUserId>-issueBearer"
1154
+ count: v.number(), // requests in current window
1155
+ windowStart: v.number(), // ms since epoch — start of 1-minute window
1156
+ }).index("by_key", ["key"]),
1157
+
1158
+ // ── oauth_audit_log ──────────────────────────────────────────────────────
1159
+ // Append-only audit trail for administrative scope profile mutations.
1160
+ // One row per patchScopeProfileEmergency invocation.
1161
+ // actorTokenHash = sha256Hex of the callerToken (raw token never stored).
1162
+ // previousState + newState allow forensic reconstruction of leaked scopes.
1163
+ // S1.2-mutation: captures Day 90 Nadia `global` leak remediation.
1164
+ // S2.1-D9: clientsRetargeted added (additive, optional for backward compat).
1165
+ oauth_audit_log: defineTable({
1166
+ eventType: v.string(),
1167
+ actorTokenHash: v.string(),
1168
+ targetProfileId: v.string(),
1169
+ previousState: v.object({
1170
+ profileId: v.string(),
1171
+ fromAllowList: v.array(v.string()),
1172
+ namespaceReadPrefixes: v.array(v.string()),
1173
+ namespaceWritePrefixes: v.array(v.string()),
1174
+ }),
1175
+ newState: v.object({
1176
+ profileId: v.string(),
1177
+ fromAllowList: v.array(v.string()),
1178
+ namespaceReadPrefixes: v.array(v.string()),
1179
+ namespaceWritePrefixes: v.array(v.string()),
1180
+ }),
1181
+ reason: v.string(),
1182
+ cascadeRevokedCount: v.number(),
1183
+ // S2.1-D9: number of oauth_clients rows retargeted during rename (0 if no rename).
1184
+ // Optional for backward compat with pre-S2.1 rows.
1185
+ clientsRetargeted: v.optional(v.number()),
1186
+ createdAt: v.number(),
1187
+ })
1188
+ .index("by_targetProfileId", ["targetProfileId"])
1189
+ .index("by_createdAt", ["createdAt"]),
1190
+
1191
+ // ── okfDurableExportProgress ─────────────────────────────────────────────
1192
+ // I1 long-task survival — durable variant of `exportOkfBundle`
1193
+ // (convex/okfBundleDurable.ts). One row per durable job (keyed by the
1194
+ // `@vantageos/agent-engine` `jobId`), tracking pagination cursors across
1195
+ // steps so a step function can resume exactly where the previous one left
1196
+ // off. `orgId` here IS the requested namespace string (see
1197
+ // okfBundleDurable.ts comment) — kept as its own field, never trusted as a
1198
+ // client-supplied scope on any step: every step re-derives its DB reads
1199
+ // from `namespace`/`orgId` on THIS row, written once at job creation by
1200
+ // the authenticated `startOkfBundleExportDurable` mutation.
1201
+ okfDurableExportProgress: defineTable({
1202
+ jobId: v.string(), // opaque id returned by durableJob.start
1203
+ orgId: v.string(), // == namespace; tenant scope for every step read
1204
+ namespace: v.string(),
1205
+ sinceMs: v.optional(v.number()),
1206
+ memoriesCursor: v.union(v.string(), v.null()),
1207
+ memoriesDone: v.boolean(),
1208
+ briefingsCursor: v.union(v.string(), v.null()),
1209
+ briefingsDone: v.boolean(),
1210
+ tasksCursor: v.union(v.string(), v.null()),
1211
+ tasksDone: v.boolean(),
1212
+ memoryCount: v.number(),
1213
+ briefingCount: v.number(),
1214
+ taskCount: v.number(),
1215
+ stepsCompleted: v.number(),
1216
+ status: v.union(v.literal("running"), v.literal("assembled")),
1217
+ createdAt: v.number(),
1218
+ updatedAt: v.number(),
1219
+ })
1220
+ .index("by_jobId", ["jobId"])
1221
+ .index("by_orgId_jobId", ["orgId", "jobId"]),
1222
+
1223
+ // ── okfDurableExportEntries ──────────────────────────────────────────────
1224
+ // Serialized bundle files (path + markdown content) appended one page at a
1225
+ // time by the durable step function. `orgId` is the FIRST index field on
1226
+ // every index (multi-tenant convention) — a step for org A can never read
1227
+ // org B's rows because every query below is scoped by `.eq("orgId", ...)`
1228
+ // before anything else.
1229
+ okfDurableExportEntries: defineTable({
1230
+ orgId: v.string(),
1231
+ jobId: v.string(),
1232
+ family: v.union(
1233
+ v.literal("memory"),
1234
+ v.literal("briefing"),
1235
+ v.literal("task"),
1236
+ ),
1237
+ seq: v.number(),
1238
+ path: v.string(),
1239
+ content: v.string(),
1240
+ })
1241
+ .index("by_org_job", ["orgId", "jobId"])
1242
+ .index("by_org_job_seq", ["orgId", "jobId", "seq"]),
1243
+ });