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,886 @@
1
+ import { AUDIT_GENESIS_HASH, canonicalJson, hashAuditEntry } from "./audit";
2
+ import { ApiError } from "./report-http";
3
+ import { analystAvatarColor } from "./report-presence";
4
+ import type { DeliveryJob, OperatorSession, ParticipantAudience, ReportState } from "./report-types";
5
+ import type { AppealDecisionInput, DecisionInput, ReviewerMessageInput } from "./report-workflow-validation";
6
+
7
+ interface WorkflowReportRow {
8
+ id: string;
9
+ public_reference: string;
10
+ state: ReportState;
11
+ queue_id: string | null;
12
+ queue_version_id: string | null;
13
+ policy_version_id: string | null;
14
+ target_reference: string;
15
+ owner_reference: string | null;
16
+ reporter_reference: string | null;
17
+ }
18
+
19
+ interface ClaimRow {
20
+ report_id: string;
21
+ reviewer_id: string;
22
+ claimed_at: string;
23
+ expires_at: string;
24
+ version: number;
25
+ }
26
+
27
+ function now(): string {
28
+ return new Date().toISOString();
29
+ }
30
+
31
+ function addMinutes(timestamp: string, minutes: number): string {
32
+ return new Date(Date.parse(timestamp) + minutes * 60_000).toISOString();
33
+ }
34
+
35
+ function parsedJson(value: unknown, fallback: unknown): unknown {
36
+ if (typeof value !== "string") return fallback;
37
+ try { return JSON.parse(value) as unknown; } catch { return fallback; }
38
+ }
39
+
40
+ function auditEvent(
41
+ db: D1Database,
42
+ input: {
43
+ idempotencyKey: string;
44
+ action: string;
45
+ actorType: string;
46
+ actorId: string;
47
+ targetType: string;
48
+ targetId: string;
49
+ details: unknown;
50
+ createdAt: string;
51
+ },
52
+ ): D1PreparedStatement {
53
+ return db.prepare(`
54
+ INSERT OR IGNORE INTO audit_events (
55
+ id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
56
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
57
+ `).bind(
58
+ crypto.randomUUID(), input.idempotencyKey, input.action, input.actorType, input.actorId,
59
+ input.targetType, input.targetId, canonicalJson(input.details), input.createdAt,
60
+ );
61
+ }
62
+
63
+ async function workflowReport(db: D1Database, reportId: string): Promise<WorkflowReportRow | null> {
64
+ return db.prepare(`
65
+ SELECT r.id, r.public_reference, r.state, r.queue_id, r.queue_version_id, r.policy_version_id,
66
+ t.target_reference, t.owner_reference, p.reference AS reporter_reference
67
+ FROM reports r
68
+ JOIN report_targets t ON t.report_id = r.id
69
+ JOIN report_participants p ON p.report_id = r.id AND p.audience = 'reporter'
70
+ WHERE r.id = ?1
71
+ LIMIT 1
72
+ `).bind(reportId).first<WorkflowReportRow>();
73
+ }
74
+
75
+ async function requireActiveClaim(db: D1Database, reportId: string, reviewerId: string, at: string): Promise<ClaimRow> {
76
+ const claim = await db.prepare(`
77
+ SELECT report_id, reviewer_id, claimed_at, expires_at, version
78
+ FROM review_claims
79
+ WHERE report_id = ?1
80
+ LIMIT 1
81
+ `).bind(reportId).first<ClaimRow>();
82
+ if (!claim || claim.reviewer_id !== reviewerId || claim.expires_at <= at) {
83
+ throw new ApiError(409, "active_claim_required", "Claim this report before changing it.");
84
+ }
85
+ return claim;
86
+ }
87
+
88
+ export async function claimReport(
89
+ db: D1Database,
90
+ reportId: string,
91
+ session: OperatorSession,
92
+ claimTtlMinutes: number,
93
+ ): Promise<{ reviewerId: string; claimedAt: string; expiresAt: string; version: number }> {
94
+ const report = await workflowReport(db, reportId);
95
+ if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
96
+ if (!["human_review", "awaiting_reporter", "human_review_requested"].includes(report.state)) {
97
+ throw new ApiError(409, "report_not_claimable", "The report is not in a claimable state.");
98
+ }
99
+ const claimedAt = now();
100
+ const expiresAt = addMinutes(claimedAt, Math.max(1, Math.min(1_440, claimTtlMinutes)));
101
+ await db.prepare(`
102
+ INSERT INTO review_claims (report_id, reviewer_id, claimed_at, expires_at, version)
103
+ VALUES (?1, ?2, ?3, ?4, 1)
104
+ ON CONFLICT(report_id) DO UPDATE SET
105
+ reviewer_id = excluded.reviewer_id,
106
+ claimed_at = excluded.claimed_at,
107
+ expires_at = excluded.expires_at,
108
+ version = review_claims.version + 1
109
+ WHERE review_claims.expires_at <= ?3 OR review_claims.reviewer_id = ?2
110
+ `).bind(reportId, session.actor.id, claimedAt, expiresAt).run();
111
+ const claim = await db.prepare(`
112
+ SELECT report_id, reviewer_id, claimed_at, expires_at, version
113
+ FROM review_claims WHERE report_id = ?1 LIMIT 1
114
+ `).bind(reportId).first<ClaimRow>();
115
+ if (!claim || claim.reviewer_id !== session.actor.id) {
116
+ throw new ApiError(409, "report_already_claimed", "Another reviewer currently holds this report.", {
117
+ expires_at: claim?.expires_at,
118
+ });
119
+ }
120
+ await db.batch([
121
+ auditEvent(db, {
122
+ idempotencyKey: `claim:${reportId}:${claim.version}`,
123
+ action: "report.claimed",
124
+ actorType: session.actor.type,
125
+ actorId: session.actor.id,
126
+ targetType: "report",
127
+ targetId: reportId,
128
+ details: { expiresAt: claim.expires_at, version: claim.version },
129
+ createdAt: claimedAt,
130
+ }),
131
+ ]);
132
+ return { reviewerId: claim.reviewer_id, claimedAt: claim.claimed_at, expiresAt: claim.expires_at, version: claim.version };
133
+ }
134
+
135
+ export async function releaseReportClaim(db: D1Database, reportId: string, session: OperatorSession): Promise<void> {
136
+ const claim = await db.prepare(`SELECT reviewer_id, version FROM review_claims WHERE report_id = ?1 LIMIT 1`)
137
+ .bind(reportId).first<{ reviewer_id: string; version: number }>();
138
+ if (!claim) return;
139
+ if (claim.reviewer_id !== session.actor.id && !session.permissions.manageConfiguration) {
140
+ throw new ApiError(409, "claim_owned_by_another_reviewer", "Only the current reviewer or an administrator can release this claim.");
141
+ }
142
+ const createdAt = now();
143
+ await db.batch([
144
+ db.prepare(`DELETE FROM review_claims WHERE report_id = ?1`).bind(reportId),
145
+ auditEvent(db, {
146
+ idempotencyKey: `claim-release:${reportId}:${claim.version}`,
147
+ action: "report.claim_released",
148
+ actorType: session.actor.type,
149
+ actorId: session.actor.id,
150
+ targetType: "report",
151
+ targetId: reportId,
152
+ details: { priorReviewerId: claim.reviewer_id, version: claim.version },
153
+ createdAt,
154
+ }),
155
+ ]);
156
+ }
157
+
158
+ export async function loadReportDetail(db: D1Database, reportId: string): Promise<Record<string, unknown> | null> {
159
+ const report = await db.prepare(`
160
+ SELECT r.id, r.public_reference AS reference, r.source, r.state, r.priority, r.reason_code AS reasonCode,
161
+ r.locale, r.queue_id AS queueId, r.submitted_at AS submittedAt, r.received_at AS receivedAt,
162
+ r.updated_at AS updatedAt, r.due_at AS dueAt, r.resolved_at AS resolvedAt,
163
+ r.policy_version_id AS policyVersionId, policy.title AS policyTitle,
164
+ policy.rules_json AS policyRulesJson, r.queue_version_id AS queuePolicyVersionId,
165
+ qv.management_mode AS queueManagementMode, qv.reviewer_instructions AS reviewerInstructions,
166
+ t.target_type AS targetType, t.target_reference AS targetReference,
167
+ t.owner_reference AS ownerReference, t.customer_url AS customerUrl,
168
+ p.reference AS reporterReference
169
+ FROM reports r
170
+ JOIN report_targets t ON t.report_id = r.id
171
+ JOIN report_participants p ON p.report_id = r.id AND p.audience = 'reporter'
172
+ LEFT JOIN policy_versions policy ON policy.id = r.policy_version_id
173
+ LEFT JOIN queue_versions qv ON qv.id = r.queue_version_id
174
+ WHERE r.id = ?1
175
+ LIMIT 1
176
+ `).bind(reportId).first<Record<string, unknown>>();
177
+ if (!report) return null;
178
+ const [answers, evidence, messages, decisions, actions, notices, aiRuns, history, claim, findings] = await Promise.all([
179
+ db.prepare(`SELECT field_key AS fieldKey, value_json AS valueJson FROM report_answers WHERE report_id = ?1 ORDER BY field_key`).bind(reportId).all<Record<string, unknown>>(),
180
+ db.prepare(`SELECT id, evidence_kind AS kind, reference, availability, created_at AS createdAt FROM evidence_references WHERE report_id = ?1 ORDER BY created_at, id`).bind(reportId).all<Record<string, unknown>>(),
181
+ db.prepare(`SELECT id, audience, direction, sender_type AS senderType, sender_id AS senderId, body, automated, delivery_state AS deliveryState, created_at AS createdAt FROM case_messages WHERE report_id = ?1 ORDER BY created_at, id LIMIT 500`).bind(reportId).all<Record<string, unknown>>(),
182
+ db.prepare(`SELECT id, decision_code AS decisionCode, policy_code AS policyCode, rationale, evidence_references_json AS evidenceReferencesJson, maker_type AS makerType, maker_id AS makerId, proposed_action_code AS proposedActionCode, created_at AS createdAt FROM decisions WHERE report_id = ?1 ORDER BY created_at DESC, id DESC`).bind(reportId).all<Record<string, unknown>>(),
183
+ db.prepare(`
184
+ SELECT id, decision_id AS decisionId, action_code AS actionCode, status, attempt_count AS attemptCount,
185
+ terminal, last_error_code AS errorCode, updated_at AS updatedAt
186
+ FROM decision_action_outbox WHERE report_id = ?1 ORDER BY created_at DESC, id DESC
187
+ `).bind(reportId).all<Record<string, unknown>>(),
188
+ db.prepare(`
189
+ SELECT id, decision_id AS decisionId, audience, delivery_state AS deliveryState, updated_at AS updatedAt
190
+ FROM notices WHERE report_id = ?1 ORDER BY created_at DESC, id DESC
191
+ `).bind(reportId).all<Record<string, unknown>>(),
192
+ db.prepare(`
193
+ SELECT ar.id, ar.status, ar.mode, ar.model, ar.prompt_version AS promptVersion,
194
+ ar.config_version_id AS configVersionId, ar.error_code AS errorCode,
195
+ ar.input_units AS inputUnits, ar.output_units AS outputUnits,
196
+ ar.estimated_cost_microusd AS estimatedCostMicrousd, ar.started_at AS startedAt,
197
+ ar.finished_at AS finishedAt, ai.outcome, ai.policy_code AS policyCode,
198
+ ai.summary, ai.confidence, ai.uncertain, ai.validation_state AS validationState,
199
+ ai.validation_errors_json AS validationErrorsJson
200
+ FROM ai_runs ar LEFT JOIN ai_results ai ON ai.ai_run_id = ar.id
201
+ WHERE ar.report_id = ?1 ORDER BY ar.started_at DESC, ar.id DESC
202
+ `).bind(reportId).all<Record<string, unknown>>(),
203
+ db.prepare(`SELECT id, from_state AS fromState, to_state AS toState, reason_code AS reasonCode, actor_type AS actorType, actor_id AS actorId, created_at AS createdAt FROM report_state_history WHERE report_id = ?1 ORDER BY created_at, id`).bind(reportId).all<Record<string, unknown>>(),
204
+ db.prepare(`
205
+ SELECT c.reviewer_id AS reviewerId, c.claimed_at AS claimedAt, c.expires_at AS expiresAt, c.version,
206
+ p.display_name AS analystDisplayName, p.avatar_url AS analystAvatarUrl,
207
+ p.avatar_color AS analystAvatarColor
208
+ FROM review_claims c
209
+ LEFT JOIN analyst_profiles p ON p.actor_id = c.reviewer_id
210
+ WHERE c.report_id = ?1 AND c.expires_at > ?2
211
+ LIMIT 1
212
+ `).bind(reportId, now()).first<Record<string, unknown>>(),
213
+ db.prepare(`
214
+ SELECT f.id, f.run_id AS runId, f.step_run_id AS stepRunId,
215
+ f.component_version_id AS componentVersionId, f.ai_run_id AS aiRunId,
216
+ f.finding_type AS findingType, f.subject_type AS subjectType,
217
+ f.subject_reference AS subjectReference, f.status, f.confidence, f.summary,
218
+ f.data_json AS dataJson, f.provenance_json AS provenanceJson,
219
+ f.uncertainty_json AS uncertaintyJson, f.input_digest AS inputDigest,
220
+ f.output_digest AS outputDigest, f.expires_at AS expiresAt,
221
+ f.created_at AS createdAt, f.superseded_at AS supersededAt,
222
+ cv.version AS componentVersion, cd.component_key AS componentKey
223
+ FROM findings f
224
+ LEFT JOIN component_versions cv ON cv.id = f.component_version_id
225
+ LEFT JOIN component_definitions cd ON cd.id = cv.definition_id
226
+ WHERE f.report_id = ?1 ORDER BY f.created_at DESC, f.id DESC
227
+ LIMIT 500
228
+ `).bind(reportId).all<Record<string, unknown>>(),
229
+ ]);
230
+ return {
231
+ ...report,
232
+ policyRules: (() => {
233
+ try {
234
+ const value: unknown = JSON.parse(String(report.policyRulesJson ?? "[]"));
235
+ return Array.isArray(value) ? value : [];
236
+ } catch { return []; }
237
+ })(),
238
+ policyRulesJson: undefined,
239
+ answers: answers.results.map((row) => ({ fieldKey: row.fieldKey, value: JSON.parse(String(row.valueJson)) })),
240
+ evidence: evidence.results,
241
+ messages: messages.results.map((row) => ({ ...row, automated: row.automated === 1 })),
242
+ decisions: decisions.results.map((row) => ({
243
+ ...row,
244
+ evidenceReferences: JSON.parse(String(row.evidenceReferencesJson)),
245
+ evidenceReferencesJson: undefined,
246
+ })),
247
+ actions: actions.results.map((row) => ({ ...row, terminal: row.terminal === 1 })),
248
+ notices: notices.results,
249
+ aiRuns: aiRuns.results.map((row) => ({
250
+ ...row,
251
+ uncertain: row.uncertain === 1,
252
+ validationErrors: row.validationErrorsJson ? JSON.parse(String(row.validationErrorsJson)) : [],
253
+ validationErrorsJson: undefined,
254
+ })),
255
+ history: history.results,
256
+ findings: findings.results.map((row) => ({
257
+ ...row,
258
+ freshness: row.status === "available" && row.expiresAt && String(row.expiresAt) <= now() ? "stale" : row.status,
259
+ data: parsedJson(row.dataJson, {}),
260
+ provenance: parsedJson(row.provenanceJson, {}),
261
+ uncertainty: parsedJson(row.uncertaintyJson, []),
262
+ dataJson: undefined,
263
+ provenanceJson: undefined,
264
+ uncertaintyJson: undefined,
265
+ })),
266
+ claim: claim ? {
267
+ reviewerId: claim.reviewerId,
268
+ claimedAt: claim.claimedAt,
269
+ expiresAt: claim.expiresAt,
270
+ version: claim.version,
271
+ analyst: {
272
+ displayName: claim.analystDisplayName ?? claim.reviewerId,
273
+ avatarUrl: claim.analystAvatarUrl ?? null,
274
+ avatarColor: claim.analystAvatarColor ?? analystAvatarColor(String(claim.reviewerId)),
275
+ },
276
+ } : null,
277
+ };
278
+ }
279
+
280
+ export interface WorkflowDeliveryOptions {
281
+ notificationEnabled: boolean;
282
+ }
283
+
284
+ export async function addReviewerMessage(
285
+ db: D1Database,
286
+ reportId: string,
287
+ session: OperatorSession,
288
+ input: ReviewerMessageInput,
289
+ options: WorkflowDeliveryOptions,
290
+ ): Promise<{ messageId: string; jobs: DeliveryJob[]; idempotentReplay: boolean }> {
291
+ const existing = await db.prepare(`
292
+ SELECT id FROM case_messages WHERE report_id = ?1 AND idempotency_key = ?2 LIMIT 1
293
+ `).bind(reportId, input.idempotencyKey).first<{ id: string }>();
294
+ if (existing) return { messageId: existing.id, jobs: [], idempotentReplay: true };
295
+ const createdAt = now();
296
+ await requireActiveClaim(db, reportId, session.actor.id, createdAt);
297
+ const report = await workflowReport(db, reportId);
298
+ if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
299
+ if (["closed", "resolved_by_human", "resolved_by_ai", "appealed"].includes(report.state)) {
300
+ throw new ApiError(409, "report_not_messageable", "The report is no longer open for reviewer messages.");
301
+ }
302
+ const messageId = crypto.randomUUID();
303
+ const noticeId = crypto.randomUUID();
304
+ const notificationId = crypto.randomUUID();
305
+ const nextState: ReportState = input.awaitReporter ? "awaiting_reporter" : report.state;
306
+ const jobs: DeliveryJob[] = options.notificationEnabled
307
+ ? [{ version: 1, jobId: `notification:${notificationId}`, type: "deliver_notification", outboxId: notificationId }]
308
+ : [];
309
+ const statements: D1PreparedStatement[] = [
310
+ db.prepare(`
311
+ INSERT INTO case_messages (
312
+ id, report_id, audience, direction, sender_type, sender_id, body, template_version_id,
313
+ automated, delivery_state, created_at, idempotency_key
314
+ ) VALUES (?1, ?2, 'reporter', 'outbound', 'human', ?3, ?4, 'reporter-follow-up-v1', 0, ?5, ?6, ?7)
315
+ `).bind(messageId, reportId, session.actor.id, input.body, options.notificationEnabled ? "queued" : "suppressed", createdAt, input.idempotencyKey),
316
+ db.prepare(`
317
+ INSERT INTO notices (
318
+ id, report_id, audience, template_version_id, rendered_body, delivery_state,
319
+ created_at, updated_at, idempotency_key, message_id
320
+ ) VALUES (?1, ?2, 'reporter', 'reporter-follow-up-v1', ?3, ?4, ?5, ?5, ?6, ?7)
321
+ `).bind(noticeId, reportId, input.body, options.notificationEnabled ? "queued" : "suppressed", createdAt, input.idempotencyKey, messageId),
322
+ auditEvent(db, {
323
+ idempotencyKey: `reviewer-message:${reportId}:${input.idempotencyKey}`,
324
+ action: "report.reporter_message_created",
325
+ actorType: session.actor.type,
326
+ actorId: session.actor.id,
327
+ targetType: "report",
328
+ targetId: reportId,
329
+ details: { messageId, audience: "reporter", nextState },
330
+ createdAt,
331
+ }),
332
+ ];
333
+ if (nextState !== report.state) {
334
+ statements.push(
335
+ db.prepare(`UPDATE reports SET state = ?2, updated_at = ?3 WHERE id = ?1`).bind(reportId, nextState, createdAt),
336
+ db.prepare(`
337
+ INSERT INTO report_state_history (id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at)
338
+ VALUES (?1, ?2, ?3, ?4, 'reviewer_requested_information', ?5, ?6, ?7)
339
+ `).bind(crypto.randomUUID(), reportId, report.state, nextState, session.actor.type, session.actor.id, createdAt),
340
+ );
341
+ }
342
+ if (options.notificationEnabled) {
343
+ statements.push(db.prepare(`
344
+ INSERT INTO notification_outbox (
345
+ id, notice_id, idempotency_key, channel, payload_json, status, next_attempt_at, created_at, updated_at
346
+ ) VALUES (?1, ?2, ?3, 'customer_webhook', ?4, 'pending', ?5, ?5, ?5)
347
+ `).bind(notificationId, noticeId, `notice:${noticeId}`, canonicalJson({
348
+ version: 1,
349
+ event: "report.message.available",
350
+ notification_id: notificationId,
351
+ report_reference: report.public_reference,
352
+ ...(report.reporter_reference ? { recipient_reference: report.reporter_reference } : {}),
353
+ audience: "reporter",
354
+ message: input.body,
355
+ }), createdAt));
356
+ }
357
+ await db.batch(statements);
358
+ return { messageId, jobs, idempotentReplay: false };
359
+ }
360
+
361
+
362
+ export interface DecisionOptions extends WorkflowDeliveryOptions {
363
+ allowedActionCodes: Set<string>;
364
+ actionEnabled: boolean;
365
+ }
366
+
367
+ export async function recordHumanDecision(
368
+ db: D1Database,
369
+ reportId: string,
370
+ session: OperatorSession,
371
+ input: DecisionInput,
372
+ options: DecisionOptions,
373
+ ): Promise<{ decisionId: string; jobs: DeliveryJob[]; idempotentReplay: boolean }> {
374
+ const existing = await db.prepare(`
375
+ SELECT id FROM decisions WHERE report_id = ?1 AND idempotency_key = ?2 LIMIT 1
376
+ `).bind(reportId, input.idempotencyKey).first<{ id: string }>();
377
+ if (existing) return { decisionId: existing.id, jobs: [], idempotentReplay: true };
378
+ const createdAt = now();
379
+ await requireActiveClaim(db, reportId, session.actor.id, createdAt);
380
+ const report = await workflowReport(db, reportId);
381
+ if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
382
+ if (!["human_review", "awaiting_reporter", "human_review_requested"].includes(report.state)) {
383
+ throw new ApiError(409, "report_not_decidable", "The report is not awaiting a human decision.");
384
+ }
385
+ if (!report.policy_version_id) throw new ApiError(409, "policy_version_missing", "The report has no selected policy version.");
386
+ const policy = await db.prepare(`SELECT rules_json FROM policy_versions WHERE id = ?1 AND published_at IS NOT NULL LIMIT 1`)
387
+ .bind(report.policy_version_id).first<{ rules_json: string }>();
388
+ const policyCodes = new Set<unknown>();
389
+ try {
390
+ const rules: unknown = JSON.parse(policy?.rules_json ?? "[]");
391
+ if (Array.isArray(rules)) rules.forEach((rule) => {
392
+ if (rule && typeof rule === "object" && !Array.isArray(rule)) policyCodes.add((rule as Record<string, unknown>).code);
393
+ });
394
+ } catch {
395
+ throw new ApiError(409, "policy_version_invalid", "The selected policy version cannot be used.");
396
+ }
397
+ if (!policyCodes.has(input.policyCode)) throw new ApiError(400, "policy_code_not_found", "The selected policy code is not in the report policy version.");
398
+ const evidence = await db.prepare(`SELECT reference FROM evidence_references WHERE report_id = ?1`)
399
+ .bind(reportId).all<{ reference: string }>();
400
+ const availableEvidence = new Set(evidence.results.map((item) => item.reference));
401
+ if (input.evidenceReferences.some((reference) => !availableEvidence.has(reference))) {
402
+ throw new ApiError(400, "evidence_reference_not_found", "Every considered evidence reference must belong to this report.");
403
+ }
404
+ if (input.actionCode && (!options.actionEnabled || !options.allowedActionCodes.has(input.actionCode))) {
405
+ throw new ApiError(400, "action_not_allowed", "The requested application action is not allowlisted for this deployment.");
406
+ }
407
+ if (input.affectedUserNotice && !report.owner_reference) {
408
+ throw new ApiError(400, "affected_user_reference_missing", "The report target has no affected-user reference for this notice.");
409
+ }
410
+ const superseded = report.state === "human_review_requested"
411
+ ? await db.prepare(`SELECT id FROM decisions WHERE report_id = ?1 ORDER BY created_at DESC, id DESC LIMIT 1`)
412
+ .bind(reportId).first<{ id: string }>()
413
+ : null;
414
+
415
+ const decisionId = crypto.randomUUID();
416
+ const reporterMessageId = crypto.randomUUID();
417
+ const reporterNoticeId = crypto.randomUUID();
418
+ const reporterNotificationId = crypto.randomUUID();
419
+ const actionId = input.actionCode ? crypto.randomUUID() : null;
420
+ const jobs: DeliveryJob[] = [];
421
+ const statements: D1PreparedStatement[] = [
422
+ db.prepare(`
423
+ INSERT INTO decisions (
424
+ id, report_id, decision_code, policy_version_id, policy_code, rationale,
425
+ evidence_references_json, maker_type, maker_id, queue_version_id, proposed_action_code, created_at, idempotency_key,
426
+ supersedes_decision_id
427
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'human', ?8, ?9, ?10, ?11, ?12, ?13)
428
+ `).bind(
429
+ decisionId, reportId, input.decisionCode, report.policy_version_id, input.policyCode, input.rationale,
430
+ canonicalJson(input.evidenceReferences), session.actor.id, report.queue_version_id, input.actionCode ?? null, createdAt, input.idempotencyKey,
431
+ superseded?.id ?? null,
432
+ ),
433
+ db.prepare(`
434
+ INSERT INTO case_messages (
435
+ id, report_id, audience, direction, sender_type, sender_id, body, template_version_id,
436
+ automated, delivery_state, created_at, idempotency_key
437
+ ) VALUES (?1, ?2, 'reporter', 'outbound', 'human', ?3, ?4, 'reporter-outcome-v1', 0, ?5, ?6, ?7)
438
+ `).bind(reporterMessageId, reportId, session.actor.id, input.reporterNotice, options.notificationEnabled ? "queued" : "suppressed", createdAt, `decision:${decisionId}:reporter-message`),
439
+ db.prepare(`
440
+ INSERT INTO notices (
441
+ id, report_id, decision_id, audience, template_version_id, rendered_body,
442
+ delivery_state, created_at, updated_at, idempotency_key, message_id
443
+ ) VALUES (?1, ?2, ?3, 'reporter', 'reporter-outcome-v1', ?4, ?5, ?6, ?6, ?7, ?8)
444
+ `).bind(reporterNoticeId, reportId, decisionId, input.reporterNotice, options.notificationEnabled ? "queued" : "suppressed", createdAt, `decision:${decisionId}:reporter-notice`, reporterMessageId),
445
+ db.prepare(`
446
+ UPDATE reports SET state = 'resolved_by_human', resolved_at = ?2, updated_at = ?2 WHERE id = ?1
447
+ `).bind(reportId, createdAt),
448
+ db.prepare(`
449
+ INSERT INTO report_state_history (id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at)
450
+ VALUES (?1, ?2, ?3, 'resolved_by_human', ?4, ?5, ?6, ?7)
451
+ `).bind(crypto.randomUUID(), reportId, report.state, input.decisionCode, session.actor.type, session.actor.id, createdAt),
452
+ db.prepare(`DELETE FROM review_claims WHERE report_id = ?1`).bind(reportId),
453
+ auditEvent(db, {
454
+ idempotencyKey: `decision:${reportId}:${input.idempotencyKey}`,
455
+ action: "report.human_decision_recorded",
456
+ actorType: session.actor.type,
457
+ actorId: session.actor.id,
458
+ targetType: "decision",
459
+ targetId: decisionId,
460
+ details: {
461
+ reportId, decisionCode: input.decisionCode, policyVersionId: report.policy_version_id,
462
+ policyCode: input.policyCode, queueVersionId: report.queue_version_id, evidenceReferences: input.evidenceReferences,
463
+ actionCode: input.actionCode ?? null,
464
+ },
465
+ createdAt,
466
+ }),
467
+ db.prepare(`
468
+ INSERT OR IGNORE INTO ai_quality_samples (id, ai_run_id, report_id, sample_reason, state, selected_at)
469
+ SELECT ?2, ar.id, ?1,
470
+ CASE ar.mode WHEN 'shadow' THEN 'shadow_human_comparison' ELSE 'assist_human_comparison' END,
471
+ 'pending', ?3
472
+ FROM ai_runs ar
473
+ WHERE ar.report_id = ?1 AND ar.mode IN ('shadow', 'assist') AND ar.status = 'succeeded'
474
+ ORDER BY ar.started_at DESC, ar.id DESC LIMIT 1
475
+ `).bind(reportId, crypto.randomUUID(), createdAt),
476
+ ];
477
+
478
+ if (options.notificationEnabled) {
479
+ statements.push(db.prepare(`
480
+ INSERT INTO notification_outbox (
481
+ id, notice_id, idempotency_key, channel, payload_json, status, next_attempt_at, created_at, updated_at
482
+ ) VALUES (?1, ?2, ?3, 'customer_webhook', ?4, 'pending', ?5, ?5, ?5)
483
+ `).bind(reporterNotificationId, reporterNoticeId, `notice:${reporterNoticeId}`, canonicalJson({
484
+ version: 1,
485
+ event: "report.outcome.available",
486
+ notification_id: reporterNotificationId,
487
+ report_reference: report.public_reference,
488
+ ...(report.reporter_reference ? { recipient_reference: report.reporter_reference } : {}),
489
+ audience: "reporter",
490
+ message: input.reporterNotice,
491
+ }), createdAt));
492
+ jobs.push({ version: 1, jobId: `notification:${reporterNotificationId}`, type: "deliver_notification", outboxId: reporterNotificationId });
493
+ }
494
+
495
+ if (input.affectedUserNotice && report.owner_reference) {
496
+ const affectedMessageId = crypto.randomUUID();
497
+ const affectedNoticeId = crypto.randomUUID();
498
+ const affectedNotificationId = crypto.randomUUID();
499
+ statements.push(
500
+ db.prepare(`
501
+ INSERT OR IGNORE INTO report_participants (id, report_id, audience, reference, contact_mode, created_at)
502
+ VALUES (?1, ?2, 'affected_user', ?3, 'customer_notification', ?4)
503
+ `).bind(crypto.randomUUID(), reportId, report.owner_reference, createdAt),
504
+ db.prepare(`
505
+ INSERT INTO case_messages (
506
+ id, report_id, audience, direction, sender_type, sender_id, body, template_version_id,
507
+ automated, delivery_state, created_at, idempotency_key
508
+ ) VALUES (?1, ?2, 'affected_user', 'outbound', 'human', ?3, ?4, 'affected-user-notice-v1', 0, ?5, ?6, ?7)
509
+ `).bind(affectedMessageId, reportId, session.actor.id, input.affectedUserNotice, options.notificationEnabled ? "queued" : "suppressed", createdAt, `decision:${decisionId}:affected-message`),
510
+ db.prepare(`
511
+ INSERT INTO notices (
512
+ id, report_id, decision_id, audience, template_version_id, rendered_body,
513
+ delivery_state, created_at, updated_at, idempotency_key, message_id
514
+ ) VALUES (?1, ?2, ?3, 'affected_user', 'affected-user-notice-v1', ?4, ?5, ?6, ?6, ?7, ?8)
515
+ `).bind(affectedNoticeId, reportId, decisionId, input.affectedUserNotice, options.notificationEnabled ? "queued" : "suppressed", createdAt, `decision:${decisionId}:affected-notice`, affectedMessageId),
516
+ );
517
+ if (options.notificationEnabled) {
518
+ statements.push(db.prepare(`
519
+ INSERT INTO notification_outbox (
520
+ id, notice_id, idempotency_key, channel, payload_json, status, next_attempt_at, created_at, updated_at
521
+ ) VALUES (?1, ?2, ?3, 'customer_webhook', ?4, 'pending', ?5, ?5, ?5)
522
+ `).bind(affectedNotificationId, affectedNoticeId, `notice:${affectedNoticeId}`, canonicalJson({
523
+ version: 1,
524
+ event: "affected_user.decision_notice.available",
525
+ notification_id: affectedNotificationId,
526
+ report_reference: report.public_reference,
527
+ recipient_reference: report.owner_reference,
528
+ audience: "affected_user",
529
+ message: input.affectedUserNotice,
530
+ }), createdAt));
531
+ jobs.push({ version: 1, jobId: `notification:${affectedNotificationId}`, type: "deliver_notification", outboxId: affectedNotificationId });
532
+ }
533
+ }
534
+
535
+ if (input.actionCode && actionId) {
536
+ statements.push(db.prepare(`
537
+ INSERT INTO decision_action_outbox (
538
+ id, report_id, decision_id, idempotency_key, action_code, target_reference,
539
+ payload_json, status, next_attempt_at, created_at, updated_at
540
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', ?8, ?8, ?8)
541
+ `).bind(actionId, reportId, decisionId, `action:${decisionId}:${input.actionCode}`, input.actionCode, report.target_reference, canonicalJson({
542
+ version: 1,
543
+ action_id: actionId,
544
+ idempotency_key: `action:${decisionId}:${input.actionCode}`,
545
+ report_id: reportId,
546
+ report_reference: report.public_reference,
547
+ decision_id: decisionId,
548
+ action_code: input.actionCode,
549
+ target_reference: report.target_reference,
550
+ policy_version_id: report.policy_version_id,
551
+ policy_code: input.policyCode,
552
+ }), createdAt));
553
+ jobs.push({ version: 1, jobId: `action:${actionId}`, type: "deliver_action", outboxId: actionId });
554
+ }
555
+
556
+ await db.batch(statements);
557
+ return { decisionId, jobs, idempotentReplay: false };
558
+ }
559
+
560
+
561
+ export async function listAppeals(db: D1Database, limit: number, allowedQueueIds?: string[] | null): Promise<Array<Record<string, unknown>>> {
562
+ const result = await db.prepare(`
563
+ SELECT a.id, a.report_id AS reportId, r.public_reference AS reportReference,
564
+ a.decision_id AS decisionId, a.appellant_audience AS appellantAudience,
565
+ a.reason, a.state, a.submitted_at AS submittedAt,
566
+ c.reviewer_id AS reviewerId, c.expires_at AS claimExpiresAt
567
+ FROM appeals a
568
+ JOIN reports r ON r.id = a.report_id
569
+ LEFT JOIN appeal_claims c ON c.appeal_id = a.id
570
+ WHERE (?2 = 1 OR r.queue_id IN (SELECT value FROM json_each(?3)))
571
+ ORDER BY a.submitted_at ASC, a.id ASC
572
+ LIMIT ?1
573
+ `).bind(
574
+ Math.max(1, Math.min(100, limit)),
575
+ allowedQueueIds === null || allowedQueueIds === undefined ? 1 : 0,
576
+ canonicalJson(allowedQueueIds ?? []),
577
+ ).all<Record<string, unknown>>();
578
+ return result.results;
579
+ }
580
+
581
+ export async function loadAppealDetail(db: D1Database, appealId: string): Promise<Record<string, unknown> | null> {
582
+ const appeal = await db.prepare(`
583
+ SELECT a.id, a.report_id AS reportId, r.public_reference AS reportReference,
584
+ a.decision_id AS decisionId, a.appellant_audience AS appellantAudience,
585
+ a.reason, a.state, a.queue_id AS queueId, a.submitted_at AS submittedAt,
586
+ a.resolved_at AS resolvedAt, c.reviewer_id AS reviewerId,
587
+ c.claimed_at AS claimedAt, c.expires_at AS claimExpiresAt, c.version AS claimVersion,
588
+ d.decision_code AS originalDecisionCode, d.policy_code AS originalPolicyCode,
589
+ d.rationale AS originalRationale, d.maker_type AS originalMakerType,
590
+ d.maker_id AS originalMakerId, d.created_at AS originalDecisionAt
591
+ FROM appeals a JOIN reports r ON r.id = a.report_id
592
+ JOIN decisions d ON d.id = a.decision_id
593
+ LEFT JOIN appeal_claims c ON c.appeal_id = a.id AND c.expires_at > ?2
594
+ WHERE a.id = ?1 LIMIT 1
595
+ `).bind(appealId, now()).first<Record<string, unknown>>();
596
+ if (!appeal) return null;
597
+ const decisions = await db.prepare(`
598
+ SELECT id, outcome, rationale, reviewer_id AS reviewerId,
599
+ corrective_action_id AS correctiveActionId, created_at AS createdAt
600
+ FROM appeal_decisions WHERE appeal_id = ?1 ORDER BY created_at, id
601
+ `).bind(appealId).all<Record<string, unknown>>();
602
+ return { ...appeal, decisions: decisions.results };
603
+ }
604
+
605
+ export async function releaseAppealClaim(
606
+ db: D1Database,
607
+ appealId: string,
608
+ session: OperatorSession,
609
+ ): Promise<void> {
610
+ const claim = await db.prepare(`SELECT reviewer_id AS reviewerId, version FROM appeal_claims WHERE appeal_id = ?1 LIMIT 1`)
611
+ .bind(appealId).first<{ reviewerId: string; version: number }>();
612
+ if (!claim) return;
613
+ if (claim.reviewerId !== session.actor.id && !session.permissions.assignTasks) {
614
+ throw new ApiError(409, "appeal_claim_owned_by_another_reviewer", "Only the current reviewer or an assignment manager can release this claim.");
615
+ }
616
+ const createdAt = now();
617
+ await db.batch([
618
+ db.prepare(`DELETE FROM appeal_claims WHERE appeal_id = ?1`).bind(appealId),
619
+ db.prepare(`UPDATE appeals SET state = 'submitted' WHERE id = ?1 AND state = 'human_review'`).bind(appealId),
620
+ auditEvent(db, {
621
+ idempotencyKey: `appeal-claim-release:${appealId}:${claim.version}`,
622
+ action: "appeal.claim_released",
623
+ actorType: session.actor.type,
624
+ actorId: session.actor.id,
625
+ targetType: "appeal",
626
+ targetId: appealId,
627
+ details: { priorReviewerId: claim.reviewerId, version: claim.version },
628
+ createdAt,
629
+ }),
630
+ ]);
631
+ }
632
+
633
+ export async function claimAppeal(
634
+ db: D1Database,
635
+ appealId: string,
636
+ session: OperatorSession,
637
+ claimTtlMinutes: number,
638
+ ): Promise<{ reviewerId: string; claimedAt: string; expiresAt: string; version: number }> {
639
+ const appeal = await db.prepare(`
640
+ SELECT a.id, a.state, d.maker_type, d.maker_id
641
+ FROM appeals a JOIN decisions d ON d.id = a.decision_id
642
+ WHERE a.id = ?1 LIMIT 1
643
+ `).bind(appealId).first<{ id: string; state: string; maker_type: string; maker_id: string }>();
644
+ if (!appeal) throw new ApiError(404, "appeal_not_found", "The appeal does not exist.");
645
+ if (!["submitted", "human_review"].includes(appeal.state)) throw new ApiError(409, "appeal_not_claimable", "The appeal is not awaiting human review.");
646
+ if (appeal.maker_type === "human" && appeal.maker_id === session.actor.id) {
647
+ throw new ApiError(409, "independent_appeal_reviewer_required", "The original decision maker cannot claim this appeal.");
648
+ }
649
+ const claimedAt = now();
650
+ const expiresAt = addMinutes(claimedAt, Math.max(1, Math.min(1_440, claimTtlMinutes)));
651
+ await db.batch([
652
+ db.prepare(`
653
+ INSERT INTO appeal_claims (appeal_id, reviewer_id, claimed_at, expires_at, version)
654
+ VALUES (?1, ?2, ?3, ?4, 1)
655
+ ON CONFLICT(appeal_id) DO UPDATE SET reviewer_id = excluded.reviewer_id,
656
+ claimed_at = excluded.claimed_at, expires_at = excluded.expires_at,
657
+ version = appeal_claims.version + 1
658
+ WHERE appeal_claims.expires_at <= ?3 OR appeal_claims.reviewer_id = ?2
659
+ `).bind(appealId, session.actor.id, claimedAt, expiresAt),
660
+ db.prepare(`UPDATE appeals SET state = 'human_review' WHERE id = ?1 AND state = 'submitted'`).bind(appealId),
661
+ ]);
662
+ const claim = await db.prepare(`SELECT reviewer_id, claimed_at, expires_at, version FROM appeal_claims WHERE appeal_id = ?1 LIMIT 1`)
663
+ .bind(appealId).first<{ reviewer_id: string; claimed_at: string; expires_at: string; version: number }>();
664
+ if (!claim || claim.reviewer_id !== session.actor.id) throw new ApiError(409, "appeal_already_claimed", "Another appeal reviewer currently holds this appeal.");
665
+ return { reviewerId: claim.reviewer_id, claimedAt: claim.claimed_at, expiresAt: claim.expires_at, version: claim.version };
666
+ }
667
+
668
+ export async function decideAppeal(
669
+ db: D1Database,
670
+ appealId: string,
671
+ session: OperatorSession,
672
+ input: AppealDecisionInput,
673
+ options: DecisionOptions,
674
+ ): Promise<{ appealDecisionId: string; jobs: DeliveryJob[]; idempotentReplay: boolean }> {
675
+ const existing = await db.prepare(`
676
+ SELECT id FROM appeal_decisions WHERE appeal_id = ?1 AND idempotency_key = ?2 LIMIT 1
677
+ `).bind(appealId, input.idempotencyKey).first<{ id: string }>();
678
+ if (existing) return { appealDecisionId: existing.id, jobs: [], idempotentReplay: true };
679
+ const createdAt = now();
680
+ const appeal = await db.prepare(`
681
+ SELECT a.id, a.report_id, a.decision_id, a.appellant_audience, a.state, a.submitted_at,
682
+ d.maker_id AS original_maker_id, r.public_reference, r.state AS report_state,
683
+ r.policy_version_id, t.target_reference,
684
+ p.reference AS participant_reference
685
+ FROM appeals a
686
+ JOIN decisions d ON d.id = a.decision_id
687
+ JOIN reports r ON r.id = a.report_id
688
+ JOIN report_targets t ON t.report_id = r.id
689
+ JOIN report_participants p ON p.report_id = r.id AND p.audience = a.appellant_audience
690
+ WHERE a.id = ?1
691
+ LIMIT 1
692
+ `).bind(appealId).first<{
693
+ id: string;
694
+ report_id: string;
695
+ decision_id: string;
696
+ appellant_audience: ParticipantAudience;
697
+ state: string;
698
+ submitted_at: string;
699
+ original_maker_id: string;
700
+ public_reference: string;
701
+ report_state: ReportState;
702
+ policy_version_id: string | null;
703
+ target_reference: string;
704
+ participant_reference: string | null;
705
+ }>();
706
+ if (!appeal) throw new ApiError(404, "appeal_not_found", "The appeal does not exist.");
707
+ if (appeal.state !== "human_review") throw new ApiError(409, "appeal_not_decidable", "The appeal is not awaiting a human decision.");
708
+ if (appeal.original_maker_id === session.actor.id) {
709
+ throw new ApiError(409, "independent_appeal_reviewer_required", "The original decision maker cannot decide this appeal.");
710
+ }
711
+ const claim = await db.prepare(`SELECT reviewer_id, expires_at FROM appeal_claims WHERE appeal_id = ?1 LIMIT 1`)
712
+ .bind(appealId).first<{ reviewer_id: string; expires_at: string }>();
713
+ if (!claim || claim.reviewer_id !== session.actor.id || claim.expires_at <= createdAt) {
714
+ throw new ApiError(409, "active_appeal_claim_required", "Claim this appeal before deciding it.");
715
+ }
716
+ if (input.correctiveActionCode && (!options.actionEnabled || !options.allowedActionCodes.has(input.correctiveActionCode))) {
717
+ throw new ApiError(400, "action_not_allowed", "The corrective application action is not allowlisted for this deployment.");
718
+ }
719
+ const appealDecisionId = crypto.randomUUID();
720
+ const messageId = crypto.randomUUID();
721
+ const noticeId = crypto.randomUUID();
722
+ const notificationId = crypto.randomUUID();
723
+ const actionId = input.correctiveActionCode ? crypto.randomUUID() : null;
724
+ const templateId = appeal.appellant_audience === "affected_user" ? "appeal-outcome-v1" : "reporter-outcome-v1";
725
+ const jobs: DeliveryJob[] = [];
726
+ const statements: D1PreparedStatement[] = [
727
+ db.prepare(`
728
+ INSERT INTO appeal_decisions (
729
+ id, appeal_id, outcome, rationale, reviewer_id, corrective_action_id, created_at, idempotency_key
730
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
731
+ `).bind(appealDecisionId, appealId, input.outcome, input.rationale, session.actor.id, actionId, createdAt, input.idempotencyKey),
732
+ db.prepare(`UPDATE appeals SET state = ?2, resolved_at = ?3 WHERE id = ?1`).bind(appealId, input.outcome, createdAt),
733
+ db.prepare(`UPDATE reports SET state = 'closed', queue_id = NULL, updated_at = ?2 WHERE id = ?1`)
734
+ .bind(appeal.report_id, createdAt),
735
+ db.prepare(`
736
+ INSERT INTO report_state_history (id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at)
737
+ VALUES (?1, ?2, ?3, 'closed', ?4, ?5, ?6, ?7)
738
+ `).bind(crypto.randomUUID(), appeal.report_id, appeal.report_state, `appeal_${input.outcome}`, session.actor.type, session.actor.id, createdAt),
739
+ db.prepare(`
740
+ INSERT INTO case_messages (
741
+ id, report_id, audience, direction, sender_type, sender_id, body, template_version_id,
742
+ automated, delivery_state, created_at, idempotency_key
743
+ ) VALUES (?1, ?2, ?3, 'outbound', 'human', ?4, ?5, ?6, 0, ?7, ?8, ?9)
744
+ `).bind(
745
+ messageId, appeal.report_id, appeal.appellant_audience, session.actor.id, input.participantNotice,
746
+ templateId, options.notificationEnabled ? "queued" : "suppressed", createdAt,
747
+ `appeal-decision:${appealDecisionId}:message`,
748
+ ),
749
+ db.prepare(`
750
+ INSERT INTO notices (
751
+ id, report_id, decision_id, audience, template_version_id, rendered_body,
752
+ delivery_state, created_at, updated_at, idempotency_key, message_id
753
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8, ?9, ?10)
754
+ `).bind(
755
+ noticeId, appeal.report_id, appeal.decision_id, appeal.appellant_audience, templateId,
756
+ input.participantNotice, options.notificationEnabled ? "queued" : "suppressed", createdAt,
757
+ `appeal-decision:${appealDecisionId}:notice`, messageId,
758
+ ),
759
+ db.prepare(`DELETE FROM appeal_claims WHERE appeal_id = ?1`).bind(appealId),
760
+ auditEvent(db, {
761
+ idempotencyKey: `appeal-decision:${appealId}:${input.idempotencyKey}`,
762
+ action: "appeal.human_decision_recorded",
763
+ actorType: session.actor.type,
764
+ actorId: session.actor.id,
765
+ targetType: "appeal_decision",
766
+ targetId: appealDecisionId,
767
+ details: {
768
+ appealId,
769
+ reportId: appeal.report_id,
770
+ outcome: input.outcome,
771
+ correctiveActionCode: input.correctiveActionCode ?? null,
772
+ },
773
+ createdAt,
774
+ }),
775
+ ];
776
+ if (input.outcome === "reversed") {
777
+ statements.push(db.prepare(`
778
+ INSERT INTO ai_usage_daily (usage_date, config_version_id, appeal_reversals, updated_at)
779
+ SELECT substr(?2, 1, 10), ar.config_version_id, 1, ?2
780
+ FROM decisions d JOIN ai_runs ar ON ar.id = d.ai_run_id
781
+ WHERE d.id = ?1
782
+ ON CONFLICT(usage_date, config_version_id) DO UPDATE SET
783
+ appeal_reversals = appeal_reversals + 1, updated_at = excluded.updated_at
784
+ `).bind(appeal.decision_id, createdAt));
785
+ }
786
+ if (options.notificationEnabled) {
787
+ statements.push(db.prepare(`
788
+ INSERT INTO notification_outbox (
789
+ id, notice_id, idempotency_key, channel, payload_json, status, next_attempt_at, created_at, updated_at
790
+ ) VALUES (?1, ?2, ?3, 'customer_webhook', ?4, 'pending', ?5, ?5, ?5)
791
+ `).bind(notificationId, noticeId, `notice:${noticeId}`, canonicalJson({
792
+ version: 1,
793
+ event: "appeal.outcome.available",
794
+ notification_id: notificationId,
795
+ report_reference: appeal.public_reference,
796
+ ...(appeal.participant_reference ? { recipient_reference: appeal.participant_reference } : {}),
797
+ audience: appeal.appellant_audience,
798
+ message: input.participantNotice,
799
+ }), createdAt));
800
+ jobs.push({ version: 1, jobId: `notification:${notificationId}`, type: "deliver_notification", outboxId: notificationId });
801
+ }
802
+ if (input.correctiveActionCode && actionId) {
803
+ statements.push(db.prepare(`
804
+ INSERT INTO decision_action_outbox (
805
+ id, report_id, decision_id, idempotency_key, action_code, target_reference,
806
+ payload_json, status, next_attempt_at, created_at, updated_at
807
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', ?8, ?8, ?8)
808
+ `).bind(
809
+ actionId, appeal.report_id, appeal.decision_id,
810
+ `appeal-action:${appealDecisionId}:${input.correctiveActionCode}`,
811
+ input.correctiveActionCode, appeal.target_reference,
812
+ canonicalJson({
813
+ version: 1,
814
+ action_id: actionId,
815
+ idempotency_key: `appeal-action:${appealDecisionId}:${input.correctiveActionCode}`,
816
+ report_id: appeal.report_id,
817
+ report_reference: appeal.public_reference,
818
+ original_decision_id: appeal.decision_id,
819
+ appeal_id: appealId,
820
+ appeal_decision_id: appealDecisionId,
821
+ action_code: input.correctiveActionCode,
822
+ target_reference: appeal.target_reference,
823
+ policy_version_id: appeal.policy_version_id,
824
+ }),
825
+ createdAt,
826
+ ));
827
+ jobs.push({ version: 1, jobId: `action:${actionId}`, type: "deliver_action", outboxId: actionId });
828
+ }
829
+ await db.batch(statements);
830
+ return { appealDecisionId, jobs, idempotentReplay: false };
831
+ }
832
+
833
+ export async function chainPendingAuditEvents(db: D1Database, limit = 50): Promise<void> {
834
+ const pending = await db.prepare(`
835
+ SELECT id, action, actor_type, actor_id, target_type, target_id, details_json, created_at
836
+ FROM audit_events
837
+ WHERE chained_at IS NULL
838
+ ORDER BY created_at ASC, id ASC
839
+ LIMIT ?1
840
+ `).bind(Math.max(1, Math.min(100, limit))).all<{
841
+ id: string;
842
+ action: string;
843
+ actor_type: string;
844
+ actor_id: string;
845
+ target_type: string;
846
+ target_id: string;
847
+ details_json: string;
848
+ created_at: string;
849
+ }>();
850
+ for (const event of pending.results) {
851
+ const tip = await db.prepare(`SELECT sequence, entry_hash FROM audit_entries ORDER BY sequence DESC LIMIT 1`)
852
+ .first<{ sequence: number; entry_hash: string }>() ?? { sequence: 0, entry_hash: AUDIT_GENESIS_HASH };
853
+ const sequence = tip.sequence + 1;
854
+ const entryHash = await hashAuditEntry({
855
+ sequence,
856
+ action: event.action,
857
+ actorType: event.actor_type,
858
+ actorId: event.actor_id,
859
+ targetType: event.target_type,
860
+ targetId: event.target_id,
861
+ detailsJson: event.details_json,
862
+ previousHash: tip.entry_hash,
863
+ createdAt: event.created_at,
864
+ });
865
+ try {
866
+ await db.batch([
867
+ db.prepare(`
868
+ INSERT INTO audit_entries (
869
+ sequence, id, action, actor_type, actor_id, target_type, target_id,
870
+ details_json, previous_hash, entry_hash, created_at
871
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
872
+ `).bind(
873
+ sequence, event.id, event.action, event.actor_type, event.actor_id, event.target_type,
874
+ event.target_id, event.details_json, tip.entry_hash, entryHash, event.created_at,
875
+ ),
876
+ db.prepare(`UPDATE audit_events SET chained_at = ?2, chain_error_code = NULL WHERE id = ?1 AND chained_at IS NULL`)
877
+ .bind(event.id, now()),
878
+ ]);
879
+ } catch (error) {
880
+ await db.prepare(`UPDATE audit_events SET chain_error_code = 'chain_contention' WHERE id = ?1 AND chained_at IS NULL`)
881
+ .bind(event.id).run();
882
+ console.warn("audit event chaining will retry", { eventId: event.id, errorType: error instanceof Error ? error.name : typeof error });
883
+ break;
884
+ }
885
+ }
886
+ }