create-safest-tools 0.2.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 (240) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +71 -0
  3. package/bin/create-safest-tools.mjs +12 -0
  4. package/package.json +34 -0
  5. package/src/cli.mjs +128 -0
  6. package/src/config.mjs +151 -0
  7. package/src/scaffold.mjs +30 -0
  8. package/template/LICENSE +201 -0
  9. package/template/README.md +35 -0
  10. package/template/console/README.md +28 -0
  11. package/template/console/analytics/AnalyticsWorkspace.tsx +55 -0
  12. package/template/console/analytics/controller.ts +133 -0
  13. package/template/console/analytics/events.ts +33 -0
  14. package/template/console/analytics/store.ts +33 -0
  15. package/template/console/analytics/types.ts +38 -0
  16. package/template/console/appeals/AppealWorkspace.tsx +73 -0
  17. package/template/console/appeals/controller.ts +125 -0
  18. package/template/console/appeals/events.ts +33 -0
  19. package/template/console/appeals/store.ts +35 -0
  20. package/template/console/appeals/types.ts +37 -0
  21. package/template/console/assistant/AssistantWorkspace.tsx +133 -0
  22. package/template/console/assistant/types.ts +34 -0
  23. package/template/console/auth/AccountJourney.tsx +69 -0
  24. package/template/console/auth/InvitationAcceptance.tsx +145 -0
  25. package/template/console/auth/OAuthButtons.tsx +78 -0
  26. package/template/console/auth/OperatorLogin.tsx +110 -0
  27. package/template/console/auth/PasswordRecovery.tsx +132 -0
  28. package/template/console/auth/account-route.ts +15 -0
  29. package/template/console/auth/browser-navigation.ts +7 -0
  30. package/template/console/auth/events.ts +15 -0
  31. package/template/console/command/CommandCentre.tsx +119 -0
  32. package/template/console/command/controller.ts +267 -0
  33. package/template/console/command/events.ts +19 -0
  34. package/template/console/command/store.ts +36 -0
  35. package/template/console/command/types.ts +61 -0
  36. package/template/console/components/AnalystIdentity.tsx +25 -0
  37. package/template/console/components/PageHeader.tsx +27 -0
  38. package/template/console/configuration/ConfigurationDialog.tsx +200 -0
  39. package/template/console/configuration/ConfigurationWorkspace.tsx +66 -0
  40. package/template/console/configuration/FormBuilder.tsx +143 -0
  41. package/template/console/configuration/api.ts +40 -0
  42. package/template/console/configuration/events.ts +14 -0
  43. package/template/console/configuration/types.ts +79 -0
  44. package/template/console/lib/format.ts +49 -0
  45. package/template/console/lib/http.ts +96 -0
  46. package/template/console/main.tsx +225 -0
  47. package/template/console/operations/OperationsWorkspace.tsx +126 -0
  48. package/template/console/operations/controller.ts +237 -0
  49. package/template/console/operations/events.ts +37 -0
  50. package/template/console/operations/store.ts +32 -0
  51. package/template/console/operations/types.ts +107 -0
  52. package/template/console/people/PeopleWorkspace.tsx +81 -0
  53. package/template/console/people/types.ts +25 -0
  54. package/template/console/profile/ProfileWorkspace.tsx +136 -0
  55. package/template/console/profile/events.ts +9 -0
  56. package/template/console/profile/types.ts +6 -0
  57. package/template/console/quality/QualityWorkspace.tsx +87 -0
  58. package/template/console/quality/controller.ts +154 -0
  59. package/template/console/quality/events.ts +34 -0
  60. package/template/console/quality/store.ts +36 -0
  61. package/template/console/quality/types.ts +70 -0
  62. package/template/console/queues/QueueEditor.tsx +173 -0
  63. package/template/console/queues/QueueWorkspace.tsx +109 -0
  64. package/template/console/queues/controller.ts +194 -0
  65. package/template/console/queues/events.ts +34 -0
  66. package/template/console/queues/store.ts +37 -0
  67. package/template/console/queues/types.ts +102 -0
  68. package/template/console/registry/RegistryDialog.tsx +198 -0
  69. package/template/console/registry/RegistryWorkspace.tsx +108 -0
  70. package/template/console/registry/events.ts +14 -0
  71. package/template/console/registry/types.ts +52 -0
  72. package/template/console/reports/ReportDrawer.tsx +159 -0
  73. package/template/console/reports/ReportWorkspace.tsx +90 -0
  74. package/template/console/reports/controller.ts +517 -0
  75. package/template/console/reports/events.ts +42 -0
  76. package/template/console/reports/store.ts +39 -0
  77. package/template/console/reports/types.ts +230 -0
  78. package/template/console/settings/BrandEditor.tsx +126 -0
  79. package/template/console/settings/ChannelDialog.tsx +241 -0
  80. package/template/console/settings/SettingsWorkspace.tsx +155 -0
  81. package/template/console/settings/events.ts +19 -0
  82. package/template/console/settings/handoff.ts +22 -0
  83. package/template/console/settings/types.ts +93 -0
  84. package/template/console/shell/WorkspaceShell.tsx +160 -0
  85. package/template/console/shell/controller.ts +182 -0
  86. package/template/console/shell/events.ts +25 -0
  87. package/template/console/shell/navigation.ts +61 -0
  88. package/template/console/shell/store.ts +56 -0
  89. package/template/console/shell/types.ts +86 -0
  90. package/template/console/workflows/CreateWorkflowDialog.tsx +87 -0
  91. package/template/console/workflows/WorkflowCanvas.tsx +42 -0
  92. package/template/console/workflows/WorkflowDialogs.tsx +6 -0
  93. package/template/console/workflows/WorkflowStudio.tsx +331 -0
  94. package/template/console/workflows/WorkflowWorkspace.tsx +82 -0
  95. package/template/console/workflows/events.ts +17 -0
  96. package/template/console/workflows/graph.ts +156 -0
  97. package/template/console/workflows/templates.ts +70 -0
  98. package/template/console/workflows/types.ts +201 -0
  99. package/template/gitignore.template +18 -0
  100. package/template/migrations/0001_reports_foundation.sql +444 -0
  101. package/template/migrations/0002_human_report_loop.sql +65 -0
  102. package/template/migrations/0003_delivery_reliability.sql +18 -0
  103. package/template/migrations/0004_public_intake.sql +14 -0
  104. package/template/migrations/0005_operations_visibility.sql +24 -0
  105. package/template/migrations/0006_ai_governance.sql +198 -0
  106. package/template/migrations/0007_ai_release_gates.sql +6 -0
  107. package/template/migrations/0008_retention_analytics_exports.sql +52 -0
  108. package/template/migrations/0009_retention_derived_copies.sql +14 -0
  109. package/template/migrations/0010_ai_quality_controls.sql +26 -0
  110. package/template/migrations/0011_analyst_presence.sql +28 -0
  111. package/template/migrations/0012_queue_policies.sql +87 -0
  112. package/template/migrations/0013_routing_agents.sql +63 -0
  113. package/template/migrations/0014_webhook_enrichments.sql +122 -0
  114. package/template/migrations/0015_queue_owned_ai.sql +55 -0
  115. package/template/migrations/0016_operator_accounts.sql +67 -0
  116. package/template/migrations/0017_operator_profiles_and_recovery.sql +33 -0
  117. package/template/migrations/0018_platform_configuration.sql +382 -0
  118. package/template/migrations/0019_workflow_authoring_runtime.sql +372 -0
  119. package/template/migrations/0020_tasks_findings_assistant_budgets.sql +427 -0
  120. package/template/migrations/0021_abuse_evidence_operations.sql +324 -0
  121. package/template/migrations/0022_workflow_dispatch_operations.sql +42 -0
  122. package/template/migrations/0023_component_connection_execution.sql +75 -0
  123. package/template/migrations/0024_access_runtime_integrity.sql +72 -0
  124. package/template/migrations/0025_installation_timezone.sql +11 -0
  125. package/template/migrations/0026_ai_and_egress_execution_controls.sql +49 -0
  126. package/template/migrations/0027_prompt_and_ai_registry.sql +37 -0
  127. package/template/migrations/0028_step_attempt_ai_provenance.sql +13 -0
  128. package/template/migrations/0029_evidence_fetch_transport.sql +5 -0
  129. package/template/migrations/0030_evidence_dlq_incidents.sql +45 -0
  130. package/template/migrations/0031_shadow_quality_integrity.sql +7 -0
  131. package/template/migrations/0032_action_delivery_outbox.sql +55 -0
  132. package/template/migrations/0033_configuration_and_assistant_drafts.sql +43 -0
  133. package/template/migrations/0034_installation_integrations.sql +31 -0
  134. package/template/migrations/0035_workspace_governance.sql +21 -0
  135. package/template/migrations/0036_published_routing_baseline.sql +15 -0
  136. package/template/migrations/0037_builtin_phishing_specialist.sql +71 -0
  137. package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +228 -0
  138. package/template/migrations/0039_secure_reporting_channels.sql +45 -0
  139. package/template/migrations/0040_notification_only_reporting.sql +24 -0
  140. package/template/migrations/0041_better_auth_credentials.sql +17 -0
  141. package/template/package.json +47 -0
  142. package/template/public/_headers +27 -0
  143. package/template/public/app-icon-192.png +0 -0
  144. package/template/public/app-icon-512.png +0 -0
  145. package/template/public/brand-icon.svg +7 -0
  146. package/template/public/brand-tokens.css +80 -0
  147. package/template/public/console/auth-shell.js +19340 -0
  148. package/template/public/customer-brand.js +58 -0
  149. package/template/public/embed/embed.css +139 -0
  150. package/template/public/embed/embed.js +408 -0
  151. package/template/public/embed/index.html +91 -0
  152. package/template/public/favicon.svg +7 -0
  153. package/template/public/fonts/Manrope-Variable.ttf +0 -0
  154. package/template/public/fonts/Newsreader-Italic-Variable.ttf +0 -0
  155. package/template/public/fonts/Newsreader-Variable.ttf +0 -0
  156. package/template/public/index.html +123 -0
  157. package/template/public/logo-primary.svg +7 -0
  158. package/template/public/logo-reversed.svg +7 -0
  159. package/template/public/manifest.webmanifest +21 -0
  160. package/template/public/public-report.js +184 -0
  161. package/template/public/report/index.html +39 -0
  162. package/template/public/report/public-report.css +20 -0
  163. package/template/public/social-card.png +0 -0
  164. package/template/public/styles.css +1521 -0
  165. package/template/public/widget.css +80 -0
  166. package/template/public/widget.js +220 -0
  167. package/template/reports.config.example.json +38 -0
  168. package/template/reports.schema.json +89 -0
  169. package/template/scripts/reports-auth-onboarding.mjs +180 -0
  170. package/template/scripts/reports-backup.mjs +276 -0
  171. package/template/scripts/reports-cloudflare-preflight.mjs +152 -0
  172. package/template/scripts/reports-deploy.mjs +173 -0
  173. package/template/scripts/reports-plan.mjs +226 -0
  174. package/template/scripts/reports-restore.mjs +180 -0
  175. package/template/scripts/reports-secrets.mjs +73 -0
  176. package/template/scripts/reports-uninstall-plan.mjs +32 -0
  177. package/template/src/agent-executor.ts +330 -0
  178. package/template/src/ai-observability.ts +163 -0
  179. package/template/src/ai-registry-validation.ts +244 -0
  180. package/template/src/ai-registry.ts +292 -0
  181. package/template/src/api-cursor.ts +84 -0
  182. package/template/src/assistant.ts +554 -0
  183. package/template/src/audit.ts +39 -0
  184. package/template/src/backup-service.ts +269 -0
  185. package/template/src/budget-control.ts +127 -0
  186. package/template/src/component-executor.ts +845 -0
  187. package/template/src/configuration-registry.ts +550 -0
  188. package/template/src/connection-egress.ts +430 -0
  189. package/template/src/connection-oauth.ts +368 -0
  190. package/template/src/evidence-service.ts +335 -0
  191. package/template/src/human-tasks.ts +316 -0
  192. package/template/src/index.ts +4114 -0
  193. package/template/src/installation-admin.ts +301 -0
  194. package/template/src/intake-abuse.ts +156 -0
  195. package/template/src/platform-registry-validation.ts +367 -0
  196. package/template/src/platform-registry.ts +753 -0
  197. package/template/src/report-admin.ts +342 -0
  198. package/template/src/report-ai-quality.ts +390 -0
  199. package/template/src/report-ai-validation.ts +80 -0
  200. package/template/src/report-ai.ts +857 -0
  201. package/template/src/report-auth.ts +292 -0
  202. package/template/src/report-better-auth.ts +583 -0
  203. package/template/src/report-context-schema.ts +132 -0
  204. package/template/src/report-context.ts +126 -0
  205. package/template/src/report-crypto.ts +142 -0
  206. package/template/src/report-delivery.ts +579 -0
  207. package/template/src/report-form-validation.ts +51 -0
  208. package/template/src/report-governance.ts +510 -0
  209. package/template/src/report-http.ts +90 -0
  210. package/template/src/report-operations.ts +685 -0
  211. package/template/src/report-operator-accounts.ts +810 -0
  212. package/template/src/report-presence.ts +398 -0
  213. package/template/src/report-queue-validation.ts +309 -0
  214. package/template/src/report-queues.ts +478 -0
  215. package/template/src/report-repository.ts +972 -0
  216. package/template/src/report-router-agent.ts +328 -0
  217. package/template/src/report-router-validation.ts +81 -0
  218. package/template/src/report-routing.ts +178 -0
  219. package/template/src/report-turnstile.ts +142 -0
  220. package/template/src/report-types.ts +224 -0
  221. package/template/src/report-validation.ts +279 -0
  222. package/template/src/report-workflow-validation.ts +119 -0
  223. package/template/src/report-workflow.ts +886 -0
  224. package/template/src/shadow-quality.ts +278 -0
  225. package/template/src/workflow-actions.ts +782 -0
  226. package/template/src/workflow-compiler.ts +230 -0
  227. package/template/src/workflow-dynamic-runtime.ts +613 -0
  228. package/template/src/workflow-effects.ts +316 -0
  229. package/template/src/workflow-expressions.ts +191 -0
  230. package/template/src/workflow-platform-types.ts +121 -0
  231. package/template/src/workflow-platform-validation.ts +540 -0
  232. package/template/src/workflow-repository.ts +916 -0
  233. package/template/src/workflow-runs.ts +686 -0
  234. package/template/src/workflow-simulator.ts +211 -0
  235. package/template/src/workspace-branding.ts +128 -0
  236. package/template/src/workspace-governance.ts +279 -0
  237. package/template/tsconfig.console.json +23 -0
  238. package/template/tsconfig.json +22 -0
  239. package/template/vite.console.config.ts +20 -0
  240. package/template/worker-configuration.d.ts +65 -0
@@ -0,0 +1,278 @@
1
+ import { canonicalJson } from "./audit";
2
+ import { sha256 } from "./report-crypto";
3
+ import { ApiError } from "./report-http";
4
+ import type { OperatorSession } from "./report-types";
5
+
6
+ interface ShadowCandidate {
7
+ shadowRunId: string;
8
+ reportId: string;
9
+ primaryRunId: string | null;
10
+ primaryState: string | null;
11
+ decisionId: string | null;
12
+ decisionCode: string | null;
13
+ policyCode: string | null;
14
+ findingId: string | null;
15
+ findingDataJson: string | null;
16
+ findingStepRunId: string | null;
17
+ }
18
+
19
+ function now(): string { return new Date().toISOString(); }
20
+
21
+ function parsedObject(value: string | null): Record<string, unknown> {
22
+ try {
23
+ const parsed = value ? JSON.parse(value) : {};
24
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+
30
+ function recommendationValues(data: Record<string, unknown>): string[] {
31
+ return [data.decision_code, data.policy_code, data.result_code, data.outcome, data.conclusion, data.classification]
32
+ .filter((value): value is string => typeof value === "string")
33
+ .map((value) => value.trim().toLowerCase())
34
+ .filter(Boolean);
35
+ }
36
+
37
+ async function qualityQueue(db: D1Database): Promise<{ id: string } | null> {
38
+ return db.prepare(`SELECT id FROM queue_definitions WHERE purpose = 'quality' AND status = 'active' ORDER BY position, id LIMIT 1`)
39
+ .first<{ id: string }>();
40
+ }
41
+
42
+ async function insertQualityTask(
43
+ db: D1Database,
44
+ input: {
45
+ comparisonId: string;
46
+ reportId: string;
47
+ shadowRunId: string;
48
+ stepRunId: string;
49
+ queueId: string;
50
+ codes: string[];
51
+ severity: string;
52
+ actorId: string;
53
+ createdAt: string;
54
+ },
55
+ ): Promise<string> {
56
+ const taskId = `${input.comparisonId}:quality-task`;
57
+ const context = {
58
+ schema_version: "1",
59
+ comparison_id: input.comparisonId,
60
+ shadow_run_id: input.shadowRunId,
61
+ comparison_codes: input.codes,
62
+ severity: input.severity,
63
+ purpose: "Review a non-authoritative shadow recommendation against the authoritative decision.",
64
+ };
65
+ const contextJson = canonicalJson(context);
66
+ const contextDigest = `sha256:${await sha256(contextJson)}`;
67
+ await db.batch([
68
+ db.prepare(`
69
+ INSERT OR IGNORE INTO human_tasks (
70
+ id, installation_id, report_id, run_id, step_run_id, task_type,
71
+ state, queue_id, context_json, context_digest, response_schema_json,
72
+ due_at, escalation_at, created_at, updated_at
73
+ ) VALUES (?1, 'default', ?2, ?3, ?4, 'quality_review', 'open', ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)
74
+ `).bind(
75
+ taskId, input.reportId, input.shadowRunId, input.stepRunId, input.queueId,
76
+ contextJson, contextDigest,
77
+ canonicalJson({
78
+ type: "object",
79
+ additionalProperties: false,
80
+ required: ["outcome", "notes"],
81
+ properties: {
82
+ outcome: { type: "string", enum: ["agree_primary", "agree_shadow", "inconclusive"] },
83
+ notes: { type: "string", minLength: 10, maxLength: 4000 },
84
+ },
85
+ }),
86
+ new Date(Date.now() + 24 * 60 * 60_000).toISOString(),
87
+ new Date(Date.now() + 12 * 60 * 60_000).toISOString(), input.createdAt,
88
+ ),
89
+ db.prepare(`UPDATE shadow_comparisons SET status = 'quality_task_created' WHERE id = ?1 AND status = 'pending'`).bind(input.comparisonId),
90
+ db.prepare(`
91
+ INSERT OR IGNORE INTO audit_events (
92
+ id, idempotency_key, action, actor_type, actor_id,
93
+ target_type, target_id, details_json, created_at
94
+ ) VALUES (?1, ?2, 'shadow.quality_task_created', 'system', ?3,
95
+ 'shadow_comparison', ?4, ?5, ?6)
96
+ `).bind(
97
+ crypto.randomUUID(), `shadow-quality-task:${input.comparisonId}`,
98
+ input.actorId, input.comparisonId, canonicalJson({ taskId, queueId: input.queueId }), input.createdAt,
99
+ ),
100
+ ]);
101
+ return taskId;
102
+ }
103
+
104
+ export async function compareCompletedShadowRuns(db: D1Database, limit = 50): Promise<{ compared: number; tasksCreated: number }> {
105
+ const rows = await db.prepare(`
106
+ SELECT shadow.id AS shadowRunId, shadow.report_id AS reportId,
107
+ primary_run.id AS primaryRunId, primary_run.state AS primaryState,
108
+ decision.id AS decisionId, decision.decision_code AS decisionCode,
109
+ decision.policy_code AS policyCode, finding.id AS findingId,
110
+ finding.data_json AS findingDataJson, finding.step_run_id AS findingStepRunId
111
+ FROM workflow_runs shadow
112
+ LEFT JOIN workflow_runs primary_run ON primary_run.id = (
113
+ SELECT candidate.id FROM workflow_runs candidate
114
+ WHERE candidate.report_id = shadow.report_id AND candidate.run_kind = 'primary'
115
+ ORDER BY candidate.created_at DESC, candidate.id DESC LIMIT 1
116
+ )
117
+ LEFT JOIN decisions decision ON decision.id = (
118
+ SELECT candidate.id FROM decisions candidate WHERE candidate.report_id = shadow.report_id
119
+ ORDER BY candidate.created_at DESC, candidate.id DESC LIMIT 1
120
+ )
121
+ LEFT JOIN findings finding ON finding.id = (
122
+ SELECT candidate.id FROM findings candidate
123
+ WHERE candidate.run_id = shadow.id AND candidate.status = 'available'
124
+ ORDER BY candidate.created_at DESC, candidate.id DESC LIMIT 1
125
+ )
126
+ WHERE shadow.run_kind = 'shadow' AND shadow.state = 'succeeded'
127
+ AND NOT EXISTS (SELECT 1 FROM shadow_comparisons comparison WHERE comparison.shadow_run_id = shadow.id)
128
+ AND (decision.id IS NOT NULL OR primary_run.state IN ('succeeded','failed','terminated','superseded'))
129
+ ORDER BY shadow.finished_at, shadow.id LIMIT ?1
130
+ `).bind(Math.max(1, Math.min(100, limit))).all<ShadowCandidate>();
131
+ const queue = await qualityQueue(db);
132
+ let compared = 0;
133
+ let tasksCreated = 0;
134
+ for (const row of rows.results) {
135
+ const data = parsedObject(row.findingDataJson);
136
+ const recommendations = recommendationValues(data);
137
+ const expected = [row.decisionCode, row.policyCode]
138
+ .filter((value): value is string => typeof value === "string")
139
+ .map((value) => value.toLowerCase());
140
+ let codes: string[];
141
+ let severity: "info" | "low" | "medium" | "high";
142
+ if (!row.decisionId) {
143
+ codes = ["authoritative_decision_missing"];
144
+ severity = "medium";
145
+ } else if (!row.findingId || recommendations.length === 0) {
146
+ codes = ["shadow_recommendation_missing"];
147
+ severity = "low";
148
+ } else if (expected.some((value) => recommendations.includes(value))) {
149
+ codes = ["shadow_agrees_with_authoritative_decision"];
150
+ severity = "info";
151
+ } else {
152
+ codes = ["shadow_decision_disagreement"];
153
+ severity = "high";
154
+ }
155
+ const comparisonId = `${row.shadowRunId}:comparison`;
156
+ const timestamp = now();
157
+ await db.batch([
158
+ db.prepare(`
159
+ INSERT OR IGNORE INTO shadow_comparisons (
160
+ id, report_id, primary_run_id, shadow_run_id, primary_decision_id,
161
+ shadow_finding_id, comparison_codes_json, severity, status, created_at
162
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9)
163
+ `).bind(
164
+ comparisonId, row.reportId, row.primaryRunId, row.shadowRunId, row.decisionId,
165
+ row.findingId, canonicalJson(codes), severity, timestamp,
166
+ ),
167
+ db.prepare(`
168
+ INSERT OR IGNORE INTO audit_events (
169
+ id, idempotency_key, action, actor_type, actor_id,
170
+ target_type, target_id, details_json, created_at
171
+ ) VALUES (?1, ?2, 'shadow.comparison_created', 'system', 'shadow-quality-controller',
172
+ 'shadow_comparison', ?3, ?4, ?5)
173
+ `).bind(
174
+ crypto.randomUUID(), `shadow-comparison:${row.shadowRunId}`, comparisonId,
175
+ canonicalJson({ reportId: row.reportId, shadowRunId: row.shadowRunId, codes, severity }), timestamp,
176
+ ),
177
+ ]);
178
+ compared += 1;
179
+ if ((severity === "high" || severity === "medium") && queue && row.findingStepRunId) {
180
+ await insertQualityTask(db, {
181
+ comparisonId, reportId: row.reportId, shadowRunId: row.shadowRunId,
182
+ stepRunId: row.findingStepRunId, queueId: queue.id, codes, severity,
183
+ actorId: "shadow-quality-controller", createdAt: timestamp,
184
+ });
185
+ tasksCreated += 1;
186
+ }
187
+ }
188
+ return { compared, tasksCreated };
189
+ }
190
+
191
+ export async function listShadowComparisons(
192
+ db: D1Database,
193
+ input: { status?: string | null; limit?: number; allowedQueueIds?: string[] | null },
194
+ ): Promise<Record<string, unknown>[]> {
195
+ const rows = await db.prepare(`
196
+ SELECT comparison.id, comparison.report_id AS reportId,
197
+ report.public_reference AS reportReference,
198
+ comparison.primary_run_id AS primaryRunId, comparison.shadow_run_id AS shadowRunId,
199
+ comparison.primary_decision_id AS primaryDecisionId,
200
+ comparison.shadow_finding_id AS shadowFindingId,
201
+ comparison.comparison_codes_json AS comparisonCodesJson,
202
+ comparison.severity, comparison.status, comparison.created_at AS createdAt,
203
+ comparison.reviewed_at AS reviewedAt
204
+ FROM shadow_comparisons comparison JOIN reports report ON report.id = comparison.report_id
205
+ WHERE (?1 IS NULL OR comparison.status = ?1)
206
+ AND (?3 = 1 OR report.queue_id IN (SELECT value FROM json_each(?4)))
207
+ ORDER BY CASE comparison.severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END,
208
+ comparison.created_at DESC, comparison.id DESC LIMIT ?2
209
+ `).bind(
210
+ input.status ?? null,
211
+ Math.max(1, Math.min(100, input.limit ?? 50)),
212
+ input.allowedQueueIds === null || input.allowedQueueIds === undefined ? 1 : 0,
213
+ canonicalJson(input.allowedQueueIds ?? []),
214
+ ).all<Record<string, unknown>>();
215
+ return rows.results.map((row) => ({
216
+ ...row,
217
+ comparisonCodes: typeof row.comparisonCodesJson === "string" ? JSON.parse(row.comparisonCodesJson) : [],
218
+ comparisonCodesJson: undefined,
219
+ }));
220
+ }
221
+
222
+ export async function createShadowQualityTask(
223
+ db: D1Database,
224
+ comparisonId: string,
225
+ session: OperatorSession,
226
+ idempotencyKey: string,
227
+ ): Promise<{ comparisonId: string; taskId: string; idempotentReplay: boolean }> {
228
+ const existing = await db.prepare(`SELECT id FROM human_tasks WHERE id = ?1 LIMIT 1`).bind(`${comparisonId}:quality-task`).first();
229
+ if (existing) return { comparisonId, taskId: `${comparisonId}:quality-task`, idempotentReplay: true };
230
+ const row = await db.prepare(`
231
+ SELECT comparison.report_id AS reportId, comparison.shadow_run_id AS shadowRunId,
232
+ comparison.comparison_codes_json AS codesJson, comparison.severity,
233
+ COALESCE(finding.step_run_id, (
234
+ SELECT step.id FROM workflow_step_runs step WHERE step.run_id = comparison.shadow_run_id
235
+ ORDER BY step.finished_at DESC, step.id DESC LIMIT 1
236
+ )) AS stepRunId
237
+ FROM shadow_comparisons comparison
238
+ LEFT JOIN findings finding ON finding.id = comparison.shadow_finding_id
239
+ WHERE comparison.id = ?1 AND comparison.status IN ('pending','quality_task_created') LIMIT 1
240
+ `).bind(comparisonId).first<{ reportId: string; shadowRunId: string; codesJson: string; severity: string; stepRunId: string | null }>();
241
+ if (!row) throw new ApiError(404, "shadow_comparison_not_found", "The pending shadow comparison does not exist.");
242
+ if (!row.stepRunId) throw new ApiError(409, "shadow_comparison_step_unavailable", "The shadow run has no step to anchor a quality task.");
243
+ const queue = await qualityQueue(db);
244
+ if (!queue) throw new ApiError(409, "quality_queue_unavailable", "No active quality queue is configured.");
245
+ const taskId = await insertQualityTask(db, {
246
+ comparisonId, reportId: row.reportId, shadowRunId: row.shadowRunId,
247
+ stepRunId: row.stepRunId, queueId: queue.id,
248
+ codes: JSON.parse(row.codesJson) as string[], severity: row.severity,
249
+ actorId: session.actor.id, createdAt: now(),
250
+ });
251
+ await db.prepare(`INSERT OR IGNORE INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) VALUES (?1, ?2, 'shadow.quality_task_requested', ?3, ?4, 'shadow_comparison', ?5, ?6, ?7)`)
252
+ .bind(crypto.randomUUID(), `shadow-quality-request:${idempotencyKey}`, session.actor.type, session.actor.id, comparisonId, canonicalJson({ taskId }), now()).run();
253
+ return { comparisonId, taskId, idempotentReplay: false };
254
+ }
255
+
256
+ export async function dismissShadowComparison(
257
+ db: D1Database,
258
+ comparisonId: string,
259
+ reason: string,
260
+ session: OperatorSession,
261
+ idempotencyKey: string,
262
+ ): Promise<{ comparisonId: string; status: string; idempotentReplay: boolean }> {
263
+ if (reason.trim().length < 5 || reason.length > 2_000) throw new ApiError(400, "shadow_dismissal_reason_invalid", "A dismissal reason is required.");
264
+ const key = `shadow-dismiss:${idempotencyKey}`;
265
+ const prior = await db.prepare(`SELECT target_id AS targetId FROM audit_events WHERE idempotency_key = ?1 AND action = 'shadow.comparison_dismissed' LIMIT 1`)
266
+ .bind(key).first<{ targetId: string }>();
267
+ if (prior) {
268
+ if (prior.targetId !== comparisonId) throw new ApiError(409, "idempotency_key_reused", "This Idempotency-Key was already used for another comparison.");
269
+ return { comparisonId, status: "dismissed", idempotentReplay: true };
270
+ }
271
+ const timestamp = now();
272
+ const changed = await db.prepare(`UPDATE shadow_comparisons SET status = 'dismissed', reviewed_at = ?2 WHERE id = ?1 AND status IN ('pending','quality_task_created')`)
273
+ .bind(comparisonId, timestamp).run();
274
+ if (changed.meta.changes !== 1) throw new ApiError(404, "shadow_comparison_not_pending", "The pending shadow comparison does not exist.");
275
+ await db.prepare(`INSERT INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) VALUES (?1, ?2, 'shadow.comparison_dismissed', ?3, ?4, 'shadow_comparison', ?5, ?6, ?7)`)
276
+ .bind(crypto.randomUUID(), key, session.actor.type, session.actor.id, comparisonId, canonicalJson({ reason: reason.trim() }), timestamp).run();
277
+ return { comparisonId, status: "dismissed", idempotentReplay: false };
278
+ }