create-safest-tools 0.6.0 → 0.7.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 (172) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -2
  3. package/src/config.mjs +2 -2
  4. package/src/update.mjs +10 -1
  5. package/template/README.md +2 -2
  6. package/template/console/README.md +2 -2
  7. package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
  8. package/template/console/analytics/controller.ts +10 -2
  9. package/template/console/analytics/types.ts +7 -0
  10. package/template/console/appeals/AppealFollowup.tsx +171 -0
  11. package/template/console/appeals/AppealWorkspace.tsx +33 -4
  12. package/template/console/appeals/RecordAppealDialog.tsx +42 -0
  13. package/template/console/appeals/appeal-corrections.css +29 -0
  14. package/template/console/appeals/controller.ts +40 -14
  15. package/template/console/appeals/events.ts +1 -0
  16. package/template/console/appeals/types.ts +6 -0
  17. package/template/console/appeals/useAppealDraftGuard.ts +12 -0
  18. package/template/console/assistant/AssistantWorkspace.tsx +28 -5
  19. package/template/console/command/CommandCentre.tsx +101 -64
  20. package/template/console/command/controller.ts +80 -70
  21. package/template/console/command/events.ts +1 -2
  22. package/template/console/command/presentation.ts +29 -0
  23. package/template/console/command/store.ts +4 -4
  24. package/template/console/command/types.ts +26 -21
  25. package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
  26. package/template/console/configuration/ConfigurationDialog.tsx +28 -13
  27. package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
  28. package/template/console/configuration/types.ts +1 -1
  29. package/template/console/lib/priority.ts +13 -0
  30. package/template/console/operations/CaseEscalations.tsx +142 -0
  31. package/template/console/operations/OperationsWorkspace.tsx +2 -0
  32. package/template/console/operations/case-escalations.css +20 -0
  33. package/template/console/people/PeopleWorkspace.tsx +8 -4
  34. package/template/console/people/PersonAccessDialog.tsx +40 -0
  35. package/template/console/people/types.ts +1 -0
  36. package/template/console/profile/ProfileWorkspace.tsx +1 -1
  37. package/template/console/registry/IntegrationSetup.tsx +41 -0
  38. package/template/console/registry/IntegrationVerification.tsx +54 -0
  39. package/template/console/registry/RegistryDetailsDialog.tsx +2 -2
  40. package/template/console/registry/RegistryDialog.tsx +56 -27
  41. package/template/console/registry/RegistryWorkspace.tsx +10 -5
  42. package/template/console/registry/integration-verification.css +25 -0
  43. package/template/console/reports/CaseActions.tsx +81 -0
  44. package/template/console/reports/CaseContinuity.tsx +27 -0
  45. package/template/console/reports/CaseNotices.tsx +48 -0
  46. package/template/console/reports/CaseOwnership.tsx +78 -0
  47. package/template/console/reports/CaseReviewTasks.tsx +148 -0
  48. package/template/console/reports/EnrichmentFindings.tsx +148 -0
  49. package/template/console/reports/InvestigationBrief.tsx +29 -0
  50. package/template/console/reports/ReportDrawer.tsx +122 -67
  51. package/template/console/reports/ReportWorkspace.tsx +112 -23
  52. package/template/console/reports/controller.ts +125 -60
  53. package/template/console/reports/events.ts +9 -2
  54. package/template/console/reports/load-inbox.ts +17 -0
  55. package/template/console/reports/presence-presentation.ts +52 -0
  56. package/template/console/reports/private-drafts.ts +32 -0
  57. package/template/console/reports/store.ts +2 -2
  58. package/template/console/reports/types.ts +50 -2
  59. package/template/console/reports/use-editing-access.ts +13 -0
  60. package/template/console/settings/SettingsWorkspace.tsx +1 -1
  61. package/template/console/shell/WorkspaceShell.tsx +55 -29
  62. package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
  63. package/template/console/shell/controller.ts +23 -16
  64. package/template/console/shell/navigation.ts +30 -39
  65. package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
  66. package/template/console/workflows/PlaybookExamples.tsx +160 -0
  67. package/template/console/workflows/PlaybookFields.tsx +109 -0
  68. package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
  69. package/template/console/workflows/PlaybookHistory.tsx +106 -0
  70. package/template/console/workflows/WorkflowDialogs.tsx +2 -1
  71. package/template/console/workflows/WorkflowStudio.tsx +108 -39
  72. package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
  73. package/template/console/workflows/templates.ts +3 -3
  74. package/template/console/workflows/types.ts +11 -0
  75. package/template/migrations/0001_safest_resolve_baseline.sql +0 -1
  76. package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
  77. package/template/migrations/0003_action_confirmation.sql +24 -0
  78. package/template/migrations/0004_notice_recovery.sql +22 -0
  79. package/template/migrations/0005_appeal_continuity.sql +17 -0
  80. package/template/migrations/0006_case_ownership.sql +23 -0
  81. package/template/migrations/0007_integration_verification.sql +21 -0
  82. package/template/migrations/0008_case_escalations.sql +135 -0
  83. package/template/migrations/0009_participant_continuation.sql +61 -0
  84. package/template/migrations/0010_appeal_corrections.sql +41 -0
  85. package/template/npm-shrinkwrap.json +8036 -0
  86. package/template/package.json +31 -22
  87. package/template/public/_headers +16 -1
  88. package/template/public/console/auth-shell.js +22151 -14847
  89. package/template/public/console/release.json +1 -0
  90. package/template/public/console/workspace-features.css +2 -0
  91. package/template/public/embed/embed.css +3 -0
  92. package/template/public/embed/embed.js +19 -1
  93. package/template/public/embed/index.html +2 -0
  94. package/template/public/index.html +9 -6
  95. package/template/public/participant.css +2 -0
  96. package/template/public/participant.html +13 -0
  97. package/template/public/participant.js +111 -0
  98. package/template/public/public-report.js +21 -1
  99. package/template/public/report/index.html +1 -1
  100. package/template/public/styles.css +599 -83
  101. package/template/public/workspace.css +124 -0
  102. package/template/scripts/reports-plan.mjs +1 -1
  103. package/template/shared/case-escalations.ts +31 -0
  104. package/template/shared/integration-progress.ts +27 -0
  105. package/template/shared/notice-inputs.ts +3 -0
  106. package/template/shared/playbook-resources.ts +14 -0
  107. package/template/shared/playbooks.ts +249 -0
  108. package/template/src/agent-executor.ts +25 -8
  109. package/template/src/ai-observability.ts +14 -13
  110. package/template/src/ai-registry-validation.ts +1 -1
  111. package/template/src/appeal-continuity.ts +75 -0
  112. package/template/src/appeal-correction-state.ts +46 -0
  113. package/template/src/appeal-corrections.ts +112 -0
  114. package/template/src/appeal-decisions.ts +85 -0
  115. package/template/src/assistant.ts +3 -4
  116. package/template/src/case-escalations.ts +223 -0
  117. package/template/src/case-investigator.ts +179 -0
  118. package/template/src/component-executor.ts +5 -4
  119. package/template/src/configuration-registry.ts +4 -1
  120. package/template/src/connection-oauth.ts +12 -3
  121. package/template/src/database-readiness.ts +14 -0
  122. package/template/src/human-tasks.ts +152 -55
  123. package/template/src/index.ts +191 -29
  124. package/template/src/installation-admin.ts +2 -1
  125. package/template/src/integration-progress.ts +45 -0
  126. package/template/src/integration-verification.ts +249 -0
  127. package/template/src/investigation-evidence.ts +95 -0
  128. package/template/src/investigator-fixture.ts +30 -0
  129. package/template/src/notice-delivery.ts +19 -0
  130. package/template/src/notice-recovery.ts +81 -0
  131. package/template/src/operator-scope.ts +31 -0
  132. package/template/src/participant-access.ts +163 -0
  133. package/template/src/participant-case.ts +87 -0
  134. package/template/src/participant-customer-access.ts +93 -0
  135. package/template/src/participant-recovery.ts +43 -0
  136. package/template/src/participant-routes.ts +67 -0
  137. package/template/src/platform-registry-validation.ts +25 -2
  138. package/template/src/platform-registry.ts +10 -7
  139. package/template/src/playbook-resources.ts +58 -0
  140. package/template/src/report-admin.ts +9 -2
  141. package/template/src/report-auth.ts +1 -28
  142. package/template/src/report-better-auth.ts +18 -6
  143. package/template/src/report-delivery.ts +149 -148
  144. package/template/src/report-email-inbound.ts +33 -22
  145. package/template/src/report-email-template.ts +5 -2
  146. package/template/src/report-email.ts +7 -30
  147. package/template/src/report-governance.ts +42 -45
  148. package/template/src/report-http.ts +37 -2
  149. package/template/src/report-operations.ts +30 -67
  150. package/template/src/report-operator-accounts.ts +7 -6
  151. package/template/src/report-ownership.ts +64 -0
  152. package/template/src/report-presence.ts +15 -9
  153. package/template/src/report-repository.ts +16 -7
  154. package/template/src/report-turnstile.ts +7 -3
  155. package/template/src/report-types.ts +1 -0
  156. package/template/src/report-workflow.ts +183 -249
  157. package/template/src/workflow-actions.ts +248 -156
  158. package/template/src/workflow-compiler.ts +63 -12
  159. package/template/src/workflow-dynamic-runtime.ts +20 -9
  160. package/template/src/workflow-effects.ts +117 -39
  161. package/template/src/workflow-event-transport.ts +16 -0
  162. package/template/src/workflow-expressions.ts +30 -0
  163. package/template/src/workflow-loader-code.ts +21 -0
  164. package/template/src/workflow-platform-types.ts +3 -0
  165. package/template/src/workflow-platform-validation.ts +23 -1
  166. package/template/src/workflow-repository.ts +131 -28
  167. package/template/src/workflow-runs.ts +61 -13
  168. package/template/src/workflow-simulator.ts +7 -4
  169. package/template/src/workspace-governance.ts +6 -4
  170. package/template/tsconfig.json +1 -0
  171. package/template/vite.console.config.ts +30 -2
  172. package/template/worker-configuration.d.ts +3 -2
@@ -0,0 +1,249 @@
1
+ import { canonicalJson } from "./audit";
2
+ import { ApiError, json, methodNotAllowed, readJsonObject } from "./report-http";
3
+ import type { OperatorSession } from "./report-types";
4
+ import { parsePlaybook, type PlaybookGuide } from "../shared/playbooks";
5
+ import type { IntegrationVerification, IntegrationVerificationList, VerificationChoice, VerificationStage } from "../shared/integration-progress";
6
+
7
+ type Row = Record<string, string | number | null>;
8
+ interface Selection { integrationId: string; formVersionId: string; queueId: string; decisionCode: string }
9
+ interface CheckRow {
10
+ id: string; integration_id: string; form_version_id: string; queue_id: string; workflow_version_id: string; pin_id: string;
11
+ decision_code: string; configuration_digest: string; configuration_json: string; report_id: string | null;
12
+ workflow_run_id: string | null; started_at: string; invalidated_at: string | null;
13
+ }
14
+ interface Snapshot {
15
+ selection: Selection; guide: PlaybookGuide; integrationName: string; formName: string; queueName: string; playbookName: string;
16
+ workflowVersionId: string; pinId: string; routingId: string; queueVersionId: string; policyVersionId: string;
17
+ records: Row[][]; runtime: Record<string, string | boolean>;
18
+ }
19
+ const denied = () => new ApiError(403, "integration_verification_denied", "Verifying a customer connection requires integration, playbook and full case/evidence access.");
20
+ function assertAccess(session: OperatorSession, queueId?: string) {
21
+ const p = session.permissions;
22
+ if (!p.manageComponents || !p.manageIntegrations || !p.manageWorkflows || !p.readReports || !p.readEvidence || session.fieldScope != null) throw denied();
23
+ if (queueId && session.queueScope != null && !session.queueScope.includes(queueId)) throw new ApiError(404, "verification_not_found", "This verification is not available in your queues.");
24
+ }
25
+ async function hash(value: unknown) {
26
+ const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonicalJson(value)));
27
+ return [...new Uint8Array(bytes)].map(byte => byte.toString(16).padStart(2, "0")).join("");
28
+ }
29
+ function localSandbox(env: Env) { return env.AI_LOCAL_FIXTURE_ENABLED === "true"; }
30
+ function selection(row: CheckRow): Selection { return { integrationId: row.integration_id, formVersionId: row.form_version_id, queueId: row.queue_id, decisionCode: row.decision_code }; }
31
+
32
+ export async function loadVerificationChoices(env: Env, session: OperatorSession): Promise<VerificationChoice> {
33
+ assertAccess(session);
34
+ const [integrations, forms, pins] = await env.DB.batch<Row>([
35
+ env.DB.prepare("SELECT id, name, form_version_id AS formVersionId FROM application_integrations WHERE installation_id='default' AND enabled=1 ORDER BY name, id"),
36
+ env.DB.prepare("SELECT id,title AS name FROM report_form_versions WHERE installation_id='default' AND published_at IS NOT NULL AND retired_at IS NULL ORDER BY title,version DESC"),
37
+ env.DB.prepare(`SELECT q.id AS queueId,q.name AS queueName,d.name,v.id AS workflowVersionId,v.graph_json
38
+ FROM queue_workflow_pins p JOIN queue_definitions q ON q.id=p.queue_id JOIN workflow_versions v ON v.id=p.workflow_version_id JOIN workflow_definitions d ON d.id=v.workflow_id
39
+ WHERE p.mode='primary' AND p.deactivated_at IS NULL AND q.enabled=1 AND q.status='active' AND v.retired_at IS NULL
40
+ AND v.authority_mode IN ('assist','bounded_auto') AND json_type(v.graph_json,'$.playbook')='object'
41
+ AND (?1=1 OR q.id IN (SELECT value FROM json_each(?2))) ORDER BY q.name,p.activated_at DESC`).bind(session.queueScope == null ? 1 : 0, canonicalJson(session.queueScope ?? [])),
42
+ ]);
43
+ return {
44
+ integrations: integrations.results.map(r => ({ id: String(r.id), name: String(r.name), formVersionId: r.formVersionId == null ? null : String(r.formVersionId) })),
45
+ forms: forms.results.map(r => ({ id: String(r.id), name: String(r.name) })),
46
+ playbooks: pins.results.flatMap(r => { try { const guide = parsePlaybook(JSON.parse(String(r.graph_json)).playbook); return [{ queueId: String(r.queueId), queueName: String(r.queueName), name: String(r.name), workflowVersionId: String(r.workflowVersionId), decisionCodes: guide.outcomes.decision_codes }]; } catch { return []; } }),
47
+ };
48
+ }
49
+
50
+ /** Secret-free snapshot of the selected path. Reads share one D1 transaction. */
51
+ async function snapshot(env: Env, choice: Selection): Promise<Snapshot> {
52
+ const pin = await env.DB.prepare(`SELECT p.id AS pinId,p.workflow_version_id AS workflowVersionId,q.active_version_id AS queueVersionId,q.name AS queueName,d.name AS playbookName,v.graph_json
53
+ FROM queue_workflow_pins p JOIN queue_definitions q ON q.id=p.queue_id JOIN workflow_versions v ON v.id=p.workflow_version_id JOIN workflow_definitions d ON d.id=v.workflow_id
54
+ WHERE p.queue_id=?1 AND p.mode='primary' AND p.deactivated_at IS NULL AND q.enabled=1 AND q.status='active'
55
+ AND v.retired_at IS NULL AND v.authority_mode IN ('assist','bounded_auto') AND json_type(v.graph_json,'$.playbook')='object'
56
+ ORDER BY p.activated_at DESC,p.id DESC LIMIT 1`).bind(choice.queueId).first<Row>();
57
+ if (!pin) throw new ApiError(409, "verification_configuration_unavailable", "Activate a primary playbook for this queue before starting verification.");
58
+ const guide = parsePlaybook(JSON.parse(String(pin.graph_json)).playbook);
59
+ if (!guide.outcomes.decision_codes.includes(choice.decisionCode)) throw new ApiError(400, "verification_decision_invalid", "Choose an outcome supported by this playbook.");
60
+ const dependencyComponents = await env.DB.prepare(`SELECT dependency_version_id AS id FROM workflow_dependencies WHERE workflow_version_id=?1 AND dependency_type='component'
61
+ UNION SELECT json_extract(value,'$.component_version_id') AS id FROM json_each((SELECT rules_json FROM routing_rule_versions WHERE installation_id='default' AND published_at IS NOT NULL ORDER BY version DESC LIMIT 1),'$.intake_enrichments')`).bind(String(pin.workflowVersionId)).all<{id:string}>();
62
+ const componentIds = canonicalJson([...new Set([...guide.evidence.map(item => item.component_version_id), ...guide.outcomes.actions.map(item => item.component_version_id), ...dependencyComponents.results.map(item=>item.id)])].filter(Boolean).sort());
63
+ const connectionIds = `SELECT connection_version_id FROM component_versions WHERE id IN (SELECT value FROM json_each(?1)) UNION SELECT dependency_version_id FROM workflow_dependencies WHERE workflow_version_id=?2 AND dependency_type='connection'`;
64
+ const query = (sql: string, ...values: (string | number | null)[]) => values.length ? env.DB.prepare(sql).bind(...values) : env.DB.prepare(sql);
65
+ const results = await env.DB.batch<Row>([
66
+ query("SELECT * FROM application_integrations WHERE id=?1 AND installation_id='default' AND enabled=1", choice.integrationId),
67
+ query("SELECT * FROM report_form_versions WHERE id=?1 AND published_at IS NOT NULL AND retired_at IS NULL", choice.formVersionId),
68
+ query("SELECT * FROM queue_definitions WHERE id=?1", choice.queueId),
69
+ query("SELECT * FROM queue_versions WHERE id=?1", String(pin.queueVersionId)),
70
+ query("SELECT * FROM queue_workflow_pins WHERE queue_id=?1 AND mode='primary' AND deactivated_at IS NULL ORDER BY id", choice.queueId),
71
+ query("SELECT * FROM workflow_versions WHERE id=?1", String(pin.workflowVersionId)),
72
+ query("SELECT * FROM routing_rule_versions WHERE installation_id='default' AND published_at IS NOT NULL ORDER BY version DESC LIMIT 1"),
73
+ query("SELECT * FROM installations WHERE id='default'"),
74
+ query("SELECT * FROM policy_versions WHERE id=?1", guide.policy_version_id),
75
+ query("SELECT * FROM component_versions WHERE id IN (SELECT value FROM json_each(?1)) ORDER BY id", componentIds),
76
+ query("SELECT d.* FROM component_definitions d WHERE d.id IN (SELECT definition_id FROM component_versions WHERE id IN (SELECT value FROM json_each(?1))) ORDER BY d.id", componentIds),
77
+ query(`SELECT * FROM connection_versions WHERE id IN (${connectionIds}) ORDER BY id`, componentIds, String(pin.workflowVersionId)),
78
+ query(`SELECT * FROM connection_definitions WHERE id IN (SELECT connection_id FROM connection_versions WHERE id IN (${connectionIds})) ORDER BY id`, componentIds, String(pin.workflowVersionId)),
79
+ query(`SELECT id,connection_version_id,strategy,secret_reference,granted_scopes_json,status,created_at,updated_at FROM connection_credentials WHERE connection_version_id IN (${connectionIds}) ORDER BY id`, componentIds, String(pin.workflowVersionId)),
80
+ query("SELECT * FROM message_template_versions WHERE id IN (SELECT value FROM json_each(?1)) ORDER BY id", canonicalJson(guide.outcomes.notices.map(item => item.template_version_id))),
81
+ query("SELECT * FROM workflow_dependencies WHERE workflow_version_id=?1 ORDER BY dependency_type,dependency_key", String(pin.workflowVersionId)),
82
+ query("SELECT id,key_id,status,valid_from,expires_at,created_at,revoked_at FROM integration_api_keys WHERE integration_id=?1 ORDER BY id", choice.integrationId),
83
+ query("SELECT * FROM routing_agent_versions WHERE installation_id='default' AND published_at IS NOT NULL ORDER BY version DESC LIMIT 1"),
84
+ query("SELECT * FROM agent_versions WHERE id=?1", guide.investigation.enabled ? guide.investigation.agent_version_id : ""),
85
+ query("SELECT * FROM model_alias_versions WHERE id IN (SELECT model_alias_version_id FROM agent_versions WHERE id=?1)", guide.investigation.enabled ? guide.investigation.agent_version_id : ""),
86
+ ]);
87
+ const records = results.map(result => result.results);
88
+ const integration = records[0]?.[0], form = records[1]?.[0], routing = records[6]?.[0];
89
+ if (!integration || !form || !routing || (integration.form_version_id && integration.form_version_id !== choice.formVersionId)
90
+ || records[4]?.length !== 1 || records[4][0]?.id !== pin.pinId || records[2]?.[0]?.active_version_id !== pin.queueVersionId) {
91
+ throw new ApiError(409, "verification_configuration_unavailable", "The reporting form or active playbook changed. Refresh the setup and choose the current configuration.");
92
+ }
93
+ const secretNames = new Set(["CONTEXT_SIGNING_SECRET", "INTEGRATION_API_KEYS_JSON", "NOTIFICATION_WEBHOOK_SECRET", "CREDENTIAL_ENCRYPTION_KEY", "RATE_LIMIT_PEPPER", "TURNSTILE_SECRET_KEY", "AI_LOCAL_FIXTURE_JSON"]);
94
+ for (const row of [...(records[11] ?? []), ...(records[13] ?? [])]) if (row.secret_reference) secretNames.add(String(row.secret_reference));
95
+ const runtime: Record<string, string | boolean> = {
96
+ publicUrl: env.PUBLIC_BASE_URL ?? "", publicIntegrationId: env.PUBLIC_INTEGRATION_ID ?? "", turnstileSiteKey: env.TURNSTILE_SITE_KEY ?? "", contextTtl: env.CONTEXT_TOKEN_TTL_SECONDS ?? "", allowedOrigins: env.ALLOWED_ORIGINS ?? "", notificationUrl: env.NOTIFICATION_WEBHOOK_URL ?? "",
97
+ emailFrom: env.REPORTER_EMAIL_FROM ?? "", emailAvailable: Boolean(env.REPORTER_EMAIL), localSandbox: localSandbox(env),
98
+ };
99
+ for (const name of [...secretNames].sort()) { const value: unknown = Reflect.get(env, name); runtime[`credential:${name}`] = await hash(typeof value === "string" ? value : null); }
100
+ return { selection: choice, guide, integrationName: String(integration.name), formName: String(form.title), queueName: String(pin.queueName), playbookName: String(pin.playbookName), workflowVersionId: String(pin.workflowVersionId), pinId: String(pin.pinId), routingId: String(routing.id), queueVersionId: String(pin.queueVersionId), policyVersionId: guide.policy_version_id, records, runtime };
101
+ }
102
+
103
+ async function current(env: Env, row: CheckRow): Promise<boolean> {
104
+ if (row.invalidated_at) return false;
105
+ try { if (await hash(await snapshot(env, selection(row))) === row.configuration_digest) return true; }
106
+ catch (error) { if (!(error instanceof ApiError) || ![400,409].includes(error.status)) throw error; }
107
+ await env.DB.prepare("UPDATE integration_verifications SET invalidated_at=COALESCE(invalidated_at,?2) WHERE id=?1").bind(row.id,new Date().toISOString()).run();
108
+ return false;
109
+ }
110
+
111
+ export async function startIntegrationVerification(env: Env, session: OperatorSession, choice: Selection): Promise<IntegrationVerification> {
112
+ assertAccess(session, choice.queueId);
113
+ const config = await snapshot(env, choice), id = crypto.randomUUID(), startedAt = new Date().toISOString();
114
+ await env.DB.prepare(`INSERT INTO integration_verifications (id,installation_id,integration_id,form_version_id,queue_id,workflow_version_id,pin_id,decision_code,configuration_digest,configuration_json,started_by,started_at)
115
+ VALUES (?1,'default',?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)`).bind(id,choice.integrationId,choice.formVersionId,choice.queueId,config.workflowVersionId,config.pinId,choice.decisionCode,await hash(config),canonicalJson(config),session.actor.id,startedAt).run();
116
+ return loadIntegrationVerification(env,session,id);
117
+ }
118
+
119
+ async function checkRow(env: Env, session: OperatorSession, id: string) {
120
+ assertAccess(session);
121
+ const row = await env.DB.prepare("SELECT * FROM integration_verifications WHERE id=?1").bind(id).first<CheckRow>();
122
+ if (!row) throw new ApiError(404,"verification_not_found","This verification could not be found.");
123
+ assertAccess(session,row.queue_id); return row;
124
+ }
125
+ const candidateSql = `SELECT r.id,r.public_reference AS reference,r.received_at AS receivedAt,wr.id AS runId
126
+ FROM reports r JOIN workflow_runs wr ON wr.report_id=r.id
127
+ WHERE r.integration_id=?1 AND r.form_version_id=?2 AND r.queue_id=?3 AND r.received_at>?4
128
+ AND r.routing_rule_version_id=?5 AND r.queue_version_id=?6 AND r.policy_version_id=?7
129
+ AND r.source IN ('server_api','signed_widget','public_page') AND r.routing_status='complete'
130
+ AND wr.run_kind='primary' AND wr.authority_mode IN ('assist','bounded_auto') AND wr.workflow_version_id=?8 AND wr.pin_id=?9
131
+ AND wr.created_at>=r.received_at AND wr.state NOT IN ('superseded','terminated')
132
+ AND NOT EXISTS (SELECT 1 FROM integration_verifications other WHERE other.report_id=r.id AND other.id!=?10)`;
133
+ function candidateQuery(env: Env, row: CheckRow, config: Snapshot, reportId?: string) {
134
+ const sql = `${candidateSql}${reportId ? " AND r.id=?11" : ""} ORDER BY r.received_at DESC,r.id,wr.created_at DESC LIMIT 20`;
135
+ const values = [row.integration_id,row.form_version_id,row.queue_id,row.started_at,config.routingId,config.queueVersionId,config.policyVersionId,row.workflow_version_id,row.pin_id,row.id];
136
+ return env.DB.prepare(sql).bind(...(reportId ? [...values,reportId] : values));
137
+ }
138
+
139
+ export async function bindIntegrationVerification(env: Env, session: OperatorSession, id: string, reportId: string): Promise<IntegrationVerification> {
140
+ const row = await checkRow(env,session,id);
141
+ if (row.report_id) {
142
+ if (row.report_id === reportId) return loadIntegrationVerification(env,session,id);
143
+ throw new ApiError(409,"verification_case_already_linked","This check already follows another case. Start a new check to use a different case.");
144
+ }
145
+ if (!await current(env,row)) throw new ApiError(409,"verification_configuration_changed","Configuration changed. Start a new check before submitting a fresh report.");
146
+ const config = JSON.parse(row.configuration_json) as Snapshot;
147
+ const candidate = await candidateQuery(env,row,config,reportId).first<{ id: string; runId: string }>();
148
+ if (!candidate) throw new ApiError(409,"verification_case_mismatch","Choose a fresh report from this integration and form after it reaches the selected queue and playbook.");
149
+ try {
150
+ // Eligibility and compare-and-set run in the same SQL statement. A second browser cannot replace the linked case.
151
+ const eligibility = candidateSql.replace(/\?(\d+)/gu, (_, position: string) => `?${Number(position) + 4}`);
152
+ const result = await env.DB.prepare(`UPDATE integration_verifications SET report_id=?2,workflow_run_id=?3,linked_at=?4
153
+ WHERE id=?1 AND report_id IS NULL AND invalidated_at IS NULL AND EXISTS (
154
+ SELECT 1 FROM (${eligibility}) eligible WHERE eligible.id=?2 AND eligible.runId=?3
155
+ )`).bind(row.id,reportId,candidate.runId,new Date().toISOString(),row.integration_id,row.form_version_id,row.queue_id,row.started_at,config.routingId,config.queueVersionId,config.policyVersionId,row.workflow_version_id,row.pin_id,row.id).run();
156
+ if (!result.meta.changes) throw new ApiError(409,"verification_case_already_linked","Another change was saved first. Refresh this check.");
157
+ } catch (error) { if (error instanceof ApiError) throw error; if (error instanceof Error && error.message.includes("UNIQUE constraint")) throw new ApiError(409,"verification_case_already_linked","Another check already follows this case. Submit a fresh report."); throw error; }
158
+ return loadIntegrationVerification(env,session,id);
159
+ }
160
+
161
+ function stage(key: VerificationStage["key"], title: string, state: VerificationStage["state"], detail: string): VerificationStage { return { key,title,state,detail }; }
162
+ async function evidenceStages(env: Env, row: CheckRow, config: Snapshot): Promise<{ report: IntegrationVerification["report"]; stages: VerificationStage[] }> {
163
+ const [reportResult, runResult, decisionsResult, findingsResult, actionsResult, noticesResult] = await env.DB.batch<Row>([
164
+ env.DB.prepare("SELECT id,public_reference AS reference,state,queue_id,form_version_id,routing_rule_version_id,policy_version_id,queue_version_id,integration_id FROM reports WHERE id=?1").bind(row.report_id),
165
+ env.DB.prepare("SELECT id,state,report_id,run_kind,authority_mode,pin_id,workflow_version_id FROM workflow_runs WHERE id=?1").bind(row.workflow_run_id),
166
+ env.DB.prepare("SELECT id,decision_code,idempotency_key FROM decisions WHERE report_id=?1 ORDER BY created_at DESC,rowid DESC LIMIT 1").bind(row.report_id),
167
+ env.DB.prepare(`SELECT f.component_version_id,s.node_id,f.status,f.expires_at,f.superseded_at,
168
+ EXISTS (SELECT 1 FROM component_runs cr WHERE cr.finding_id=f.id AND cr.report_id=f.report_id AND cr.workflow_run_id=f.run_id
169
+ AND cr.step_run_id=f.step_run_id AND cr.component_version_id=f.component_version_id AND cr.state='succeeded' AND cr.cache_hit=0 AND cr.attempt_count>0
170
+ AND (NOT EXISTS (SELECT 1 FROM component_versions cv WHERE cv.id=f.component_version_id AND cv.connection_version_id IS NOT NULL)
171
+ OR EXISTS (SELECT 1 FROM connection_attempts ca JOIN component_versions cv ON cv.id=f.component_version_id WHERE ca.component_run_id=cr.id AND ca.connection_version_id=cv.connection_version_id AND ca.operation_id=cr.operation_id AND ca.response_class='success' AND ca.response_digest IS NOT NULL))) AS fresh_read
172
+ FROM findings f JOIN workflow_step_runs s ON s.id=f.step_run_id AND s.run_id=f.run_id
173
+ WHERE f.report_id=?1 AND f.run_id=?2 ORDER BY f.created_at DESC,f.id`).bind(row.report_id,row.workflow_run_id),
174
+ env.DB.prepare(`SELECT p.id,p.state,p.action_code,p.component_version_id,p.confirmed_at,p.last_receipt_json,p.operation_id,p.payload_digest,p.decision_id,
175
+ EXISTS (SELECT 1 FROM connection_attempts a JOIN component_versions cv ON cv.id=p.component_version_id WHERE a.action_proposal_id=p.id AND a.operation_id=p.operation_id AND a.connection_version_id=cv.connection_version_id AND a.response_class='success' AND a.response_digest IS NOT NULL) AS customer_attempt
176
+ FROM action_proposals p WHERE p.report_id=?1 AND p.run_id=?2 ORDER BY p.created_at DESC,p.id`).bind(row.report_id,row.workflow_run_id),
177
+ env.DB.prepare(`SELECT m.audience,m.template_version_id,m.delivery_state,m.audience_checked_at,n.decision_id,
178
+ CASE WHEN n.delivery_state='delivered' AND c.report_id=m.report_id AND c.audience=m.audience AND participant.report_id=m.report_id AND participant.audience=m.audience
179
+ AND participant.contact_mode!='none' AND participant.reference_redacted_at IS NULL AND (
180
+ EXISTS (SELECT 1 FROM email_deliveries e WHERE e.message_id=m.id AND e.audience=m.audience AND e.state='delivered' AND e.delivered_at IS NOT NULL AND e.attempt_count>0)
181
+ OR EXISTS (SELECT 1 FROM notification_outbox o JOIN notification_attempts a ON a.notification_id=o.id
182
+ WHERE o.notice_id=n.id AND o.status='delivered' AND o.payload_redacted_at IS NULL AND a.response_status BETWEEN 200 AND 299
183
+ AND json_extract(o.payload_json,'$.audience')=m.audience
184
+ AND json_extract(o.payload_json,'$.recipient_reference')=participant.reference)
185
+ ) THEN 1 ELSE 0 END AS confirmed
186
+ FROM case_messages m JOIN notices n ON n.message_id=m.id AND n.report_id=m.report_id AND n.audience=m.audience AND n.template_version_id=m.template_version_id
187
+ JOIN conversations c ON c.id=m.conversation_id JOIN report_participants participant ON participant.id=c.participant_id
188
+ WHERE m.report_id=?1 AND m.workflow_run_id=?2 AND m.direction='outbound' AND m.redacted_at IS NULL AND n.redacted_at IS NULL
189
+ ORDER BY m.created_at DESC,m.id`).bind(row.report_id,row.workflow_run_id),
190
+ ]);
191
+ const report = reportResult.results[0], run = runResult.results[0], decision = decisionsResult.results[0];
192
+ const identityMatches = report && run && report.integration_id===row.integration_id && report.form_version_id===row.form_version_id && report.queue_id===row.queue_id
193
+ && report.routing_rule_version_id===config.routingId && report.queue_version_id===config.queueVersionId && report.policy_version_id===config.policyVersionId
194
+ && run.report_id===row.report_id && run.run_kind==='primary' && ['assist','bounded_auto'].includes(String(run.authority_mode)) && run.pin_id===row.pin_id && run.workflow_version_id===row.workflow_version_id;
195
+ const stages: VerificationStage[] = [stage("intake","Receive the fresh report",identityMatches?"complete":"blocked",identityMatches?"This case came through the selected integration, form, routing and active playbook.":"The linked case no longer matches the selected route and playbook. Start a fresh check.")];
196
+ const required = config.guide.evidence.filter(item => item.required), now = new Date().toISOString();
197
+ const missing = required.filter(item => !findingsResult.results.some(f => f.component_version_id===item.component_version_id && f.node_id===`evidence-${item.id}` && f.status==='available' && !f.superseded_at && (!f.expires_at || String(f.expires_at)>now) && f.fresh_read===1));
198
+ const failedEvidence = missing.some(item => findingsResult.results.some(f => f.component_version_id===item.component_version_id && f.node_id===`evidence-${item.id}` && ['unavailable','invalid','stale'].includes(String(f.status))));
199
+ stages.push(stage("context","Retrieve required customer evidence",!required.length?"not_required":!missing.length?"complete":failedEvidence?"blocked":"waiting",!required.length?"This path has no required evidence capabilities.":missing.length?`Still need a fresh successful customer read: ${missing.map(item => item.label).join(", ")}. Cached evidence does not verify the connection.`:"Every required capability returned fresh evidence for this case."));
200
+ const expectedActions = config.guide.outcomes.actions.filter(item => item.decision_code===row.decision_code);
201
+ const actionComplete = (a: Row) => { try { const receipt = JSON.parse(String(a.last_receipt_json)); return a.state==='applied' && a.customer_attempt===1 && a.confirmed_at && receipt.status==='applied' && receipt.action_id===a.id && receipt.operation_id===a.operation_id && receipt.payload_digest===a.payload_digest && a.decision_id===decision?.id; } catch { return false; } };
202
+ const missingActions = expectedActions.filter(item => !actionsResult.results.some(a => a.action_code===item.action_code && a.component_version_id===item.component_version_id && actionComplete(a)));
203
+ const failedActions = actionsResult.results.some(a => ['failed','rejected','cancelled','compensation_required','compensated'].includes(String(a.state)));
204
+ stages.push(stage("action","Confirm the customer change",!expectedActions.length?"not_required":!missingActions.length?"complete":failedActions?"blocked":"waiting",!expectedActions.length?"No customer action is configured for this outcome. Other action paths need their own check.":missingActions.length?`Awaiting an applied receipt for ${missingActions.map(item => item.action_code.replaceAll('_',' ')).join(', ')}. Approval or acceptance alone is insufficient.`:"The customer application confirmed each approved change for this decision."));
205
+ const expectedNotices = config.guide.outcomes.notices;
206
+ const missingNotices = expectedNotices.filter(item => !noticesResult.results.some(n => n.audience===item.audience && n.template_version_id===item.template_version_id && n.decision_id===decision?.id && n.delivery_state==='delivered' && n.audience_checked_at && n.confirmed===1));
207
+ const failedNotices = noticesResult.results.some(n => ['failed','suppressed'].includes(String(n.delivery_state)));
208
+ stages.push(stage("notice","Deliver the outcome to participants",!expectedNotices.length?"not_required":!missingNotices.length?"complete":failedNotices?"blocked":"waiting",!expectedNotices.length?"This path has no configured participant outcome notices.":missingNotices.length?`Awaiting confirmed delivery to ${missingNotices.map(item => item.audience==='reporter'?'the reporter':'the affected person').join(' and ')}. Open the case Outcome section to recover the participant delivery.`:"The configured outcome reached the delivery provider for each correct case participant. This does not establish that they read it."));
209
+ const terminal = identityMatches && run.state==='succeeded' && ['resolved_by_human','resolved_by_ai','closed'].includes(String(report.state))
210
+ && decision?.decision_code===row.decision_code && String(decision.idempotency_key).startsWith(`workflow-decision:${row.workflow_run_id}:`)
211
+ && !actionsResult.results.some(a => !actionComplete(a)) && !noticesResult.results.some(n => n.delivery_state!=='delivered');
212
+ const failed = !identityMatches || ['failed','terminated','repair_required','superseded'].includes(String(run?.state)) || (decision && decision.decision_code!==row.decision_code);
213
+ stages.push(stage("playbook","Finish this outcome path",terminal?"complete":failed?"blocked":"waiting",terminal?"This primary playbook finished with the selected decision and no unconfirmed actions or messages.":failed?"The case needs attention or followed a different outcome. Open it to review the reason; use a new check for a different scenario.":"Continue the case through review, confirmed customer actions and outcome delivery."));
214
+ return { report: report?{id:String(report.id),reference:String(report.reference)}:null, stages };
215
+ }
216
+
217
+ export async function loadIntegrationVerification(env: Env, session: OperatorSession, id: string): Promise<IntegrationVerification> {
218
+ const row = await checkRow(env,session,id), config = JSON.parse(row.configuration_json) as Snapshot;
219
+ const currentBefore = await current(env,row);
220
+ const evidence = row.report_id ? await evidenceStages(env,row,config) : { report:null, stages:[stage("intake","Submit a fresh test report","waiting","Use a safe test account and target in your customer product, then refresh this check and select that report. A check follows normal review, action and notification behavior.")] };
221
+ const candidates = !row.report_id && currentBefore ? (await candidateQuery(env,row,config).all<{id:string;reference:string;receivedAt:string}>()).results : [];
222
+ // Recheck after saved evidence reads. Verification is derived on each response, never a durable success flag.
223
+ const stillCurrent = currentBefore && await current(env,row);
224
+ const state: IntegrationVerification["state"] = !stillCurrent ? "stale" : !row.report_id ? "awaiting_case" : evidence.stages.some(s=>s.state==='blocked') ? "blocked" : evidence.stages.every(s=>['complete','not_required'].includes(s.state)) ? "verified" : "in_progress";
225
+ return { id:row.id,startedAt:row.started_at,integrationName:config.integrationName,formName:config.formName,queueName:config.queueName,playbookName:config.playbookName,decisionCode:row.decision_code,state,report:evidence.report,candidates:candidates.map(({id,reference,receivedAt})=>({id,reference,receivedAt})),stages:evidence.stages,localSandbox:localSandbox(env)||config.runtime.localSandbox===true };
226
+ }
227
+
228
+ export async function listIntegrationVerifications(env: Env, session: OperatorSession): Promise<IntegrationVerificationList> {
229
+ assertAccess(session);
230
+ const [choices, rows] = await Promise.all([
231
+ loadVerificationChoices(env,session),
232
+ env.DB.prepare(`SELECT id FROM integration_verifications WHERE (?1=1 OR queue_id IN (SELECT value FROM json_each(?2))) ORDER BY started_at DESC,id DESC LIMIT 5`).bind(session.queueScope==null?1:0,canonicalJson(session.queueScope??[])).all<{id:string}>(),
233
+ ]);
234
+ const runs = await Promise.all(rows.results.map(row=>loadIntegrationVerification(env,session,row.id)));
235
+ return { choices,runs };
236
+ }
237
+
238
+ /** Caller must use requireOperator for authentication and existing CSRF enforcement. */
239
+ export async function handleIntegrationVerification(request: Request, env: Env, session: OperatorSession): Promise<Response> {
240
+ const match = /^\/v1\/admin\/integration-verifications(?:\/([^/]+)(?:\/(bind))?)?$/u.exec(new URL(request.url).pathname);
241
+ if (!match) throw new ApiError(404,"not_found","This route does not exist.");
242
+ assertAccess(session);
243
+ if (request.method==='GET' && !match[2]) return json(match[1]?await loadIntegrationVerification(env,session,match[1]):await listIntegrationVerifications(env,session));
244
+ if (request.method!=='POST' || (match[1] && !match[2])) return methodNotAllowed(match[1]&&match[2]?['POST']:['GET',...(!match[1]?['POST']:[])]);
245
+ const body = await readJsonObject(request,4096);
246
+ const text = (key:string) => { const value = body[key]; if(typeof value!=='string' || !value.trim() || value.length>160) throw new ApiError(400,"verification_input_invalid",`Choose a valid ${key.replaceAll('_',' ')}.`); return value.trim(); };
247
+ if(match[1]) return json(await bindIntegrationVerification(env,session,match[1],text('report_id')));
248
+ return json(await startIntegrationVerification(env,session,{integrationId:text('integration_id'),formVersionId:text('form_version_id'),queueId:text('queue_id'),decisionCode:text('decision_code')}),{status:201});
249
+ }
@@ -0,0 +1,95 @@
1
+ import { canonicalJson } from "./audit";
2
+ import { loadAgent } from "./agent-executor";
3
+ import { ComponentExecutionError, loadWorkflowExecutionContext, executeAssistantReadOnlyComponent } from "./component-executor";
4
+ import { parseWorkflowGraph } from "./workflow-platform-validation";
5
+ import { evaluateInputMapping, validateJsonSchemaValue } from "./workflow-expressions";
6
+ import { sha256 } from "./report-crypto";
7
+ import type { JsonObject, WorkflowNodeV1 } from "./workflow-platform-types";
8
+
9
+ export interface InvestigationRequest { runId: string; node: WorkflowNodeV1; branchKey: string; stepRunId: string }
10
+ export interface InvestigationSource { id: string; digest: string; label: string; expiresAt: string | null }
11
+ export type InvestigationConfig = Awaited<ReturnType<typeof loadInvestigation>>;
12
+ export function investigationError(code: string): never { throw new ComponentExecutionError(code, false); }
13
+ export const investigationTools = ["read_report_context", "read_findings", "check_evidence", "finish_investigation"];
14
+
15
+ export async function loadInvestigation(env: Env, request: InvestigationRequest) {
16
+ const row = await env.DB.prepare(`SELECT r.state, v.graph_json AS graphJson FROM workflow_runs r JOIN workflow_versions v ON v.id=r.workflow_version_id WHERE r.id=?1`).bind(request.runId).first<{state:string;graphJson:string}>();
17
+ if (!row || ["succeeded","failed","terminated","superseded"].includes(row.state)) investigationError("investigation_run_unavailable");
18
+ const graph = parseWorkflowGraph(JSON.parse(row.graphJson));
19
+ const node = graph.nodes.find(candidate => candidate.id === request.node.id);
20
+ if (!node || node.kind !== "ai_proposal" || node.config.investigator !== true || canonicalJson(node) !== canonicalJson(request.node)
21
+ || request.stepRunId !== `${request.runId}:${node.id}:${request.branchKey || "main"}:1`) investigationError("investigation_scope_denied");
22
+ const agent = await loadAgent(env.DB, String(node.config.agent_version_id));
23
+ // Provider configuration is authoritative. A missing adapter must never become a fallback to another provider.
24
+ if (agent.provider !== "workers_ai" || !agent.model.startsWith("@cf/")) investigationError("investigation_model_adapter_unavailable");
25
+ const runtime = await loadWorkflowExecutionContext(env, request.runId, request.branchKey);
26
+ if (runtime.row.authorityMode === "off") investigationError("investigation_authority_disabled");
27
+ const input = evaluateInputMapping(node.inputMapping, runtime.context);
28
+ // Findings are fetched through a scoped tool, preserving their source identities and freshness.
29
+ if (validateJsonSchemaValue(input, JSON.parse(agent.inputSchemaJson)).length) investigationError("agent_input_schema_invalid");
30
+ delete input.findings;
31
+ const inputJson = canonicalJson(input);
32
+ if (new TextEncoder().encode(inputJson).byteLength > 65536) investigationError("investigation_context_too_large");
33
+ const policyVersionId=graph.playbook?.policy_version_id;
34
+ const policy=policyVersionId?await env.DB.prepare(`SELECT id,title AS name,rules_json AS rulesJson FROM policy_versions WHERE id=?1 AND published_at IS NOT NULL`).bind(policyVersionId).first<{id:string;name:string;rulesJson:string}>():null;
35
+ if(policyVersionId && !policy) investigationError("investigation_policy_unavailable");
36
+ const manifest: string[] = JSON.parse(agent.toolManifestJson);
37
+ const allowedTools = investigationTools.filter(tool => tool === "finish_investigation" || manifest.includes(tool));
38
+ return { agent, runtime:{...runtime,context:{}}, input, inputJson, inputDigest: `sha256:${await sha256(inputJson)}`, allowedTools,
39
+ maxSteps: Math.max(1, Math.min(20, Number(node.config.max_steps) || 8, agent.maximumTurns)),
40
+ policy:policy?{id:policy.id,name:policy.name,rules:JSON.parse(policy.rulesJson),customer_guidance:graph.playbook?.policy_text??""}:null,
41
+ policyCode:graph.playbook?.policy_code??null,
42
+ instructions: graph.playbook?.investigation.instructions ?? "",
43
+ decisionCodes: graph.playbook?.outcomes.decision_codes ?? [],
44
+ capabilities: graph.playbook?.evidence ?? [],
45
+ };
46
+ }
47
+
48
+ export async function assertInvestigationActive(env: Env, request: InvestigationRequest) {
49
+ const row = await env.DB.prepare(`SELECT state FROM workflow_runs WHERE id=?1`).bind(request.runId).first<{state:string}>();
50
+ if (!row || ["succeeded","failed","terminated","superseded"].includes(row.state)) investigationError("investigation_run_unavailable");
51
+ }
52
+
53
+ export async function readInvestigationFinding(env: Env, config: InvestigationConfig, id?: string) {
54
+ const base = `FROM findings WHERE report_id=?1 AND (run_id=?2 OR run_id IS NULL) AND status='available' AND (expires_at IS NULL OR expires_at>?3)`;
55
+ const params = [config.runtime.row.reportId,config.runtime.row.runId,new Date().toISOString()];
56
+ if (!id) {
57
+ const rows = await env.DB.prepare(`SELECT id, finding_type AS type, summary, created_at AS createdAt, expires_at AS expiresAt ${base} ORDER BY created_at DESC,id LIMIT 100`).bind(...params).all();
58
+ return { sources: rows.results, instruction: "Read a source by its id before citing it. Summaries alone are not reviewed evidence." };
59
+ }
60
+ const row = await env.DB.prepare(`SELECT id, finding_type AS type, data_json AS dataJson, provenance_json AS provenanceJson, uncertainty_json AS uncertaintyJson, output_digest AS digest, expires_at AS expiresAt ${base} AND id=?4`).bind(...params,id).first<{id:string;type:string;dataJson:string;provenanceJson:string;uncertaintyJson:string;digest:string;expiresAt:string|null}>();
61
+ if (!row) investigationError("investigation_source_unavailable");
62
+ if (new TextEncoder().encode(row.dataJson).byteLength > 32768) investigationError("investigation_source_too_large");
63
+ return { source: { id:row.id,digest:`sha256:${await sha256(row.dataJson)}`,label:row.type,expiresAt:row.expiresAt } as InvestigationSource,
64
+ data:JSON.parse(row.dataJson), provenance:JSON.parse(row.provenanceJson), uncertainty:JSON.parse(row.uncertaintyJson) };
65
+ }
66
+
67
+ export async function checkInvestigationEvidence(env: Env, request: InvestigationRequest, config: InvestigationConfig, capabilityId: string) {
68
+ const capability = config.capabilities.find(candidate => candidate.id === capabilityId);
69
+ if (!capability) investigationError("investigation_capability_denied");
70
+ const runtime = await loadWorkflowExecutionContext(env,request.runId,request.branchKey);
71
+ // The customer-approved mapping owns the target and input. The model chooses only a declared capability.
72
+ return executeAssistantReadOnlyComponent(env,{ reportId:config.runtime.row.reportId,componentVersionId:capability.component_version_id,
73
+ componentInput:evaluateInputMapping(capability.input_mapping,runtime.context),operationId:`${request.stepRunId}:evidence:${capability.id}`,
74
+ actorId:request.stepRunId,source:"investigator" });
75
+ }
76
+
77
+ export async function validateInvestigationProposal(env: Env, config: InvestigationConfig, sources: InvestigationSource[], proposal: JsonObject) {
78
+ if (validateJsonSchemaValue(proposal,JSON.parse(config.agent.outputSchemaJson)).length) investigationError("agent_output_schema_invalid");
79
+ if (!["ready_for_review","needs_information"].includes(String(proposal.result_code)) || typeof proposal.summary !== "string" || !proposal.summary.trim()
80
+ || !Array.isArray(proposal.citations) || !Array.isArray(proposal.missing_information) || !Array.isArray(proposal.uncertainty)
81
+ || [...proposal.citations,...proposal.missing_information,...proposal.uncertainty].some(value => typeof value !== "string")) investigationError("investigation_proposal_invalid");
82
+ if (proposal.result_code === "ready_for_review" && (!proposal.citations.length || proposal.missing_information.length)) investigationError("investigation_evidence_incomplete");
83
+ if (proposal.decision_code && config.decisionCodes.length && !config.decisionCodes.includes(String(proposal.decision_code))) investigationError("investigation_outcome_denied");
84
+ if (proposal.policy_code && config.policyCode && proposal.policy_code!==config.policyCode) investigationError("investigation_policy_denied");
85
+ if (proposal.action || proposal.message || proposal.automation_eligible === true) investigationError("investigation_effect_denied");
86
+ for (const id of proposal.citations) {
87
+ const source = sources.find(candidate => candidate.id === id);
88
+ if (!source) investigationError("investigation_citation_not_read");
89
+ if (source.id === `case:${config.runtime.row.reportId}:intake`) continue;
90
+ const current = await readInvestigationFinding(env,config,source.id);
91
+ if (!("source" in current) || current.source?.digest !== source.digest) investigationError("investigation_source_changed");
92
+ }
93
+ if (new TextEncoder().encode(canonicalJson(proposal)).byteLength > 32768) investigationError("agent_output_too_large");
94
+ return proposal;
95
+ }
@@ -0,0 +1,30 @@
1
+ import type { LanguageModelV4, LanguageModelV4StreamPart } from "@ai-sdk/provider";
2
+
3
+ /** Explicit local-only orchestration fixture. It makes no inference-quality claim or provider call. */
4
+ export function investigatorFixture(env: Pick<Env,"AI_LOCAL_FIXTURE_ENABLED"|"AI_LOCAL_FIXTURE_JSON"|"PUBLIC_BASE_URL">): LanguageModelV4 | null {
5
+ if (env.AI_LOCAL_FIXTURE_ENABLED!=="true") return null;
6
+ let origin: URL;
7
+ try { origin=new URL(env.PUBLIC_BASE_URL); } catch { throw new Error("investigation_fixture_origin_invalid"); }
8
+ if (!["localhost","127.0.0.1"].includes(origin.hostname)) throw new Error("investigation_fixture_prohibited");
9
+ const fixture=JSON.parse(env.AI_LOCAL_FIXTURE_JSON) as {investigator_steps?:{tool:string;input:unknown;delay_ms?:number;error?:boolean}[]};
10
+ if (!Array.isArray(fixture.investigator_steps) || !fixture.investigator_steps.length || fixture.investigator_steps.length>20) throw new Error("investigation_fixture_missing");
11
+ return {
12
+ specificationVersion:"v4",provider:"safest_local_fixture",modelId:"scripted-investigator",supportedUrls:{},
13
+ async doGenerate(){throw new Error("investigation_fixture_stream_required");},
14
+ async doStream(options) {
15
+ const index=options.prompt.reduce((count,message)=>count+(Array.isArray(message.content)?message.content.filter(part=>part.type==="tool-call").length:0),0);
16
+ const entry=fixture.investigator_steps![Math.min(index,fixture.investigator_steps!.length-1)]!;
17
+ if(entry.error) throw new Error("investigation_fixture_provider_error");
18
+ if(entry.delay_ms) await new Promise(resolve=>setTimeout(resolve,Math.min(5000,Math.max(0,entry.delay_ms!))));
19
+ const sourceIds=options.prompt.flatMap(message=>Array.isArray(message.content)?message.content.flatMap(part=>{
20
+ if(part.type!=="tool-result" || part.output.type!=="json")return [];
21
+ const output=part.output.value as {source?:{id?:unknown}};
22
+ return typeof output?.source?.id==="string"?[output.source.id]:[];
23
+ }):[]);
24
+ const input=JSON.parse(JSON.stringify(entry.input).replaceAll('"__LAST_READ_SOURCE__"',JSON.stringify(sourceIds.at(-1)??"missing-fixture-source")));
25
+ const call={type:"tool-call" as const,toolCallId:`fixture-${index}`,toolName:entry.tool,input:JSON.stringify(input)};
26
+ const parts:LanguageModelV4StreamPart[]=[{type:"stream-start",warnings:[]},call,{type:"finish",finishReason:{unified:"tool-calls",raw:"tool_calls"},usage:{inputTokens:{total:120,noCache:120,cacheRead:0,cacheWrite:0},outputTokens:{total:40,text:40,reasoning:0}}}];
27
+ return {stream:new ReadableStream<LanguageModelV4StreamPart>({start(controller){for(const part of parts)controller.enqueue(part);controller.close();}})};
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,19 @@
1
+ import { reconcileAppealCompletion } from "./appeal-continuity";
2
+
3
+ /** The receipt in D1 is authoritative. Events only shorten the next durable check. */
4
+ export async function signalNoticeDelivery(env: Env, messageId: string, attemptKey: string): Promise<void> {
5
+ const message = await env.DB.prepare(`SELECT workflow_run_id AS runId, report_id AS reportId, delivery_state AS state FROM case_messages WHERE id = ?1`)
6
+ .bind(messageId).first<{ runId: string | null; reportId: string; state: string }>();
7
+ if (message?.state === "delivered") await reconcileAppealCompletion(env.DB,message.reportId);
8
+ if (!message?.runId) return;
9
+ try {
10
+ const { sendWorkflowEvent } = await import("./workflow-runs");
11
+ await sendWorkflowEvent(env, message.runId, {
12
+ eventType: `notice.${messageId}.updated`, eventKey: `${messageId}:${attemptKey}:${message.state}`,
13
+ sourceType: "system", sourceId: "notice-delivery", payload: { message_id: messageId },
14
+ });
15
+ } catch {
16
+ // The workflow also checks its receipt on a bounded timeout.
17
+ console.warn("Notice delivery recorded; workflow wake-up awaits reconciliation", { messageId });
18
+ }
19
+ }
@@ -0,0 +1,81 @@
1
+ import { canonicalJson } from "./audit";
2
+ import { assertOperatorReportAccess } from "./report-auth";
3
+ import { reporterEmailConfigured, selectParticipantDelivery } from "./report-email";
4
+ import { ApiError } from "./report-http";
5
+ import { sha256 } from "./report-crypto";
6
+ import type { DeliveryJob, OperatorSession } from "./report-types";
7
+
8
+ export async function retryParticipantNotice(env: Env, reportId: string, noticeId: string, expectedUpdatedAt: string, session: OperatorSession, key: string) {
9
+ if (!session.permissions.messageReporters) throw new ApiError(403, "notice_retry_denied", "Your role cannot retry participant notices.");
10
+ await assertOperatorReportAccess(env.DB, session, reportId);
11
+ const replay = await env.DB.prepare(`SELECT action, actor_id AS actorId, target_id AS targetId FROM configuration_mutations WHERE idempotency_key = ?1`)
12
+ .bind(key).first<{ action: string; actorId: string; targetId: string }>();
13
+ if (replay) {
14
+ if (replay.action !== "notice.retry" || replay.actorId !== session.actor.id || replay.targetId !== `${reportId}:${noticeId}`) throw new ApiError(409, "idempotency_key_reused", "This retry key belongs to another request.");
15
+ return { noticeId, idempotentReplay: true };
16
+ }
17
+ const notice = await env.DB.prepare(`SELECT n.id, n.message_id AS messageId, n.audience, n.rendered_body AS body,
18
+ n.delivery_state AS state, n.updated_at AS updatedAt, n.redacted_at AS redactedAt, m.redacted_at AS messageRedactedAt,
19
+ r.installation_id AS installationId, r.public_reference AS reference,
20
+ p.reference AS participantReference, p.contact_mode AS contactMode, p.contact_ciphertext AS contactCiphertext
21
+ FROM notices n JOIN reports r ON r.id = n.report_id JOIN case_messages m ON m.id = n.message_id
22
+ LEFT JOIN report_participants p ON p.report_id = n.report_id AND p.audience = n.audience
23
+ WHERE n.id = ?1 AND n.report_id = ?2`)
24
+ .bind(noticeId, reportId).first<{ id: string; messageId: string; audience: "reporter" | "affected_user"; body: string; state: string; updatedAt: string; redactedAt: string | null; messageRedactedAt: string | null; installationId: string; reference: string; participantReference: string | null; contactMode: string | null; contactCiphertext: string | null }>();
25
+ if (!notice) throw new ApiError(404, "notice_not_found", "This participant notice is unavailable.");
26
+ if (!["failed", "suppressed"].includes(notice.state) || notice.updatedAt !== expectedUpdatedAt || notice.redactedAt || notice.messageRedactedAt) throw new ApiError(409, "notice_changed", "The notice changed or cannot be retried. Refresh its delivery status.");
27
+ const [email, notification] = await Promise.all([
28
+ env.DB.prepare(`SELECT id FROM email_deliveries WHERE message_id = ?1 LIMIT 1`).bind(notice.messageId).first<{ id: string }>(),
29
+ env.DB.prepare(`SELECT id FROM notification_outbox WHERE notice_id = ?1 LIMIT 1`).bind(noticeId).first<{ id: string }>(),
30
+ ]);
31
+ const delivery = !email && !notification ? await selectParticipantDelivery({
32
+ reportId, audience: notice.audience, contactMode: notice.contactMode ?? "", contactCiphertext: notice.contactCiphertext,
33
+ reference: notice.participantReference, emailEnabled: reporterEmailConfigured(env),
34
+ notificationEnabled: Boolean(env.NOTIFICATION_WEBHOOK_URL?.trim() && env.NOTIFICATION_WEBHOOK_SECRET?.trim()),
35
+ contactEncryptionKey: env.CREDENTIAL_ENCRYPTION_KEY, recipientHashPepper: env.RATE_LIMIT_PEPPER,
36
+ }) : null;
37
+ if (!email && !notification && !delivery?.channel) throw new ApiError(409, "notice_channel_unavailable", "Connect a participant notification channel and provide contact permission before retrying this notice.");
38
+ const timestamp = new Date().toISOString();
39
+ const response = { noticeId, idempotentReplay: false };
40
+ const guard = `EXISTS (SELECT 1 FROM configuration_mutations WHERE idempotency_key = ?1 AND action = 'notice.retry')`;
41
+ const statements = [
42
+ env.DB.prepare(`UPDATE notices SET delivery_state = 'queued', updated_at = ?3 WHERE id = ?1 AND report_id = ?2
43
+ AND updated_at = ?4 AND delivery_state IN ('failed','suppressed') AND redacted_at IS NULL
44
+ AND EXISTS (SELECT 1 FROM case_messages WHERE id = notices.message_id AND redacted_at IS NULL)
45
+ AND (?5 = 1 OR EXISTS (SELECT 1 FROM reports WHERE id = notices.report_id AND queue_id IN (SELECT value FROM json_each(?6))))
46
+ AND NOT EXISTS (SELECT 1 FROM notification_outbox WHERE notice_id = ?1 AND (status IN ('pending','delivering','delivered') OR terminal = 0))
47
+ AND NOT EXISTS (SELECT 1 FROM email_deliveries WHERE message_id = notices.message_id AND (state IN ('pending','sending','delivered') OR (state = 'failed' AND next_attempt_at IS NOT NULL)))`)
48
+ .bind(noticeId, reportId, timestamp, expectedUpdatedAt, session.queueScope === null || session.queueScope === undefined ? 1 : 0, canonicalJson(session.queueScope ?? [])),
49
+ env.DB.prepare(`INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at)
50
+ SELECT ?1, 'notice.retry', ?2, ?3, ?4, ?5 WHERE changes() = 1`)
51
+ .bind(key, session.actor.id, `${reportId}:${noticeId}`, canonicalJson(response), timestamp),
52
+ env.DB.prepare(`UPDATE case_messages SET delivery_state = 'queued' WHERE id = ?2 AND ${guard}`).bind(key, notice.messageId),
53
+ ];
54
+ let job: DeliveryJob;
55
+ if (email) {
56
+ statements.push(env.DB.prepare(`UPDATE email_deliveries SET state = 'pending', attempt_limit = attempt_count + 8, next_attempt_at = ?3, last_error_code = NULL, updated_at = ?3 WHERE id = ?2 AND ${guard}`).bind(key, email.id, timestamp));
57
+ job = { version: 1, type: "deliver_email", jobId: `email:${email.id}`, outboxId: email.id };
58
+ } else if (notification) {
59
+ statements.push(env.DB.prepare(`UPDATE notification_outbox SET status = 'pending', terminal = 0, attempt_limit = attempt_count + 8, next_attempt_at = ?3, lease_expires_at = NULL, last_error_code = NULL, updated_at = ?3 WHERE id = ?2 AND ${guard}`).bind(key, notification.id, timestamp));
60
+ job = { version: 1, type: "deliver_notification", jobId: `notification:${notification.id}`, outboxId: notification.id };
61
+ } else if (delivery?.channel === "email") {
62
+ const id = crypto.randomUUID();
63
+ statements.push(env.DB.prepare(`INSERT INTO email_deliveries (id, installation_id, message_id, audience, recipient_hash, provider, state, next_attempt_at, created_at, updated_at)
64
+ SELECT ?2, ?3, ?4, ?5, ?6, 'cloudflare_email_service', 'pending', ?7, ?7, ?7 WHERE ${guard}`)
65
+ .bind(key, id, notice.installationId, notice.messageId, notice.audience, delivery.recipientHash!, timestamp));
66
+ job = { version: 1, type: "deliver_email", jobId: `email:${id}`, outboxId: id };
67
+ } else {
68
+ const id = crypto.randomUUID();
69
+ statements.push(env.DB.prepare(`INSERT INTO notification_outbox (id, notice_id, idempotency_key, channel, payload_json, status, next_attempt_at, created_at, updated_at)
70
+ SELECT ?2, ?3, ?4, 'customer_webhook', ?5, 'pending', ?6, ?6, ?6 WHERE ${guard}`)
71
+ .bind(key, id, noticeId, `notice:${noticeId}`, canonicalJson({version: 1, event: "report.message.available", notification_id: id, report_reference: notice.reference, audience: notice.audience, ...(notice.participantReference ? {recipient_reference: notice.participantReference} : {}), recipient_contact_fingerprint: notice.contactCiphertext ? await sha256(notice.contactCiphertext) : null, message: notice.body}), timestamp));
72
+ job = { version: 1, type: "deliver_notification", jobId: `notification:${id}`, outboxId: id };
73
+ }
74
+ statements.push(env.DB.prepare(`INSERT INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at)
75
+ SELECT ?2, ?3, 'notice.retry_requested', ?4, ?5, 'notice', ?6, ?7, ?8 WHERE ${guard}`)
76
+ .bind(key, crypto.randomUUID(), `notice-retry:${key}`, session.actor.type, session.actor.id, noticeId, canonicalJson({reportId, audience: notice.audience, messageId: notice.messageId, originalBodyPreserved: true}), timestamp));
77
+ const written = await env.DB.batch(statements);
78
+ if (written[0]?.meta.changes !== 1) throw new ApiError(409, "notice_changed", "The notice changed while retrying. Refresh its delivery status.");
79
+ await env.DELIVERY_JOBS.send(job, {contentType: "json"}).catch(() => {});
80
+ return response;
81
+ }
@@ -0,0 +1,31 @@
1
+ import { ApiError } from "./report-http";
2
+ import type { OperatorSession } from "./report-types";
3
+
4
+ export function assertOperatorQueueAccess(session: OperatorSession, queueId: string | null): void {
5
+ if (session.queueScope === null || session.queueScope === undefined) return;
6
+ if (!queueId || !session.queueScope.includes(queueId)) throw new ApiError(403, "operator_queue_scope_denied", "Your role is not authorized for this queue.");
7
+ }
8
+
9
+ export async function assertOperatorReportAccess(
10
+ db: D1Database,
11
+ session: OperatorSession,
12
+ reportId: string,
13
+ ): Promise<void> {
14
+ if (session.queueScope === null || session.queueScope === undefined) return;
15
+ const report = await db.prepare(`SELECT queue_id AS queueId FROM reports WHERE id = ?1 LIMIT 1`)
16
+ .bind(reportId).first<{ queueId: string | null }>();
17
+ if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
18
+ assertOperatorQueueAccess(session, report.queueId);
19
+ }
20
+
21
+ export async function assertOperatorWorkflowRunAccess(
22
+ db: D1Database,
23
+ session: OperatorSession,
24
+ runId: string,
25
+ ): Promise<void> {
26
+ if (session.queueScope === null || session.queueScope === undefined) return;
27
+ const run = await db.prepare(`SELECT queue_id AS queueId FROM workflow_runs WHERE id = ?1 LIMIT 1`)
28
+ .bind(runId).first<{ queueId: string | null }>();
29
+ if (!run) throw new ApiError(404, "workflow_run_not_found", "The workflow run does not exist.");
30
+ assertOperatorQueueAccess(session, run.queueId);
31
+ }