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,390 @@
1
+ import { canonicalJson } from "./audit";
2
+ import { ApiError } from "./report-http";
3
+ import type { OperatorSession } from "./report-types";
4
+
5
+ const issueCodes = new Set([
6
+ "incorrect_outcome",
7
+ "incorrect_policy_code",
8
+ "unsupported_evidence",
9
+ "unsafe_or_inaccurate_message",
10
+ "should_have_escalated",
11
+ "other",
12
+ ]);
13
+
14
+ interface QualityReviewInput {
15
+ agrees: boolean;
16
+ issueCodes: string[];
17
+ correctedOutcomeCode: string | null;
18
+ correctedPolicyCode: string | null;
19
+ notes: string;
20
+ idempotencyKey: string;
21
+ }
22
+
23
+ interface PauseConfig {
24
+ id: string;
25
+ mode: string;
26
+ enabled: number;
27
+ pause_state: string;
28
+ minimum_shadow_runs: number;
29
+ minimum_quality_reviews: number;
30
+ maximum_failure_rate: number;
31
+ maximum_disagreement_rate: number;
32
+ }
33
+
34
+ function now(): string {
35
+ return new Date().toISOString();
36
+ }
37
+
38
+ function addMinutes(timestamp: string, minutes: number): string {
39
+ return new Date(Date.parse(timestamp) + minutes * 60_000).toISOString();
40
+ }
41
+
42
+ function jsonValue(value: string | null): unknown {
43
+ if (!value) return null;
44
+ try { return JSON.parse(value); } catch { return null; }
45
+ }
46
+
47
+ function auditEvent(
48
+ db: D1Database,
49
+ input: { key: string; action: string; actorType: string; actorId: string; targetType: string; targetId: string; details: unknown; createdAt: string },
50
+ ): D1PreparedStatement {
51
+ return db.prepare(`
52
+ INSERT OR IGNORE INTO audit_events (
53
+ id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
54
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
55
+ `).bind(
56
+ crypto.randomUUID(), input.key, input.action, input.actorType, input.actorId,
57
+ input.targetType, input.targetId, canonicalJson(input.details), input.createdAt,
58
+ );
59
+ }
60
+
61
+ function boundedOptional(value: unknown, name: string): string | null {
62
+ if (value === null || value === undefined || value === "") return null;
63
+ if (typeof value !== "string" || value.trim().length > 100) {
64
+ throw new ApiError(400, "invalid_quality_review", `${name} must contain at most 100 characters.`);
65
+ }
66
+ return value.trim() || null;
67
+ }
68
+
69
+ export function parseQualityReview(value: unknown, idempotencyKeyValue: string | null): QualityReviewInput {
70
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new ApiError(400, "invalid_quality_review", "The quality review must be an object.");
71
+ const input = value as Record<string, unknown>;
72
+ if (Object.keys(input).some((key) => !["agrees", "issue_codes", "corrected_outcome_code", "corrected_policy_code", "notes"].includes(key))) {
73
+ throw new ApiError(400, "invalid_quality_review", "The quality review contains unsupported fields.");
74
+ }
75
+ const idempotencyKey = idempotencyKeyValue?.trim() ?? "";
76
+ if (idempotencyKey.length < 8 || idempotencyKey.length > 200) {
77
+ throw new ApiError(400, "idempotency_key_required", "A stable Idempotency-Key header containing 8 to 200 characters is required.");
78
+ }
79
+ if (typeof input.agrees !== "boolean") throw new ApiError(400, "invalid_quality_review", "agrees must be a boolean.");
80
+ if (!Array.isArray(input.issue_codes) || input.issue_codes.length > 10
81
+ || input.issue_codes.some((code) => typeof code !== "string" || !issueCodes.has(code))) {
82
+ throw new ApiError(400, "invalid_quality_review", "issue_codes contains an unsupported value.");
83
+ }
84
+ const selectedIssueCodes = [...new Set(input.issue_codes as string[])];
85
+ const notes = typeof input.notes === "string" ? input.notes.trim() : "";
86
+ if (notes.length > 4_000 || (!input.agrees && notes.length < 10)) {
87
+ throw new ApiError(400, "invalid_quality_review", "Disagreements require notes containing 10 to 4,000 characters.");
88
+ }
89
+ if (input.agrees && selectedIssueCodes.length) throw new ApiError(400, "invalid_quality_review", "An agreeing review cannot include issue codes.");
90
+ if (!input.agrees && !selectedIssueCodes.length) throw new ApiError(400, "invalid_quality_review", "A disagreeing review requires at least one issue code.");
91
+ return {
92
+ agrees: input.agrees,
93
+ issueCodes: selectedIssueCodes,
94
+ correctedOutcomeCode: boundedOptional(input.corrected_outcome_code, "corrected_outcome_code"),
95
+ correctedPolicyCode: boundedOptional(input.corrected_policy_code, "corrected_policy_code"),
96
+ notes,
97
+ idempotencyKey,
98
+ };
99
+ }
100
+
101
+ export async function listAiQualitySamples(
102
+ db: D1Database,
103
+ state: "pending" | "claimed" | "complete" | null,
104
+ limit: number,
105
+ allowedQueueIds?: string[] | null,
106
+ ): Promise<Array<Record<string, unknown>>> {
107
+ const result = await db.prepare(`
108
+ SELECT qs.id, qs.report_id AS reportId, r.public_reference AS reportReference,
109
+ r.reason_code AS allegation, r.state AS reportState, t.target_type AS targetType,
110
+ qs.sample_reason AS sampleReason, qs.state, qs.selected_at AS selectedAt,
111
+ qs.claimed_by AS claimedBy, qs.claim_expires_at AS claimExpiresAt,
112
+ ar.id AS aiRunId, ar.config_version_id AS configVersionId, ar.mode, ar.status AS aiRunStatus, ar.model,
113
+ ar.prompt_version AS promptVersion, ar.policy_version_id AS policyVersionId,
114
+ ai.outcome AS aiOutcome, ai.policy_code AS aiPolicyCode, ai.confidence,
115
+ d.decision_code AS latestDecisionOutcome, d.maker_type AS latestDecisionMaker,
116
+ qr.agrees, qr.created_at AS reviewedAt
117
+ FROM ai_quality_samples qs
118
+ JOIN reports r ON r.id = qs.report_id
119
+ JOIN report_targets t ON t.report_id = r.id
120
+ JOIN ai_runs ar ON ar.id = qs.ai_run_id
121
+ LEFT JOIN ai_results ai ON ai.ai_run_id = ar.id
122
+ LEFT JOIN decisions d ON d.id = (
123
+ SELECT id FROM decisions WHERE report_id = r.id ORDER BY created_at DESC, id DESC LIMIT 1
124
+ )
125
+ LEFT JOIN quality_reviews qr ON qr.sample_id = qs.id
126
+ WHERE (?1 IS NULL OR qs.state = ?1)
127
+ AND (?3 = 1 OR r.queue_id IN (SELECT value FROM json_each(?4)))
128
+ ORDER BY CASE qs.state WHEN 'pending' THEN 0 WHEN 'claimed' THEN 1 ELSE 2 END,
129
+ qs.selected_at, qs.id LIMIT ?2
130
+ `).bind(
131
+ state,
132
+ Math.max(1, Math.min(100, limit)),
133
+ allowedQueueIds === null || allowedQueueIds === undefined ? 1 : 0,
134
+ canonicalJson(allowedQueueIds ?? []),
135
+ ).all<Record<string, unknown>>();
136
+ return result.results.map((row) => ({ ...row, agrees: row.agrees === null ? null : row.agrees === 1 }));
137
+ }
138
+
139
+ export async function loadAiQualitySample(db: D1Database, sampleId: string): Promise<Record<string, unknown> | null> {
140
+ const row = await db.prepare(`
141
+ SELECT qs.id, qs.report_id AS reportId, r.public_reference AS reportReference,
142
+ r.reason_code AS allegation, r.state AS reportState, t.target_type AS targetType,
143
+ qs.sample_reason AS sampleReason, qs.state, qs.selected_at AS selectedAt,
144
+ qs.claimed_by AS claimedBy, qs.claimed_at AS claimedAt, qs.claim_expires_at AS claimExpiresAt,
145
+ ar.id AS aiRunId, ar.config_version_id AS configVersionId, ar.mode, ar.provider, ar.model,
146
+ ar.prompt_version AS promptVersion, ar.policy_version_id AS policyVersionId,
147
+ ar.status AS aiRunStatus, ar.input_references_json AS inputReferencesJson,
148
+ ar.output_json AS rawOutputJson, ar.started_at AS aiStartedAt, ar.finished_at AS aiFinishedAt,
149
+ ai.outcome AS aiOutcome, ai.policy_code AS aiPolicyCode,
150
+ ai.evidence_references_json AS aiEvidenceReferencesJson, ai.summary AS aiSummary,
151
+ ai.confidence, ai.uncertain, ai.message_template_id AS messageTemplateId,
152
+ ai.message_variables_json AS messageVariablesJson, ai.action_code AS actionCode,
153
+ ai.escalation_code AS escalationCode, ai.validation_state AS validationState,
154
+ ai.validation_errors_json AS validationErrorsJson,
155
+ qr.id AS qualityReviewId, qr.reviewer_id AS qualityReviewerId, qr.agrees,
156
+ qr.issue_codes_json AS issueCodesJson, qr.corrected_outcome_code AS correctedOutcomeCode,
157
+ qr.corrected_policy_code AS correctedPolicyCode, qr.notes AS qualityNotes,
158
+ qr.created_at AS reviewedAt
159
+ FROM ai_quality_samples qs
160
+ JOIN reports r ON r.id = qs.report_id
161
+ JOIN report_targets t ON t.report_id = r.id
162
+ JOIN ai_runs ar ON ar.id = qs.ai_run_id
163
+ LEFT JOIN ai_results ai ON ai.ai_run_id = ar.id
164
+ LEFT JOIN quality_reviews qr ON qr.sample_id = qs.id
165
+ WHERE qs.id = ?1 LIMIT 1
166
+ `).bind(sampleId).first<Record<string, unknown>>();
167
+ if (!row) return null;
168
+ const tools = await db.prepare(`
169
+ SELECT id, tool_name AS toolName, input_references_json AS inputReferencesJson,
170
+ status, error_code AS errorCode, created_at AS createdAt
171
+ FROM ai_tool_calls WHERE ai_run_id = ?1 ORDER BY created_at, id
172
+ `).bind(row.aiRunId).all<Record<string, unknown>>();
173
+ return {
174
+ ...row,
175
+ uncertain: row.uncertain === 1,
176
+ agrees: row.agrees === null ? null : row.agrees === 1,
177
+ inputReferences: jsonValue(String(row.inputReferencesJson ?? "")),
178
+ rawOutput: jsonValue(row.rawOutputJson === null ? null : String(row.rawOutputJson)),
179
+ aiEvidenceReferences: jsonValue(row.aiEvidenceReferencesJson === null ? null : String(row.aiEvidenceReferencesJson)),
180
+ messageVariables: jsonValue(row.messageVariablesJson === null ? null : String(row.messageVariablesJson)),
181
+ validationErrors: jsonValue(row.validationErrorsJson === null ? null : String(row.validationErrorsJson)),
182
+ issueCodes: jsonValue(row.issueCodesJson === null ? null : String(row.issueCodesJson)),
183
+ toolCalls: tools.results.map((tool) => ({
184
+ ...tool,
185
+ inputReferences: jsonValue(String(tool.inputReferencesJson ?? "")),
186
+ inputReferencesJson: undefined,
187
+ })),
188
+ inputReferencesJson: undefined,
189
+ rawOutputJson: undefined,
190
+ aiEvidenceReferencesJson: undefined,
191
+ messageVariablesJson: undefined,
192
+ validationErrorsJson: undefined,
193
+ issueCodesJson: undefined,
194
+ };
195
+ }
196
+
197
+ export async function claimAiQualitySample(
198
+ db: D1Database,
199
+ sampleId: string,
200
+ session: OperatorSession,
201
+ ttlMinutes: number,
202
+ ): Promise<Record<string, unknown>> {
203
+ const claimedAt = now();
204
+ const expiresAt = addMinutes(claimedAt, Math.max(1, Math.min(1_440, ttlMinutes)));
205
+ const sample = await db.prepare(`SELECT state, claimed_by, claim_expires_at FROM ai_quality_samples WHERE id = ?1 LIMIT 1`)
206
+ .bind(sampleId).first<{ state: string; claimed_by: string | null; claim_expires_at: string | null }>();
207
+ if (!sample) throw new ApiError(404, "quality_sample_not_found", "The quality sample does not exist.");
208
+ if (sample.state === "complete") throw new ApiError(409, "quality_sample_complete", "This quality sample is already complete.");
209
+ if (sample.state === "claimed" && sample.claimed_by !== session.actor.id && (sample.claim_expires_at ?? "") > claimedAt) {
210
+ throw new ApiError(409, "quality_sample_claimed", "Another reviewer currently holds this quality sample.");
211
+ }
212
+ const result = await db.prepare(`
213
+ UPDATE ai_quality_samples SET state = 'claimed', claimed_by = ?2, claimed_at = ?3, claim_expires_at = ?4
214
+ WHERE id = ?1 AND state != 'complete'
215
+ AND (state = 'pending' OR claimed_by = ?2 OR claim_expires_at IS NULL OR claim_expires_at <= ?3)
216
+ `).bind(sampleId, session.actor.id, claimedAt, expiresAt).run();
217
+ if ((result.meta.changes ?? 0) < 1) throw new ApiError(409, "quality_sample_claimed", "Another reviewer currently holds this quality sample.");
218
+ await db.batch([auditEvent(db, {
219
+ key: `quality-claim:${sampleId}:${session.actor.id}:${claimedAt}`,
220
+ action: "ai.quality_sample_claimed",
221
+ actorType: session.actor.type,
222
+ actorId: session.actor.id,
223
+ targetType: "ai_quality_sample",
224
+ targetId: sampleId,
225
+ details: { expiresAt },
226
+ createdAt: claimedAt,
227
+ })]);
228
+ return { reviewerId: session.actor.id, claimedAt, expiresAt };
229
+ }
230
+
231
+ async function pauseConfiguration(
232
+ db: D1Database,
233
+ config: PauseConfig,
234
+ reason: "quality_threshold_exceeded" | "failure_threshold_exceeded",
235
+ observed: Record<string, unknown>,
236
+ ): Promise<boolean> {
237
+ if (config.mode !== "autonomous_low_priority" || config.enabled !== 1 || config.pause_state !== "ready") return false;
238
+ const pausedAt = now();
239
+ const result = await db.prepare(`
240
+ UPDATE ai_queue_config_versions SET pause_state = 'quality_paused', pause_reason = ?2, paused_at = ?3
241
+ WHERE id = ?1 AND pause_state = 'ready' AND enabled = 1 AND mode = 'autonomous_low_priority'
242
+ `).bind(config.id, reason, pausedAt).run();
243
+ if ((result.meta.changes ?? 0) < 1) return false;
244
+ await db.batch([auditEvent(db, {
245
+ key: `ai-auto-pause:${config.id}:${reason}`,
246
+ action: "ai.queue_automatically_paused",
247
+ actorType: "system",
248
+ actorId: "ai-quality-controller-v1",
249
+ targetType: "ai_queue_configuration",
250
+ targetId: config.id,
251
+ details: { reason, ...observed },
252
+ createdAt: pausedAt,
253
+ })]);
254
+ return true;
255
+ }
256
+
257
+ export async function evaluateAiFailurePause(db: D1Database, configId: string): Promise<boolean> {
258
+ const config = await db.prepare(`SELECT * FROM ai_queue_config_versions WHERE id = ?1 LIMIT 1`)
259
+ .bind(configId).first<PauseConfig>();
260
+ if (!config) return false;
261
+ const stats = await db.prepare(`
262
+ SELECT COUNT(*) AS total, SUM(CASE WHEN status IN ('failed', 'invalid') THEN 1 ELSE 0 END) AS failures
263
+ FROM ai_runs WHERE config_version_id = ?1
264
+ `).bind(configId).first<{ total: number; failures: number | null }>();
265
+ const total = stats?.total ?? 0;
266
+ const failures = stats?.failures ?? 0;
267
+ const failureRate = total ? failures / total : 0;
268
+ if (total < config.minimum_shadow_runs || failureRate <= config.maximum_failure_rate) return false;
269
+ return pauseConfiguration(db, config, "failure_threshold_exceeded", {
270
+ observedRuns: total,
271
+ observedFailures: failures,
272
+ observedFailureRate: failureRate,
273
+ configuredMaximumFailureRate: config.maximum_failure_rate,
274
+ });
275
+ }
276
+
277
+ export async function evaluateAiQualityPause(db: D1Database, configId: string): Promise<boolean> {
278
+ const config = await db.prepare(`SELECT * FROM ai_queue_config_versions WHERE id = ?1 LIMIT 1`)
279
+ .bind(configId).first<PauseConfig>();
280
+ if (!config) return false;
281
+ const stats = await db.prepare(`
282
+ SELECT COUNT(*) AS total, SUM(CASE WHEN qr.agrees = 0 THEN 1 ELSE 0 END) AS disagreements
283
+ FROM quality_reviews qr
284
+ JOIN ai_quality_samples qs ON qs.id = qr.sample_id
285
+ JOIN ai_runs ar ON ar.id = qs.ai_run_id
286
+ WHERE ar.config_version_id = ?1
287
+ `).bind(configId).first<{ total: number; disagreements: number | null }>();
288
+ const total = stats?.total ?? 0;
289
+ const disagreements = stats?.disagreements ?? 0;
290
+ const disagreementRate = total ? disagreements / total : 0;
291
+ if (total < config.minimum_quality_reviews || disagreementRate <= config.maximum_disagreement_rate) return false;
292
+ return pauseConfiguration(db, config, "quality_threshold_exceeded", {
293
+ observedReviews: total,
294
+ observedDisagreements: disagreements,
295
+ observedDisagreementRate: disagreementRate,
296
+ configuredMaximumDisagreementRate: config.maximum_disagreement_rate,
297
+ });
298
+ }
299
+
300
+ export async function recordAiQualityReview(
301
+ db: D1Database,
302
+ sampleId: string,
303
+ session: OperatorSession,
304
+ input: QualityReviewInput,
305
+ ): Promise<{ reviewId: string; idempotentReplay: boolean; queuePaused: boolean; reportReopened: boolean }> {
306
+ const existing = await db.prepare(`SELECT id FROM quality_reviews WHERE idempotency_key = ?1 LIMIT 1`)
307
+ .bind(input.idempotencyKey).first<{ id: string }>();
308
+ if (existing) return { reviewId: existing.id, idempotentReplay: true, queuePaused: false, reportReopened: false };
309
+ const reviewedAt = now();
310
+ const sample = await db.prepare(`
311
+ SELECT qs.state, qs.claimed_by, qs.claim_expires_at, qs.report_id, ar.config_version_id,
312
+ r.state AS report_state
313
+ FROM ai_quality_samples qs JOIN ai_runs ar ON ar.id = qs.ai_run_id
314
+ JOIN reports r ON r.id = qs.report_id WHERE qs.id = ?1 LIMIT 1
315
+ `).bind(sampleId).first<{
316
+ state: string; claimed_by: string | null; claim_expires_at: string | null;
317
+ report_id: string; config_version_id: string; report_state: string;
318
+ }>();
319
+ if (!sample) throw new ApiError(404, "quality_sample_not_found", "The quality sample does not exist.");
320
+ if (sample.state === "complete") throw new ApiError(409, "quality_sample_complete", "This quality sample is already complete.");
321
+ if (sample.state !== "claimed" || sample.claimed_by !== session.actor.id || (sample.claim_expires_at ?? "") <= reviewedAt) {
322
+ throw new ApiError(409, "active_quality_claim_required", "Claim this quality sample before recording a review.");
323
+ }
324
+ const reviewId = crypto.randomUUID();
325
+ const reopen = !input.agrees && sample.report_state === "resolved_by_ai";
326
+ const fallback = reopen
327
+ ? await db.prepare(`
328
+ SELECT q.id, q.active_version_id
329
+ FROM ai_queue_config_versions c
330
+ JOIN queue_definitions q ON q.id = c.fallback_queue_id
331
+ JOIN queue_versions qv ON qv.id = q.active_version_id
332
+ WHERE c.id = ?1 AND q.status = 'active' AND qv.management_mode = 'human'
333
+ LIMIT 1
334
+ `).bind(sample.config_version_id).first<{ id: string; active_version_id: string }>()
335
+ ?? await db.prepare(`
336
+ SELECT q.id, q.active_version_id
337
+ FROM queue_definitions q JOIN queue_versions qv ON qv.id = q.active_version_id
338
+ WHERE q.purpose = 'reports' AND q.status = 'active' AND qv.management_mode = 'human'
339
+ ORDER BY q.position, q.id LIMIT 1
340
+ `).first<{ id: string; active_version_id: string }>()
341
+ : null;
342
+ const statements: D1PreparedStatement[] = [
343
+ db.prepare(`
344
+ INSERT INTO quality_reviews (
345
+ id, sample_id, reviewer_id, agrees, notes, created_at, issue_codes_json,
346
+ corrected_outcome_code, corrected_policy_code, idempotency_key
347
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
348
+ `).bind(
349
+ reviewId, sampleId, session.actor.id, input.agrees ? 1 : 0, input.notes, reviewedAt,
350
+ canonicalJson(input.issueCodes), input.correctedOutcomeCode, input.correctedPolicyCode, input.idempotencyKey,
351
+ ),
352
+ db.prepare(`UPDATE ai_quality_samples SET state = 'complete', claim_expires_at = NULL WHERE id = ?1`).bind(sampleId),
353
+ auditEvent(db, {
354
+ key: `quality-review:${input.idempotencyKey}`,
355
+ action: "ai.quality_review_recorded",
356
+ actorType: session.actor.type,
357
+ actorId: session.actor.id,
358
+ targetType: "ai_quality_sample",
359
+ targetId: sampleId,
360
+ details: { agrees: input.agrees, issueCodes: input.issueCodes, reportReopened: reopen },
361
+ createdAt: reviewedAt,
362
+ }),
363
+ ];
364
+ if (reopen) {
365
+ statements.push(
366
+ db.prepare(`
367
+ UPDATE reports SET state = 'human_review_requested', queue_id = ?2, queue_version_id = ?3,
368
+ priority = MAX(priority, 70), resolved_at = NULL, updated_at = ?4
369
+ WHERE id = ?1 AND state = 'resolved_by_ai'
370
+ `).bind(sample.report_id, fallback?.id ?? null, fallback?.active_version_id ?? null, reviewedAt),
371
+ db.prepare(`
372
+ INSERT INTO report_state_history (id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at)
373
+ VALUES (?1, ?2, 'resolved_by_ai', 'human_review_requested', 'ai_quality_disagreement', ?3, ?4, ?5)
374
+ `).bind(crypto.randomUUID(), sample.report_id, session.actor.type, session.actor.id, reviewedAt),
375
+ auditEvent(db, {
376
+ key: `quality-reopen:${sampleId}`,
377
+ action: "ai.quality_disagreement_reopened_report",
378
+ actorType: session.actor.type,
379
+ actorId: session.actor.id,
380
+ targetType: "report",
381
+ targetId: sample.report_id,
382
+ details: { sampleId, issueCodes: input.issueCodes, fallbackQueueId: fallback?.id ?? null },
383
+ createdAt: reviewedAt,
384
+ }),
385
+ );
386
+ }
387
+ await db.batch(statements);
388
+ const queuePaused = await evaluateAiQualityPause(db, sample.config_version_id);
389
+ return { reviewId, idempotentReplay: false, queuePaused, reportReopened: reopen };
390
+ }
@@ -0,0 +1,80 @@
1
+ export type AiMode = "off" | "shadow" | "assist" | "autonomous_low_priority";
2
+
3
+ export interface ValidatedAiResult {
4
+ outcome: string;
5
+ policyCode: string | null;
6
+ evidenceReferences: string[];
7
+ summary: string;
8
+ confidence: number | null;
9
+ uncertain: boolean;
10
+ message: { templateId: string; variables: Record<string, string> } | null;
11
+ actionCode: string | null;
12
+ escalationCode: string | null;
13
+ }
14
+
15
+ function optionalString(value: unknown, maximum: number): string | null | undefined {
16
+ if (value === null || value === undefined) return null;
17
+ if (typeof value !== "string" || !value.trim() || value.trim().length > maximum) return undefined;
18
+ return value.trim();
19
+ }
20
+
21
+ export function validateAiResult(
22
+ value: unknown,
23
+ bounds: { allowedOutcomes: Set<string>; allowedPolicyCodes: Set<string>; allowedEvidenceReferences: Set<string>; allowedTemplateIds: Set<string>; allowedActionCodes: Set<string>; confidenceThreshold: number },
24
+ ): { result: ValidatedAiResult | null; errors: string[] } {
25
+ const errors: string[] = [];
26
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { result: null, errors: ["output_not_object"] };
27
+ const input = value as Record<string, unknown>;
28
+ const allowedKeys = new Set(["outcome", "policy_code", "evidence_references", "summary", "confidence", "uncertain", "message", "action", "escalation"]);
29
+ if (Object.keys(input).some((key) => !allowedKeys.has(key))) errors.push("unknown_output_field");
30
+ const outcome = optionalString(input.outcome, 100);
31
+ if (!outcome || !bounds.allowedOutcomes.has(outcome)) errors.push("outcome_not_allowed");
32
+ const policyCode = optionalString(input.policy_code, 100);
33
+ if (outcome !== "escalate_to_human" && (!policyCode || !bounds.allowedPolicyCodes.has(policyCode))) errors.push("policy_code_not_allowed");
34
+ const summary = optionalString(input.summary, 500);
35
+ if (!summary) errors.push("summary_invalid");
36
+ const confidence = typeof input.confidence === "number" && Number.isFinite(input.confidence) && input.confidence >= 0 && input.confidence <= 1
37
+ ? input.confidence : null;
38
+ if (confidence === null) errors.push("confidence_invalid");
39
+ if (typeof input.uncertain !== "boolean") errors.push("uncertainty_invalid");
40
+ const uncertain = input.uncertain === true || confidence === null || confidence < bounds.confidenceThreshold;
41
+ const evidenceReferences = Array.isArray(input.evidence_references)
42
+ ? input.evidence_references.filter((entry): entry is string => typeof entry === "string" && entry.length <= 2_048)
43
+ : [];
44
+ if (!Array.isArray(input.evidence_references) || evidenceReferences.length !== input.evidence_references.length
45
+ || evidenceReferences.some((reference) => !bounds.allowedEvidenceReferences.has(reference))) errors.push("evidence_reference_not_supplied");
46
+
47
+ let message: ValidatedAiResult["message"] = null;
48
+ if (input.message !== null && input.message !== undefined) {
49
+ if (!input.message || typeof input.message !== "object" || Array.isArray(input.message)) errors.push("message_invalid");
50
+ else {
51
+ const messageInput = input.message as Record<string, unknown>;
52
+ const templateId = optionalString(messageInput.template_id, 100);
53
+ const variablesInput = messageInput.variables;
54
+ const variables: Record<string, string> = {};
55
+ if (!templateId || !bounds.allowedTemplateIds.has(templateId)) errors.push("message_template_not_allowed");
56
+ if (!variablesInput || typeof variablesInput !== "object" || Array.isArray(variablesInput)) errors.push("message_variables_invalid");
57
+ else for (const [key, child] of Object.entries(variablesInput)) {
58
+ if (!/^[a-z][a-z0-9_]{0,49}$/u.test(key) || typeof child !== "string" || child.trim().length < 1 || child.trim().length > 160) {
59
+ errors.push("message_variables_invalid");
60
+ break;
61
+ }
62
+ variables[key] = child.trim();
63
+ }
64
+ if (templateId && bounds.allowedTemplateIds.has(templateId)) message = { templateId, variables };
65
+ }
66
+ }
67
+ if (outcome === "insufficient_information" && !message) errors.push("follow_up_message_required");
68
+ const actionCode = input.action === null || input.action === undefined ? null : optionalString(input.action, 100);
69
+ if (actionCode === undefined || actionCode && !bounds.allowedActionCodes.has(actionCode)) errors.push("action_not_allowed");
70
+ const escalationCode = input.escalation === null || input.escalation === undefined ? null : optionalString(input.escalation, 100);
71
+ if (escalationCode === undefined) errors.push("escalation_invalid");
72
+ if (errors.length || !outcome || !summary) return { result: null, errors: [...new Set(errors)] };
73
+ return {
74
+ result: {
75
+ outcome, policyCode: policyCode ?? null, evidenceReferences, summary, confidence, uncertain,
76
+ message, actionCode: actionCode ?? null, escalationCode: escalationCode ?? null,
77
+ },
78
+ errors: [],
79
+ };
80
+ }