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
@@ -1,10 +1,13 @@
1
+ import { changeReportOwner } from "./report-ownership";
2
+ export { decideAppeal } from "./appeal-decisions";
1
3
  import { AUDIT_GENESIS_HASH, canonicalJson, hashAuditEntry } from "./audit";
2
4
  import { decryptReportContact } from "./report-crypto";
3
5
  import { selectParticipantDelivery, type ParticipantDelivery } from "./report-email";
4
6
  import { ApiError } from "./report-http";
5
7
  import { analystAvatarColor } from "./report-presence";
8
+ import { assertOperatorReportAccess } from "./operator-scope";
6
9
  import type { DeliveryJob, OperatorSession, ParticipantAudience, ReportState } from "./report-types";
7
- import type { AppealDecisionInput, DecisionInput, ReviewerMessageInput } from "./report-workflow-validation";
10
+ import type { DecisionInput, ReviewerMessageInput } from "./report-workflow-validation";
8
11
 
9
12
  interface WorkflowReportRow {
10
13
  id: string;
@@ -86,11 +89,33 @@ async function requireActiveClaim(db: D1Database, reportId: string, reviewerId:
86
89
  LIMIT 1
87
90
  `).bind(reportId).first<ClaimRow>();
88
91
  if (!claim || claim.reviewer_id !== reviewerId || claim.expires_at <= at) {
89
- throw new ApiError(409, "active_claim_required", "Claim this report before changing it.");
92
+ throw new ApiError(409, "active_claim_required", "Start or resume review to edit this case. Your private draft is unchanged.");
90
93
  }
91
94
  return claim;
92
95
  }
93
96
 
97
+ async function commitReviewerMutation(db: D1Database, report: WorkflowReportRow, claim: ClaimRow, session: OperatorSession, statements: D1PreparedStatement[], decision = false): Promise<void> {
98
+ const id = crypto.randomUUID();
99
+ try {
100
+ await db.batch([
101
+ db.prepare(`INSERT INTO report_edit_guards (id,authorized)
102
+ SELECT ?1,EXISTS (SELECT 1 FROM reports r JOIN review_claims c ON c.report_id = r.id
103
+ WHERE r.id = ?2 AND r.state = ?3 AND r.policy_version_id IS ?4 AND r.queue_version_id IS ?5
104
+ AND c.reviewer_id = ?6 AND c.version = ?7 AND c.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now')
105
+ AND (?8 = 1 OR r.queue_id IN (SELECT value FROM json_each(?9)))
106
+ AND (?10 = 0 OR NOT EXISTS (SELECT 1 FROM workflow_runs wr JOIN workflow_versions wv ON wv.id = wr.workflow_version_id
107
+ WHERE wr.report_id = r.id AND wr.run_kind = 'primary' AND json_type(wv.graph_json,'$.playbook') = 'object'
108
+ AND wr.state NOT IN ('succeeded','failed','terminated','superseded'))))`)
109
+ .bind(id,report.id,report.state,report.policy_version_id,report.queue_version_id,session.actor.id,claim.version,session.queueScope == null ? 1 : 0,canonicalJson(session.queueScope ?? []),decision ? 1 : 0),
110
+ ...statements,
111
+ db.prepare("DELETE FROM report_edit_guards WHERE id = ?1").bind(id),
112
+ ]);
113
+ } catch (error) {
114
+ if (String(error).includes("active_report_edit_access")) throw new ApiError(409,"active_claim_required","Case ownership, editing access or the review requirements changed before saving. Refresh the case and resume review. Your private draft is unchanged.");
115
+ throw error;
116
+ }
117
+ }
118
+
94
119
  export async function claimReport(
95
120
  db: D1Database,
96
121
  reportId: string,
@@ -104,37 +129,26 @@ export async function claimReport(
104
129
  }
105
130
  const claimedAt = now();
106
131
  const expiresAt = addMinutes(claimedAt, Math.max(1, Math.min(1_440, claimTtlMinutes)));
107
- await db.prepare(`
108
- INSERT INTO review_claims (report_id, reviewer_id, claimed_at, expires_at, version)
109
- VALUES (?1, ?2, ?3, ?4, 1)
110
- ON CONFLICT(report_id) DO UPDATE SET
111
- reviewer_id = excluded.reviewer_id,
112
- claimed_at = excluded.claimed_at,
113
- expires_at = excluded.expires_at,
114
- version = review_claims.version + 1
115
- WHERE review_claims.expires_at <= ?3 OR review_claims.reviewer_id = ?2
116
- `).bind(reportId, session.actor.id, claimedAt, expiresAt).run();
117
- const claim = await db.prepare(`
118
- SELECT report_id, reviewer_id, claimed_at, expires_at, version
119
- FROM review_claims WHERE report_id = ?1 LIMIT 1
120
- `).bind(reportId).first<ClaimRow>();
121
- if (!claim || claim.reviewer_id !== session.actor.id) {
122
- throw new ApiError(409, "report_already_claimed", "Another reviewer currently holds this report.", {
123
- expires_at: claim?.expires_at,
124
- });
125
- }
126
- await db.batch([
127
- auditEvent(db, {
128
- idempotencyKey: `claim:${reportId}:${claim.version}`,
129
- action: "report.claimed",
130
- actorType: session.actor.type,
131
- actorId: session.actor.id,
132
- targetType: "report",
133
- targetId: reportId,
134
- details: { expiresAt: claim.expires_at, version: claim.version },
135
- createdAt: claimedAt,
136
- }),
132
+ const written = await db.batch([
133
+ db.prepare(`
134
+ INSERT INTO review_claims (report_id, reviewer_id, claimed_at, expires_at, version)
135
+ SELECT r.id, ?2, ?3, ?4, COALESCE((SELECT MAX(CAST(json_extract(details_json, '$.version') AS INTEGER))
136
+ FROM audit_events WHERE target_type = 'report' AND target_id = ?1), 0) + 1
137
+ FROM reports r WHERE r.id = ?1 AND r.state IN ('human_review','awaiting_reporter','human_review_requested')
138
+ AND (?5 = 1 OR r.queue_id IN (SELECT value FROM json_each(?6)))
139
+ ON CONFLICT(report_id) DO UPDATE SET claimed_at = excluded.claimed_at,
140
+ expires_at = excluded.expires_at, version = review_claims.version + 1
141
+ WHERE review_claims.reviewer_id = ?2
142
+ `).bind(reportId, session.actor.id, claimedAt, expiresAt, session.queueScope == null ? 1 : 0, canonicalJson(session.queueScope ?? [])),
143
+ db.prepare(`INSERT INTO audit_events (id,idempotency_key,action,actor_type,actor_id,target_type,target_id,details_json,created_at)
144
+ SELECT ?1,?1,'report.claimed',?2,?3,'report',?4,json_object('expiresAt',expires_at,'version',version),?5
145
+ FROM review_claims WHERE report_id = ?4 AND changes() = 1`)
146
+ .bind(crypto.randomUUID(),session.actor.type,session.actor.id,reportId,claimedAt),
137
147
  ]);
148
+ const claim = await db.prepare(`SELECT report_id,reviewer_id,claimed_at,expires_at,version FROM review_claims WHERE report_id = ?1`).bind(reportId).first<ClaimRow>();
149
+ if (written[0]?.meta.changes !== 1 || !claim || claim.reviewer_id !== session.actor.id) {
150
+ throw new ApiError(409, "report_already_claimed", "This case is assigned to another reviewer or is no longer ready for review. Refresh to see its current owner.");
151
+ }
138
152
  return { reviewerId: claim.reviewer_id, claimedAt: claim.claimed_at, expiresAt: claim.expires_at, version: claim.version };
139
153
  }
140
154
 
@@ -143,28 +157,16 @@ export async function releaseReportClaim(db: D1Database, reportId: string, sessi
143
157
  .bind(reportId).first<{ reviewer_id: string; version: number }>();
144
158
  if (!claim) return;
145
159
  if (claim.reviewer_id !== session.actor.id && !session.permissions.manageConfiguration) {
146
- throw new ApiError(409, "claim_owned_by_another_reviewer", "Only the current reviewer or an administrator can release this claim.");
160
+ throw new ApiError(409, "claim_owned_by_another_reviewer", "Only the case owner or an administrator can return this case.");
147
161
  }
148
- const createdAt = now();
149
- await db.batch([
150
- db.prepare(`DELETE FROM review_claims WHERE report_id = ?1`).bind(reportId),
151
- auditEvent(db, {
152
- idempotencyKey: `claim-release:${reportId}:${claim.version}`,
153
- action: "report.claim_released",
154
- actorType: session.actor.type,
155
- actorId: session.actor.id,
156
- targetType: "report",
157
- targetId: reportId,
158
- details: { priorReviewerId: claim.reviewer_id, version: claim.version },
159
- createdAt,
160
- }),
161
- ]);
162
+ // Keep the legacy endpoint compatible while using the same atomic handoff.
163
+ await changeReportOwner(db,reportId,session,{ reviewerId:null,version:claim.version,note:"Returned to the queue." });
162
164
  }
163
165
 
164
166
  export async function loadReportDetail(
165
167
  db: D1Database,
166
168
  reportId: string,
167
- options: { contactEncryptionKey?: string; includeReporterEmail?: boolean } = {},
169
+ options: { contactEncryptionKey?: string; includeReporterEmail?: boolean; includeCustomerContext?: boolean; allowedQueueIds?: string[] | null } = {},
168
170
  ): Promise<Record<string, unknown> | null> {
169
171
  const report = await db.prepare(`
170
172
  SELECT r.id, r.public_reference AS reference, r.source, r.state, r.priority, r.reason_code AS reasonCode,
@@ -176,10 +178,13 @@ export async function loadReportDetail(
176
178
  policy.rules_json AS policyRulesJson, r.queue_version_id AS queuePolicyVersionId,
177
179
  qv.management_mode AS queueManagementMode, qv.reviewer_instructions AS reviewerInstructions,
178
180
  t.target_type AS targetType, t.target_reference AS targetReference,
179
- t.owner_reference AS ownerReference, t.customer_url AS customerUrl,
181
+ t.owner_reference AS ownerReference, t.customer_url AS customerUrl, t.signed_facts_json AS customerFactsJson,
180
182
  p.reference AS reporterReference, p.contact_mode AS reporterContactMode,
181
183
  p.contact_ciphertext AS reporterContactCiphertext,
182
- p.reference_redacted_at AS reporterReferenceRedactedAt
184
+ p.reference_redacted_at AS reporterReferenceRedactedAt,
185
+ EXISTS (SELECT 1 FROM workflow_runs wr JOIN workflow_versions wv ON wv.id = wr.workflow_version_id
186
+ WHERE wr.report_id = r.id AND wr.run_kind = 'primary' AND json_type(wv.graph_json, '$.playbook') = 'object'
187
+ AND wr.state NOT IN ('succeeded','failed','terminated','superseded')) AS playbookManaged
183
188
  FROM reports r
184
189
  JOIN report_targets t ON t.report_id = r.id
185
190
  JOIN report_participants p ON p.report_id = r.id AND p.audience = 'reporter'
@@ -190,7 +195,7 @@ export async function loadReportDetail(
190
195
  LIMIT 1
191
196
  `).bind(reportId).first<Record<string, unknown>>();
192
197
  if (!report) return null;
193
- const [answers, evidence, messages, decisions, actions, notices, aiRuns, history, claim, findings] = await Promise.all([
198
+ const [answers, evidence, messages, decisions, actions, notices, aiRuns, history, claim, findings, related, appeals, handoffs] = await Promise.all([
194
199
  db.prepare(`SELECT field_key AS fieldKey, value_json AS valueJson FROM report_answers WHERE report_id = ?1 ORDER BY field_key`).bind(reportId).all<Record<string, unknown>>(),
195
200
  db.prepare(`SELECT id, evidence_kind AS kind, reference, availability, created_at AS createdAt FROM evidence_references WHERE report_id = ?1 ORDER BY created_at, id`).bind(reportId).all<Record<string, unknown>>(),
196
201
  db.prepare(`SELECT id, audience, direction, sender_type AS senderType, sender_id AS senderId, body, automated, delivery_state AS deliveryState, created_at AS createdAt FROM case_messages WHERE report_id = ?1 ORDER BY created_at, id LIMIT 500`).bind(reportId).all<Record<string, unknown>>(),
@@ -201,8 +206,15 @@ export async function loadReportDetail(
201
206
  FROM decision_action_outbox WHERE report_id = ?1 ORDER BY created_at DESC, id DESC
202
207
  `).bind(reportId).all<Record<string, unknown>>(),
203
208
  db.prepare(`
204
- SELECT id, decision_id AS decisionId, audience, delivery_state AS deliveryState, updated_at AS updatedAt
205
- FROM notices WHERE report_id = ?1 ORDER BY created_at DESC, id DESC
209
+ SELECT n.id, n.message_id AS messageId, n.decision_id AS decisionId, n.audience, n.delivery_state AS deliveryState, n.updated_at AS updatedAt,
210
+ CASE WHEN n.idempotency_key LIKE 'appeal-decision:%' THEN 'appeal' ELSE 'case' END AS purpose,
211
+ CASE WHEN EXISTS (SELECT 1 FROM email_deliveries e WHERE e.message_id = n.message_id) THEN 'email'
212
+ WHEN EXISTS (SELECT 1 FROM notification_outbox o WHERE o.notice_id = n.id) THEN 'customer_webhook' ELSE NULL END AS channel,
213
+ COALESCE((SELECT SUM(attempt_count) FROM email_deliveries e WHERE e.message_id = n.message_id), 0)
214
+ + COALESCE((SELECT SUM(attempt_count) FROM notification_outbox o WHERE o.notice_id = n.id), 0) AS attemptCount,
215
+ COALESCE((SELECT last_error_code FROM email_deliveries e WHERE e.message_id = n.message_id ORDER BY e.updated_at DESC LIMIT 1),
216
+ (SELECT last_error_code FROM notification_outbox o WHERE o.notice_id = n.id ORDER BY o.updated_at DESC LIMIT 1)) AS errorCode
217
+ FROM notices n WHERE n.report_id = ?1 ORDER BY n.created_at DESC, n.id DESC
206
218
  `).bind(reportId).all<Record<string, unknown>>(),
207
219
  db.prepare(`
208
220
  SELECT ar.id, ar.status, ar.mode, ar.model, ar.prompt_version AS promptVersion,
@@ -222,9 +234,9 @@ export async function loadReportDetail(
222
234
  p.avatar_color AS analystAvatarColor
223
235
  FROM review_claims c
224
236
  LEFT JOIN analyst_profiles p ON p.actor_id = c.reviewer_id
225
- WHERE c.report_id = ?1 AND c.expires_at > ?2
237
+ WHERE c.report_id = ?1
226
238
  LIMIT 1
227
- `).bind(reportId, now()).first<Record<string, unknown>>(),
239
+ `).bind(reportId).first<Record<string, unknown>>(),
228
240
  db.prepare(`
229
241
  SELECT f.id, f.run_id AS runId, f.step_run_id AS stepRunId,
230
242
  f.component_version_id AS componentVersionId, f.ai_run_id AS aiRunId,
@@ -241,6 +253,22 @@ export async function loadReportDetail(
241
253
  WHERE f.report_id = ?1 ORDER BY f.created_at DESC, f.id DESC
242
254
  LIMIT 500
243
255
  `).bind(reportId).all<Record<string, unknown>>(),
256
+ db.prepare(`SELECT r.id, r.public_reference AS reference, r.state, r.reason_code AS reasonCode, r.submitted_at AS submittedAt, q.name AS queueName
257
+ FROM duplicate_group_members source JOIN duplicate_group_members member ON member.group_id = source.group_id
258
+ JOIN reports origin ON origin.id = source.report_id JOIN reports r ON r.id = member.report_id
259
+ LEFT JOIN queue_definitions q ON q.id = r.queue_id
260
+ WHERE source.report_id = ?1 AND source.removed_at IS NULL AND member.removed_at IS NULL AND r.id != ?1
261
+ AND (?2 = 1 OR (r.queue_id IN (SELECT value FROM json_each(?3)) AND origin.queue_id IN (SELECT value FROM json_each(?3))))
262
+ ORDER BY r.submitted_at, r.id LIMIT 50`)
263
+ .bind(reportId,options.allowedQueueIds===null?1:0,canonicalJson(options.allowedQueueIds??[])).all<Record<string,unknown>>(),
264
+ db.prepare(`SELECT a.id, a.appellant_audience AS audience, a.state, a.submitted_at AS submittedAt, ad.completion_state AS completionState,
265
+ ad.correction_required AS correctionRequired, ad.correction_confirmed_at AS correctionConfirmedAt,
266
+ n.delivery_state AS noticeState FROM appeals a LEFT JOIN appeal_decisions ad ON ad.appeal_id = a.id
267
+ LEFT JOIN notices n ON n.idempotency_key = 'appeal-decision:' || ad.id || ':notice' WHERE a.report_id = ?1 ORDER BY a.submitted_at, a.id`)
268
+ .bind(reportId).all<Record<string,unknown>>(),
269
+ db.prepare(`SELECT h.id,h.from_reviewer_id AS fromReviewerId,h.to_reviewer_id AS toReviewerId,h.note,h.created_at AS createdAt,
270
+ COALESCE(p.display_name,h.actor_id) AS authorName FROM report_handoffs h LEFT JOIN analyst_profiles p ON p.actor_id = h.actor_id
271
+ WHERE h.report_id = ?1 ORDER BY h.created_at DESC,h.id DESC LIMIT 100`).bind(reportId).all<Record<string,unknown>>(),
244
272
  ]);
245
273
  const formFieldsValue = parsedJson(report.formFieldsJson, []);
246
274
  const formFields = Array.isArray(formFieldsValue)
@@ -298,6 +326,11 @@ export async function loadReportDetail(
298
326
  }
299
327
  return {
300
328
  ...report,
329
+ relatedReports: related.results,
330
+ appeals: appeals.results,
331
+ playbookManaged: report.playbookManaged === 1,
332
+ customerFacts: options.includeCustomerContext ? parsedJson(report.customerFactsJson, {}) : undefined,
333
+ customerFactsJson: undefined,
301
334
  reporterEmail,
302
335
  reporterContactStatus,
303
336
  reporterContactCiphertext: undefined,
@@ -315,7 +348,7 @@ export async function loadReportDetail(
315
348
  messages: messages.results.map((row) => ({ ...row, automated: row.automated === 1 })),
316
349
  decisions: decisions.results.map((row) => ({
317
350
  ...row,
318
- evidenceReferences: JSON.parse(String(row.evidenceReferencesJson)),
351
+ evidenceReferences: parsedJson(row.evidenceReferencesJson, []),
319
352
  evidenceReferencesJson: undefined,
320
353
  })),
321
354
  actions: actions.results.map((row) => ({ ...row, terminal: row.terminal === 1 })),
@@ -323,7 +356,7 @@ export async function loadReportDetail(
323
356
  aiRuns: aiRuns.results.map((row) => ({
324
357
  ...row,
325
358
  uncertain: row.uncertain === 1,
326
- validationErrors: row.validationErrorsJson ? JSON.parse(String(row.validationErrorsJson)) : [],
359
+ validationErrors: parsedJson(row.validationErrorsJson, []),
327
360
  validationErrorsJson: undefined,
328
361
  })),
329
362
  history: history.results,
@@ -337,7 +370,12 @@ export async function loadReportDetail(
337
370
  provenanceJson: undefined,
338
371
  uncertaintyJson: undefined,
339
372
  })),
340
- claim: claim ? {
373
+ claim: claim && String(claim.expiresAt) > now() ? {
374
+ reviewerId: claim.reviewerId, claimedAt: claim.claimedAt, expiresAt: claim.expiresAt, version: claim.version,
375
+ analyst: { displayName: claim.analystDisplayName ?? claim.reviewerId, avatarUrl: claim.analystAvatarUrl ?? null, avatarColor: claim.analystAvatarColor ?? analystAvatarColor(String(claim.reviewerId)) },
376
+ } : null,
377
+ handoffs: handoffs.results,
378
+ assignment: claim ? {
341
379
  reviewerId: claim.reviewerId,
342
380
  claimedAt: claim.claimedAt,
343
381
  expiresAt: claim.expiresAt,
@@ -390,7 +428,7 @@ export async function addReviewerMessage(
390
428
  `).bind(reportId, input.idempotencyKey).first<{ id: string; deliveryState: string; deliveryChannel: ParticipantDelivery["channel"] }>();
391
429
  if (existing) return { messageId: existing.id, jobs: [], deliveryState: existing.deliveryState, deliveryChannel: existing.deliveryChannel, idempotentReplay: true };
392
430
  const createdAt = now();
393
- await requireActiveClaim(db, reportId, session.actor.id, createdAt);
431
+ const claim = await requireActiveClaim(db, reportId, session.actor.id, createdAt);
394
432
  const report = await workflowReport(db, reportId);
395
433
  if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
396
434
  if (["closed", "resolved_by_human", "resolved_by_ai", "appealed"].includes(report.state)) {
@@ -461,7 +499,7 @@ export async function addReviewerMessage(
461
499
  message: input.body,
462
500
  }), createdAt));
463
501
  }
464
- await db.batch(statements);
502
+ await commitReviewerMutation(db, report, claim, session, statements);
465
503
  return { messageId, jobs, deliveryState: delivery.state, deliveryChannel: delivery.channel, idempotentReplay: false };
466
504
  }
467
505
 
@@ -482,8 +520,13 @@ export async function recordHumanDecision(
482
520
  SELECT id FROM decisions WHERE report_id = ?1 AND idempotency_key = ?2 LIMIT 1
483
521
  `).bind(reportId, input.idempotencyKey).first<{ id: string }>();
484
522
  if (existing) return { decisionId: existing.id, jobs: [], idempotentReplay: true };
523
+ if (await db.prepare(`SELECT 1 FROM workflow_runs wr JOIN workflow_versions wv ON wv.id = wr.workflow_version_id
524
+ WHERE wr.report_id = ?1 AND wr.run_kind = 'primary' AND json_type(wv.graph_json, '$.playbook') = 'object'
525
+ AND wr.state NOT IN ('succeeded','failed','terminated','superseded') LIMIT 1`).bind(reportId).first()) {
526
+ throw new ApiError(409, "playbook_review_required", "This case is following a published playbook. Complete its current review task to continue the case.");
527
+ }
485
528
  const createdAt = now();
486
- await requireActiveClaim(db, reportId, session.actor.id, createdAt);
529
+ const claim = await requireActiveClaim(db, reportId, session.actor.id, createdAt);
487
530
  const report = await workflowReport(db, reportId);
488
531
  if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
489
532
  if (!["human_review", "awaiting_reporter", "human_review_requested"].includes(report.state)) {
@@ -669,11 +712,57 @@ export async function recordHumanDecision(
669
712
  jobs.push({ version: 1, jobId: `action:${actionId}`, type: "deliver_action", outboxId: actionId });
670
713
  }
671
714
 
672
- await db.batch(statements);
715
+ await commitReviewerMutation(db, report, claim, session, statements, true);
673
716
  return { decisionId, jobs, idempotentReplay: false };
674
717
  }
675
718
 
676
719
 
720
+ export async function recordParticipantAppeal(
721
+ db: D1Database, reportId: string, session: OperatorSession,
722
+ input: { audience: ParticipantAudience; reason: string; idempotencyKey: string },
723
+ ): Promise<{ appealId: string; idempotentReplay: boolean }> {
724
+ if (!session.permissions.reviewAppeals) throw new ApiError(403,"appeal_record_denied","Your role cannot record appeals.");
725
+ await assertOperatorReportAccess(db,session,reportId);
726
+ const existing = await db.prepare(`SELECT a.id, a.reason, a.appellant_audience AS audience, e.actor_id AS actorId FROM appeals a
727
+ LEFT JOIN audit_events e ON e.idempotency_key = 'appeal-recorded:' || a.id WHERE a.report_id = ?1 AND a.idempotency_key = ?2 LIMIT 1`)
728
+ .bind(reportId, input.idempotencyKey).first<{ id: string; reason: string; audience: string; actorId: string | null }>();
729
+ if (existing) {
730
+ if (existing.reason !== input.reason || existing.audience !== input.audience || existing.actorId !== session.actor.id) throw new ApiError(409,"idempotency_key_reused","This appeal request key belongs to another participant, reason or operator.");
731
+ return { appealId: existing.id, idempotentReplay: true };
732
+ }
733
+ const report = await db.prepare(`SELECT state FROM reports WHERE id = ?1 LIMIT 1`).bind(reportId).first<{ state: string }>();
734
+ if (!report || !["resolved_by_human", "resolved_by_ai", "appealed", "closed"].includes(report.state)) throw new ApiError(409, "appeal_requires_decision", "Only a resolved report can receive an appeal.");
735
+ const decision = await db.prepare(`SELECT id FROM decisions WHERE report_id = ?1 ORDER BY created_at DESC, id DESC LIMIT 1`).bind(reportId).first<{ id: string }>();
736
+ if (!decision) throw new ApiError(409, "appeal_requires_decision", "The report has no decision to appeal.");
737
+ const participant = await db.prepare(`SELECT id FROM report_participants WHERE report_id = ?1 AND audience = ?2 AND reference_redacted_at IS NULL LIMIT 1`).bind(reportId, input.audience).first();
738
+ if (!participant) throw new ApiError(400, "appeal_participant_missing", "This report has no participant with the selected audience.");
739
+ const queue = await db.prepare(`SELECT id FROM queue_definitions WHERE queue_kind = 'appeal' AND enabled = 1 AND status = 'active' ORDER BY position, id LIMIT 1`).first<{ id: string }>();
740
+ if (!queue) throw new ApiError(409, "appeal_queue_unavailable", "Enable an appeal queue before recording an appeal.");
741
+ const appealId = crypto.randomUUID();
742
+ const createdAt = now();
743
+ await db.batch([
744
+ db.prepare(`INSERT OR IGNORE INTO appeals (id, report_id, decision_id, appellant_audience, reason, state, queue_id, submitted_at, idempotency_key)
745
+ SELECT ?1, ?2, ?3, ?4, ?5, 'submitted', ?6, ?7, ?8 FROM reports r WHERE r.id = ?2 AND r.state = ?9
746
+ AND (?10 = 1 OR r.queue_id IN (SELECT value FROM json_each(?11)))
747
+ AND EXISTS (SELECT 1 FROM report_participants WHERE report_id = ?2 AND audience = ?4 AND reference_redacted_at IS NULL)
748
+ AND EXISTS (SELECT 1 FROM decisions WHERE id = ?3 AND sensitive_redacted_at IS NULL)
749
+ AND ?3 = (SELECT id FROM decisions WHERE report_id = ?2 ORDER BY created_at DESC, id DESC LIMIT 1)
750
+ AND EXISTS (SELECT 1 FROM queue_definitions WHERE id = ?6 AND queue_kind = 'appeal' AND enabled = 1 AND status = 'active')`)
751
+ .bind(appealId, reportId, decision.id, input.audience, input.reason, queue.id, createdAt, input.idempotencyKey, report.state,session.queueScope==null?1:0,canonicalJson(session.queueScope??[])),
752
+ db.prepare(`UPDATE reports SET state = 'appealed', updated_at = ?2 WHERE id = ?1 AND EXISTS (SELECT 1 FROM appeals WHERE id = ?3)`).bind(reportId, createdAt, appealId),
753
+ db.prepare(`INSERT INTO report_state_history (id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at) SELECT ?1, ?2, ?3, 'appealed', 'participant_appeal_recorded', ?4, ?5, ?6 WHERE EXISTS (SELECT 1 FROM appeals WHERE id = ?7)`)
754
+ .bind(crypto.randomUUID(), reportId, report.state, session.actor.type, session.actor.id, createdAt, appealId),
755
+ db.prepare(`INSERT INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) SELECT ?1, ?2, 'appeal.recorded', ?3, ?4, 'appeal', ?5, ?6, ?7 WHERE EXISTS (SELECT 1 FROM appeals WHERE id = ?5)`)
756
+ .bind(crypto.randomUUID(), `appeal-recorded:${appealId}`, session.actor.type, session.actor.id, appealId, canonicalJson({ reportId, decisionId: decision.id, audience: input.audience }), createdAt),
757
+ ]);
758
+ const saved = await db.prepare(`SELECT a.id, a.idempotency_key AS idempotencyKey, a.reason, e.actor_id AS actorId FROM appeals a
759
+ LEFT JOIN audit_events e ON e.idempotency_key = 'appeal-recorded:' || a.id WHERE a.decision_id = ?1 AND a.appellant_audience = ?2 LIMIT 1`)
760
+ .bind(decision.id, input.audience).first<{ id: string; idempotencyKey: string; reason: string; actorId: string | null }>();
761
+ if (!saved || saved.idempotencyKey !== input.idempotencyKey) throw new ApiError(409, "appeal_already_recorded", "An appeal for this participant and decision already exists.");
762
+ if (saved.reason !== input.reason || saved.actorId !== session.actor.id) throw new ApiError(409,"idempotency_key_reused","This appeal request key belongs to another operator or reason.");
763
+ return { appealId: saved.id, idempotentReplay: saved.id !== appealId };
764
+ }
765
+
677
766
  export async function listAppeals(db: D1Database, limit: number, allowedQueueIds?: string[] | null): Promise<Array<Record<string, unknown>>> {
678
767
  const result = await db.prepare(`
679
768
  SELECT a.id, a.report_id AS reportId, r.public_reference AS reportReference,
@@ -711,9 +800,14 @@ export async function loadAppealDetail(db: D1Database, appealId: string): Promis
711
800
  `).bind(appealId, now()).first<Record<string, unknown>>();
712
801
  if (!appeal) return null;
713
802
  const decisions = await db.prepare(`
714
- SELECT id, outcome, rationale, reviewer_id AS reviewerId,
715
- corrective_action_id AS correctiveActionId, created_at AS createdAt
716
- FROM appeal_decisions WHERE appeal_id = ?1 ORDER BY created_at, id
803
+ SELECT ad.id, ad.outcome, ad.rationale, ad.reviewer_id AS reviewerId,
804
+ ad.corrective_action_id AS correctiveActionId, ad.created_at AS createdAt,
805
+ ad.completion_state AS completionState, ad.correction_required AS correctionRequired,
806
+ ad.correction_reference AS correctionReference, ad.correction_note AS correctionNote,
807
+ ad.correction_confirmed_by AS correctionConfirmedBy, ad.correction_confirmed_at AS correctionConfirmedAt,
808
+ n.delivery_state AS noticeState
809
+ FROM appeal_decisions ad LEFT JOIN notices n ON n.idempotency_key = 'appeal-decision:' || ad.id || ':notice'
810
+ WHERE ad.appeal_id = ?1 ORDER BY ad.created_at, ad.id
717
811
  `).bind(appealId).all<Record<string, unknown>>();
718
812
  return { ...appeal, decisions: decisions.results };
719
813
  }
@@ -723,6 +817,10 @@ export async function releaseAppealClaim(
723
817
  appealId: string,
724
818
  session: OperatorSession,
725
819
  ): Promise<void> {
820
+ if (!session.permissions.reviewAppeals) throw new ApiError(403,"appeal_claim_denied","Your role cannot review appeals.");
821
+ const appeal = await db.prepare(`SELECT report_id AS reportId FROM appeals WHERE id = ?1`).bind(appealId).first<{reportId:string}>();
822
+ if (!appeal) throw new ApiError(404,"appeal_not_found","The appeal does not exist.");
823
+ await assertOperatorReportAccess(db,session,appeal.reportId);
726
824
  const claim = await db.prepare(`SELECT reviewer_id AS reviewerId, version FROM appeal_claims WHERE appeal_id = ?1 LIMIT 1`)
727
825
  .bind(appealId).first<{ reviewerId: string; version: number }>();
728
826
  if (!claim) return;
@@ -730,20 +828,16 @@ export async function releaseAppealClaim(
730
828
  throw new ApiError(409, "appeal_claim_owned_by_another_reviewer", "Only the current reviewer or an assignment manager can release this claim.");
731
829
  }
732
830
  const createdAt = now();
733
- await db.batch([
734
- db.prepare(`DELETE FROM appeal_claims WHERE appeal_id = ?1`).bind(appealId),
735
- db.prepare(`UPDATE appeals SET state = 'submitted' WHERE id = ?1 AND state = 'human_review'`).bind(appealId),
736
- auditEvent(db, {
737
- idempotencyKey: `appeal-claim-release:${appealId}:${claim.version}`,
738
- action: "appeal.claim_released",
739
- actorType: session.actor.type,
740
- actorId: session.actor.id,
741
- targetType: "appeal",
742
- targetId: appealId,
743
- details: { priorReviewerId: claim.reviewerId, version: claim.version },
744
- createdAt,
745
- }),
831
+ const result = await db.batch([
832
+ db.prepare(`DELETE FROM appeal_claims WHERE appeal_id = ?1 AND version = ?2 AND reviewer_id = ?3
833
+ AND EXISTS (SELECT 1 FROM appeals a JOIN reports r ON r.id = a.report_id WHERE a.id = ?1 AND a.state = 'human_review'
834
+ AND (?4 = 1 OR r.queue_id IN (SELECT value FROM json_each(?5))))`).bind(appealId,claim.version,claim.reviewerId,session.queueScope==null?1:0,canonicalJson(session.queueScope??[])),
835
+ db.prepare(`UPDATE appeals SET state = 'submitted' WHERE id = ?1 AND state = 'human_review' AND changes() = 1`).bind(appealId),
836
+ db.prepare(`INSERT INTO audit_events(id,idempotency_key,action,actor_type,actor_id,target_type,target_id,details_json,created_at)
837
+ SELECT ?1,?2,'appeal.claim_released',?3,?4,'appeal',?5,?6,?7 WHERE changes() = 1`)
838
+ .bind(crypto.randomUUID(),`appeal-claim-release:${appealId}:${claim.version}`,session.actor.type,session.actor.id,appealId,canonicalJson({priorReviewerId:claim.reviewerId,version:claim.version}),createdAt),
746
839
  ]);
840
+ if (result[0]?.meta.changes !== 1) throw new ApiError(409,"appeal_claim_changed","The appeal claim changed. Refresh before releasing it.");
747
841
  }
748
842
 
749
843
  export async function claimAppeal(
@@ -752,200 +846,40 @@ export async function claimAppeal(
752
846
  session: OperatorSession,
753
847
  claimTtlMinutes: number,
754
848
  ): Promise<{ reviewerId: string; claimedAt: string; expiresAt: string; version: number }> {
849
+ if (!session.permissions.reviewAppeals) throw new ApiError(403,"appeal_claim_denied","Your role cannot review appeals.");
755
850
  const appeal = await db.prepare(`
756
- SELECT a.id, a.state, d.maker_type, d.maker_id
851
+ SELECT a.id, a.report_id, a.state, d.maker_type, d.maker_id
757
852
  FROM appeals a JOIN decisions d ON d.id = a.decision_id
758
853
  WHERE a.id = ?1 LIMIT 1
759
- `).bind(appealId).first<{ id: string; state: string; maker_type: string; maker_id: string }>();
854
+ `).bind(appealId).first<{ id: string; report_id: string; state: string; maker_type: string; maker_id: string }>();
760
855
  if (!appeal) throw new ApiError(404, "appeal_not_found", "The appeal does not exist.");
856
+ await assertOperatorReportAccess(db,session,appeal.report_id);
761
857
  if (!["submitted", "human_review"].includes(appeal.state)) throw new ApiError(409, "appeal_not_claimable", "The appeal is not awaiting human review.");
762
858
  if (appeal.maker_type === "human" && appeal.maker_id === session.actor.id) {
763
859
  throw new ApiError(409, "independent_appeal_reviewer_required", "The original decision maker cannot claim this appeal.");
764
860
  }
765
861
  const claimedAt = now();
766
862
  const expiresAt = addMinutes(claimedAt, Math.max(1, Math.min(1_440, claimTtlMinutes)));
767
- await db.batch([
863
+ const written = await db.batch([
768
864
  db.prepare(`
769
865
  INSERT INTO appeal_claims (appeal_id, reviewer_id, claimed_at, expires_at, version)
770
- VALUES (?1, ?2, ?3, ?4, 1)
866
+ SELECT ?1, ?2, ?3, ?4, 1 FROM appeals a JOIN decisions d ON d.id = a.decision_id JOIN reports r ON r.id = a.report_id
867
+ WHERE a.id = ?1 AND a.state IN ('submitted','human_review') AND a.reason_redacted_at IS NULL AND r.state = 'appealed'
868
+ AND d.maker_id != ?2 AND (?5 = 1 OR r.queue_id IN (SELECT value FROM json_each(?6)))
771
869
  ON CONFLICT(appeal_id) DO UPDATE SET reviewer_id = excluded.reviewer_id,
772
870
  claimed_at = excluded.claimed_at, expires_at = excluded.expires_at,
773
871
  version = appeal_claims.version + 1
774
872
  WHERE appeal_claims.expires_at <= ?3 OR appeal_claims.reviewer_id = ?2
775
- `).bind(appealId, session.actor.id, claimedAt, expiresAt),
776
- db.prepare(`UPDATE appeals SET state = 'human_review' WHERE id = ?1 AND state = 'submitted'`).bind(appealId),
873
+ `).bind(appealId, session.actor.id, claimedAt, expiresAt,session.queueScope==null?1:0,canonicalJson(session.queueScope??[])),
874
+ db.prepare(`UPDATE appeals SET state = 'human_review' WHERE id = ?1 AND state = 'submitted' AND changes() = 1`).bind(appealId),
777
875
  ]);
876
+ if (written[0]?.meta.changes !== 1) throw new ApiError(409,"appeal_claim_changed","The appeal or claim changed. Refresh before claiming it.");
778
877
  const claim = await db.prepare(`SELECT reviewer_id, claimed_at, expires_at, version FROM appeal_claims WHERE appeal_id = ?1 LIMIT 1`)
779
878
  .bind(appealId).first<{ reviewer_id: string; claimed_at: string; expires_at: string; version: number }>();
780
879
  if (!claim || claim.reviewer_id !== session.actor.id) throw new ApiError(409, "appeal_already_claimed", "Another appeal reviewer currently holds this appeal.");
781
880
  return { reviewerId: claim.reviewer_id, claimedAt: claim.claimed_at, expiresAt: claim.expires_at, version: claim.version };
782
881
  }
783
882
 
784
- export async function decideAppeal(
785
- db: D1Database,
786
- appealId: string,
787
- session: OperatorSession,
788
- input: AppealDecisionInput,
789
- options: DecisionOptions,
790
- ): Promise<{ appealDecisionId: string; jobs: DeliveryJob[]; idempotentReplay: boolean }> {
791
- const existing = await db.prepare(`
792
- SELECT id FROM appeal_decisions WHERE appeal_id = ?1 AND idempotency_key = ?2 LIMIT 1
793
- `).bind(appealId, input.idempotencyKey).first<{ id: string }>();
794
- if (existing) return { appealDecisionId: existing.id, jobs: [], idempotentReplay: true };
795
- const createdAt = now();
796
- const appeal = await db.prepare(`
797
- SELECT a.id, a.report_id, a.decision_id, a.appellant_audience, a.state, a.submitted_at,
798
- d.maker_id AS original_maker_id, r.public_reference, r.state AS report_state,
799
- r.policy_version_id, t.target_reference,
800
- p.reference AS participant_reference
801
- FROM appeals a
802
- JOIN decisions d ON d.id = a.decision_id
803
- JOIN reports r ON r.id = a.report_id
804
- JOIN report_targets t ON t.report_id = r.id
805
- JOIN report_participants p ON p.report_id = r.id AND p.audience = a.appellant_audience
806
- WHERE a.id = ?1
807
- LIMIT 1
808
- `).bind(appealId).first<{
809
- id: string;
810
- report_id: string;
811
- decision_id: string;
812
- appellant_audience: ParticipantAudience;
813
- state: string;
814
- submitted_at: string;
815
- original_maker_id: string;
816
- public_reference: string;
817
- report_state: ReportState;
818
- policy_version_id: string | null;
819
- target_reference: string;
820
- participant_reference: string | null;
821
- }>();
822
- if (!appeal) throw new ApiError(404, "appeal_not_found", "The appeal does not exist.");
823
- if (appeal.state !== "human_review") throw new ApiError(409, "appeal_not_decidable", "The appeal is not awaiting a human decision.");
824
- if (appeal.original_maker_id === session.actor.id) {
825
- throw new ApiError(409, "independent_appeal_reviewer_required", "The original decision maker cannot decide this appeal.");
826
- }
827
- const claim = await db.prepare(`SELECT reviewer_id, expires_at FROM appeal_claims WHERE appeal_id = ?1 LIMIT 1`)
828
- .bind(appealId).first<{ reviewer_id: string; expires_at: string }>();
829
- if (!claim || claim.reviewer_id !== session.actor.id || claim.expires_at <= createdAt) {
830
- throw new ApiError(409, "active_appeal_claim_required", "Claim this appeal before deciding it.");
831
- }
832
- if (input.correctiveActionCode && (!options.actionEnabled || !options.allowedActionCodes.has(input.correctiveActionCode))) {
833
- throw new ApiError(400, "action_not_allowed", "The corrective application action is not allowlisted for this deployment.");
834
- }
835
- const appealDecisionId = crypto.randomUUID();
836
- const messageId = crypto.randomUUID();
837
- const noticeId = crypto.randomUUID();
838
- const notificationId = crypto.randomUUID();
839
- const actionId = input.correctiveActionCode ? crypto.randomUUID() : null;
840
- const templateId = appeal.appellant_audience === "affected_user" ? "appeal-outcome-v1" : "reporter-outcome-v1";
841
- const jobs: DeliveryJob[] = [];
842
- const statements: D1PreparedStatement[] = [
843
- db.prepare(`
844
- INSERT INTO appeal_decisions (
845
- id, appeal_id, outcome, rationale, reviewer_id, corrective_action_id, created_at, idempotency_key
846
- ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
847
- `).bind(appealDecisionId, appealId, input.outcome, input.rationale, session.actor.id, actionId, createdAt, input.idempotencyKey),
848
- db.prepare(`UPDATE appeals SET state = ?2, resolved_at = ?3 WHERE id = ?1`).bind(appealId, input.outcome, createdAt),
849
- db.prepare(`UPDATE reports SET state = 'closed', queue_id = NULL, updated_at = ?2 WHERE id = ?1`)
850
- .bind(appeal.report_id, createdAt),
851
- db.prepare(`
852
- INSERT INTO report_state_history (id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at)
853
- VALUES (?1, ?2, ?3, 'closed', ?4, ?5, ?6, ?7)
854
- `).bind(crypto.randomUUID(), appeal.report_id, appeal.report_state, `appeal_${input.outcome}`, session.actor.type, session.actor.id, createdAt),
855
- db.prepare(`
856
- INSERT INTO case_messages (
857
- id, report_id, audience, direction, sender_type, sender_id, body, template_version_id,
858
- automated, delivery_state, created_at, idempotency_key
859
- ) VALUES (?1, ?2, ?3, 'outbound', 'human', ?4, ?5, ?6, 0, ?7, ?8, ?9)
860
- `).bind(
861
- messageId, appeal.report_id, appeal.appellant_audience, session.actor.id, input.participantNotice,
862
- templateId, options.notificationEnabled ? "queued" : "suppressed", createdAt,
863
- `appeal-decision:${appealDecisionId}:message`,
864
- ),
865
- db.prepare(`
866
- INSERT INTO notices (
867
- id, report_id, decision_id, audience, template_version_id, rendered_body,
868
- delivery_state, created_at, updated_at, idempotency_key, message_id
869
- ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8, ?9, ?10)
870
- `).bind(
871
- noticeId, appeal.report_id, appeal.decision_id, appeal.appellant_audience, templateId,
872
- input.participantNotice, options.notificationEnabled ? "queued" : "suppressed", createdAt,
873
- `appeal-decision:${appealDecisionId}:notice`, messageId,
874
- ),
875
- db.prepare(`DELETE FROM appeal_claims WHERE appeal_id = ?1`).bind(appealId),
876
- auditEvent(db, {
877
- idempotencyKey: `appeal-decision:${appealId}:${input.idempotencyKey}`,
878
- action: "appeal.human_decision_recorded",
879
- actorType: session.actor.type,
880
- actorId: session.actor.id,
881
- targetType: "appeal_decision",
882
- targetId: appealDecisionId,
883
- details: {
884
- appealId,
885
- reportId: appeal.report_id,
886
- outcome: input.outcome,
887
- correctiveActionCode: input.correctiveActionCode ?? null,
888
- },
889
- createdAt,
890
- }),
891
- ];
892
- if (input.outcome === "reversed") {
893
- statements.push(db.prepare(`
894
- INSERT INTO ai_usage_daily (usage_date, config_version_id, appeal_reversals, updated_at)
895
- SELECT substr(?2, 1, 10), ar.config_version_id, 1, ?2
896
- FROM decisions d JOIN ai_runs ar ON ar.id = d.ai_run_id
897
- WHERE d.id = ?1
898
- ON CONFLICT(usage_date, config_version_id) DO UPDATE SET
899
- appeal_reversals = appeal_reversals + 1, updated_at = excluded.updated_at
900
- `).bind(appeal.decision_id, createdAt));
901
- }
902
- if (options.notificationEnabled) {
903
- statements.push(db.prepare(`
904
- INSERT INTO notification_outbox (
905
- id, notice_id, idempotency_key, channel, payload_json, status, next_attempt_at, created_at, updated_at
906
- ) VALUES (?1, ?2, ?3, 'customer_webhook', ?4, 'pending', ?5, ?5, ?5)
907
- `).bind(notificationId, noticeId, `notice:${noticeId}`, canonicalJson({
908
- version: 1,
909
- event: "appeal.outcome.available",
910
- notification_id: notificationId,
911
- report_reference: appeal.public_reference,
912
- ...(appeal.participant_reference ? { recipient_reference: appeal.participant_reference } : {}),
913
- audience: appeal.appellant_audience,
914
- message: input.participantNotice,
915
- }), createdAt));
916
- jobs.push({ version: 1, jobId: `notification:${notificationId}`, type: "deliver_notification", outboxId: notificationId });
917
- }
918
- if (input.correctiveActionCode && actionId) {
919
- statements.push(db.prepare(`
920
- INSERT INTO decision_action_outbox (
921
- id, report_id, decision_id, idempotency_key, action_code, target_reference,
922
- payload_json, status, next_attempt_at, created_at, updated_at
923
- ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', ?8, ?8, ?8)
924
- `).bind(
925
- actionId, appeal.report_id, appeal.decision_id,
926
- `appeal-action:${appealDecisionId}:${input.correctiveActionCode}`,
927
- input.correctiveActionCode, appeal.target_reference,
928
- canonicalJson({
929
- version: 1,
930
- action_id: actionId,
931
- idempotency_key: `appeal-action:${appealDecisionId}:${input.correctiveActionCode}`,
932
- report_id: appeal.report_id,
933
- report_reference: appeal.public_reference,
934
- original_decision_id: appeal.decision_id,
935
- appeal_id: appealId,
936
- appeal_decision_id: appealDecisionId,
937
- action_code: input.correctiveActionCode,
938
- target_reference: appeal.target_reference,
939
- policy_version_id: appeal.policy_version_id,
940
- }),
941
- createdAt,
942
- ));
943
- jobs.push({ version: 1, jobId: `action:${actionId}`, type: "deliver_action", outboxId: actionId });
944
- }
945
- await db.batch(statements);
946
- return { appealDecisionId, jobs, idempotentReplay: false };
947
- }
948
-
949
883
  export async function chainPendingAuditEvents(db: D1Database, limit = 50): Promise<void> {
950
884
  const pending = await db.prepare(`
951
885
  SELECT id, action, actor_type, actor_id, target_type, target_id, details_json, created_at