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
@@ -179,7 +179,7 @@ export function parseAgentVersion(value: unknown): AgentVersionInput {
179
179
  "maximum_turns", "approval_policy", "input_schema", "output_schema",
180
180
  ], "agent version");
181
181
  if (!Array.isArray(input.tool_manifest) || input.tool_manifest.length > 8
182
- || input.tool_manifest.some((tool) => typeof tool !== "string" || !["read_report_context", "read_findings"].includes(tool))
182
+ || input.tool_manifest.some((tool) => typeof tool !== "string" || !["read_report_context", "read_findings", "check_evidence"].includes(tool))
183
183
  || new Set(input.tool_manifest).size !== input.tool_manifest.length) {
184
184
  throw new ApiError(400, "invalid_ai_configuration", "tool_manifest contains unsupported or duplicate tools.");
185
185
  }
@@ -0,0 +1,75 @@
1
+ import { canonicalJson } from "./audit";
2
+ import { correctionContext, correctionOperatorEligible, correctionWriteGate, gateBindings } from "./appeal-correction-state";
3
+ import { sha256 } from "./report-crypto";
4
+ import { ApiError } from "./report-http";
5
+ import type { OperatorSession } from "./report-types";
6
+
7
+ /** Recovery also calls this: a lost wake-up must not strand a saved handoff. */
8
+ export async function reconcileAppealCompletion(db: D1Database, reportId?: string): Promise<number> {
9
+ // Exact saved customer receipts and explicit human evidence are separate sources.
10
+ await db.prepare(`UPDATE appeal_corrections SET verified_at=(SELECT confirmed_at FROM action_proposals WHERE id=appeal_corrections.proposal_id),verified_by='customer_receipt',updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
11
+ WHERE mode='automated' AND verified_at IS NULL AND (?1 IS NULL OR report_id=?1)
12
+ AND EXISTS(SELECT 1 FROM action_proposals p JOIN reports r ON r.id=p.report_id WHERE p.id=appeal_corrections.proposal_id AND p.appeal_id=appeal_corrections.appeal_id AND p.compensates_proposal_id=appeal_corrections.original_proposal_id AND p.state='applied' AND p.confirmed_at IS NOT NULL AND p.delivery_terminal=1 AND r.integration_id=appeal_corrections.integration_id AND p.target_json=appeal_corrections.original_target_json
13
+ AND json_extract(p.last_receipt_json,'$.status')='applied' AND json_extract(p.last_receipt_json,'$.action_id')=p.id AND json_extract(p.last_receipt_json,'$.operation_id')=p.operation_id AND json_extract(p.last_receipt_json,'$.payload_digest')=p.payload_digest)`).bind(reportId??null).run();
14
+ await db.prepare(`UPDATE appeal_decisions SET correction_confirmed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'),correction_confirmed_by='correction-reconciler',correction_reference='See individual correction records',correction_note='Every original customer action has a verified correction.'
15
+ WHERE correction_required=1 AND correction_confirmed_at IS NULL AND completion_state='waiting' AND (?1 IS NULL OR appeal_id IN(SELECT id FROM appeals WHERE report_id=?1))
16
+ AND EXISTS(SELECT 1 FROM appeal_corrections c WHERE c.appeal_decision_id=appeal_decisions.id)
17
+ AND NOT EXISTS(SELECT 1 FROM appeal_corrections c WHERE c.appeal_decision_id=appeal_decisions.id AND c.verified_at IS NULL)
18
+ AND NOT EXISTS(SELECT 1 FROM appeals a JOIN action_proposals p ON p.report_id=a.report_id WHERE a.id=appeal_decisions.appeal_id AND (p.decision_id=a.decision_id OR p.decision_id IS NULL) AND p.appeal_id IS NULL AND p.compensates_proposal_id IS NULL AND p.state NOT IN ('rejected','cancelled','compensated') AND NOT EXISTS(SELECT 1 FROM appeal_corrections c WHERE c.appeal_decision_id=appeal_decisions.id AND c.original_proposal_id=p.id AND c.verified_at IS NOT NULL))
19
+ AND NOT EXISTS(SELECT 1 FROM appeals a JOIN decision_action_outbox o ON o.decision_id=a.decision_id WHERE a.id=appeal_decisions.appeal_id AND NOT EXISTS(SELECT 1 FROM appeal_decisions old WHERE old.corrective_action_id=o.id) AND NOT EXISTS(SELECT 1 FROM appeal_corrections c WHERE c.appeal_decision_id=appeal_decisions.id AND c.original_outbox_id=o.id AND c.verified_at IS NOT NULL))
20
+ AND NOT EXISTS(SELECT 1 FROM appeals a JOIN action_proposals p ON p.report_id=a.report_id WHERE a.id=appeal_decisions.appeal_id AND p.state IN ('proposed','approval_required','approved','queued','applying'))
21
+ AND NOT EXISTS(SELECT 1 FROM appeals a JOIN decision_action_outbox o ON o.report_id=a.report_id WHERE a.id=appeal_decisions.appeal_id AND o.status IN ('pending','delivering') AND o.terminal=0)`).bind(reportId??null).run();
22
+ const rows = await db.prepare(`SELECT ad.id, a.report_id AS reportId FROM appeal_decisions ad JOIN appeals a ON a.id = ad.appeal_id
23
+ WHERE ad.completion_state = 'waiting' AND (?1 IS NULL OR a.report_id = ?1)
24
+ AND (ad.correction_required = 0 OR ad.correction_confirmed_at IS NOT NULL)
25
+ AND EXISTS (SELECT 1 FROM notices WHERE idempotency_key = 'appeal-decision:' || ad.id || ':notice' AND delivery_state = 'delivered')
26
+ ORDER BY ad.created_at, ad.id LIMIT 100`)
27
+ .bind(reportId ?? null).all<{ id: string; reportId: string }>();
28
+ let completed = 0;
29
+ for (const row of rows.results) {
30
+ const at = new Date().toISOString();
31
+ const result = await db.batch([
32
+ db.prepare(`UPDATE appeal_decisions SET completion_state = 'completed' WHERE id = ?1 AND completion_state = 'waiting'
33
+ AND (correction_required = 0 OR correction_confirmed_at IS NOT NULL)
34
+ AND EXISTS (SELECT 1 FROM notices WHERE idempotency_key = 'appeal-decision:' || ?1 || ':notice' AND delivery_state = 'delivered')`)
35
+ .bind(row.id),
36
+ db.prepare(`INSERT OR IGNORE INTO audit_events (id,idempotency_key,action,actor_type,actor_id,target_type,target_id,details_json,created_at)
37
+ SELECT ?1, ?2, 'appeal.followup_completed', 'system', 'appeal-reconciler', 'appeal_decision', ?3, ?4, ?5 WHERE changes() = 1`)
38
+ .bind(crypto.randomUUID(), `appeal-completed:${row.id}`, row.id, canonicalJson({reportId: row.reportId}), at),
39
+ db.prepare(`INSERT INTO report_state_history (id,report_id,from_state,to_state,reason_code,actor_type,actor_id,created_at)
40
+ SELECT ?1, id, state, 'closed', 'appeal_followup_completed', 'system', 'appeal-reconciler', ?3 FROM reports
41
+ WHERE id = ?2 AND state = 'appealed'
42
+ AND NOT EXISTS (SELECT 1 FROM appeals a LEFT JOIN appeal_decisions ad ON ad.appeal_id = a.id
43
+ WHERE a.report_id = ?2 AND (ad.id IS NULL OR ad.completion_state = 'waiting'))`)
44
+ .bind(crypto.randomUUID(), row.reportId, at),
45
+ db.prepare(`UPDATE reports SET state = 'closed', updated_at = ?2 WHERE id = ?1 AND state = 'appealed' AND changes() = 1`)
46
+ .bind(row.reportId, at),
47
+ ]);
48
+ completed += result[0]?.meta.changes ?? 0;
49
+ }
50
+ return completed;
51
+ }
52
+
53
+ /** Legacy route remains a strict adapter: verification now requires a saved assignment. */
54
+ export async function confirmAppealCorrection(db:D1Database,appealId:string,session:OperatorSession,input:{reference:string;note:string;idempotencyKey:string;correctionId?:string}) {
55
+ const ctx=await correctionContext(db,appealId,session);
56
+ const rows=await db.prepare(`SELECT id FROM appeal_corrections WHERE appeal_id=?1 AND mode='manual' AND (?2 IS NULL OR id=?2)`).bind(appealId,input.correctionId??null).all<{id:string}>();
57
+ if(rows.results.length!==1)throw new ApiError(409,'appeal_correction_plan_required','Assign a specific manual correction with a responsible operator and deadline before verifying it.');
58
+ const id=rows.results[0]!.id;
59
+ if(input.reference.trim().length<3||input.reference.length>500||input.note.trim().length<3||input.note.length>4000)throw new ApiError(400,'correction_evidence_required','Record the customer reference and how you verified the correction.');
60
+ const digest=await sha256(canonicalJson({appealId,correctionId:id,reference:input.reference,note:input.note}));
61
+ const replay=await db.prepare(`SELECT actor_id AS actorId,target_id AS targetId,response_json AS response FROM configuration_mutations WHERE idempotency_key=?1`).bind(input.idempotencyKey).first<{actorId:string;targetId:string;response:string}>();
62
+ if(replay){if(replay.actorId!==session.actor.id||replay.targetId!==id||replay.response!==canonicalJson({digest}))throw new ApiError(409,'idempotency_key_reused','This request key belongs to different correction evidence.');await reconcileAppealCompletion(db,ctx.reportId);return{idempotentReplay:true};}
63
+ const at=new Date().toISOString();
64
+ const result=await db.batch([
65
+ db.prepare(`UPDATE appeal_corrections SET verified_at=?7,verified_by=?2,evidence_reference=?8,evidence_note=?9,updated_at=?7 WHERE id=?6 AND mode='manual' AND verified_at IS NULL AND ${correctionWriteGate()}
66
+ AND EXISTS(SELECT 1 FROM operator_users u JOIN reports r ON r.id=appeal_corrections.report_id WHERE u.id=appeal_corrections.assigned_operator_id AND ${correctionOperatorEligible})
67
+ AND NOT EXISTS(SELECT 1 FROM action_proposals WHERE report_id=appeal_corrections.report_id AND state IN ('proposed','approval_required','approved','queued','applying'))
68
+ AND NOT EXISTS(SELECT 1 FROM decision_action_outbox WHERE report_id=appeal_corrections.report_id AND status IN ('pending','delivering') AND terminal=0)`)
69
+ .bind(...gateBindings(ctx,session),id,at,input.reference.trim(),input.note.trim()),
70
+ db.prepare(`INSERT INTO configuration_mutations(idempotency_key,action,actor_id,target_id,response_json,created_at) SELECT ?1,'appeal.correction_verified',?2,?3,?4,?5 WHERE changes()=1`).bind(input.idempotencyKey,session.actor.id,id,canonicalJson({digest}),at),
71
+ 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.correction_verified',?3,?4,'appeal_correction',?5,?6,?7 WHERE changes()=1`).bind(crypto.randomUUID(),`appeal-correction-verified:${input.idempotencyKey}`,session.actor.type,session.actor.id,id,canonicalJson({reportId:ctx.reportId,evidenceDigest:digest,verification:'human'}),at),
72
+ ]);
73
+ if(result[0]?.meta.changes!==1)throw new ApiError(409,'appeal_correction_changed','The assignment or case changed, or a customer action is still in flight. Refresh before verifying.');
74
+ await reconcileAppealCompletion(db,ctx.reportId);return{idempotentReplay:false};
75
+ }
@@ -0,0 +1,46 @@
1
+ import { canonicalJson } from "./audit";
2
+ import { assertOperatorQueueAccess, assertOperatorReportAccess } from "./operator-scope";
3
+ import { ApiError } from "./report-http";
4
+ import type { OperatorSession } from "./report-types";
5
+
6
+ // The role-binding union mirrors operator authorization. Used inside write gates.
7
+ export const correctionOperatorEligible = `u.status='active' AND (
8
+ EXISTS (SELECT 1 FROM json_each(u.roles_json) WHERE value='owner') OR (
9
+ (EXISTS (SELECT 1 FROM role_bindings b WHERE b.installation_id=r.installation_id AND b.principal_type='operator_user' AND b.principal_id=u.id AND b.revoked_at IS NULL AND b.role IN ('administrator','reviewer','appeal_reviewer','operations'))
10
+ OR (NOT EXISTS (SELECT 1 FROM role_bindings b WHERE b.installation_id=r.installation_id AND b.principal_type='operator_user' AND b.principal_id=u.id AND b.revoked_at IS NULL)
11
+ AND EXISTS (SELECT 1 FROM json_each(u.roles_json) WHERE value IN ('administrator','reviewer','appeal_reviewer','operations'))))
12
+ AND (NOT EXISTS (SELECT 1 FROM role_bindings b WHERE b.installation_id=r.installation_id AND b.principal_type='operator_user' AND b.principal_id=u.id AND b.revoked_at IS NULL)
13
+ OR EXISTS (SELECT 1 FROM role_bindings b WHERE b.installation_id=r.installation_id AND b.principal_type='operator_user' AND b.principal_id=u.id AND b.revoked_at IS NULL AND (json_array_length(b.queue_scope_json)=0 OR r.queue_id IN (SELECT value FROM json_each(b.queue_scope_json)))))))`;
14
+
15
+ export const correctionReviewerEligible = `(${correctionOperatorEligible.replaceAll("'administrator','reviewer','appeal_reviewer','operations'", "'administrator','appeal_reviewer'")}) AND (
16
+ EXISTS(SELECT 1 FROM json_each(u.roles_json) WHERE value='owner') OR (
17
+ (NOT EXISTS(SELECT 1 FROM role_bindings b WHERE b.installation_id=r.installation_id AND b.principal_type='operator_user' AND b.principal_id=u.id AND b.revoked_at IS NULL)
18
+ OR EXISTS(SELECT 1 FROM role_bindings b WHERE b.installation_id=r.installation_id AND b.principal_type='operator_user' AND b.principal_id=u.id AND b.revoked_at IS NULL AND (json_array_length(b.queue_scope_json)=0 OR a.queue_id IN(SELECT value FROM json_each(b.queue_scope_json)))))
19
+ AND (NOT EXISTS(SELECT 1 FROM role_bindings b WHERE b.installation_id=r.installation_id AND b.principal_type='operator_user' AND b.principal_id=u.id AND b.revoked_at IS NULL)
20
+ OR EXISTS(SELECT 1 FROM role_bindings b WHERE b.installation_id=r.installation_id AND b.principal_type='operator_user' AND b.principal_id=u.id AND b.revoked_at IS NULL AND NOT EXISTS(SELECT 1 FROM json_each(b.field_scope_json))))))`;
21
+
22
+ export const originalActionsSql = `SELECT 'proposal:' || p.id AS sourceKey,p.id AS proposalId,NULL AS outboxId,p.operation_id AS operationId,p.target_json AS targetJson,p.action_code AS actionCode,p.state,p.confirmed_at AS confirmedAt,p.run_id AS runId,p.step_run_id AS stepRunId,p.payload_digest AS payloadDigest
23
+ FROM action_proposals p WHERE p.report_id=a.report_id AND (p.decision_id=a.decision_id OR p.decision_id IS NULL) AND p.appeal_id IS NULL AND p.compensates_proposal_id IS NULL AND p.state NOT IN ('rejected','cancelled','compensated')
24
+ UNION ALL SELECT 'outbox:' || o.id,NULL,o.id,o.idempotency_key,json_object('reference',o.target_reference),o.action_code,o.status,NULL,NULL,NULL,NULL FROM decision_action_outbox o WHERE o.report_id=a.report_id AND o.decision_id=a.decision_id AND NOT EXISTS(SELECT 1 FROM appeal_decisions old WHERE old.corrective_action_id=o.id)`;
25
+ export interface OriginalAction {sourceKey:string;proposalId:string|null;outboxId:string|null;operationId:string;targetJson:string;actionCode:string;state:string;confirmedAt:string|null;runId:string|null;stepRunId:string|null;payloadDigest:string|null}
26
+ export interface CorrectionContext {id:string;appealId:string;reportId:string;decisionId:string;reviewerId:string;originalMakerId:string;installationId:string;integrationId:string;policyVersionId:string|null;completionState:string;correctionRequired:number;state:string;redactedAt:string|null;appealQueueId:string|null}
27
+ export async function correctionContext(db:D1Database,appealId:string,session:OperatorSession,write=true):Promise<CorrectionContext> {
28
+ if(session.fieldScope!=null)throw new ApiError(403,'correction_fields_restricted','Correction approval and verification require complete access to the original decision, customer action and appeal evidence.');
29
+ if(write&&!session.permissions.decideAppeals)throw new ApiError(403,'appeal_correction_denied','Your role cannot approve or verify appeal corrections.');
30
+ const row=await db.prepare(`SELECT ad.id,a.id AS appealId,a.report_id AS reportId,a.decision_id AS decisionId,ad.reviewer_id AS reviewerId,d.maker_id AS originalMakerId,r.installation_id AS installationId,r.integration_id AS integrationId,r.policy_version_id AS policyVersionId,ad.completion_state AS completionState,ad.correction_required AS correctionRequired,r.state,ad.rationale_redacted_at AS redactedAt,a.queue_id AS appealQueueId FROM appeals a JOIN appeal_decisions ad ON ad.appeal_id=a.id JOIN decisions d ON d.id=a.decision_id JOIN reports r ON r.id=a.report_id WHERE a.id=?1`).bind(appealId).first<CorrectionContext>();
31
+ if(!row)throw new ApiError(404,'appeal_not_found','This appeal has no recorded verdict.');
32
+ await assertOperatorReportAccess(db,session,row.reportId);assertOperatorQueueAccess(session,row.appealQueueId);
33
+ if(write&&(row.originalMakerId===session.actor.id))throw new ApiError(403,'independent_appeal_reviewer_required','An independent appeal reviewer must approve and verify corrections; the original decision maker cannot do so.');
34
+ return row;
35
+ }
36
+ export async function originalActions(db:D1Database,appealId:string):Promise<OriginalAction[]> {
37
+ return (await db.prepare(`SELECT originals.* FROM appeals a JOIN (${originalActionsSql.replaceAll('a.report_id','?2').replaceAll('a.decision_id','?3')}) originals WHERE a.id=?1`).bind(appealId,
38
+ (await db.prepare('SELECT report_id AS reportId FROM appeals WHERE id=?1').bind(appealId).first<{reportId:string}>())?.reportId??'',
39
+ (await db.prepare('SELECT decision_id AS decisionId FROM appeals WHERE id=?1').bind(appealId).first<{decisionId:string}>())?.decisionId??'').all<OriginalAction>()).results;
40
+ }
41
+ // This predicate is re-evaluated in the transaction authorizing external work.
42
+ export function correctionWriteGate():string {return `EXISTS(SELECT 1 FROM appeal_decisions ad JOIN appeals a ON a.id=ad.appeal_id JOIN decisions d ON d.id=a.decision_id JOIN reports r ON r.id=a.report_id JOIN operator_users u ON u.id=?2
43
+ WHERE ad.id=?1 AND d.maker_id!=?2 AND ad.outcome IN ('modified','reversed') AND ad.completion_state='waiting' AND ad.correction_required=1 AND ad.correction_confirmed_at IS NULL AND ad.rationale_redacted_at IS NULL AND r.state='appealed' AND r.integration_id=?3 AND a.decision_id=(SELECT id FROM decisions WHERE report_id=r.id ORDER BY created_at DESC,rowid DESC LIMIT 1)
44
+ AND (?4=1 OR (r.queue_id IN (SELECT value FROM json_each(?5)) AND a.queue_id IN (SELECT value FROM json_each(?5)))) AND ${correctionReviewerEligible}
45
+ AND NOT EXISTS(SELECT 1 FROM appeal_decisions newer JOIN appeals na ON na.id=newer.appeal_id WHERE na.report_id=r.id AND newer.created_at>ad.created_at AND newer.outcome='upheld'))`;}
46
+ export function gateBindings(ctx:CorrectionContext,session:OperatorSession):(string|number)[]{return[ctx.id,session.actor.id,ctx.integrationId,session.queueScope==null?1:0,canonicalJson(session.queueScope??[])];}
@@ -0,0 +1,112 @@
1
+ import { canonicalJson } from './audit';
2
+ import { sha256 } from './report-crypto';
3
+ import { ApiError } from './report-http';
4
+ import type { OperatorSession, ActionProposalJob } from './report-types';
5
+ import type { JsonValue, JsonObject } from './workflow-platform-types';
6
+ import { validateJsonSchemaValue } from './workflow-expressions';
7
+ import { correctionContext, correctionOperatorEligible, correctionWriteGate, gateBindings, originalActions } from './appeal-correction-state';
8
+
9
+ interface Capability {id:string;name:string;version:number;inputSchemaJson:string;approvalPolicyJson:string;connectionVersionId:string;actionCode?:string;correctsActionCodes?:string[];inputSchema?:JsonObject}
10
+ function object(value:unknown):Record<string,unknown>{if(!value||typeof value!=='object'||Array.isArray(value))throw new ApiError(400,'correction_invalid','Use a structured correction payload.');return value as Record<string,unknown>;}
11
+ function text(value:unknown,min=3,max=4000):string{if(typeof value!=='string'||value.trim().length<min||value.length>max)throw new ApiError(400,'correction_invalid',`Provide text between ${min} and ${max} characters.`);return value.trim();}
12
+ export async function correctionCapabilities(db:D1Database,installationId:string):Promise<Capability[]> {
13
+ const rows=await db.prepare(`SELECT v.id,d.name,v.version,v.input_schema_json AS inputSchemaJson,v.approval_policy_json AS approvalPolicyJson,v.connection_version_id AS connectionVersionId FROM component_versions v JOIN component_definitions d ON d.id=v.definition_id JOIN connection_versions cv ON cv.id=v.connection_version_id JOIN connection_definitions cd ON cd.id=cv.connection_id WHERE d.installation_id=?1 AND d.status='active' AND d.effect_class='side_effecting' AND (d.active_version_id IS NULL OR d.active_version_id=v.id) AND v.retired_at IS NULL AND v.published_at IS NOT NULL AND v.implementation_kind IN ('webhook','external_api') AND cd.status='active' AND cv.retired_at IS NULL AND cv.published_at IS NOT NULL ORDER BY d.name,v.version DESC`).bind(installationId).all<Capability>();
14
+ return rows.results.flatMap(row=>{try{const policy=object(JSON.parse(row.approvalPolicyJson)),correction=object(policy.correction),schema=object(JSON.parse(row.inputSchemaJson));
15
+ if(policy.mode!=='human'||typeof correction.action_code!=='string'||!Array.isArray(correction.corrects_action_codes)||!correction.corrects_action_codes.every(code=>typeof code==='string')||schema.type!=='object')return[];
16
+ const properties=object(schema.properties);if(!properties.original_operation_id)return[];
17
+ if(Object.values(properties).some(value=>{const field=object(value);return !['string','integer','number','boolean'].includes(String(field.type));}))return[];
18
+ return[{...row,actionCode:correction.action_code,correctsActionCodes:correction.corrects_action_codes as string[],inputSchema:schema as JsonObject}];}catch{return[];}});
19
+ }
20
+ export async function loadAppealCorrections(db:D1Database,appealId:string,session:OperatorSession){
21
+ const ctx=await correctionContext(db,appealId,session,false);
22
+ const [originals,capabilities,owners,corrections]=await Promise.all([originalActions(db,appealId),correctionCapabilities(db,ctx.installationId),
23
+ db.prepare(`SELECT u.id,u.display_name AS displayName FROM operator_users u JOIN reports r ON r.id=?1 WHERE ${correctionOperatorEligible} ORDER BY u.display_name,u.id`).bind(ctx.reportId).all(),
24
+ db.prepare(`SELECT c.id,c.source_key AS sourceKey,c.mode,c.proposal_id AS proposalId,c.assigned_operator_id AS assignedOperatorId,u.display_name AS assignedOperatorName,c.due_at AS dueAt,c.instructions,c.verified_at AS verifiedAt,c.verified_by AS verifiedBy,c.evidence_reference AS evidenceReference,c.evidence_note AS evidenceNote,p.state,p.accepted_at AS acceptedAt,p.confirmed_at AS confirmedAt,p.last_error_code AS lastErrorCode,p.payload_digest AS payloadDigest,p.delivery_terminal AS deliveryTerminal,p.last_receipt_json AS receiptJson,p.operation_id AS operationId,p.action_code AS actionCode FROM appeal_corrections c LEFT JOIN action_proposals p ON p.id=c.proposal_id LEFT JOIN operator_users u ON u.id=c.assigned_operator_id WHERE c.appeal_id=?1 ORDER BY c.created_at,c.id`).bind(appealId).all()]);
25
+ return{canApprove:!!session.permissions.decideAppeals&&ctx.originalMakerId!==session.actor.id&&ctx.completionState==='waiting',originals:originals.map(row=>({...row,target:JSON.parse(row.targetJson),capabilityIds:row.state==='applied'&&row.confirmedAt&&row.runId?capabilities.filter(c=>c.correctsActionCodes?.includes(row.actionCode)).map(c=>c.id):[]})),capabilities:capabilities.map(({id,name,version,inputSchema,actionCode,correctsActionCodes})=>({id,name:`${name} (v${version})`,inputSchema,actionCode,correctsActionCodes})),owners:owners.results,corrections:corrections.results};
26
+ }
27
+ export async function createAppealCorrection(env:Env,appealId:string,session:OperatorSession,value:Record<string,unknown>,idempotencyKey:string){
28
+ const ctx=await correctionContext(env.DB,appealId,session);
29
+ const sourceKey=text(value.source_key,3,600),mode=value.mode;
30
+ if(mode!=='manual'&&mode!=='automated')throw new ApiError(400,'correction_invalid','Choose an automated or manual correction.');
31
+ if(value.approved!==true)throw new ApiError(400,'correction_approval_required','Review the target and exact correction, then approve it explicitly.');
32
+ const owner=text(value.assigned_operator_id,1,200),instructions=text(value.instructions),dueInput=typeof value.due_at==='string'?value.due_at:'';
33
+ if(!Number.isFinite(Date.parse(dueInput)))throw new ApiError(400,'correction_due_invalid','Choose a valid correction deadline.');
34
+ const dueAt=new Date(dueInput).toISOString();
35
+ if(!Number.isFinite(Date.parse(dueAt))||Date.parse(dueAt)<=Date.now()||Date.parse(dueAt)>Date.now()+30*86400000)throw new ApiError(400,'correction_due_invalid','Choose a future deadline within 30 days.');
36
+ const requestDigest=await sha256(canonicalJson({appealId,sourceKey,mode,owner,instructions,dueAt,componentVersionId:value.component_version_id??null,payload:value.payload??null}));
37
+ const prior=await env.DB.prepare('SELECT id,request_digest AS digest,approved_by AS actorId,proposal_id AS proposalId FROM appeal_corrections WHERE idempotency_key=?1').bind(idempotencyKey).first<{id:string;digest:string;actorId:string;proposalId:string|null}>();
38
+ if(prior){if(prior.digest!==requestDigest||prior.actorId!==session.actor.id)throw new ApiError(409,'idempotency_key_reused','This request key belongs to a different correction.');return{correctionId:prior.id,proposalId:prior.proposalId,idempotentReplay:true};}
39
+ const original=(await originalActions(env.DB,appealId)).find(row=>row.sourceKey===sourceKey);
40
+ if(!original)throw new ApiError(400,'correction_original_invalid','Choose an original customer action from this appealed decision.');
41
+ const id=crypto.randomUUID(),at=new Date().toISOString(),proposalId=mode==='automated'?crypto.randomUUID():null;
42
+ let capability:Capability|undefined,payload:Record<string,unknown>={},payloadDigest='',operationId='';
43
+ const preconditions={report_state:'appealed',latest_decision_id:ctx.decisionId,appeal_decision_id:ctx.id,original_operation_id:original.operationId,original_payload_digest:original.payloadDigest};
44
+ if(mode==='automated'){
45
+ if(!original.proposalId||original.state!=='applied'||!original.confirmedAt||!original.runId)throw new ApiError(409,'correction_original_unconfirmed','This action requires assigned manual correction and verification.');
46
+ capability=(await correctionCapabilities(env.DB,ctx.installationId)).find(row=>row.id===value.component_version_id&&row.correctsActionCodes?.includes(original.actionCode));
47
+ if(!capability)throw new ApiError(409,'correction_capability_unavailable','Choose an active correction capability configured for this original action.');
48
+ payload={...object(value.payload),original_operation_id:original.operationId};
49
+ if(validateJsonSchemaValue(payload as JsonValue,capability.inputSchema!).length)throw new ApiError(422,'correction_payload_invalid','Complete the correction fields using the configured capability requirements.');
50
+ operationId=`appeal-correction:${ctx.id}:${original.proposalId}`;
51
+ payloadDigest=`sha256:${await sha256(canonicalJson({schema_version:'1',action_id:proposalId,action_code:capability.actionCode,target:JSON.parse(original.targetJson),payload,preconditions,component_version_id:capability.id,workflow_version_id:(await env.DB.prepare('SELECT workflow_version_id AS id FROM workflow_runs WHERE id=?1').bind(original.runId).first<{id:string}>())?.id,policy_version_id:ctx.policyVersionId,expires_at:dueAt,installation_id:ctx.installationId}))}`;
52
+ }
53
+ const gate=correctionWriteGate();const bindings=gateBindings(ctx,session);
54
+ // One gate row is the transaction fence. All later writes depend on its creation.
55
+ const insert=env.DB.prepare(`INSERT INTO appeal_corrections (id,appeal_decision_id,appeal_id,report_id,integration_id,source_key,original_proposal_id,original_outbox_id,original_operation_id,original_target_json,mode,proposal_id,assigned_operator_id,due_at,instructions,approved_by,request_digest,idempotency_key,created_at,updated_at)
56
+ SELECT ?6,?1,?7,?8,?3,?9,?10,?11,?12,?13,'manual',NULL,?15,?16,?17,?2,?18,?19,?20,?20 WHERE ${gate}
57
+ AND EXISTS(SELECT 1 FROM operator_users u JOIN reports r ON r.id=?8 WHERE u.id=?15 AND ${correctionOperatorEligible})
58
+ AND NOT EXISTS(SELECT 1 FROM appeal_corrections WHERE appeal_decision_id=?1 AND source_key=?9)
59
+ AND (?10 IS NULL OR EXISTS(SELECT 1 FROM action_proposals p WHERE p.id=?10 AND p.report_id=?8 AND p.operation_id=?12 AND p.target_json=?13 AND p.appeal_id IS NULL AND p.compensates_proposal_id IS NULL AND (?14='manual' OR (p.state='applied' AND p.confirmed_at IS NOT NULL AND p.payload_digest=?21))))
60
+ AND (?11 IS NULL OR EXISTS(SELECT 1 FROM decision_action_outbox o WHERE o.id=?11 AND o.report_id=?8 AND o.idempotency_key=?12))
61
+ AND (?14='manual' OR EXISTS(SELECT 1 FROM component_versions v JOIN component_definitions d ON d.id=v.definition_id JOIN connection_versions cv ON cv.id=v.connection_version_id JOIN connection_definitions cd ON cd.id=cv.connection_id WHERE v.id=?22 AND v.approval_policy_json=?23 AND v.input_schema_json=?24 AND d.status='active' AND (d.active_version_id IS NULL OR d.active_version_id=v.id) AND v.retired_at IS NULL AND cd.status='active' AND cv.retired_at IS NULL))`)
62
+ .bind(...bindings,id,appealId,ctx.reportId,sourceKey,original.proposalId,original.outboxId,original.operationId,original.targetJson,mode,owner,dueAt,instructions,requestDigest,idempotencyKey,at,original.payloadDigest,capability?.id??null,capability?.approvalPolicyJson??null,capability?.inputSchemaJson??null);
63
+ // Insert the plan as manual inside the same transaction, then link its new proposal;
64
+ // the FK and CHECK constraints never admit an automated plan without an operation.
65
+ const statements=[insert];
66
+ if(proposalId&&capability){
67
+ statements.push(env.DB.prepare(`INSERT INTO action_proposals(id,installation_id,report_id,appeal_id,run_id,step_run_id,decision_id,action_code,target_json,payload_json,preconditions_json,payload_digest,component_version_id,authority_mode,approval_policy_json,state,operation_id,compensates_proposal_id,created_by_type,created_by_id,next_attempt_at,created_at,updated_at)
68
+ SELECT ?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,'assist',?14,'approval_required',?15,?16,?17,?18,?19,?19,?19 WHERE EXISTS(SELECT 1 FROM appeal_corrections WHERE id=?20)
69
+ AND EXISTS(SELECT 1 FROM action_proposals p WHERE p.id=?16 AND p.state='applied' AND p.confirmed_at IS NOT NULL AND p.payload_digest=?21 AND p.target_json=?9)
70
+ AND EXISTS(SELECT 1 FROM component_versions v JOIN component_definitions d ON d.id=v.definition_id JOIN connection_versions cv ON cv.id=v.connection_version_id JOIN connection_definitions cd ON cd.id=cv.connection_id WHERE v.id=?13 AND v.approval_policy_json=?22 AND v.input_schema_json=?23 AND d.status='active' AND (d.active_version_id IS NULL OR d.active_version_id=v.id) AND v.retired_at IS NULL AND cd.status='active' AND cv.retired_at IS NULL)`)
71
+ .bind(proposalId,ctx.installationId,ctx.reportId,appealId,original.runId,original.stepRunId,ctx.decisionId,capability.actionCode!,original.targetJson,canonicalJson(payload),canonicalJson(preconditions),payloadDigest,capability.id,canonicalJson({mode:'human',expires_at:dueAt,appeal_correction:true}),operationId,original.proposalId,session.actor.type,session.actor.id,at,id,original.payloadDigest,capability.approvalPolicyJson,capability.inputSchemaJson));
72
+ statements.push(env.DB.prepare(`INSERT INTO action_approvals(id,proposal_id,approver_id,payload_digest,decision,reason,idempotency_key,created_at) SELECT ?1,?2,?3,?4,'approved',?5,?6,?7 WHERE EXISTS(SELECT 1 FROM action_proposals WHERE id=?2)`).bind(crypto.randomUUID(),proposalId,session.actor.id,payloadDigest,instructions,`appeal-correction:${idempotencyKey}`,at));
73
+ statements.push(env.DB.prepare(`UPDATE action_proposals SET state='queued' WHERE id=?1 AND EXISTS(SELECT 1 FROM action_approvals WHERE proposal_id=?1)`).bind(proposalId));
74
+ statements.push(env.DB.prepare(`UPDATE appeal_corrections SET mode='automated',proposal_id=?2 WHERE id=?1`).bind(id,proposalId));
75
+ }
76
+ 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) SELECT ?1,?2,'appeal.correction_approved',?3,?4,'appeal_correction',?5,?6,?7 WHERE EXISTS(SELECT 1 FROM appeal_corrections WHERE id=?5)`).bind(crypto.randomUUID(),`appeal-correction-plan:${idempotencyKey}`,session.actor.type,session.actor.id,id,canonicalJson({reportId:ctx.reportId,appealId,sourceKey,mode,proposalId,owner,dueAt,payloadDigest:payloadDigest||null}),at));
77
+ let result;try{result=await env.DB.batch(statements);}catch(error){if(String(error).includes('UNIQUE constraint'))throw new ApiError(409,'appeal_correction_changed','A correction was already planned. Refresh before continuing.');throw error;}
78
+ if(result[0]?.meta.changes!==1)throw new ApiError(409,'appeal_correction_changed','The appeal, owner or customer capability changed. Refresh before approving.');
79
+ if(proposalId&&result[1]?.meta.changes!==1)throw new ApiError(409,'appeal_correction_changed','The customer action or capability changed. No automated correction was approved.');
80
+ if(proposalId){const job:ActionProposalJob={version:1,type:'apply_action_proposal',jobId:`action-proposal:${proposalId}`,proposalId,runId:original.runId!};await env.DELIVERY_JOBS.send(job,{contentType:'json'}).catch(()=>{});}
81
+ return{correctionId:id,proposalId,idempotentReplay:false};
82
+ }
83
+
84
+ export async function retryAppealCorrection(env:Env,appealId:string,correctionId:string,session:OperatorSession,idempotencyKey:string){
85
+ await correctionContext(env.DB,appealId,session);
86
+ const row=await env.DB.prepare(`SELECT c.proposal_id AS proposalId,p.payload_digest AS payloadDigest FROM appeal_corrections c JOIN action_proposals p ON p.id=c.proposal_id WHERE c.id=?1 AND c.appeal_id=?2 AND c.mode='automated'`).bind(correctionId,appealId).first<{proposalId:string;payloadDigest:string}>();
87
+ if(!row)throw new ApiError(404,'correction_not_found','The automated correction does not exist.');
88
+ const {retryActionProposal}=await import('./workflow-actions');
89
+ return retryActionProposal(env,row.proposalId,row.payloadDigest,session,idempotencyKey);
90
+ }
91
+
92
+ /** Reassignment never rewrites the approved customer operation or its receipt. */
93
+ export async function reassignAppealCorrection(env:Env,appealId:string,correctionId:string,session:OperatorSession,value:Record<string,unknown>,idempotencyKey:string){
94
+ const ctx=await correctionContext(env.DB,appealId,session);
95
+ const owner=text(value.assigned_operator_id,1,200),note=text(value.note),dueInput=typeof value.due_at==='string'?value.due_at:'';
96
+ if(!Number.isFinite(Date.parse(dueInput))||Date.parse(dueInput)<=Date.now()||Date.parse(dueInput)>Date.now()+30*86400000)throw new ApiError(400,'correction_due_invalid','Choose a future correction deadline within 30 days.');
97
+ const dueAt=new Date(dueInput).toISOString(),manual=value.manual_recovery===true;
98
+ const digest=await sha256(canonicalJson({appealId,correctionId,owner,dueAt,note,manual}));
99
+ const prior=await env.DB.prepare('SELECT actor_id AS actorId,target_id AS targetId,response_json AS response FROM configuration_mutations WHERE idempotency_key=?1').bind(idempotencyKey).first<{actorId:string;targetId:string;response:string}>();
100
+ if(prior){if(prior.actorId!==session.actor.id||prior.targetId!==correctionId||prior.response!==canonicalJson({digest}))throw new ApiError(409,'idempotency_key_reused','This request key belongs to another correction assignment.');return{idempotentReplay:true};}
101
+ const at=new Date().toISOString();
102
+ const result=await env.DB.batch([
103
+ env.DB.prepare(`UPDATE appeal_corrections SET assigned_operator_id=?7,due_at=?8,instructions=?9,mode=CASE WHEN ?10=1 THEN 'manual' ELSE mode END,updated_at=?11 WHERE id=?6 AND appeal_id=?12 AND verified_at IS NULL AND ${correctionWriteGate()}
104
+ AND EXISTS(SELECT 1 FROM operator_users u JOIN reports r ON r.id=appeal_corrections.report_id WHERE u.id=?7 AND ${correctionOperatorEligible})
105
+ AND (?10=0 OR mode='manual' OR EXISTS(SELECT 1 FROM action_proposals p WHERE p.id=appeal_corrections.proposal_id AND p.state='failed' AND p.delivery_terminal=1 AND p.lease_expires_at IS NULL))`)
106
+ .bind(...gateBindings(ctx,session),correctionId,owner,dueAt,note,manual?1:0,at,appealId),
107
+ env.DB.prepare(`INSERT INTO configuration_mutations(idempotency_key,action,actor_id,target_id,response_json,created_at) SELECT ?1,'appeal.correction_reassigned',?2,?3,?4,?5 WHERE changes()=1`).bind(idempotencyKey,session.actor.id,correctionId,canonicalJson({digest}),at),
108
+ env.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.correction_reassigned',?3,?4,'appeal_correction',?5,?6,?7 WHERE changes()=1`).bind(crypto.randomUUID(),`appeal-correction-reassigned:${idempotencyKey}`,session.actor.type,session.actor.id,correctionId,canonicalJson({reportId:ctx.reportId,assignedOperatorId:owner,dueAt,manualRecovery:manual,noteDigest:await sha256(note)}),at),
109
+ ]);
110
+ if(result[0]?.meta.changes!==1)throw new ApiError(409,'appeal_correction_changed','The correction or operator changed. An automated correction must stop before manual recovery can begin.');
111
+ return{idempotentReplay:false};
112
+ }
@@ -0,0 +1,85 @@
1
+ import { canonicalJson } from "./audit";
2
+ import { assertOperatorReportAccess } from "./operator-scope";
3
+ import { sha256 } from "./report-crypto";
4
+ import { selectParticipantDelivery } from "./report-email";
5
+ import { ApiError } from "./report-http";
6
+ import type { DeliveryJob, OperatorSession, ParticipantAudience } from "./report-types";
7
+ import type { DecisionOptions } from "./report-workflow";
8
+ import type { AppealDecisionInput } from "./report-workflow-validation";
9
+
10
+ export async function decideAppeal(db: D1Database, appealId: string, session: OperatorSession, input: AppealDecisionInput, options: DecisionOptions) {
11
+ if (!session.permissions.decideAppeals) throw new ApiError(403,"appeal_decision_denied","Your role cannot decide appeals.");
12
+ const appeal = await db.prepare(`SELECT a.report_id, a.decision_id, a.appellant_audience, a.state,
13
+ d.maker_id AS original_maker_id, r.public_reference, r.installation_id, r.policy_version_id, t.target_reference,
14
+ p.reference AS participant_reference, p.contact_mode AS participant_contact_mode, p.contact_ciphertext AS participant_contact_ciphertext
15
+ FROM appeals a JOIN decisions d ON d.id = a.decision_id JOIN reports r ON r.id = a.report_id JOIN report_targets t ON t.report_id = r.id
16
+ JOIN report_participants p ON p.report_id = r.id AND p.audience = a.appellant_audience WHERE a.id = ?1`)
17
+ .bind(appealId).first<{report_id:string;decision_id:string;appellant_audience:ParticipantAudience;state:string;original_maker_id:string;public_reference:string;installation_id:string;policy_version_id:string|null;target_reference:string;participant_reference:string|null;participant_contact_mode:string;participant_contact_ciphertext:string|null}>();
18
+ if (!appeal) throw new ApiError(404,"appeal_not_found","The appeal does not exist.");
19
+ await assertOperatorReportAccess(db,session,appeal.report_id);
20
+ const digest = await sha256(canonicalJson({appealId, outcome:input.outcome,rationale:input.rationale,participantNotice:input.participantNotice,correctiveActionCode:input.correctiveActionCode??null}));
21
+ const existing = await db.prepare(`SELECT ad.id, ad.reviewer_id AS reviewerId, ad.request_digest AS digest, ad.outcome, ad.rationale,
22
+ (SELECT body FROM case_messages WHERE idempotency_key = 'appeal-decision:' || ad.id || ':message') AS participantNotice,
23
+ (SELECT action_code FROM decision_action_outbox WHERE id = ad.corrective_action_id) AS correctiveActionCode
24
+ FROM appeal_decisions ad WHERE ad.appeal_id = ?1 AND ad.idempotency_key = ?2`)
25
+ .bind(appealId,input.idempotencyKey).first<{id:string;reviewerId:string;digest:string|null;outcome:string;rationale:string;participantNotice:string|null;correctiveActionCode:string|null}>();
26
+ if (existing) {
27
+ const savedDigest=existing.digest??await sha256(canonicalJson({appealId,outcome:existing.outcome,rationale:existing.rationale,participantNotice:existing.participantNotice,correctiveActionCode:existing.correctiveActionCode}));
28
+ if (existing.reviewerId !== session.actor.id || savedDigest !== digest) throw new ApiError(409,"idempotency_key_reused","This appeal request key belongs to another reviewer or different decision text.");
29
+ return {appealDecisionId:existing.id,jobs:[] as DeliveryJob[],idempotentReplay:true};
30
+ }
31
+ if(input.correctiveActionCode)throw new ApiError(400,'correction_plan_required','Record the verdict first, then approve a configured correction for the exact original customer action in Follow-up.');
32
+ if (appeal.state !== "human_review") throw new ApiError(409,"appeal_not_decidable","The appeal is not awaiting a human decision.");
33
+ if (appeal.original_maker_id === session.actor.id) throw new ApiError(409,"independent_appeal_reviewer_required","The original decision maker cannot decide this appeal.");
34
+ const at = new Date().toISOString();
35
+ const claim = await db.prepare(`SELECT reviewer_id, expires_at, version FROM appeal_claims WHERE appeal_id = ?1`).bind(appealId).first<{reviewer_id:string;expires_at:string;version:number}>();
36
+ if (!claim || claim.reviewer_id !== session.actor.id || claim.expires_at <= at) throw new ApiError(409,"active_appeal_claim_required","Claim this appeal before deciding it.");
37
+ if (input.correctiveActionCode && (!options.actionEnabled || !options.allowedActionCodes.has(input.correctiveActionCode))) throw new ApiError(400,"action_not_allowed","The corrective application action is not allowlisted for this deployment.");
38
+ const appealDecisionId=crypto.randomUUID(),messageId=crypto.randomUUID(),noticeId=crypto.randomUUID(),notificationId=crypto.randomUUID();
39
+ const actionId=input.correctiveActionCode?crypto.randomUUID():null;
40
+ const templateId=appeal.appellant_audience==="affected_user"?"appeal-outcome-v1":"reporter-outcome-v1";
41
+ const delivery=await selectParticipantDelivery({reportId:appeal.report_id,audience:appeal.appellant_audience,contactMode:appeal.participant_contact_mode,contactCiphertext:appeal.participant_contact_ciphertext,reference:appeal.participant_reference,emailEnabled:options.emailEnabled,notificationEnabled:options.notificationEnabled,contactEncryptionKey:options.contactEncryptionKey,recipientHashPepper:options.recipientHashPepper});
42
+ const saved=(sql:string,values:(string|number|null)[])=>db.prepare(sql.replaceAll("/*saved*/",`EXISTS (SELECT 1 FROM appeal_decisions WHERE id = ?${values.length+1})`)).bind(...values,appealDecisionId);
43
+ const statements=[
44
+ db.prepare(`INSERT INTO appeal_decisions (id,appeal_id,outcome,rationale,reviewer_id,corrective_action_id,created_at,idempotency_key,request_digest,completion_state,correction_required)
45
+ SELECT ?1,?2,?3,?4,?5,NULL,?7,?8,?9,'waiting',CASE WHEN ?3 != 'upheld' AND (?6 IS NOT NULL
46
+ OR EXISTS (SELECT 1 FROM action_proposals WHERE report_id = a.report_id AND state NOT IN ('rejected','cancelled','compensated'))
47
+ OR EXISTS (SELECT 1 FROM decision_action_outbox WHERE report_id = a.report_id)) THEN 1 ELSE 0 END
48
+ FROM appeals a JOIN decisions d ON d.id = a.decision_id JOIN reports r ON r.id = a.report_id JOIN appeal_claims c ON c.appeal_id = a.id
49
+ WHERE a.id = ?2 AND a.state = 'human_review' AND r.state = 'appealed' AND a.reason_redacted_at IS NULL
50
+ AND d.maker_id != ?5 AND c.reviewer_id = ?5 AND c.version = ?10 AND c.expires_at > MAX(?7, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
51
+ AND (?11 = 1 OR r.queue_id IN (SELECT value FROM json_each(?12)))
52
+ AND NOT EXISTS (SELECT 1 FROM appeal_decisions WHERE appeal_id = ?2)`)
53
+ .bind(appealDecisionId,appealId,input.outcome,input.rationale,session.actor.id,actionId,at,input.idempotencyKey,digest,claim.version,session.queueScope==null?1:0,canonicalJson(session.queueScope??[])),
54
+ saved(`UPDATE appeals SET state = ?2, resolved_at = ?3 WHERE id = ?1 AND /*saved*/`,[appealId,input.outcome,at]),
55
+ saved(`INSERT INTO case_messages (id,report_id,audience,direction,sender_type,sender_id,body,template_version_id,automated,delivery_state,created_at,idempotency_key)
56
+ SELECT ?1,?2,?3,'outbound','human',?4,?5,?6,0,?7,?8,?9 WHERE /*saved*/`,[messageId,appeal.report_id,appeal.appellant_audience,session.actor.id,input.participantNotice,templateId,delivery.state,at,`appeal-decision:${appealDecisionId}:message`]),
57
+ saved(`INSERT INTO notices (id,report_id,decision_id,audience,template_version_id,rendered_body,delivery_state,created_at,updated_at,idempotency_key,message_id)
58
+ SELECT ?1,?2,?3,?4,?5,?6,?7,?8,?8,?9,?10 WHERE /*saved*/`,[noticeId,appeal.report_id,appeal.decision_id,appeal.appellant_audience,templateId,input.participantNotice,delivery.state,at,`appeal-decision:${appealDecisionId}:notice`,messageId]),
59
+ saved(`DELETE FROM appeal_claims WHERE appeal_id = ?1 AND /*saved*/`,[appealId]),
60
+ saved(`INSERT INTO audit_events (id,idempotency_key,action,actor_type,actor_id,target_type,target_id,details_json,created_at)
61
+ SELECT ?1,?2,'appeal.human_decision_recorded',?3,?4,'appeal_decision',?5,?6,?7 WHERE /*saved*/`,[crypto.randomUUID(),`appeal-decision:${appealId}:${input.idempotencyKey}`,session.actor.type,session.actor.id,appealDecisionId,canonicalJson({appealId,reportId:appeal.report_id,outcome:input.outcome,correctiveActionCode:input.correctiveActionCode??null}),at]),
62
+ ];
63
+ const jobs:DeliveryJob[]=[];
64
+ if (delivery.channel==="email" && delivery.recipientHash) {
65
+ statements.push(saved(`INSERT INTO email_deliveries (id,installation_id,message_id,audience,recipient_hash,provider,state,attempt_count,next_attempt_at,created_at,updated_at)
66
+ SELECT ?1,?2,?3,?4,?5,'cloudflare_email_service','pending',0,?6,?6,?6 WHERE /*saved*/`,[notificationId,appeal.installation_id,messageId,appeal.appellant_audience,delivery.recipientHash,at]));
67
+ jobs.push({version:1,jobId:`email:${notificationId}`,type:"deliver_email",outboxId:notificationId});
68
+ } else if (delivery.channel==="customer_webhook") {
69
+ statements.push(saved(`INSERT INTO notification_outbox (id,notice_id,idempotency_key,channel,payload_json,status,next_attempt_at,created_at,updated_at)
70
+ SELECT ?1,?2,?3,'customer_webhook',?4,'pending',?5,?5,?5 WHERE /*saved*/`,[notificationId,noticeId,`notice:${noticeId}`,canonicalJson({version:1,event:"appeal.outcome.available",notification_id:notificationId,report_reference:appeal.public_reference,...(appeal.participant_reference?{recipient_reference:appeal.participant_reference}:{}),recipient_contact_fingerprint:appeal.participant_contact_ciphertext?await sha256(appeal.participant_contact_ciphertext):null,audience:appeal.appellant_audience,message:input.participantNotice}),at]));
71
+ jobs.push({version:1,jobId:`notification:${notificationId}`,type:"deliver_notification",outboxId:notificationId});
72
+ }
73
+ if (input.correctiveActionCode && actionId) {
74
+ statements.push(saved(`INSERT INTO decision_action_outbox (id,report_id,decision_id,idempotency_key,action_code,target_reference,payload_json,status,next_attempt_at,created_at,updated_at)
75
+ SELECT ?1,?2,?3,?4,?5,?6,?7,'pending',?8,?8,?8 WHERE /*saved*/`,[actionId,appeal.report_id,appeal.decision_id,`appeal-action:${appealDecisionId}:${input.correctiveActionCode}`,input.correctiveActionCode,appeal.target_reference,canonicalJson({version:1,action_id:actionId,idempotency_key:`appeal-action:${appealDecisionId}:${input.correctiveActionCode}`,report_id:appeal.report_id,report_reference:appeal.public_reference,original_decision_id:appeal.decision_id,appeal_id:appealId,appeal_decision_id:appealDecisionId,action_code:input.correctiveActionCode,target_reference:appeal.target_reference,policy_version_id:appeal.policy_version_id}),at]));
76
+ jobs.push({version:1,jobId:`action:${actionId}`,type:"deliver_action",outboxId:actionId});
77
+ statements.push(saved(`UPDATE appeal_decisions SET corrective_action_id = ?1 WHERE /*saved*/ AND id = ?2`,[actionId,appealDecisionId]));
78
+ }
79
+ if (input.outcome==="reversed") statements.push(saved(`INSERT INTO ai_usage_daily (usage_date,config_version_id,appeal_reversals,updated_at)
80
+ SELECT substr(?2,1,10),ar.config_version_id,1,?2 FROM decisions d JOIN ai_runs ar ON ar.id=d.ai_run_id WHERE d.id=?1 AND /*saved*/
81
+ ON CONFLICT(usage_date,config_version_id) DO UPDATE SET appeal_reversals=appeal_reversals+1,updated_at=excluded.updated_at`,[appeal.decision_id,at]));
82
+ const result=await db.batch(statements);
83
+ if (result[0]?.meta.changes!==1) throw new ApiError(409,"appeal_changed","The claim, case or appeal changed. Refresh before submitting again.");
84
+ return {appealDecisionId,jobs,idempotentReplay:false};
85
+ }
@@ -237,17 +237,16 @@ async function searchAnswer(db: D1Database, prompt: string, session: OperatorSes
237
237
  requirePermission(session, "searchWorkspace");
238
238
  const term = prompt.replace(/^(?:find|search|show)(?:\s+me)?(?:\s+reports?)?\s*/iu, "").trim().slice(0, 100);
239
239
  if (term.length < 2) throw new ApiError(400, "assistant_search_term_required", "Include at least two characters to search reports.");
240
- const pattern = `%${term.replace(/[%_]/gu, "")} %`.replace(/ %$/u, "%");
241
240
  const result = await db.prepare(`
242
241
  SELECT DISTINCT r.id, r.public_reference AS publicReference, r.state,
243
242
  r.reason_code AS reasonCode, r.queue_id AS queueId, r.received_at AS receivedAt
244
243
  FROM reports r LEFT JOIN report_targets t ON t.report_id = r.id
245
- WHERE (lower(r.public_reference) LIKE lower(?1) OR lower(r.reason_code) LIKE lower(?1)
246
- OR lower(COALESCE(t.target_reference, '')) LIKE lower(?1))
244
+ WHERE (instr(lower(r.public_reference), lower(?1)) > 0 OR instr(lower(r.reason_code), lower(?1)) > 0
245
+ OR instr(lower(COALESCE(t.target_reference, '')), lower(?1)) > 0)
247
246
  AND (?2 = 1 OR r.queue_id IN (SELECT value FROM json_each(?3)))
248
247
  ORDER BY r.received_at DESC, r.id DESC LIMIT 20
249
248
  `).bind(
250
- pattern,
249
+ term,
251
250
  session.queueScope === null || session.queueScope === undefined ? 1 : 0,
252
251
  canonicalJson(session.queueScope ?? []),
253
252
  ).all<Record<string, unknown>>();