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,3 +1,6 @@
1
+ import { reconcileAppealCompletion } from "./appeal-continuity";
2
+ import { correctionReviewerEligible } from "./appeal-correction-state";
3
+ import { workflowRunEventType } from "./workflow-event-transport";
1
4
  import { canonicalJson } from "./audit";
2
5
  import { BudgetControlError, reserveBudgets, settleBudgets } from "./budget-control";
3
6
  import { ConnectionExecutionError, executeConnectionJson } from "./connection-egress";
@@ -35,6 +38,8 @@ interface ActionComponentRow {
35
38
  interface ProposalRow {
36
39
  id: string;
37
40
  installationId: string;
41
+ integrationId: string;
42
+ appealId: string | null;
38
43
  reportId: string;
39
44
  runId: string;
40
45
  stepRunId: string;
@@ -54,9 +59,15 @@ interface ProposalRow {
54
59
  deliveryAttemptCount: number;
55
60
  deliveryTerminal: number;
56
61
  lastErrorCode: string | null;
62
+ acceptedAt: string | null;
63
+ confirmedAt: string | null;
64
+ confirmationDeadlineAt: string | null;
65
+ deliveryCycle: number;
66
+ cycleStartAttempt: number;
57
67
  }
58
68
 
59
69
  const ACTION_MAX_ATTEMPTS = 8;
70
+ const ACTION_CONFIRMATION_TIMEOUT_MS = 15 * 60_000;
60
71
 
61
72
  export class RetryableActionProposalError extends Error {
62
73
  constructor(readonly delaySeconds: number) {
@@ -147,6 +158,7 @@ export async function prepareWorkflowAction(
147
158
  branchKey: string;
148
159
  stepRunId: string;
149
160
  authorityMode: WorkflowAuthorityMode;
161
+ awaitRecovery?: boolean;
150
162
  },
151
163
  ): Promise<Record<string, unknown>> {
152
164
  if (input.authorityMode === "shadow" || input.authorityMode === "off") throw new WorkflowEffectError("action_authority_insufficient");
@@ -170,14 +182,25 @@ export async function prepareWorkflowAction(
170
182
  const proposalId = `${input.stepRunId}:proposal`;
171
183
  const operationId = `action:${input.runId}:${input.node.id}:${input.branchKey || "main"}:${mapped.idempotency_key}`;
172
184
  const existing = await env.DB.prepare(`
173
- SELECT id, state, payload_digest AS payloadDigest,
185
+ SELECT id, state, payload_digest AS payloadDigest, preconditions_json AS preconditionsJson,
174
186
  approval_policy_json AS approvalPolicyJson,
175
187
  delivery_terminal AS deliveryTerminal, last_error_code AS lastErrorCode
176
188
  FROM action_proposals WHERE operation_id = ?1 LIMIT 1
177
189
  `).bind(operationId).first<{
178
- id: string; state: string; payloadDigest: string; approvalPolicyJson: string;
190
+ id: string; state: string; payloadDigest: string; preconditionsJson: string; approvalPolicyJson: string;
179
191
  deliveryTerminal: number; lastErrorCode: string | null;
180
192
  }>();
193
+ if (typeof preconditions.decision_code === "string" && preconditions.latest_decision_id === undefined) {
194
+ if (existing) {
195
+ const pinnedDecision = parsedObject(existing.preconditionsJson).latest_decision_id;
196
+ if (pinnedDecision !== undefined) preconditions.latest_decision_id = pinnedDecision;
197
+ } else {
198
+ const decision = await env.DB.prepare(`SELECT id, decision_code AS code FROM decisions WHERE report_id = ?1 ORDER BY created_at DESC, rowid DESC LIMIT 1`)
199
+ .bind(context.reportId).first<{ id: string; code: string }>();
200
+ if (!decision || decision.code !== preconditions.decision_code) throw new WorkflowEffectError("action_precondition_changed");
201
+ preconditions.latest_decision_id = decision.id;
202
+ }
203
+ }
181
204
  const policy = approvalMode(input.node, component, input.authorityMode);
182
205
  const expiresSeconds = Number(input.node.config.expiry_seconds ?? 86_400);
183
206
  if (!Number.isInteger(expiresSeconds) || expiresSeconds < 60 || expiresSeconds > 2_678_400) throw new WorkflowEffectError("action_expiry_invalid");
@@ -202,6 +225,8 @@ export async function prepareWorkflowAction(
202
225
  if (existing.state === "rejected") return { port: "rejected", proposalId: existing.id, payloadDigest, idempotentReplay: true };
203
226
  if (existing.state === "cancelled") return { port: "timeout", proposalId: existing.id, payloadDigest, idempotentReplay: true };
204
227
  if (existing.state === "failed" && existing.deliveryTerminal === 1) {
228
+ const expiry = parsedObject(existing.approvalPolicyJson).expires_at;
229
+ if (input.awaitRecovery && typeof expiry === "string" && expiry > now()) return { ...deliveryPending(existing.id, payloadDigest, expiry), recoveryRequired: true };
205
230
  return { port: "error", proposalId: existing.id, payloadDigest, errorCode: existing.lastErrorCode ?? "action_delivery_failed", idempotentReplay: true };
206
231
  }
207
232
  if (existing.state === "approval_required") {
@@ -226,13 +251,14 @@ export async function prepareWorkflowAction(
226
251
  id, installation_id, report_id, run_id, step_run_id, action_code,
227
252
  target_json, payload_json, preconditions_json, payload_digest,
228
253
  component_version_id, authority_mode, approval_policy_json, state,
229
- operation_id, created_by_type, created_by_id, next_attempt_at, created_at, updated_at
230
- ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 'system', ?4, ?17, ?16, ?16)
254
+ operation_id, created_by_type, created_by_id, next_attempt_at, created_at, updated_at, decision_id
255
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 'system', ?4, ?17, ?16, ?16, (SELECT id FROM decisions WHERE id = ?18 AND report_id = ?3))
231
256
  `).bind(
232
257
  proposalId, context.installationId, context.reportId, input.runId, input.stepRunId,
233
258
  actionCode, canonicalJson(target), canonicalJson(payload), canonicalJson(preconditions),
234
259
  payloadDigest, componentVersionId, input.authorityMode, canonicalJson(approvalPolicy),
235
260
  initialState, operationId, timestamp, policy === "human" ? null : timestamp,
261
+ typeof preconditions.latest_decision_id === "string" ? preconditions.latest_decision_id : null,
236
262
  )];
237
263
  if (policy === "human") {
238
264
  const taskId = `${input.stepRunId}:action-approval-task`;
@@ -263,7 +289,7 @@ export async function prepareWorkflowAction(
263
289
 
264
290
  async function loadProposal(db: D1Database, proposalId: string): Promise<ProposalRow> {
265
291
  const proposal = await db.prepare(`
266
- SELECT p.id, p.installation_id AS installationId, p.report_id AS reportId,
292
+ SELECT p.id, p.installation_id AS installationId, p.report_id AS reportId, p.appeal_id AS appealId,
267
293
  p.run_id AS runId, p.step_run_id AS stepRunId, p.action_code AS actionCode,
268
294
  p.target_json AS targetJson, p.payload_json AS payloadJson,
269
295
  p.preconditions_json AS preconditionsJson, p.payload_digest AS payloadDigest,
@@ -271,7 +297,10 @@ async function loadProposal(db: D1Database, proposalId: string): Promise<Proposa
271
297
  p.approval_policy_json AS approvalPolicyJson, p.state, p.operation_id AS operationId,
272
298
  r.workflow_version_id AS workflowVersionId, rp.policy_version_id AS policyVersionId,
273
299
  rp.queue_id AS queueId, p.delivery_attempt_count AS deliveryAttemptCount,
274
- p.delivery_terminal AS deliveryTerminal, p.last_error_code AS lastErrorCode
300
+ p.delivery_terminal AS deliveryTerminal, p.last_error_code AS lastErrorCode,
301
+ rp.integration_id AS integrationId, p.accepted_at AS acceptedAt, p.confirmed_at AS confirmedAt,
302
+ p.confirmation_deadline_at AS confirmationDeadlineAt, p.delivery_cycle AS deliveryCycle,
303
+ p.cycle_start_attempt AS cycleStartAttempt
275
304
  FROM action_proposals p JOIN workflow_runs r ON r.id = p.run_id
276
305
  JOIN reports rp ON rp.id = p.report_id WHERE p.id = ?1 LIMIT 1
277
306
  `).bind(proposalId).first<ProposalRow>();
@@ -283,24 +312,37 @@ async function validatePreconditions(db: D1Database, proposal: ProposalRow): Pro
283
312
  const preconditions = parsedObject(proposal.preconditionsJson);
284
313
  const report = await db.prepare(`
285
314
  SELECT state, queue_id AS queueId,
286
- (SELECT id FROM decisions WHERE report_id = reports.id ORDER BY created_at DESC, id DESC LIMIT 1) AS latestDecisionId,
315
+ (SELECT id FROM decisions WHERE report_id = reports.id ORDER BY created_at DESC, rowid DESC LIMIT 1) AS latestDecisionId,
316
+ (SELECT decision_code FROM decisions WHERE report_id = reports.id ORDER BY created_at DESC, rowid DESC LIMIT 1) AS latestDecisionCode,
287
317
  (SELECT target_reference FROM report_targets WHERE report_id = reports.id ORDER BY created_at, id LIMIT 1) AS targetReference
288
318
  FROM reports WHERE id = ?1 LIMIT 1
289
- `).bind(proposal.reportId).first<{ state: string; queueId: string | null; latestDecisionId: string | null; targetReference: string | null }>();
319
+ `).bind(proposal.reportId).first<{ state: string; queueId: string | null; latestDecisionId: string | null; latestDecisionCode: string | null; targetReference: string | null }>();
290
320
  if (!report) throw new WorkflowEffectError("action_report_missing");
321
+ if (proposal.appealId) {
322
+ const eligible=await db.prepare(`SELECT c.id FROM appeal_corrections c JOIN appeal_decisions ad ON ad.id=c.appeal_decision_id JOIN appeals a ON a.id=ad.appeal_id JOIN decisions d ON d.id=a.decision_id JOIN reports r ON r.id=c.report_id JOIN action_proposals original ON original.id=c.original_proposal_id JOIN operator_users u ON u.id=c.approved_by
323
+ WHERE c.proposal_id=?1 AND c.appeal_id=?2 AND c.mode='automated' AND c.integration_id=?3 AND r.integration_id=c.integration_id AND r.state='appealed' AND ad.completion_state='waiting' AND ad.correction_confirmed_at IS NULL AND ad.outcome IN ('modified','reversed') AND ad.rationale_redacted_at IS NULL
324
+ AND d.maker_id!=c.approved_by AND ${correctionReviewerEligible}
325
+ AND original.state='applied' AND original.confirmed_at IS NOT NULL AND original.operation_id=c.original_operation_id AND original.target_json=c.original_target_json AND original.target_json=?4
326
+ AND original.payload_digest=?5 AND ad.id=?6 AND a.decision_id=?7
327
+ 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')`)
328
+ .bind(proposal.id,proposal.appealId,proposal.integrationId,proposal.targetJson,preconditions.original_payload_digest??null,preconditions.appeal_decision_id??null,preconditions.latest_decision_id??null).first();
329
+ if(!eligible)throw new WorkflowEffectError('appeal_correction_precondition_changed');
330
+ } else if (["appealed", "closed"].includes(report.state)) throw new WorkflowEffectError("action_case_no_longer_eligible");
291
331
  const checks: [JsonValue | undefined, unknown][] = [
292
332
  [preconditions.report_state, report.state],
293
333
  [preconditions.queue_id, report.queueId],
294
334
  [preconditions.latest_decision_id, report.latestDecisionId],
335
+ [preconditions.decision_code, report.latestDecisionCode],
295
336
  [preconditions.target_reference, report.targetReference],
296
337
  ];
297
338
  if (checks.some(([expected, actual]) => expected !== undefined && expected !== actual)) throw new WorkflowEffectError("action_precondition_changed");
298
339
  }
299
340
 
300
341
  async function emitActionResolution(env: Env, proposal: ProposalRow): Promise<void> {
301
- const eventId = `${proposal.id}:delivery-resolution`;
342
+ if(proposal.appealId){await reconcileAppealCompletion(env.DB,proposal.reportId);return;}
343
+ const eventId = `${proposal.id}:delivery-resolution${proposal.deliveryCycle > 1 ? `:${proposal.deliveryCycle}` : ""}`;
302
344
  const eventType = `action.${proposal.id}.delivery-resolved`;
303
- const eventKey = `action-delivery:${proposal.id}`;
345
+ const eventKey = `action-delivery:${proposal.id}:${proposal.deliveryCycle}`;
304
346
  const payload = {
305
347
  proposal_id: proposal.id,
306
348
  state: proposal.state,
@@ -327,7 +369,7 @@ async function emitActionResolution(env: Env, proposal: ProposalRow): Promise<vo
327
369
  .bind(proposal.runId).first<{ instanceId: string | null }>();
328
370
  if (!run?.instanceId) return;
329
371
  try {
330
- await (await env.WORKFLOWS.get(run.instanceId)).sendEvent({ type: eventType, payload });
372
+ await (await env.WORKFLOWS.get(run.instanceId)).sendEvent({ type: await workflowRunEventType(env.DB, proposal.runId, eventType), payload });
331
373
  await env.DB.batch([
332
374
  env.DB.prepare(`UPDATE workflow_event_inbox SET delivered_at = ?2, delivery_attempts = delivery_attempts + 1, last_error_code = NULL WHERE id = ?1`).bind(eventId, now()),
333
375
  env.DB.prepare(`UPDATE workflow_dispatch_outbox SET state = 'delivered', last_error_code = NULL, updated_at = ?2 WHERE id = ?1`).bind(eventId, now()),
@@ -343,8 +385,8 @@ async function emitActionResolution(env: Env, proposal: ProposalRow): Promise<vo
343
385
  export async function pendingActionProposalJobs(db: D1Database, limit = 50): Promise<ActionProposalJob[]> {
344
386
  const rows = await db.prepare(`
345
387
  SELECT id, run_id AS runId FROM action_proposals
346
- WHERE delivery_terminal = 0 AND delivery_attempt_count < ?2
347
- AND ((state IN ('queued','failed') AND next_attempt_at <= ?1)
388
+ WHERE delivery_terminal = 0
389
+ AND ((state IN ('queued','failed') AND next_attempt_at <= ?1 AND (delivery_attempt_count - cycle_start_attempt < ?2 OR accepted_at IS NOT NULL))
348
390
  OR (state = 'applying' AND lease_expires_at <= ?1))
349
391
  ORDER BY next_attempt_at, id LIMIT ?3
350
392
  `).bind(now(), ACTION_MAX_ATTEMPTS, Math.max(1, Math.min(100, limit))).all<{ id: string; runId: string }>();
@@ -354,120 +396,164 @@ export async function pendingActionProposalJobs(db: D1Database, limit = 50): Pro
354
396
  export async function reconcileActionProposalEvents(env: Env, limit = 50): Promise<number> {
355
397
  const rows = await env.DB.prepare(`
356
398
  SELECT p.id FROM action_proposals p
357
- WHERE (p.state = 'applied' OR (p.state = 'failed' AND p.delivery_terminal = 1))
358
- AND NOT EXISTS (SELECT 1 FROM workflow_event_inbox e WHERE e.id = p.id || ':delivery-resolution')
399
+ WHERE p.appeal_id IS NULL AND (p.state = 'applied' OR (p.state = 'failed' AND p.delivery_terminal = 1))
400
+ AND NOT EXISTS (SELECT 1 FROM workflow_event_inbox e WHERE e.id = p.id || ':delivery-resolution' || CASE WHEN p.delivery_cycle > 1 THEN ':' || p.delivery_cycle ELSE '' END)
359
401
  ORDER BY p.updated_at, p.id LIMIT ?1
360
402
  `).bind(Math.max(1, Math.min(100, limit))).all<{ id: string }>();
361
403
  for (const row of rows.results) await emitActionResolution(env, await loadProposal(env.DB, row.id));
362
404
  return rows.results.length;
363
405
  }
364
406
 
407
+ interface ActionReceipt {
408
+ action_id: string;
409
+ operation_id: string;
410
+ payload_digest: string;
411
+ status: "accepted" | "applied" | "failed";
412
+ terminal: boolean;
413
+ error_code: string | null;
414
+ retry_after_seconds: number;
415
+ provider_request_id: string | null;
416
+ }
417
+
418
+ function actionReceipt(value: Record<string, unknown>, proposal: ProposalRow): ActionReceipt {
419
+ if (value.action_id !== proposal.id || value.operation_id !== proposal.operationId || value.payload_digest !== proposal.payloadDigest) {
420
+ throw new WorkflowEffectError("action_result_identity_mismatch");
421
+ }
422
+ if (!["accepted", "applied", "failed"].includes(String(value.status))) throw new WorkflowEffectError("action_confirmation_required");
423
+ if (value.terminal !== undefined && typeof value.terminal !== "boolean") throw new WorkflowEffectError("action_result_invalid");
424
+ if (value.retry_after_seconds !== undefined && (!Number.isInteger(value.retry_after_seconds) || Number(value.retry_after_seconds) < 1 || Number(value.retry_after_seconds) > 21_600)) {
425
+ throw new WorkflowEffectError("action_result_invalid");
426
+ }
427
+ if (value.error_code !== undefined && (typeof value.error_code !== "string" || !/^[a-zA-Z0-9_.:-]{1,200}$/u.test(value.error_code))) throw new WorkflowEffectError("action_result_invalid");
428
+ if (value.provider_request_id !== undefined && (typeof value.provider_request_id !== "string" || value.provider_request_id.length > 500)) throw new WorkflowEffectError("action_result_invalid");
429
+ const status = value.status as ActionReceipt["status"];
430
+ return {
431
+ action_id: proposal.id, operation_id: proposal.operationId, payload_digest: proposal.payloadDigest, status,
432
+ terminal: status === "applied" || (status === "failed" && value.terminal !== false),
433
+ error_code: status === "failed" ? String(value.error_code ?? "action_provider_failed") : null,
434
+ retry_after_seconds: Number(value.retry_after_seconds ?? 30),
435
+ provider_request_id: typeof value.provider_request_id === "string" ? value.provider_request_id : null,
436
+ };
437
+ }
438
+
439
+ async function saveActionReceipt(
440
+ env: Env,
441
+ proposal: ProposalRow,
442
+ receipt: ActionReceipt,
443
+ actor: { type: "system" | "service_identity"; id: string },
444
+ attempt?: { number: number; leaseExpiresAt: string; connectionAttemptId: string; responseDigest: string },
445
+ ): Promise<{ proposal: ProposalRow; changed: boolean }> {
446
+ const timestamp = now();
447
+ const expiry = parsedObject(proposal.approvalPolicyJson).expires_at;
448
+ if (typeof expiry !== "string") throw new WorkflowEffectError("action_approval_expiry_missing");
449
+ const deadline = proposal.confirmationDeadlineAt ?? new Date(Math.min(Date.parse(expiry), Date.now() + ACTION_CONFIRMATION_TIMEOUT_MS)).toISOString();
450
+ const terminal = receipt.terminal || (receipt.status === "failed" && proposal.deliveryAttemptCount - proposal.cycleStartAttempt >= ACTION_MAX_ATTEMPTS);
451
+ const state = receipt.status === "accepted" ? "queued" : receipt.status;
452
+ const nextAttemptAt = receipt.status === "accepted" ? deadline : new Date(Date.now() + receipt.retry_after_seconds * 1_000).toISOString();
453
+ const receiptJson = canonicalJson(receipt);
454
+ const auditKey = `action-result:${proposal.operationId}:${proposal.deliveryCycle}:${await sha256(receiptJson)}`;
455
+ const result = await env.DB.batch([
456
+ env.DB.prepare(`UPDATE action_proposals SET state = ?2, delivery_terminal = ?3,
457
+ accepted_at = CASE WHEN ?2 = 'queued' THEN COALESCE(accepted_at, ?4) WHEN ?2 = 'failed' AND ?3 = 0 THEN NULL ELSE accepted_at END,
458
+ confirmed_at = CASE WHEN ?2 = 'applied' THEN ?4 ELSE confirmed_at END,
459
+ confirmation_deadline_at = CASE WHEN ?2 = 'queued' THEN ?5 WHEN ?2 = 'failed' AND ?3 = 0 THEN NULL ELSE confirmation_deadline_at END,
460
+ last_receipt_json = ?6, next_attempt_at = ?7, lease_expires_at = NULL, last_error_code = ?8, updated_at = ?4
461
+ WHERE id = ?1 AND delivery_terminal = 0 AND state IN ('queued','applying','failed')
462
+ AND delivery_attempt_count > cycle_start_attempt AND delivery_cycle = ?9
463
+ AND (?10 IS NULL OR (state = 'applying' AND delivery_attempt_count = ?10 AND lease_expires_at = ?11))
464
+ AND EXISTS (SELECT 1 FROM reports WHERE id = action_proposals.report_id AND integration_id = ?12)
465
+ AND (appeal_id IS NULL OR (json_extract(approval_policy_json,'$.expires_at') > MAX(?4,strftime('%Y-%m-%dT%H:%M:%fZ','now'))
466
+ AND EXISTS(SELECT 1 FROM appeal_corrections c JOIN appeal_decisions ad ON ad.id=c.appeal_decision_id JOIN appeals a ON a.id=ad.appeal_id JOIN reports r ON r.id=c.report_id JOIN action_proposals original ON original.id=c.original_proposal_id
467
+ WHERE c.proposal_id=action_proposals.id AND c.mode='automated' AND c.integration_id=?12 AND c.original_target_json=action_proposals.target_json AND c.appeal_id=action_proposals.appeal_id AND ad.completion_state='waiting' AND ad.correction_confirmed_at IS NULL AND r.state='appealed'
468
+ AND original.operation_id=c.original_operation_id AND original.target_json=c.original_target_json AND original.state='applied' AND original.confirmed_at IS NOT NULL
469
+ AND a.decision_id=(SELECT id FROM decisions WHERE report_id=r.id ORDER BY created_at DESC,rowid DESC LIMIT 1)
470
+ AND ad.id=json_extract(action_proposals.preconditions_json,'$.appeal_decision_id')
471
+ 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'))))` )
472
+ .bind(proposal.id, state, terminal ? 1 : 0, timestamp, deadline, receiptJson, nextAttemptAt, receipt.error_code,
473
+ proposal.deliveryCycle, attempt?.number ?? null, attempt?.leaseExpiresAt ?? null, proposal.integrationId),
474
+ env.DB.prepare(`INSERT OR IGNORE INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at)
475
+ SELECT ?1, ?2, ?3, ?4, ?5, 'action_proposal', ?6, ?7, ?8 WHERE changes() = 1`)
476
+ .bind(crypto.randomUUID(), auditKey, receipt.status === "applied" ? "workflow.action_applied" : "workflow.action_result_received",
477
+ actor.type, actor.id, proposal.id, canonicalJson({ ...receipt, terminal, deliveryCycle: proposal.deliveryCycle,
478
+ ...(attempt ? { connectionAttemptId: attempt.connectionAttemptId, responseDigest: attempt.responseDigest } : {}) }), timestamp),
479
+ ]);
480
+ return { proposal: await loadProposal(env.DB, proposal.id), changed: result[0]?.meta.changes === 1 };
481
+ }
482
+
365
483
  export async function processActionProposalJob(env: Env, job: ActionProposalJob): Promise<void> {
366
484
  const timestamp = now();
367
- const leaseExpiresAt = new Date(Date.now() + 60_000).toISOString();
485
+ // A connection may run for up to 120 seconds. The action lease outlives it.
486
+ const leaseExpiresAt = new Date(Date.now() + 150_000).toISOString();
368
487
  const claimed = await env.DB.prepare(`
369
488
  UPDATE action_proposals SET state = 'applying',
370
- delivery_attempt_count = delivery_attempt_count + 1,
489
+ delivery_attempt_count = delivery_attempt_count + CASE WHEN accepted_at IS NULL AND delivery_attempt_count - cycle_start_attempt < ?5 THEN 1 ELSE 0 END,
490
+ last_error_code = CASE WHEN accepted_at IS NULL AND delivery_attempt_count - cycle_start_attempt >= ?5 THEN 'action_delivery_attempts_exhausted' ELSE last_error_code END,
371
491
  lease_expires_at = ?3, updated_at = ?2
372
492
  WHERE id = ?1 AND run_id = ?4 AND delivery_terminal = 0
373
- AND ((state IN ('queued','failed') AND next_attempt_at <= ?2)
493
+ AND ((state IN ('queued','failed') AND next_attempt_at <= ?2 AND (delivery_attempt_count - cycle_start_attempt < ?5 OR accepted_at IS NOT NULL))
374
494
  OR (state = 'applying' AND lease_expires_at <= ?2))
375
495
  RETURNING id
376
- `).bind(job.proposalId, timestamp, leaseExpiresAt, job.runId).first();
496
+ `).bind(job.proposalId, timestamp, leaseExpiresAt, job.runId, ACTION_MAX_ATTEMPTS).first();
497
+ // Duplicate or premature queue deliveries are acknowledged. D1 reconciliation
498
+ // owns the next due attempt; repeatedly retrying here can exhaust the queue.
499
+ if (!claimed) return;
377
500
  let proposal = await loadProposal(env.DB, job.proposalId);
378
- if (!claimed) {
379
- if (proposal.state === "applied" || (proposal.state === "failed" && proposal.deliveryTerminal === 1)) return;
380
- throw new RetryableActionProposalError(5);
381
- }
501
+ const claimedAttempt = proposal.deliveryAttemptCount;
502
+ const claimedCycle = proposal.deliveryCycle;
382
503
  const approvalPolicy = parsedObject(proposal.approvalPolicyJson);
383
504
  let reservations: Awaited<ReturnType<typeof reserveBudgets>> = [];
505
+ let requestSent = false;
384
506
  try {
385
- if (typeof approvalPolicy.expires_at !== "string" || approvalPolicy.expires_at <= timestamp) {
386
- throw new WorkflowEffectError("action_approval_expired");
387
- }
507
+ if (proposal.acceptedAt) throw new WorkflowEffectError("action_confirmation_timed_out");
508
+ if (proposal.lastErrorCode === "action_delivery_attempts_exhausted") throw new WorkflowEffectError("action_delivery_attempts_exhausted");
509
+ if (typeof approvalPolicy.expires_at !== "string" || approvalPolicy.expires_at <= timestamp) throw new WorkflowEffectError("action_approval_expired");
388
510
  await validatePreconditions(env.DB, proposal);
511
+ if (approvalPolicy.mode === "human") {
512
+ const approval = await env.DB.prepare(`SELECT id FROM action_approvals WHERE proposal_id = ?1 AND decision = 'approved' AND payload_digest = ?2 LIMIT 1`)
513
+ .bind(proposal.id, proposal.payloadDigest).first();
514
+ if (!approval) throw new WorkflowEffectError("action_approval_missing");
515
+ }
389
516
  const component = await actionComponent(env.DB, proposal.componentVersionId);
390
- const reference = /^(?:(GET|POST|PUT|PATCH|DELETE|HEAD)\s+)?(\/[^\s]*)$/u.exec(component.implementationReference.trim());
517
+ const reference = /^(?:(POST|PUT|PATCH|DELETE)\s+)?(\/[^\s]*)$/u.exec(component.implementationReference.trim());
391
518
  if (!reference || !component.connectionVersionId) throw new WorkflowEffectError("action_component_operation_invalid");
392
519
  reservations = await reserveBudgets(env.DB, {
393
520
  installationId: proposal.installationId,
394
- scopes: [
395
- { type: "workflow", id: proposal.workflowVersionId },
396
- { type: "component", id: proposal.componentVersionId },
397
- ...(proposal.queueId ? [{ type: "queue", id: proposal.queueId }] : []),
398
- ],
399
- metric: "actions", amount: 1, operationType: "action_proposal",
400
- operationId: proposal.operationId, attemptId: String(proposal.deliveryAttemptCount),
521
+ scopes: [{ type: "workflow", id: proposal.workflowVersionId }, { type: "component", id: proposal.componentVersionId },
522
+ ...(proposal.queueId ? [{ type: "queue", id: proposal.queueId }] : [])],
523
+ metric: "actions", amount: 1, operationType: "action_proposal", operationId: proposal.operationId, attemptId: String(claimedAttempt),
401
524
  });
525
+ requestSent = true;
402
526
  const result = await executeConnectionJson(env, {
403
- connectionVersionId: component.connectionVersionId,
404
- actionProposalId: proposal.id,
405
- operationId: proposal.operationId,
406
- attemptNumber: proposal.deliveryAttemptCount,
407
- method: reference[1] ?? "POST",
408
- path: reference[2]!,
527
+ connectionVersionId: component.connectionVersionId, actionProposalId: proposal.id,
528
+ operationId: proposal.operationId, attemptNumber: claimedAttempt, method: reference[1] ?? "POST", path: reference[2]!,
409
529
  body: {
410
- schema_version: "1",
411
- action_id: proposal.id,
412
- operation_id: proposal.operationId,
413
- payload_digest: proposal.payloadDigest,
414
- action_code: proposal.actionCode,
415
- target: parsedObject(proposal.targetJson),
416
- payload: parsedObject(proposal.payloadJson),
417
- preconditions: parsedObject(proposal.preconditionsJson),
418
- workflow_version_id: proposal.workflowVersionId,
419
- policy_version_id: proposal.policyVersionId,
530
+ schema_version: "1", action_id: proposal.id, operation_id: proposal.operationId, payload_digest: proposal.payloadDigest,
531
+ action_code: proposal.actionCode, target: parsedObject(proposal.targetJson), payload: parsedObject(proposal.payloadJson),
532
+ preconditions: parsedObject(proposal.preconditionsJson), workflow_version_id: proposal.workflowVersionId,
533
+ policy_version_id: proposal.policyVersionId, expires_at: approvalPolicy.expires_at,
420
534
  },
421
535
  });
422
- const outputErrors = validateJsonSchemaValue(result.data as JsonValue, parsedObject(component.outputSchemaJson));
423
- if (outputErrors.length) throw new WorkflowEffectError("action_response_schema_invalid");
424
- const finishedAt = now();
425
- await env.DB.batch([
426
- env.DB.prepare(`UPDATE action_proposals SET state = 'applied', delivery_terminal = 1, lease_expires_at = NULL, last_error_code = NULL, updated_at = ?2 WHERE id = ?1 AND state = 'applying'`).bind(proposal.id, finishedAt),
427
- env.DB.prepare(`
428
- INSERT OR IGNORE INTO audit_events (
429
- id, idempotency_key, action, actor_type, actor_id,
430
- target_type, target_id, details_json, created_at
431
- ) VALUES (?1, ?2, 'workflow.action_applied', 'system', ?3,
432
- 'action_proposal', ?4, ?5, ?6)
433
- `).bind(crypto.randomUUID(), `workflow-action-applied:${proposal.operationId}`, proposal.runId, proposal.id, canonicalJson({
434
- actionCode: proposal.actionCode, payloadDigest: proposal.payloadDigest,
435
- connectionAttemptId: result.connectionAttemptId, responseDigest: result.responseDigest,
436
- }), finishedAt),
437
- ]);
438
- await settleBudgets(env.DB, reservations, true).catch(() => {});
439
- proposal = await loadProposal(env.DB, proposal.id);
440
- await emitActionResolution(env, proposal);
536
+ if (validateJsonSchemaValue(result.data as JsonValue, parsedObject(component.outputSchemaJson)).length) throw new WorkflowEffectError("action_response_schema_invalid");
537
+ const receipt = actionReceipt(result.data, proposal);
538
+ proposal = (await saveActionReceipt(env, proposal, receipt, { type: "system", id: proposal.runId }, {
539
+ number: claimedAttempt, leaseExpiresAt, connectionAttemptId: result.connectionAttemptId, responseDigest: result.responseDigest,
540
+ })).proposal;
441
541
  } catch (error) {
442
- const failure = error instanceof ConnectionExecutionError
542
+ const failure = error instanceof ConnectionExecutionError || error instanceof BudgetControlError
443
543
  ? new WorkflowEffectError(error.code, error.retryable, error.delaySeconds)
444
- : error instanceof BudgetControlError
445
- ? new WorkflowEffectError(error.code, error.retryable, error.delaySeconds)
446
- : error instanceof WorkflowEffectError
447
- ? error
448
- : new WorkflowEffectError("action_delivery_failed", true, 15);
449
- const terminal = !failure.retryable || proposal.deliveryAttemptCount >= ACTION_MAX_ATTEMPTS;
450
- const delaySeconds = Math.max(1, Math.min(21_600, failure.delaySeconds * (2 ** Math.min(proposal.deliveryAttemptCount - 1, 7))));
451
- await env.DB.prepare(`
452
- UPDATE action_proposals SET state = 'failed', delivery_terminal = ?2,
453
- next_attempt_at = ?3, lease_expires_at = NULL, last_error_code = ?4, updated_at = ?5
454
- WHERE id = ?1 AND state = 'applying'
455
- `).bind(
456
- proposal.id, terminal ? 1 : 0,
457
- new Date(Date.now() + delaySeconds * 1_000).toISOString(), failure.code, now(),
458
- ).run();
459
- await settleBudgets(env.DB, reservations, false);
544
+ : error instanceof WorkflowEffectError ? error : new WorkflowEffectError("action_delivery_failed", true, 15);
545
+ const terminal = !failure.retryable || claimedAttempt - proposal.cycleStartAttempt >= ACTION_MAX_ATTEMPTS;
546
+ const delaySeconds = Math.max(1, Math.min(21_600, failure.delaySeconds * (2 ** Math.min(claimedAttempt - proposal.cycleStartAttempt - 1, 7))));
547
+ await env.DB.prepare(`UPDATE action_proposals SET state = 'failed', delivery_terminal = ?2,
548
+ next_attempt_at = ?3, lease_expires_at = NULL, last_error_code = ?4, updated_at = ?5
549
+ WHERE id = ?1 AND state = 'applying' AND delivery_terminal = 0 AND delivery_attempt_count = ?6 AND delivery_cycle = ?7 AND lease_expires_at = ?8`)
550
+ .bind(proposal.id, terminal ? 1 : 0, new Date(Date.now() + delaySeconds * 1_000).toISOString(), failure.code, now(), claimedAttempt, claimedCycle, leaseExpiresAt).run();
460
551
  proposal = await loadProposal(env.DB, proposal.id);
461
- if (proposal.state === "applied") {
462
- await emitActionResolution(env, proposal);
463
- return;
464
- }
465
- if (terminal) {
466
- await emitActionResolution(env, proposal);
467
- return;
468
- }
469
- throw new RetryableActionProposalError(delaySeconds);
552
+ } finally {
553
+ // A request can have reached the customer even if its response was lost.
554
+ await settleBudgets(env.DB, reservations, requestSent);
470
555
  }
556
+ if (proposal.state === "applied" || proposal.deliveryTerminal === 1) await emitActionResolution(env, proposal);
471
557
  }
472
558
 
473
559
  export async function acceptActionResult(
@@ -476,69 +562,56 @@ export async function acceptActionResult(
476
562
  integrationId: string,
477
563
  ): Promise<{ proposalId: string; state: string; idempotentReplay: boolean }> {
478
564
  const proposalId = typeof value.action_id === "string" ? value.action_id : "";
479
- const operationId = typeof value.operation_id === "string" ? value.operation_id : "";
480
- const payloadDigest = typeof value.payload_digest === "string" ? value.payload_digest : "";
481
- const status = value.status;
482
- if (!proposalId || proposalId.length > 500 || !operationId || operationId.length > 500
483
- || !/^sha256:[a-f0-9]{64}$/u.test(payloadDigest)
484
- || (status !== "applied" && status !== "failed")) {
485
- throw new ApiError(400, "action_result_invalid", "action_id, operation_id, payload_digest, and a valid status are required.");
565
+ if (!proposalId || proposalId.length > 500 || !["applied", "failed"].includes(String(value.status))) {
566
+ throw new ApiError(400, "action_result_invalid", "An action identity and an applied or failed result are required.");
486
567
  }
487
- let proposal = await loadProposal(env.DB, proposalId);
488
- if (proposal.operationId !== operationId || proposal.payloadDigest !== payloadDigest) {
489
- throw new ApiError(409, "action_result_identity_mismatch", "The action result does not match the immutable proposal operation and payload digest.");
568
+ // Scope the lookup itself: another integration cannot probe a case or replay
569
+ // a valid receipt learned elsewhere.
570
+ const owned = await env.DB.prepare(`SELECT p.id FROM action_proposals p JOIN reports r ON r.id = p.report_id
571
+ WHERE p.id = ?1 AND r.integration_id = ?2 LIMIT 1`).bind(proposalId, integrationId).first();
572
+ if (!owned) throw new ApiError(404, "action_result_unavailable", "This action is not available to this integration.");
573
+ const proposal = await loadProposal(env.DB, proposalId);
574
+ let receipt: ActionReceipt;
575
+ try { receipt = actionReceipt(value, proposal); }
576
+ catch (error) {
577
+ throw new ApiError(error instanceof WorkflowEffectError && error.code === "action_result_identity_mismatch" ? 409 : 400,
578
+ error instanceof WorkflowEffectError ? error.code : "action_result_invalid", "The result must match the immutable action operation and payload.");
490
579
  }
491
- if (proposal.state === "applied" && status === "applied") return { proposalId, state: "applied", idempotentReplay: true };
492
- if (proposal.state === "failed" && proposal.deliveryTerminal === 1 && status === "failed") return { proposalId, state: "failed", idempotentReplay: true };
493
- if (!["queued", "applying", "failed"].includes(proposal.state) || proposal.deliveryTerminal === 1) {
494
- throw new ApiError(409, "action_result_terminal", "The action proposal is not awaiting an asynchronous result.");
580
+ if (proposal.state === receipt.status && proposal.deliveryTerminal === 1) return { proposalId, state: proposal.state, idempotentReplay: true };
581
+ if (!["queued", "applying", "failed"].includes(proposal.state) || proposal.deliveryTerminal === 1 || proposal.deliveryAttemptCount <= proposal.cycleStartAttempt) {
582
+ throw new ApiError(409, "action_result_terminal", "The action is not awaiting a customer result.");
495
583
  }
496
- const timestamp = now();
497
- const terminal = value.terminal !== false;
498
- const errorCode = typeof value.error_code === "string" && value.error_code.length <= 200
499
- ? value.error_code : "action_provider_failed";
500
- const nextAttemptAt = new Date(Date.now() + Math.max(1, Math.min(21_600, Number(value.retry_after_seconds) || 30)) * 1_000).toISOString();
501
- await env.DB.batch([
502
- env.DB.prepare(`
503
- UPDATE action_proposals SET state = ?2, delivery_terminal = ?3,
504
- next_attempt_at = ?4, lease_expires_at = NULL, last_error_code = ?5,
505
- updated_at = ?6 WHERE id = ?1 AND delivery_terminal = 0
506
- `).bind(
507
- proposalId, status === "applied" ? "applied" : "failed",
508
- status === "applied" || terminal ? 1 : 0, nextAttemptAt,
509
- status === "applied" ? null : errorCode, timestamp,
510
- ),
511
- env.DB.prepare(`
512
- INSERT OR IGNORE INTO audit_events (
513
- id, idempotency_key, action, actor_type, actor_id,
514
- target_type, target_id, details_json, created_at
515
- ) VALUES (?1, ?2, 'workflow.action_result_received', 'service_identity', ?3,
516
- 'action_proposal', ?4, ?5, ?6)
517
- `).bind(crypto.randomUUID(), `action-result:${operationId}:${status}`, integrationId, proposalId, canonicalJson({
518
- status, payloadDigest, terminal: status === "applied" || terminal,
519
- providerRequestId: typeof value.provider_request_id === "string" ? value.provider_request_id.slice(0, 500) : null,
520
- errorCode: status === "failed" ? errorCode : null,
521
- }), timestamp),
522
- ]);
523
- proposal = await loadProposal(env.DB, proposalId);
524
- if (proposal.state === "applied" || proposal.deliveryTerminal === 1) await emitActionResolution(env, proposal);
525
- else await env.DELIVERY_JOBS.send(actionJob(proposal.id, proposal.runId), { contentType: "json" }).catch(() => {});
526
- return { proposalId, state: proposal.state, idempotentReplay: false };
584
+ const saved = await saveActionReceipt(env, proposal, receipt, { type: "service_identity", id: integrationId });
585
+ if (!saved.changed && saved.proposal.state !== receipt.status) throw new ApiError(409, "action_result_conflict", "Another result or operator action was recorded first. Refresh the action before retrying.");
586
+ if (saved.proposal.state === "applied" || saved.proposal.deliveryTerminal === 1) await emitActionResolution(env, saved.proposal);
587
+ return { proposalId, state: saved.proposal.state, idempotentReplay: !saved.changed };
527
588
  }
528
589
 
529
590
  export async function completeWorkflowAction(
530
591
  env: Env,
531
- input: { proposalId: string; eventResult: { status: "received"; event: unknown } | { status: "timed_out" } },
592
+ input: { proposalId: string; awaitRecovery?: boolean; eventResult: { status: "received"; event: unknown } | { status: "timed_out" } },
532
593
  ): Promise<Record<string, unknown>> {
533
- const proposal = await loadProposal(env.DB, input.proposalId);
594
+ let proposal = await loadProposal(env.DB, input.proposalId);
534
595
  if (proposal.state === "applied") return { port: "applied", proposalId: proposal.id, payloadDigest: proposal.payloadDigest };
535
596
  if (proposal.state === "rejected") return { port: "rejected", proposalId: proposal.id, payloadDigest: proposal.payloadDigest };
536
597
  if (proposal.state === "failed" && proposal.deliveryTerminal === 1) {
598
+ const expiry = parsedObject(proposal.approvalPolicyJson).expires_at;
599
+ if (input.awaitRecovery && input.eventResult.status !== "timed_out" && typeof expiry === "string" && expiry > now()) {
600
+ return { ...deliveryPending(proposal.id, proposal.payloadDigest, expiry), recoveryRequired: true, errorCode: proposal.lastErrorCode };
601
+ }
537
602
  return { port: "error", proposalId: proposal.id, payloadDigest: proposal.payloadDigest, errorCode: proposal.lastErrorCode ?? "action_delivery_failed" };
538
603
  }
539
604
  if (input.eventResult.status === "timed_out") {
540
- await env.DB.prepare(`UPDATE action_proposals SET state = 'cancelled', delivery_terminal = 1, updated_at = ?2 WHERE id = ?1 AND state IN ('approval_required','approved','queued','failed') AND delivery_terminal = 0`)
541
- .bind(proposal.id, now()).run();
605
+ await env.DB.batch([
606
+ env.DB.prepare(`UPDATE action_proposals SET state = CASE WHEN accepted_at IS NOT NULL OR state = 'applying' THEN 'failed' ELSE 'cancelled' END,
607
+ delivery_terminal = 1, lease_expires_at = NULL, last_error_code = 'action_confirmation_timed_out', updated_at = ?2
608
+ WHERE id = ?1 AND state IN ('approval_required','approved','queued','applying','failed') AND delivery_terminal = 0`)
609
+ .bind(proposal.id, now()),
610
+ env.DB.prepare(`UPDATE human_tasks SET state = 'cancelled', updated_at = ?2 WHERE action_proposal_id = ?1 AND state IN ('open','claimed')
611
+ AND EXISTS (SELECT 1 FROM action_proposals WHERE id = ?1 AND state IN ('cancelled','failed'))`).bind(proposal.id, now()),
612
+ ]);
613
+ proposal = await loadProposal(env.DB, proposal.id);
614
+ if (proposal.state === "applied") return { port: "applied", proposalId: proposal.id, payloadDigest: proposal.payloadDigest };
542
615
  return { port: "timeout", proposalId: proposal.id, payloadDigest: proposal.payloadDigest };
543
616
  }
544
617
  if (["queued", "applying", "failed"].includes(proposal.state)) {
@@ -571,6 +644,8 @@ export async function loadActionProposalDetail(db: D1Database, proposalId: strin
571
644
  p.authority_mode AS authorityMode, p.approval_policy_json AS approvalPolicyJson,
572
645
  p.state, p.operation_id AS operationId, p.compensates_proposal_id AS compensatesProposalId,
573
646
  p.delivery_attempt_count AS deliveryAttemptCount, p.delivery_terminal AS deliveryTerminal,
647
+ p.accepted_at AS acceptedAt, p.confirmed_at AS confirmedAt, p.confirmation_deadline_at AS confirmationDeadlineAt,
648
+ p.delivery_cycle AS deliveryCycle, p.last_receipt_json AS lastReceiptJson,
574
649
  p.next_attempt_at AS nextAttemptAt, p.last_error_code AS lastErrorCode,
575
650
  p.created_by_type AS createdByType, p.created_by_id AS createdById,
576
651
  p.created_at AS createdAt, p.updated_at AS updatedAt
@@ -586,6 +661,8 @@ export async function loadActionProposalDetail(db: D1Database, proposalId: strin
586
661
  return {
587
662
  proposal: {
588
663
  ...proposal,
664
+ receipt: typeof proposal.lastReceiptJson === "string" ? JSON.parse(proposal.lastReceiptJson) : null,
665
+ lastReceiptJson: undefined,
589
666
  target: typeof proposal.targetJson === "string" ? JSON.parse(proposal.targetJson) : {},
590
667
  payload: typeof proposal.payloadJson === "string" ? JSON.parse(proposal.payloadJson) : {},
591
668
  preconditions: typeof proposal.preconditionsJson === "string" ? JSON.parse(proposal.preconditionsJson) : {},
@@ -628,7 +705,7 @@ async function emitApprovalEvent(
628
705
  .bind(proposal.runId).first<{ instanceId: string | null }>();
629
706
  if (!instance?.instanceId) return;
630
707
  try {
631
- await (await env.WORKFLOWS.get(instance.instanceId)).sendEvent({ type: eventType, payload });
708
+ await (await env.WORKFLOWS.get(instance.instanceId)).sendEvent({ type: await workflowRunEventType(env.DB, proposal.runId, eventType), payload });
632
709
  const deliveredAt = now();
633
710
  await env.DB.batch([
634
711
  env.DB.prepare(`UPDATE workflow_event_inbox SET delivered_at = ?2, delivery_attempts = delivery_attempts + 1, last_error_code = NULL WHERE id = ?1`).bind(approvalId, deliveredAt),
@@ -663,7 +740,7 @@ export async function decideActionProposal(
663
740
  }
664
741
  const approvalId = crypto.randomUUID();
665
742
  const timestamp = now();
666
- await env.DB.batch([
743
+ try { await env.DB.batch([
667
744
  env.DB.prepare(`
668
745
  INSERT INTO action_approvals (
669
746
  id, proposal_id, approver_id, payload_digest, decision,
@@ -687,7 +764,10 @@ export async function decideActionProposal(
687
764
  session.actor.type, session.actor.id, proposalId,
688
765
  canonicalJson({ payloadDigest: proposal.payloadDigest, reason: input.reason?.trim() ?? null }), timestamp,
689
766
  ),
690
- ]);
767
+ ]); } catch (error) {
768
+ if (String(error).includes("action_approval_state_changed")) throw new ApiError(409, "action_approval_changed", "The proposal changed or its authorization expired. Refresh it before deciding.");
769
+ throw error;
770
+ }
691
771
  const updated = await loadProposal(env.DB, proposalId);
692
772
  await emitApprovalEvent(env, updated, approvalId, input.decision, session.actor.id);
693
773
  return { proposalId, approvalId, state: input.decision, idempotentReplay: false };
@@ -701,22 +781,34 @@ export async function retryActionProposal(
701
781
  idempotencyKey: string,
702
782
  ): Promise<{ proposalId: string; state: string; idempotentReplay: boolean }> {
703
783
  const key = `action-retry:${idempotencyKey}`;
704
- const prior = await env.DB.prepare(`SELECT target_id AS targetId FROM audit_events WHERE idempotency_key = ?1 AND action = 'action.retry_requested' LIMIT 1`)
705
- .bind(key).first<{ targetId: string }>();
784
+ const prior = await env.DB.prepare(`SELECT target_id AS targetId, actor_id AS actorId FROM audit_events WHERE idempotency_key = ?1 AND action = 'action.retry_requested' LIMIT 1`)
785
+ .bind(key).first<{ targetId: string; actorId: string }>();
706
786
  if (prior) {
707
- if (prior.targetId !== proposalId) throw new ApiError(409, "idempotency_key_reused", "This Idempotency-Key was already used for another action retry.");
708
- return { proposalId, state: "queued", idempotentReplay: true };
787
+ if (prior.targetId !== proposalId || prior.actorId !== session.actor.id) throw new ApiError(409, "idempotency_key_reused", "This Idempotency-Key was already used for another action retry.");
788
+ const saved = await loadProposal(env.DB, proposalId);
789
+ if (saved.payloadDigest !== payloadDigest) throw new ApiError(409, "action_retry_digest_mismatch", "Retry requires the original payload digest.");
790
+ return { proposalId, state: saved.state, idempotentReplay: true };
709
791
  }
710
792
  const proposal = await loadProposal(env.DB, proposalId);
711
793
  if (proposal.payloadDigest !== payloadDigest) throw new ApiError(409, "action_retry_digest_mismatch", "Retry requires the exact immutable payload digest.");
712
794
  if (proposal.state !== "failed" || proposal.deliveryTerminal !== 1) throw new ApiError(409, "action_not_retryable", "Only a terminal failed action can be retried.");
713
795
  const timestamp = now();
714
- await env.DB.batch([
715
- env.DB.prepare(`UPDATE action_proposals SET state = 'queued', delivery_terminal = 0, delivery_attempt_count = 0, next_attempt_at = ?2, lease_expires_at = NULL, last_error_code = NULL, updated_at = ?2 WHERE id = ?1 AND state = 'failed' AND delivery_terminal = 1`)
716
- .bind(proposalId, timestamp),
717
- env.DB.prepare(`INSERT INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) VALUES (?1, ?2, 'action.retry_requested', ?3, ?4, 'action_proposal', ?5, ?6, ?7)`)
718
- .bind(crypto.randomUUID(), key, session.actor.type, session.actor.id, proposalId, canonicalJson({ payloadDigest }), timestamp),
796
+ const policy = parsedObject(proposal.approvalPolicyJson);
797
+ if (typeof policy.expires_at !== "string" || policy.expires_at <= timestamp) throw new ApiError(409, "action_approval_expired", "The action authorization expired. Review and authorize a new proposal before sending it.");
798
+ await validatePreconditions(env.DB, proposal);
799
+ const result = await env.DB.batch([
800
+ env.DB.prepare(`UPDATE action_proposals SET state = 'queued', delivery_terminal = 0,
801
+ cycle_start_attempt = delivery_attempt_count, delivery_cycle = delivery_cycle + 1,
802
+ accepted_at = NULL, confirmation_deadline_at = NULL, next_attempt_at = ?2, lease_expires_at = NULL, last_error_code = NULL, updated_at = ?2
803
+ WHERE id = ?1 AND state = 'failed' AND delivery_terminal = 1 AND delivery_cycle = ?3
804
+ AND json_extract(approval_policy_json, '$.expires_at') > ?2
805
+ AND (appeal_id IS NOT NULL OR EXISTS (SELECT 1 FROM workflow_runs WHERE id = action_proposals.run_id AND state NOT IN ('terminated','cancelled')))`)
806
+ .bind(proposalId, timestamp, proposal.deliveryCycle),
807
+ env.DB.prepare(`INSERT INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at)
808
+ SELECT ?1, ?2, 'action.retry_requested', ?3, ?4, 'action_proposal', ?5, ?6, ?7 WHERE changes() = 1`)
809
+ .bind(crypto.randomUUID(), key, session.actor.type, session.actor.id, proposalId, canonicalJson({ payloadDigest, deliveryCycle: proposal.deliveryCycle + 1 }), timestamp),
719
810
  ]);
811
+ if (result[0]?.meta.changes !== 1) throw new ApiError(409, "action_retry_conflict", "The action changed before the retry was saved. Refresh it and try again.");
720
812
  await env.DELIVERY_JOBS.send(actionJob(proposalId, proposal.runId), { contentType: "json" }).catch(() => {});
721
813
  return { proposalId, state: "queued", idempotentReplay: false };
722
814
  }