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,342 @@
1
+ import { canonicalJson } from "./audit";
2
+ import { decodeApiCursor, encodeApiCursor } from "./api-cursor";
3
+ import { ApiError } from "./report-http";
4
+ import type { OperatorSession, ReportState } from "./report-types";
5
+
6
+ function now(): string {
7
+ return new Date().toISOString();
8
+ }
9
+
10
+ function parsed(value: unknown, fallback: unknown = null): unknown {
11
+ if (typeof value !== "string") return fallback;
12
+ try {
13
+ return JSON.parse(value) as unknown;
14
+ } catch {
15
+ return fallback;
16
+ }
17
+ }
18
+
19
+ function auditStatement(
20
+ db: D1Database,
21
+ input: {
22
+ key: string;
23
+ action: string;
24
+ session: OperatorSession;
25
+ targetType: string;
26
+ targetId: string;
27
+ details: unknown;
28
+ createdAt: string;
29
+ },
30
+ ): D1PreparedStatement {
31
+ return db.prepare(`
32
+ INSERT INTO audit_events (
33
+ id, idempotency_key, action, actor_type, actor_id, target_type,
34
+ target_id, details_json, created_at
35
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
36
+ `).bind(
37
+ crypto.randomUUID(), input.key, input.action, input.session.actor.type,
38
+ input.session.actor.id, input.targetType, input.targetId,
39
+ canonicalJson(input.details), input.createdAt,
40
+ );
41
+ }
42
+
43
+ async function mutationReplay(
44
+ db: D1Database,
45
+ key: string,
46
+ action: string,
47
+ session: OperatorSession,
48
+ targetId: string,
49
+ ): Promise<Record<string, unknown> | null> {
50
+ const row = await db.prepare(`
51
+ SELECT action, actor_id AS actorId, target_id AS targetId, details_json AS detailsJson
52
+ FROM audit_events WHERE idempotency_key = ?1 LIMIT 1
53
+ `).bind(key).first<{ action: string; actorId: string; targetId: string; detailsJson: string }>();
54
+ if (!row) return null;
55
+ if (row.action !== action || row.actorId !== session.actor.id || row.targetId !== targetId) {
56
+ throw new ApiError(409, "idempotency_key_reused", "This Idempotency-Key was already used for another operation.");
57
+ }
58
+ const details = parsed(row.detailsJson, {});
59
+ return details && typeof details === "object" && !Array.isArray(details)
60
+ ? (details as Record<string, unknown>) : {};
61
+ }
62
+
63
+ interface ReportOperationRow {
64
+ id: string;
65
+ state: ReportState;
66
+ queueId: string | null;
67
+ queueVersionId: string | null;
68
+ }
69
+
70
+ async function reportOperationRow(db: D1Database, reportId: string): Promise<ReportOperationRow> {
71
+ const report = await db.prepare(`
72
+ SELECT id, state, queue_id AS queueId, queue_version_id AS queueVersionId
73
+ FROM reports WHERE id = ?1 LIMIT 1
74
+ `).bind(reportId).first<ReportOperationRow>();
75
+ if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
76
+ return report;
77
+ }
78
+
79
+ interface ActiveQueueRow {
80
+ id: string;
81
+ versionId: string;
82
+ managementMode: "human" | "ai" | "hybrid";
83
+ rolloutMode: "off" | "shadow" | "assist" | "autonomous_low_priority";
84
+ }
85
+
86
+ async function activeReportQueue(db: D1Database, queueId: string): Promise<ActiveQueueRow> {
87
+ const queue = await db.prepare(`
88
+ SELECT q.id, q.active_version_id AS versionId,
89
+ v.management_mode AS managementMode, v.mode AS rolloutMode
90
+ FROM queue_definitions q JOIN queue_versions v ON v.id = q.active_version_id
91
+ WHERE q.id = ?1 AND q.purpose = 'reports' AND q.status = 'active'
92
+ AND v.published_at IS NOT NULL AND v.retired_at IS NULL
93
+ LIMIT 1
94
+ `).bind(queueId).first<ActiveQueueRow>();
95
+ if (!queue) throw new ApiError(409, "report_queue_unavailable", "The destination must be an active report queue with a published policy.");
96
+ return queue;
97
+ }
98
+
99
+ export async function reassignReport(
100
+ db: D1Database,
101
+ reportId: string,
102
+ queueId: string,
103
+ reason: string,
104
+ session: OperatorSession,
105
+ idempotencyKey: string,
106
+ ): Promise<Record<string, unknown>> {
107
+ const key = `report-reassign:${idempotencyKey}`;
108
+ const replay = await mutationReplay(db, key, "report.reassigned", session, reportId);
109
+ if (replay) return { ...replay, idempotentReplay: true };
110
+ const [report, queue] = await Promise.all([reportOperationRow(db, reportId), activeReportQueue(db, queueId)]);
111
+ if (["resolved_by_human", "resolved_by_ai", "closed"].includes(report.state)) {
112
+ throw new ApiError(409, "report_reassignment_terminal", "A resolved or closed report must be reopened before reassignment.");
113
+ }
114
+ const nextState: ReportState = queue.managementMode === "human" || queue.rolloutMode === "off"
115
+ ? "human_review" : "ai_review";
116
+ const createdAt = now();
117
+ const result = {
118
+ reportId,
119
+ previousQueueId: report.queueId,
120
+ queueId: queue.id,
121
+ queueVersionId: queue.versionId,
122
+ state: nextState,
123
+ };
124
+ await db.batch([
125
+ db.prepare(`
126
+ UPDATE reports SET queue_id = ?2, queue_version_id = ?3, state = ?4,
127
+ routing_status = 'complete', routing_error_code = NULL, updated_at = ?5
128
+ WHERE id = ?1
129
+ `).bind(reportId, queue.id, queue.versionId, nextState, createdAt),
130
+ db.prepare(`DELETE FROM review_claims WHERE report_id = ?1`).bind(reportId),
131
+ db.prepare(`
132
+ INSERT INTO report_state_history (
133
+ id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at
134
+ ) VALUES (?1, ?2, ?3, ?4, 'operator_reassigned', ?5, ?6, ?7)
135
+ `).bind(crypto.randomUUID(), reportId, report.state, nextState, session.actor.type, session.actor.id, createdAt),
136
+ auditStatement(db, {
137
+ key, action: "report.reassigned", session, targetType: "report", targetId: reportId,
138
+ details: { ...result, reason }, createdAt,
139
+ }),
140
+ ]);
141
+ return { ...result, idempotentReplay: false };
142
+ }
143
+
144
+ export async function closeReport(
145
+ db: D1Database,
146
+ reportId: string,
147
+ reason: string,
148
+ session: OperatorSession,
149
+ idempotencyKey: string,
150
+ ): Promise<Record<string, unknown>> {
151
+ const key = `report-close:${idempotencyKey}`;
152
+ const replay = await mutationReplay(db, key, "report.closed", session, reportId);
153
+ if (replay) return { ...replay, idempotentReplay: true };
154
+ const report = await reportOperationRow(db, reportId);
155
+ if (report.state === "closed") throw new ApiError(409, "report_already_closed", "The report is already closed.");
156
+ const createdAt = now();
157
+ const result = { reportId, previousState: report.state, state: "closed", reason };
158
+ await db.batch([
159
+ db.prepare(`UPDATE reports SET state = 'closed', resolved_at = COALESCE(resolved_at, ?2), updated_at = ?2 WHERE id = ?1`)
160
+ .bind(reportId, createdAt),
161
+ db.prepare(`DELETE FROM review_claims WHERE report_id = ?1`).bind(reportId),
162
+ db.prepare(`UPDATE human_tasks SET state = 'cancelled', updated_at = ?2 WHERE report_id = ?1 AND state IN ('open','claimed')`)
163
+ .bind(reportId, createdAt),
164
+ db.prepare(`UPDATE human_task_claims SET released_at = ?2, release_reason = 'report_closed' WHERE task_id IN (SELECT id FROM human_tasks WHERE report_id = ?1) AND released_at IS NULL`)
165
+ .bind(reportId, createdAt),
166
+ db.prepare(`
167
+ INSERT INTO report_state_history (id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at)
168
+ VALUES (?1, ?2, ?3, 'closed', ?4, ?5, ?6, ?7)
169
+ `).bind(crypto.randomUUID(), reportId, report.state, reason, session.actor.type, session.actor.id, createdAt),
170
+ auditStatement(db, {
171
+ key, action: "report.closed", session, targetType: "report", targetId: reportId,
172
+ details: result, createdAt,
173
+ }),
174
+ ]);
175
+ return { ...result, idempotentReplay: false };
176
+ }
177
+
178
+ export async function reopenReport(
179
+ db: D1Database,
180
+ reportId: string,
181
+ queueId: string,
182
+ reason: string,
183
+ session: OperatorSession,
184
+ idempotencyKey: string,
185
+ ): Promise<Record<string, unknown>> {
186
+ const key = `report-reopen:${idempotencyKey}`;
187
+ const replay = await mutationReplay(db, key, "report.reopened", session, reportId);
188
+ if (replay) return { ...replay, idempotentReplay: true };
189
+ const [report, queue] = await Promise.all([reportOperationRow(db, reportId), activeReportQueue(db, queueId)]);
190
+ if (!["resolved_by_human", "resolved_by_ai", "closed"].includes(report.state)) {
191
+ throw new ApiError(409, "report_not_reopenable", "Only a resolved or closed report can be reopened.");
192
+ }
193
+ const nextState: ReportState = queue.managementMode === "human" || queue.rolloutMode === "off"
194
+ ? "human_review" : "ai_review";
195
+ const createdAt = now();
196
+ const result = { reportId, previousState: report.state, queueId: queue.id, queueVersionId: queue.versionId, state: nextState, reason };
197
+ await db.batch([
198
+ db.prepare(`UPDATE reports SET state = ?2, queue_id = ?3, queue_version_id = ?4, resolved_at = NULL, updated_at = ?5 WHERE id = ?1`)
199
+ .bind(reportId, nextState, queue.id, queue.versionId, createdAt),
200
+ db.prepare(`
201
+ INSERT INTO report_state_history (id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at)
202
+ VALUES (?1, ?2, ?3, ?4, 'operator_reopened', ?5, ?6, ?7)
203
+ `).bind(crypto.randomUUID(), reportId, report.state, nextState, session.actor.type, session.actor.id, createdAt),
204
+ auditStatement(db, {
205
+ key, action: "report.reopened", session, targetType: "report", targetId: reportId,
206
+ details: result, createdAt,
207
+ }),
208
+ ]);
209
+ return { ...result, idempotentReplay: false };
210
+ }
211
+
212
+ export async function listReportFindings(db: D1Database, reportId: string): Promise<Record<string, unknown>[]> {
213
+ const timestamp = now();
214
+ const rows = await db.prepare(`
215
+ SELECT f.id, f.run_id AS runId, f.step_run_id AS stepRunId,
216
+ f.component_version_id AS componentVersionId, f.ai_run_id AS aiRunId,
217
+ f.finding_type AS findingType, f.subject_type AS subjectType,
218
+ f.subject_reference AS subjectReference, f.status, f.confidence, f.summary,
219
+ f.data_json AS dataJson, f.provenance_json AS provenanceJson,
220
+ f.uncertainty_json AS uncertaintyJson, f.input_digest AS inputDigest,
221
+ f.output_digest AS outputDigest, f.expires_at AS expiresAt,
222
+ f.created_at AS createdAt, f.superseded_at AS supersededAt,
223
+ cv.version AS componentVersion, cd.component_key AS componentKey
224
+ FROM findings f
225
+ LEFT JOIN component_versions cv ON cv.id = f.component_version_id
226
+ LEFT JOIN component_definitions cd ON cd.id = cv.definition_id
227
+ WHERE f.report_id = ?1 ORDER BY f.created_at DESC, f.id DESC
228
+ LIMIT 500
229
+ `).bind(reportId).all<Record<string, unknown>>();
230
+ return rows.results.map((row) => ({
231
+ ...row,
232
+ freshness: row.status === "available" && row.expiresAt && String(row.expiresAt) <= timestamp ? "stale" : row.status,
233
+ data: parsed(row.dataJson, {}),
234
+ provenance: parsed(row.provenanceJson, {}),
235
+ uncertainty: parsed(row.uncertaintyJson, []),
236
+ dataJson: undefined,
237
+ provenanceJson: undefined,
238
+ uncertaintyJson: undefined,
239
+ }));
240
+ }
241
+
242
+ export async function listReportActions(db: D1Database, reportId: string): Promise<Record<string, unknown>[]> {
243
+ const [proposals, decisions] = await Promise.all([
244
+ db.prepare(`
245
+ SELECT p.id, 'proposal' AS recordKind, p.run_id AS runId, p.step_run_id AS stepRunId,
246
+ p.decision_id AS decisionId, p.action_code AS actionCode, p.state,
247
+ p.payload_digest AS payloadDigest, p.authority_mode AS authorityMode,
248
+ p.operation_id AS operationId, p.compensates_proposal_id AS compensatesProposalId,
249
+ p.delivery_attempt_count AS attemptCount, p.last_error_code AS errorCode,
250
+ p.delivery_terminal AS terminal, p.created_at AS createdAt, p.updated_at AS updatedAt
251
+ FROM action_proposals p WHERE p.report_id = ?1 ORDER BY p.created_at DESC, p.id DESC
252
+ `).bind(reportId).all<Record<string, unknown>>(),
253
+ db.prepare(`
254
+ SELECT a.id, 'decision_outbox' AS recordKind, a.decision_id AS decisionId,
255
+ a.action_code AS actionCode, a.status AS state, a.idempotency_key AS operationId,
256
+ a.attempt_count AS attemptCount, a.last_error_code AS errorCode,
257
+ a.terminal, a.created_at AS createdAt, a.updated_at AS updatedAt
258
+ FROM decision_action_outbox a WHERE a.report_id = ?1 ORDER BY a.created_at DESC, a.id DESC
259
+ `).bind(reportId).all<Record<string, unknown>>(),
260
+ ]);
261
+ const combined: Record<string, unknown>[] = [...proposals.results, ...decisions.results];
262
+ return combined
263
+ .map<Record<string, unknown>>((row) => ({ ...row, terminal: row.terminal === 1 }))
264
+ .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)) || String(b.id).localeCompare(String(a.id)));
265
+ }
266
+
267
+ type TimelineCursor = { createdAt: string; id: string };
268
+
269
+ export async function loadReportTimeline(
270
+ db: D1Database,
271
+ reportId: string,
272
+ input: { cursor: string | null; cursorSecret: string; limit: number },
273
+ ): Promise<{ events: Record<string, unknown>[]; nextCursor: string | null }> {
274
+ await reportOperationRow(db, reportId);
275
+ const cursorScope = `report-timeline:${reportId}`;
276
+ const cursor = await decodeApiCursor<TimelineCursor>(input.cursorSecret, cursorScope, input.cursor);
277
+ if (cursor && (typeof cursor.createdAt !== "string" || cursor.createdAt.length > 50
278
+ || typeof cursor.id !== "string" || cursor.id.length > 100)) {
279
+ throw new ApiError(400, "cursor_invalid", "cursor is invalid or does not belong to this query.");
280
+ }
281
+ const limit = Math.max(1, Math.min(100, input.limit));
282
+ const cursorCreatedAt = cursor?.createdAt ?? null;
283
+ const cursorId = cursor?.id ?? null;
284
+ const sourceLimit = limit + 1;
285
+ const sources = await Promise.all([
286
+ db.prepare(`SELECT h.id, h.created_at AS createdAt, 'state_transition' AS eventType, h.reason_code AS code, h.actor_type AS actorType, h.actor_id AS actorId, json_object('from_state', h.from_state, 'to_state', h.to_state) AS detailsJson FROM report_state_history h WHERE h.report_id = ?1 AND (?2 IS NULL OR h.created_at < ?2 OR (h.created_at = ?2 AND h.id < ?3)) ORDER BY h.created_at DESC, h.id DESC LIMIT ?4`).bind(reportId, cursorCreatedAt, cursorId, sourceLimit).all<Record<string, unknown>>(),
287
+ db.prepare(`SELECT d.id, d.created_at AS createdAt, 'decision' AS eventType, d.decision_code AS code, d.maker_type AS actorType, d.maker_id AS actorId, json_object('policy_code', d.policy_code, 'policy_version_id', d.policy_version_id) AS detailsJson FROM decisions d WHERE d.report_id = ?1 AND (?2 IS NULL OR d.created_at < ?2 OR (d.created_at = ?2 AND d.id < ?3)) ORDER BY d.created_at DESC, d.id DESC LIMIT ?4`).bind(reportId, cursorCreatedAt, cursorId, sourceLimit).all<Record<string, unknown>>(),
288
+ db.prepare(`SELECT f.id, f.created_at AS createdAt, 'finding' AS eventType, f.finding_type AS code, 'component' AS actorType, COALESCE(f.component_version_id, f.ai_run_id, 'system') AS actorId, json_object('status', f.status, 'summary', f.summary, 'confidence', f.confidence) AS detailsJson FROM findings f WHERE f.report_id = ?1 AND (?2 IS NULL OR f.created_at < ?2 OR (f.created_at = ?2 AND f.id < ?3)) ORDER BY f.created_at DESC, f.id DESC LIMIT ?4`).bind(reportId, cursorCreatedAt, cursorId, sourceLimit).all<Record<string, unknown>>(),
289
+ db.prepare(`SELECT cr.id, cr.created_at AS createdAt, 'component_run' AS eventType, cr.state AS code, 'component' AS actorType, cr.component_version_id AS actorId, json_object('attempt_count', cr.attempt_count, 'error_code', cr.error_code, 'finding_id', cr.finding_id) AS detailsJson FROM component_runs cr WHERE cr.report_id = ?1 AND (?2 IS NULL OR cr.created_at < ?2 OR (cr.created_at = ?2 AND cr.id < ?3)) ORDER BY cr.created_at DESC, cr.id DESC LIMIT ?4`).bind(reportId, cursorCreatedAt, cursorId, sourceLimit).all<Record<string, unknown>>(),
290
+ db.prepare(`SELECT wr.id, wr.created_at AS createdAt, 'workflow_run' AS eventType, wr.state AS code, 'workflow' AS actorType, wr.workflow_version_id AS actorId, json_object('run_kind', wr.run_kind, 'authority_mode', wr.authority_mode, 'error_code', wr.error_code) AS detailsJson FROM workflow_runs wr WHERE wr.report_id = ?1 AND (?2 IS NULL OR wr.created_at < ?2 OR (wr.created_at = ?2 AND wr.id < ?3)) ORDER BY wr.created_at DESC, wr.id DESC LIMIT ?4`).bind(reportId, cursorCreatedAt, cursorId, sourceLimit).all<Record<string, unknown>>(),
291
+ db.prepare(`SELECT m.id, m.created_at AS createdAt, 'message' AS eventType, m.direction AS code, m.sender_type AS actorType, m.sender_id AS actorId, json_object('audience', m.audience, 'automated', m.automated, 'delivery_state', m.delivery_state) AS detailsJson FROM case_messages m WHERE m.report_id = ?1 AND (?2 IS NULL OR m.created_at < ?2 OR (m.created_at = ?2 AND m.id < ?3)) ORDER BY m.created_at DESC, m.id DESC LIMIT ?4`).bind(reportId, cursorCreatedAt, cursorId, sourceLimit).all<Record<string, unknown>>(),
292
+ db.prepare(`SELECT p.id, p.created_at AS createdAt, 'action' AS eventType, p.state AS code, p.created_by_type AS actorType, p.created_by_id AS actorId, json_object('action_code', p.action_code, 'payload_digest', p.payload_digest, 'operation_id', p.operation_id) AS detailsJson FROM action_proposals p WHERE p.report_id = ?1 AND (?2 IS NULL OR p.created_at < ?2 OR (p.created_at = ?2 AND p.id < ?3)) ORDER BY p.created_at DESC, p.id DESC LIMIT ?4`).bind(reportId, cursorCreatedAt, cursorId, sourceLimit).all<Record<string, unknown>>(),
293
+ ]);
294
+ const combined = sources.flatMap((source) => source.results)
295
+ .sort((left, right) => String(right.createdAt).localeCompare(String(left.createdAt)) || String(right.id).localeCompare(String(left.id)));
296
+ const page = combined.slice(0, limit).map<Record<string, unknown>>((row) => ({
297
+ ...row,
298
+ details: parsed(row.detailsJson, {}),
299
+ detailsJson: undefined,
300
+ }));
301
+ const last = page.at(-1);
302
+ return {
303
+ events: page,
304
+ nextCursor: combined.length > limit && last
305
+ ? await encodeApiCursor(input.cursorSecret, cursorScope, { createdAt: String(last.createdAt), id: String(last.id) })
306
+ : null,
307
+ };
308
+ }
309
+
310
+ export async function loadDecisionDetail(db: D1Database, decisionId: string): Promise<Record<string, unknown> | null> {
311
+ const decision = await db.prepare(`
312
+ SELECT d.id, d.report_id AS reportId, r.public_reference AS reportReference,
313
+ d.decision_code AS decisionCode, d.policy_version_id AS policyVersionId,
314
+ d.policy_code AS policyCode, d.rationale, d.evidence_references_json AS evidenceReferencesJson,
315
+ d.maker_type AS makerType, d.maker_id AS makerId, d.queue_version_id AS queueVersionId,
316
+ d.ai_run_id AS aiRunId, d.proposed_action_code AS proposedActionCode,
317
+ d.supersedes_decision_id AS supersedesDecisionId, d.created_at AS createdAt
318
+ FROM decisions d JOIN reports r ON r.id = d.report_id WHERE d.id = ?1 LIMIT 1
319
+ `).bind(decisionId).first<Record<string, unknown>>();
320
+ if (!decision) return null;
321
+ const [decisionActions, proposals, notices, appeal] = await Promise.all([
322
+ db.prepare(`SELECT id, action_code AS actionCode, status, attempt_count AS attemptCount, last_error_code AS errorCode, terminal, created_at AS createdAt, updated_at AS updatedAt FROM decision_action_outbox WHERE decision_id = ?1 ORDER BY created_at, id`)
323
+ .bind(decisionId).all<Record<string, unknown>>(),
324
+ db.prepare(`SELECT id, action_code AS actionCode, state, payload_digest AS payloadDigest, operation_id AS operationId, created_at AS createdAt, updated_at AS updatedAt FROM action_proposals WHERE decision_id = ?1 ORDER BY created_at, id`)
325
+ .bind(decisionId).all<Record<string, unknown>>(),
326
+ db.prepare(`SELECT id, audience, delivery_state AS deliveryState, created_at AS createdAt, updated_at AS updatedAt FROM notices WHERE decision_id = ?1 ORDER BY created_at, id`)
327
+ .bind(decisionId).all<Record<string, unknown>>(),
328
+ db.prepare(`SELECT id, appellant_audience AS appellantAudience, state, submitted_at AS submittedAt, resolved_at AS resolvedAt FROM appeals WHERE decision_id = ?1 ORDER BY submitted_at DESC LIMIT 1`)
329
+ .bind(decisionId).first<Record<string, unknown>>(),
330
+ ]);
331
+ return {
332
+ ...decision,
333
+ evidenceReferences: parsed(decision.evidenceReferencesJson, []),
334
+ evidenceReferencesJson: undefined,
335
+ actions: [
336
+ ...proposals.results.map((row) => ({ ...row, recordKind: "proposal" })),
337
+ ...decisionActions.results.map((row) => ({ ...row, recordKind: "decision_outbox", terminal: row.terminal === 1 })),
338
+ ],
339
+ notices: notices.results,
340
+ appeal: appeal ?? null,
341
+ };
342
+ }