create-safest-tools 0.6.1 → 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 (154) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/src/config.mjs +2 -2
  4. package/template/README.md +1 -1
  5. package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
  6. package/template/console/analytics/controller.ts +10 -2
  7. package/template/console/analytics/types.ts +7 -0
  8. package/template/console/appeals/AppealFollowup.tsx +171 -0
  9. package/template/console/appeals/AppealWorkspace.tsx +33 -4
  10. package/template/console/appeals/RecordAppealDialog.tsx +42 -0
  11. package/template/console/appeals/appeal-corrections.css +29 -0
  12. package/template/console/appeals/controller.ts +40 -14
  13. package/template/console/appeals/events.ts +1 -0
  14. package/template/console/appeals/types.ts +6 -0
  15. package/template/console/appeals/useAppealDraftGuard.ts +12 -0
  16. package/template/console/assistant/AssistantWorkspace.tsx +28 -5
  17. package/template/console/command/CommandCentre.tsx +12 -6
  18. package/template/console/command/controller.ts +13 -9
  19. package/template/console/command/presentation.ts +1 -6
  20. package/template/console/command/store.ts +1 -0
  21. package/template/console/command/types.ts +1 -0
  22. package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
  23. package/template/console/configuration/ConfigurationDialog.tsx +28 -13
  24. package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
  25. package/template/console/configuration/types.ts +1 -1
  26. package/template/console/lib/priority.ts +13 -0
  27. package/template/console/operations/CaseEscalations.tsx +142 -0
  28. package/template/console/operations/OperationsWorkspace.tsx +2 -0
  29. package/template/console/operations/case-escalations.css +20 -0
  30. package/template/console/people/PeopleWorkspace.tsx +8 -4
  31. package/template/console/people/PersonAccessDialog.tsx +40 -0
  32. package/template/console/people/types.ts +1 -0
  33. package/template/console/profile/ProfileWorkspace.tsx +1 -1
  34. package/template/console/registry/IntegrationSetup.tsx +41 -0
  35. package/template/console/registry/IntegrationVerification.tsx +54 -0
  36. package/template/console/registry/RegistryDetailsDialog.tsx +1 -1
  37. package/template/console/registry/RegistryDialog.tsx +34 -4
  38. package/template/console/registry/RegistryWorkspace.tsx +10 -5
  39. package/template/console/registry/integration-verification.css +25 -0
  40. package/template/console/reports/CaseActions.tsx +81 -0
  41. package/template/console/reports/CaseContinuity.tsx +27 -0
  42. package/template/console/reports/CaseNotices.tsx +48 -0
  43. package/template/console/reports/CaseOwnership.tsx +78 -0
  44. package/template/console/reports/CaseReviewTasks.tsx +148 -0
  45. package/template/console/reports/InvestigationBrief.tsx +29 -0
  46. package/template/console/reports/ReportDrawer.tsx +109 -67
  47. package/template/console/reports/ReportWorkspace.tsx +11 -12
  48. package/template/console/reports/controller.ts +96 -49
  49. package/template/console/reports/events.ts +9 -2
  50. package/template/console/reports/load-inbox.ts +17 -0
  51. package/template/console/reports/private-drafts.ts +32 -0
  52. package/template/console/reports/store.ts +2 -2
  53. package/template/console/reports/types.ts +41 -2
  54. package/template/console/reports/use-editing-access.ts +13 -0
  55. package/template/console/settings/SettingsWorkspace.tsx +1 -1
  56. package/template/console/shell/WorkspaceShell.tsx +55 -29
  57. package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
  58. package/template/console/shell/controller.ts +22 -8
  59. package/template/console/shell/navigation.ts +30 -39
  60. package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
  61. package/template/console/workflows/PlaybookExamples.tsx +160 -0
  62. package/template/console/workflows/PlaybookFields.tsx +109 -0
  63. package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
  64. package/template/console/workflows/PlaybookHistory.tsx +106 -0
  65. package/template/console/workflows/WorkflowDialogs.tsx +2 -1
  66. package/template/console/workflows/WorkflowStudio.tsx +108 -39
  67. package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
  68. package/template/console/workflows/templates.ts +3 -3
  69. package/template/console/workflows/types.ts +11 -0
  70. package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
  71. package/template/migrations/0003_action_confirmation.sql +24 -0
  72. package/template/migrations/0004_notice_recovery.sql +22 -0
  73. package/template/migrations/0005_appeal_continuity.sql +17 -0
  74. package/template/migrations/0006_case_ownership.sql +23 -0
  75. package/template/migrations/0007_integration_verification.sql +21 -0
  76. package/template/migrations/0008_case_escalations.sql +135 -0
  77. package/template/migrations/0009_participant_continuation.sql +61 -0
  78. package/template/migrations/0010_appeal_corrections.sql +41 -0
  79. package/template/npm-shrinkwrap.json +8036 -0
  80. package/template/package.json +25 -17
  81. package/template/public/_headers +16 -1
  82. package/template/public/console/auth-shell.js +18701 -11800
  83. package/template/public/console/release.json +1 -0
  84. package/template/public/console/workspace-features.css +2 -0
  85. package/template/public/embed/embed.css +3 -0
  86. package/template/public/embed/embed.js +19 -1
  87. package/template/public/embed/index.html +2 -0
  88. package/template/public/index.html +9 -6
  89. package/template/public/participant.css +2 -0
  90. package/template/public/participant.html +13 -0
  91. package/template/public/participant.js +111 -0
  92. package/template/public/public-report.js +21 -1
  93. package/template/public/report/index.html +1 -1
  94. package/template/public/styles.css +329 -10
  95. package/template/public/workspace.css +124 -0
  96. package/template/scripts/reports-plan.mjs +1 -1
  97. package/template/shared/case-escalations.ts +31 -0
  98. package/template/shared/integration-progress.ts +27 -0
  99. package/template/shared/notice-inputs.ts +3 -0
  100. package/template/shared/playbook-resources.ts +14 -0
  101. package/template/shared/playbooks.ts +249 -0
  102. package/template/src/agent-executor.ts +25 -8
  103. package/template/src/ai-observability.ts +14 -13
  104. package/template/src/ai-registry-validation.ts +1 -1
  105. package/template/src/appeal-continuity.ts +75 -0
  106. package/template/src/appeal-correction-state.ts +46 -0
  107. package/template/src/appeal-corrections.ts +112 -0
  108. package/template/src/appeal-decisions.ts +85 -0
  109. package/template/src/assistant.ts +3 -4
  110. package/template/src/case-escalations.ts +223 -0
  111. package/template/src/case-investigator.ts +179 -0
  112. package/template/src/component-executor.ts +3 -2
  113. package/template/src/configuration-registry.ts +4 -1
  114. package/template/src/database-readiness.ts +8 -3
  115. package/template/src/human-tasks.ts +152 -55
  116. package/template/src/index.ts +178 -18
  117. package/template/src/installation-admin.ts +2 -1
  118. package/template/src/integration-progress.ts +45 -0
  119. package/template/src/integration-verification.ts +249 -0
  120. package/template/src/investigation-evidence.ts +95 -0
  121. package/template/src/investigator-fixture.ts +30 -0
  122. package/template/src/notice-delivery.ts +19 -0
  123. package/template/src/notice-recovery.ts +81 -0
  124. package/template/src/operator-scope.ts +31 -0
  125. package/template/src/participant-access.ts +163 -0
  126. package/template/src/participant-case.ts +87 -0
  127. package/template/src/participant-customer-access.ts +93 -0
  128. package/template/src/participant-recovery.ts +43 -0
  129. package/template/src/participant-routes.ts +67 -0
  130. package/template/src/playbook-resources.ts +58 -0
  131. package/template/src/report-admin.ts +9 -2
  132. package/template/src/report-auth.ts +1 -28
  133. package/template/src/report-delivery.ts +136 -129
  134. package/template/src/report-email-template.ts +5 -2
  135. package/template/src/report-governance.ts +42 -45
  136. package/template/src/report-operations.ts +16 -63
  137. package/template/src/report-ownership.ts +64 -0
  138. package/template/src/report-repository.ts +16 -7
  139. package/template/src/report-types.ts +1 -0
  140. package/template/src/report-workflow.ts +181 -274
  141. package/template/src/workflow-actions.ts +248 -156
  142. package/template/src/workflow-compiler.ts +63 -12
  143. package/template/src/workflow-dynamic-runtime.ts +18 -7
  144. package/template/src/workflow-effects.ts +95 -18
  145. package/template/src/workflow-event-transport.ts +16 -0
  146. package/template/src/workflow-platform-types.ts +3 -0
  147. package/template/src/workflow-platform-validation.ts +23 -1
  148. package/template/src/workflow-repository.ts +131 -28
  149. package/template/src/workflow-runs.ts +9 -4
  150. package/template/src/workflow-simulator.ts +7 -4
  151. package/template/src/workspace-governance.ts +6 -4
  152. package/template/tsconfig.json +1 -0
  153. package/template/vite.console.config.ts +30 -2
  154. package/template/worker-configuration.d.ts +3 -2
@@ -1,11 +1,13 @@
1
1
  import { canonicalJson } from "./audit";
2
2
  import type { DeliveryJob } from "./report-types";
3
- import { decryptReportContact } from "./report-crypto";
3
+ import { decryptReportContact, sha256 } from "./report-crypto";
4
4
  import { BudgetControlError, reserveBudgets, settleBudgets } from "./budget-control";
5
5
  import { reporterEmailBinding, reporterEmailFrom, reporterEmailSubjectPrefix, reporterReplyAddress } from "./report-email";
6
6
  import { renderReporterEmail } from "./report-email-template";
7
- import { readBoundedBytes } from "./report-http";
7
+ import { ApiError, readBoundedBytes } from "./report-http";
8
8
  import { loadWorkspaceBrand } from "./workspace-branding";
9
+ import { signalNoticeDelivery } from "./notice-delivery";
10
+ import { issueParticipantAccessLink, lookupParticipantAccessLink } from "./participant-access";
9
11
 
10
12
  const MAX_ATTEMPTS = 8;
11
13
  const RESPONSE_LIMIT = 16 * 1024;
@@ -29,6 +31,8 @@ interface NotificationOutboxRow {
29
31
  payload_json: string;
30
32
  status: string;
31
33
  attempt_count: number;
34
+ attempt_limit: number;
35
+ message_id: string | null;
32
36
  audience: "reporter" | "affected_user";
33
37
  recipient_reference: string | null;
34
38
  contact_ciphertext: string | null;
@@ -41,6 +45,9 @@ interface EmailDeliveryRow {
41
45
  audience: "reporter" | "affected_user";
42
46
  state: string;
43
47
  attemptCount: number;
48
+ attemptLimit: number;
49
+ claimedAt: string;
50
+ recipientHash: string;
44
51
  recipientReference: string | null;
45
52
  contactCiphertext: string | null;
46
53
  body: string;
@@ -58,7 +65,7 @@ export class RetryableDeliveryError extends Error {
58
65
  }
59
66
 
60
67
  export class DeliveryConfigurationError extends Error {
61
- constructor(message: string) {
68
+ constructor(message: string, readonly code?: string) {
62
69
  super(message);
63
70
  this.name = "DeliveryConfigurationError";
64
71
  }
@@ -186,16 +193,16 @@ async function claimNotification(db: D1Database, id: string): Promise<Notificati
186
193
  UPDATE notification_outbox
187
194
  SET status = 'delivering', attempt_count = attempt_count + 1,
188
195
  lease_expires_at = ?2, updated_at = ?1
189
- WHERE id = ?3 AND terminal = 0 AND attempt_count < ?4
196
+ WHERE id = ?3 AND terminal = 0 AND attempt_count < attempt_limit
190
197
  AND (
191
198
  status = 'pending'
192
199
  OR (status = 'failed' AND next_attempt_at <= ?1)
193
200
  OR (status = 'delivering' AND lease_expires_at <= ?1)
194
201
  )
195
- `).bind(claimedAt, plusSeconds(claimedAt, 60), id, MAX_ATTEMPTS).run();
202
+ `).bind(claimedAt, plusSeconds(claimedAt, 60), id).run();
196
203
  if ((result.meta.changes ?? 0) < 1) return null;
197
204
  return db.prepare(`
198
- SELECT o.id, n.report_id, o.notice_id, o.idempotency_key, o.payload_json, o.status, o.attempt_count,
205
+ SELECT o.id, n.report_id, o.notice_id, o.idempotency_key, o.payload_json, o.status, o.attempt_count, o.attempt_limit, n.message_id,
199
206
  n.audience, p.reference AS recipient_reference, p.contact_ciphertext
200
207
  FROM notification_outbox o JOIN notices n ON n.id = o.notice_id
201
208
  LEFT JOIN report_participants p ON p.report_id = n.report_id AND p.audience = n.audience
@@ -208,14 +215,14 @@ async function claimEmail(db: D1Database, id: string): Promise<EmailDeliveryRow
208
215
  const staleAt = plusSeconds(claimedAt, -60);
209
216
  const result = await db.prepare(`
210
217
  UPDATE email_deliveries SET state = 'sending', attempt_count = attempt_count + 1,
211
- updated_at = ?2 WHERE id = ?1 AND attempt_count < ?3
218
+ updated_at = ?2 WHERE id = ?1 AND attempt_count < attempt_limit
212
219
  AND (state = 'pending' OR (state = 'failed' AND next_attempt_at <= ?2)
213
- OR (state = 'sending' AND updated_at <= ?4))
214
- `).bind(id, claimedAt, MAX_ATTEMPTS, staleAt).run();
220
+ OR (state = 'sending' AND updated_at <= ?3))
221
+ `).bind(id, claimedAt, staleAt).run();
215
222
  if ((result.meta.changes ?? 0) < 1) return null;
216
223
  return db.prepare(`
217
224
  SELECT e.id, m.report_id AS reportId, e.message_id AS messageId,
218
- e.audience, e.state, e.attempt_count AS attemptCount,
225
+ e.audience, e.state, e.attempt_count AS attemptCount, e.attempt_limit AS attemptLimit, e.updated_at AS claimedAt, e.recipient_hash AS recipientHash,
219
226
  p.reference AS recipientReference, p.contact_ciphertext AS contactCiphertext,
220
227
  m.body, m.template_version_id AS templateVersionId, r.public_reference AS reportReference,
221
228
  r.installation_id AS installationId, r.queue_id AS queueId
@@ -230,14 +237,17 @@ async function claimEmail(db: D1Database, id: string): Promise<EmailDeliveryRow
230
237
  function deliveryAudit(
231
238
  db: D1Database,
232
239
  input: { key: string; action: string; targetType: string; targetId: string; reportId: string; details: unknown; createdAt: string },
240
+ receipt?: { table: "notification_attempts" | "email_delivery_attempts"; id: string },
233
241
  ): D1PreparedStatement {
234
242
  return db.prepare(`
235
243
  INSERT OR IGNORE INTO audit_events (
236
244
  id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
237
- ) VALUES (?1, ?2, ?3, 'system', 'delivery-worker', ?4, ?5, ?6, ?7)
245
+ ) SELECT ?1, ?2, ?3, 'system', 'delivery-worker', ?4, ?5, ?6, ?7
246
+ ${receipt ? `WHERE EXISTS (SELECT 1 FROM ${receipt.table} WHERE id = ?8)` : ""}
238
247
  `).bind(
239
248
  crypto.randomUUID(), input.key, input.action, input.targetType, input.targetId,
240
249
  canonicalJson({ reportId: input.reportId, ...input.details as Record<string, unknown> }), input.createdAt,
250
+ ...(receipt ? [receipt.id] : []),
241
251
  );
242
252
  }
243
253
 
@@ -331,14 +341,14 @@ async function processAction(db: D1Database, outboxId: string, env: Env): Promis
331
341
  if (!terminal) throw new RetryableDeliveryError(retryDelay);
332
342
  }
333
343
 
334
- async function updateNoticeDelivery(db: D1Database, noticeId: string, state: "delivered" | "failed", timestamp: string): Promise<void> {
335
- await db.batch([
336
- db.prepare(`UPDATE notices SET delivery_state = ?2, updated_at = ?3 WHERE id = ?1`).bind(noticeId, state, timestamp),
337
- db.prepare(`
338
- UPDATE case_messages SET delivery_state = ?2
339
- WHERE id = (SELECT message_id FROM notices WHERE id = ?1)
340
- `).bind(noticeId, state),
341
- ]);
344
+ function localNotificationTransport(env: Env): typeof fetch | undefined {
345
+ // Only the disposable loopback demo can use its local customer service binding.
346
+ if (env.AI_LOCAL_FIXTURE_ENABLED !== "true" || env.NOTIFICATION_WEBHOOK_URL !== "https://safest-notice-harness.invalid/api/safest/notices") return;
347
+ try {if (!["localhost", "127.0.0.1"].includes(new URL(env.PUBLIC_BASE_URL).hostname)) return;} catch {return;}
348
+ const candidate: unknown = Reflect.get(env, "NOTICE_CUSTOMER");
349
+ if (!candidate || typeof candidate !== "object" || !("fetch" in candidate) || typeof candidate.fetch !== "function") return;
350
+ const binding = candidate as Fetcher;
351
+ return (input, init) => binding.fetch(input, init);
342
352
  }
343
353
 
344
354
  async function processNotification(db: D1Database, outboxId: string, env: Env): Promise<void> {
@@ -355,13 +365,29 @@ async function processNotification(db: D1Database, outboxId: string, env: Env):
355
365
  } catch {
356
366
  payload = {};
357
367
  }
368
+ if ("recipient_contact_fingerprint" in payload) {
369
+ const fingerprint = row.contact_ciphertext ? await sha256(row.contact_ciphertext) : null;
370
+ if (payload.recipient_contact_fingerprint !== fingerprint || (payload.recipient_reference ?? null) !== row.recipient_reference) throw new DeliveryConfigurationError("The participant identity changed after this notice was prepared.", "notice_recipient_changed");
371
+ delete payload.recipient_contact_fingerprint;
372
+ }
358
373
  const recipientEmail = row.contact_ciphertext
359
374
  ? (await decryptReportContact(env.CREDENTIAL_ENCRYPTION_KEY, row.contact_ciphertext, row.report_id, row.audience)).trim().toLowerCase()
360
375
  : null;
376
+ const accessInput = {
377
+ reportId: row.report_id, audience: row.audience, deliveryKey: `notice:${row.id}`,
378
+ expectedIdentity: { reference: row.recipient_reference, contactCiphertext: row.contact_ciphertext },
379
+ };
380
+ // Keep webhook retries byte-equivalent even for a notice first sent by an
381
+ // older build without a participant link. Existing grants reproduce the
382
+ // original link; an upgrade must never add a new credential to an old retry.
383
+ const participantUrl = row.attempt_count === 1
384
+ ? await issueParticipantAccessLink(env, accessInput)
385
+ : await lookupParticipantAccessLink(env, accessInput);
361
386
  const deliveryPayload = canonicalJson({
362
387
  ...payload,
363
388
  ...(row.recipient_reference ? { recipient_reference: row.recipient_reference } : {}),
364
389
  ...(recipientEmail ? { recipient_email: recipientEmail } : {}),
390
+ ...(participantUrl ? { participant_url: participantUrl } : {}),
365
391
  });
366
392
  const response = await sendWebhook({
367
393
  url: env.NOTIFICATION_WEBHOOK_URL,
@@ -370,70 +396,81 @@ async function processNotification(db: D1Database, outboxId: string, env: Env):
370
396
  event: "participant.notification.requested",
371
397
  idempotencyKey: row.idempotency_key,
372
398
  payload: deliveryPayload,
373
- });
399
+ }, localNotificationTransport(env));
374
400
  status = response.status;
375
401
  } catch (error) {
376
402
  errorCode = error instanceof DeliveryConfigurationError
377
- ? "webhook_configuration_error"
403
+ ? error.code ?? "webhook_configuration_error"
404
+ : error instanceof ApiError && error.code === "participant_access_unavailable" ? "notice_recipient_changed"
378
405
  : error instanceof DOMException && error.name === "AbortError" ? "webhook_timeout" : "webhook_network_error";
379
406
  }
380
407
  const finishedAt = now();
381
408
  const attemptId = crypto.randomUUID();
382
- if (status !== null && responseClass(status) === "success") {
383
- await db.batch([
384
- db.prepare(`
385
- UPDATE notification_outbox SET status = 'delivered', terminal = 1,
386
- lease_expires_at = NULL, last_error_code = NULL, updated_at = ?2 WHERE id = ?1
387
- `).bind(row.id, finishedAt),
388
- db.prepare(`
389
- INSERT INTO notification_attempts (
390
- id, notification_id, attempt_number, response_class, response_status, started_at, finished_at
391
- ) VALUES (?1, ?2, ?3, 'delivered', ?4, ?5, ?6)
392
- `).bind(attemptId, row.id, row.attempt_count, status, startedAt, finishedAt),
393
- deliveryAudit(db, {
394
- key: `notification-delivery:${row.id}:delivered`,
395
- action: "notification.delivered",
396
- targetType: "notification",
397
- targetId: row.id,
398
- reportId: row.report_id,
399
- details: { responseStatus: status, attemptCount: row.attempt_count },
400
- createdAt: finishedAt,
401
- }),
402
- ]);
403
- await updateNoticeDelivery(db, row.notice_id, "delivered", finishedAt);
404
- return;
405
- }
406
- if (status !== null) errorCode = `webhook_http_${status}`;
407
- const temporary = errorCode !== "webhook_configuration_error"
409
+ const delivered = status !== null && responseClass(status) === "success";
410
+ if (status !== null && !delivered) errorCode = `webhook_http_${status}`;
411
+ const temporary = errorCode !== "webhook_configuration_error" && errorCode !== "notice_recipient_changed"
408
412
  && (status === null || responseClass(status) === "temporary_failure");
409
- const terminal = !temporary || row.attempt_count >= MAX_ATTEMPTS;
413
+ const terminal = delivered || !temporary || row.attempt_count >= row.attempt_limit;
410
414
  const retryDelay = delay(row.attempt_count);
411
415
  const statements = [
412
- db.prepare(`
413
- UPDATE notification_outbox SET status = 'failed', terminal = ?2,
414
- next_attempt_at = ?3, lease_expires_at = NULL, last_error_code = ?4, updated_at = ?5
415
- WHERE id = ?1
416
- `).bind(row.id, terminal ? 1 : 0, plusSeconds(finishedAt, retryDelay), errorCode, finishedAt),
417
- db.prepare(`
418
- INSERT INTO notification_attempts (
419
- id, notification_id, attempt_number, response_class, response_status, error_code, started_at, finished_at
420
- ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
421
- `).bind(attemptId, row.id, row.attempt_count, terminal ? "permanent_failure" : "temporary_failure", status, errorCode, startedAt, finishedAt),
416
+ db.prepare(`UPDATE notification_outbox SET status = ?2, terminal = ?3,
417
+ next_attempt_at = ?4, lease_expires_at = NULL, last_error_code = ?5, updated_at = ?6
418
+ WHERE id = ?1 AND status = 'delivering' AND attempt_count = ?7 AND terminal = 0`)
419
+ .bind(row.id, delivered ? "delivered" : "failed", terminal ? 1 : 0, plusSeconds(finishedAt, retryDelay), delivered ? null : errorCode, finishedAt, row.attempt_count),
420
+ db.prepare(`INSERT INTO notification_attempts (id, notification_id, attempt_number, response_class, response_status, error_code, started_at, finished_at)
421
+ SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 WHERE changes() = 1`)
422
+ .bind(attemptId, row.id, row.attempt_count, delivered ? "delivered" : terminal ? "permanent_failure" : "temporary_failure", status, delivered ? null : errorCode, startedAt, finishedAt),
422
423
  ];
423
- if (terminal) statements.push(deliveryAudit(db, {
424
- key: `notification-delivery:${row.id}:failed`,
425
- action: "notification.delivery_failed",
426
- targetType: "notification",
427
- targetId: row.id,
428
- reportId: row.report_id,
429
- details: { errorCode, responseStatus: status, attemptCount: row.attempt_count },
430
- createdAt: finishedAt,
431
- }));
432
- await db.batch(statements);
433
- if (terminal) await updateNoticeDelivery(db, row.notice_id, "failed", finishedAt);
424
+ if (terminal) statements.push(
425
+ db.prepare(`UPDATE notices SET delivery_state = ?2, updated_at = ?3 WHERE id = ?1
426
+ AND EXISTS (SELECT 1 FROM notification_attempts WHERE id = ?4)`)
427
+ .bind(row.notice_id, delivered ? "delivered" : "failed", finishedAt, attemptId),
428
+ db.prepare(`UPDATE case_messages SET delivery_state = ?2 WHERE id = (SELECT message_id FROM notices WHERE id = ?1)
429
+ AND EXISTS (SELECT 1 FROM notification_attempts WHERE id = ?3)`)
430
+ .bind(row.notice_id, delivered ? "delivered" : "failed", attemptId),
431
+ deliveryAudit(db, {
432
+ key: `notification-delivery:${row.id}:${row.attempt_count}:${delivered ? "delivered" : "failed"}`,
433
+ action: delivered ? "notification.delivered" : "notification.delivery_failed", targetType: "notification", targetId: row.id,
434
+ reportId: row.report_id, details: { responseStatus: status, attemptCount: row.attempt_count, errorCode: delivered ? null : errorCode }, createdAt: finishedAt,
435
+ }, {table: "notification_attempts", id: attemptId}),
436
+ );
437
+ const written = await db.batch(statements);
438
+ if (written[0]?.meta.changes !== 1) return;
439
+ if (terminal && row.message_id) await signalNoticeDelivery(env, row.message_id, `notification-${row.attempt_count}`);
434
440
  if (!terminal) throw new RetryableDeliveryError(retryDelay);
435
441
  }
436
442
 
443
+ async function commitEmailResult(db: D1Database, env: Env, row: EmailDeliveryRow, input: {
444
+ state: "delivered" | "failed" | "suppressed"; terminal: boolean; nextAttemptAt: string | null;
445
+ errorCode: string | null; providerMessageId?: string | null;
446
+ }): Promise<boolean> {
447
+ const timestamp = now(), attemptId = crypto.randomUUID();
448
+ const statements = [
449
+ db.prepare(`UPDATE email_deliveries SET state = ?2, provider_message_id = COALESCE(?3, provider_message_id),
450
+ last_error_code = ?4, next_attempt_at = ?5, delivered_at = CASE WHEN ?2 = 'delivered' THEN ?6 ELSE delivered_at END, updated_at = ?6
451
+ WHERE id = ?1 AND state = 'sending' AND attempt_count = ?7 AND updated_at = ?8`)
452
+ .bind(row.id, input.state, input.providerMessageId ?? null, input.errorCode, input.nextAttemptAt, timestamp, row.attemptCount, row.claimedAt),
453
+ db.prepare(`INSERT INTO email_delivery_attempts (id, delivery_id, attempt_number, result, provider_message_id, error_code, started_at, finished_at)
454
+ SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 WHERE changes() = 1`)
455
+ .bind(attemptId, row.id, row.attemptCount, input.state, input.providerMessageId ?? null, input.errorCode, row.claimedAt, timestamp),
456
+ ];
457
+ if (input.terminal) statements.push(
458
+ db.prepare(`UPDATE case_messages SET delivery_state = ?2 WHERE id = ?1 AND EXISTS (SELECT 1 FROM email_delivery_attempts WHERE id = ?3)`)
459
+ .bind(row.messageId, input.state, attemptId),
460
+ db.prepare(`UPDATE notices SET delivery_state = ?2, updated_at = ?3 WHERE message_id = ?1 AND EXISTS (SELECT 1 FROM email_delivery_attempts WHERE id = ?4)`)
461
+ .bind(row.messageId, input.state, timestamp, attemptId),
462
+ deliveryAudit(db, {
463
+ key: `email-delivery:${row.id}:${row.attemptCount}:${input.state}`, action: input.state === "delivered" ? "email.delivered" : "email.delivery_failed",
464
+ targetType: "email_delivery", targetId: row.id, reportId: row.reportId,
465
+ details: {provider: "cloudflare_email_service", attemptCount: row.attemptCount, errorCode: input.errorCode}, createdAt: timestamp,
466
+ }, {table: "email_delivery_attempts", id: attemptId}),
467
+ );
468
+ const written = await db.batch(statements);
469
+ if (written[0]?.meta.changes !== 1) return false;
470
+ if (input.terminal) await signalNoticeDelivery(env, row.messageId, `email-${row.attemptCount}`);
471
+ return true;
472
+ }
473
+
437
474
  async function processEmail(db: D1Database, deliveryId: string, env: Env): Promise<void> {
438
475
  const row = await claimEmail(db, deliveryId);
439
476
  if (!row) return;
@@ -449,22 +486,16 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
449
486
  });
450
487
  } catch (error) {
451
488
  if (!(error instanceof BudgetControlError)) throw error;
452
- const timestamp = now();
453
- if (error.retryable) {
454
- await db.prepare(`UPDATE email_deliveries SET state = 'failed', last_error_code = 'email_budget_deferred', next_attempt_at = ?2, updated_at = ?3 WHERE id = ?1`)
455
- .bind(row.id, plusSeconds(timestamp, error.delaySeconds), timestamp).run();
456
- throw new RetryableDeliveryError(error.delaySeconds);
457
- }
458
- await db.batch([
459
- db.prepare(`UPDATE email_deliveries SET state = 'suppressed', last_error_code = 'email_budget_exhausted', next_attempt_at = NULL, updated_at = ?2 WHERE id = ?1`).bind(row.id, timestamp),
460
- deliveryAudit(db, {
461
- key: `email-delivery:${row.id}:budget-suppressed`, action: "email.budget_exhausted",
462
- targetType: "email_delivery", targetId: row.id, reportId: row.reportId,
463
- details: { fallback: "customer_webhook", fallbackReference: error.fallbackReference }, createdAt: timestamp,
464
- }),
465
- ]);
489
+ const terminal = !error.retryable || row.attemptCount >= row.attemptLimit;
490
+ const committed = await commitEmailResult(db, env, row, {
491
+ state: terminal ? "suppressed" : "failed", terminal,
492
+ errorCode: terminal ? "email_budget_exhausted" : "email_budget_deferred",
493
+ nextAttemptAt: terminal ? null : plusSeconds(now(), error.delaySeconds),
494
+ });
495
+ if (committed && !terminal) throw new RetryableDeliveryError(error.delaySeconds);
466
496
  return;
467
497
  }
498
+
468
499
  let providerMessageId: string | null = null;
469
500
  let providerAttempted = false;
470
501
  let errorCode: string | null = null;
@@ -476,6 +507,7 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
476
507
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email) || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(from)) {
477
508
  throw new DeliveryConfigurationError("Email sender or recipient configuration is invalid.");
478
509
  }
510
+ if (row.recipientHash && row.recipientHash !== `sha256:${await sha256(`email-recipient-v1:${env.RATE_LIMIT_PEPPER}:${email}`)}`) throw new DeliveryConfigurationError("The participant address changed after this notice was prepared.", "notice_recipient_changed");
479
511
  const prefix = reporterEmailSubjectPrefix(env);
480
512
  const [brand, replyTo] = await Promise.all([
481
513
  loadWorkspaceBrand(db),
@@ -493,6 +525,10 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
493
525
  const rendered = renderReporterEmail({
494
526
  brand, body: row.body, reportReference: row.reportReference,
495
527
  templateVersionId: row.templateVersionId, subjectPrefix: prefix, audience: row.audience,
528
+ participantUrl: await issueParticipantAccessLink(env, {
529
+ reportId: row.reportId, audience: row.audience, deliveryKey: `email:${row.id}`,
530
+ expectedIdentity: { reference: row.recipientReference, contactCiphertext: row.contactCiphertext },
531
+ }),
496
532
  });
497
533
  providerAttempted = true;
498
534
  const result = await binding.send({
@@ -507,50 +543,21 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
507
543
  providerMessageId = typeof result.messageId === "string" ? result.messageId.slice(0, 300) : null;
508
544
  } catch (error) {
509
545
  errorCode = error instanceof DeliveryConfigurationError
510
- ? "email_configuration_error"
546
+ ? error.code ?? "email_configuration_error"
547
+ : error instanceof ApiError && error.code === "participant_access_unavailable" ? "notice_recipient_changed"
511
548
  : typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
512
549
  ? `email_${error.code}`.slice(0, 100) : "email_provider_error";
513
550
  }
514
- const finishedAt = now();
515
- if (!errorCode) {
516
- await settleBudgets(db, reservations, true);
517
- await db.batch([
518
- db.prepare(`UPDATE email_deliveries SET state = 'delivered', provider_message_id = ?2, last_error_code = NULL, next_attempt_at = NULL, delivered_at = ?3, updated_at = ?3 WHERE id = ?1`)
519
- .bind(row.id, providerMessageId, finishedAt),
520
- db.prepare(`UPDATE case_messages SET delivery_state = 'delivered' WHERE id = ?1`).bind(row.messageId),
521
- db.prepare(`UPDATE notices SET delivery_state = 'delivered', updated_at = ?2 WHERE message_id = ?1`).bind(row.messageId, finishedAt),
522
- deliveryAudit(db, {
523
- key: `email-delivery:${row.id}:delivered`, action: "email.delivered",
524
- targetType: "email_delivery", targetId: row.id, reportId: row.reportId,
525
- details: { provider: "cloudflare_email_service", attemptCount: row.attemptCount }, createdAt: finishedAt,
526
- }),
527
- ]);
528
- return;
529
- }
530
- const permanentConfigurationFailure = errorCode === "email_configuration_error"
531
- || errorCode === "email_contact_decryption_failed"
532
- || errorCode === "email_contact_encryption_unavailable";
533
- const terminal = permanentConfigurationFailure || row.attemptCount >= MAX_ATTEMPTS;
534
551
  await settleBudgets(db, reservations, providerAttempted);
552
+ const configurationFailure = errorCode === "email_configuration_error" || errorCode === "notice_recipient_changed"
553
+ || errorCode === "email_contact_decryption_failed" || errorCode === "email_contact_encryption_unavailable";
554
+ const terminal = !errorCode || configurationFailure || row.attemptCount >= row.attemptLimit;
535
555
  const retryDelay = delay(row.attemptCount);
536
- const nextAttemptAt = terminal ? null : plusSeconds(finishedAt, retryDelay);
537
- const statements: D1PreparedStatement[] = [
538
- db.prepare(`UPDATE email_deliveries SET state = 'failed', last_error_code = ?2, next_attempt_at = ?3, updated_at = ?4 WHERE id = ?1`)
539
- .bind(row.id, errorCode, nextAttemptAt, finishedAt),
540
- ];
541
- if (terminal) {
542
- statements.push(
543
- db.prepare(`UPDATE case_messages SET delivery_state = 'failed' WHERE id = ?1 AND delivery_state != 'delivered'`).bind(row.messageId),
544
- db.prepare(`UPDATE notices SET delivery_state = 'failed', updated_at = ?2 WHERE message_id = ?1 AND delivery_state != 'delivered'`).bind(row.messageId, finishedAt),
545
- deliveryAudit(db, {
546
- key: `email-delivery:${row.id}:failed`, action: "email.delivery_failed",
547
- targetType: "email_delivery", targetId: row.id, reportId: row.reportId,
548
- details: { errorCode, attemptCount: row.attemptCount }, createdAt: finishedAt,
549
- }),
550
- );
551
- }
552
- await db.batch(statements);
553
- if (!terminal) throw new RetryableDeliveryError(retryDelay);
556
+ const committed = await commitEmailResult(db, env, row, {
557
+ state: errorCode ? "failed" : "delivered", terminal,
558
+ errorCode, providerMessageId, nextAttemptAt: terminal ? null : plusSeconds(now(), retryDelay),
559
+ });
560
+ if (committed && !terminal) throw new RetryableDeliveryError(retryDelay);
554
561
  }
555
562
 
556
563
  export async function processDeliveryJob(db: D1Database, job: DeliveryJob, env: Env): Promise<void> {
@@ -573,21 +580,21 @@ export async function pendingDeliveryJobs(db: D1Database, limit = 50): Promise<D
573
580
  `).bind(at, MAX_ATTEMPTS, boundedLimit).all<{ id: string }>(),
574
581
  db.prepare(`
575
582
  SELECT id FROM notification_outbox
576
- WHERE terminal = 0 AND attempt_count < ?2 AND (
583
+ WHERE terminal = 0 AND attempt_count < attempt_limit AND (
577
584
  (status IN ('pending', 'failed') AND next_attempt_at <= ?1)
578
585
  OR (status = 'delivering' AND lease_expires_at <= ?1)
579
586
  )
580
- ORDER BY next_attempt_at, id LIMIT ?3
581
- `).bind(at, MAX_ATTEMPTS, boundedLimit).all<{ id: string }>(),
587
+ ORDER BY next_attempt_at, id LIMIT ?2
588
+ `).bind(at, boundedLimit).all<{ id: string }>(),
582
589
  db.prepare(`
583
590
  SELECT id FROM email_deliveries
584
- WHERE attempt_count < ?2 AND (
591
+ WHERE attempt_count < attempt_limit AND (
585
592
  (state = 'pending' AND COALESCE(next_attempt_at, created_at) <= ?1)
586
593
  OR (state = 'failed' AND next_attempt_at IS NOT NULL AND next_attempt_at <= ?1)
587
- OR (state = 'sending' AND updated_at <= ?3)
594
+ OR (state = 'sending' AND updated_at <= ?2)
588
595
  )
589
- ORDER BY COALESCE(next_attempt_at, created_at), id LIMIT ?4
590
- `).bind(at, MAX_ATTEMPTS, plusSeconds(at, -60), boundedLimit).all<{ id: string }>(),
596
+ ORDER BY COALESCE(next_attempt_at, created_at), id LIMIT ?3
597
+ `).bind(at, plusSeconds(at, -60), boundedLimit).all<{ id: string }>(),
591
598
  ]);
592
599
  return [
593
600
  ...actions.results.map((row): DeliveryJob => ({ version: 1, jobId: `action:${row.id}`, type: "deliver_action", outboxId: row.id })),
@@ -153,6 +153,7 @@ export function renderReporterEmail(input: {
153
153
  templateVersionId: string | null;
154
154
  subjectPrefix: string;
155
155
  audience?: CaseEmailAudience;
156
+ participantUrl?: string;
156
157
  }): RenderedEmail {
157
158
  const emailKind = caseKind(input.templateVersionId);
158
159
  const audience = input.audience ?? "reporter";
@@ -196,7 +197,8 @@ export function renderReporterEmail(input: {
196
197
  category: copy.category,
197
198
  heading: copy.heading,
198
199
  intro: copy.intro,
199
- contentHtml: `<div style="margin:24px 0 0;padding:20px;border-left:4px solid ${input.brand.accent_color};border-radius:${Math.min(input.brand.border_radius, 12)}px;background-color:${input.brand.background_color};color:${input.brand.text_color};font-size:16px;line-height:1.65">${messageHtml(input.body)}</div>`,
200
+ contentHtml: `<div style="margin:24px 0 0;padding:20px;border-left:4px solid ${input.brand.accent_color};border-radius:${Math.min(input.brand.border_radius, 12)}px;background-color:${input.brand.background_color};color:${input.brand.text_color};font-size:16px;line-height:1.65">${messageHtml(input.body)}</div>`
201
+ + (input.participantUrl ? `<p style="margin:24px 0"><a href="${escapeHtml(input.participantUrl)}" style="display:inline-block;padding:14px 20px;border-radius:8px;background:${actionColors(input.brand).background};color:${actionColors(input.brand).foreground};text-decoration:none;font-weight:700">Open your case</a></p><p style="font-size:14px;line-height:1.6">Read your conversation, add information or request an independent appeal. This private link is for you only; do not forward it. It opens once within 30 days. Request a fresh link from the case page if needed.</p>` : ""),
200
202
  details: [
201
203
  { label: "Report reference", value: input.reportReference },
202
204
  { label: "Status", value: copy.status },
@@ -206,7 +208,8 @@ export function renderReporterEmail(input: {
206
208
  return {
207
209
  subject: copy.subject,
208
210
  html,
209
- text: `${copy.heading}\n\n${copy.intro}\n\n${input.body}\n\nReport reference: ${input.reportReference}\nStatus: ${copy.status}\n\n${replyCopy}\n\nFor privacy, this email includes only the information needed for this update.`,
211
+ text: `${copy.heading}\n\n${copy.intro}\n\n${input.body}\n\nReport reference: ${input.reportReference}\nStatus: ${copy.status}\n\n${replyCopy}\n\nFor privacy, this email includes only the information needed for this update.`
212
+ + (input.participantUrl ? `\n\nOpen your case: ${input.participantUrl}\nRead your conversation, add information or request an independent appeal. This private link is for you only; do not forward it. It opens once within 30 days. Request a fresh link from the case page if needed.` : ""),
210
213
  };
211
214
  }
212
215
 
@@ -100,57 +100,49 @@ function percentile(values: number[], ratio: number): number | null {
100
100
  return Math.round(values[index] ?? 0);
101
101
  }
102
102
 
103
- export async function loadAnalytics(db: D1Database, days = 30): Promise<Record<string, unknown>> {
103
+ export async function loadAnalytics(db: D1Database, days = 30, queueScope: string[] | null = null): Promise<Record<string, unknown>> {
104
104
  const generatedAt = now();
105
105
  const boundedDays = Math.max(1, Math.min(366, days));
106
106
  const fromDate = subtractDays(generatedAt, boundedDays).slice(0, 10);
107
- const [series, durations, backlog, aiUsage] = await Promise.all([
108
- db.prepare(`
109
- SELECT aggregate_date AS aggregateDate, metric_key AS metricKey, dimension_key AS dimensionKey,
110
- dimension_value AS dimensionValue, value
111
- FROM daily_aggregates WHERE aggregate_date >= ?1
112
- ORDER BY aggregate_date, metric_key, dimension_key, dimension_value LIMIT 10000
113
- `).bind(fromDate).all<Record<string, unknown>>(),
114
- db.prepare(`
115
- SELECT CAST((julianday(resolved_at) - julianday(received_at)) * 86400 AS INTEGER) AS seconds
116
- FROM reports WHERE resolved_at IS NOT NULL AND received_at >= ?1
117
- ORDER BY seconds LIMIT 10000
118
- `).bind(`${fromDate}T00:00:00.000Z`).all<{ seconds: number }>(),
119
- db.prepare(`
120
- SELECT queue_id AS queueId, state, COUNT(*) AS count,
121
- CAST(MAX((julianday(?1) - julianday(received_at)) * 86400) AS INTEGER) AS oldestAgeSeconds
122
- FROM reports WHERE state IN ('received', 'human_review', 'ai_review', 'awaiting_reporter', 'human_review_requested', 'appealed')
123
- GROUP BY queue_id, state ORDER BY queue_id, state
124
- `).bind(generatedAt).all<Record<string, unknown>>(),
125
- db.prepare(`
126
- SELECT usage_date AS usageDate, SUM(reports_started) AS reportsStarted,
127
- SUM(reports_resolved) AS reportsResolved, SUM(reports_escalated) AS reportsEscalated,
128
- SUM(failures) AS failures, SUM(estimated_cost_microusd) AS estimatedCostMicrousd,
129
- SUM(eligible_reports) AS eligibleReports, SUM(waiting_for_reporter) AS waitingForReporter,
130
- SUM(human_review_requests) AS humanReviewRequests, SUM(appeals_filed) AS appealsFiled,
131
- SUM(appeal_reversals) AS appealReversals, SUM(invalid_outputs) AS invalidOutputs,
132
- SUM(provider_errors) AS providerErrors, SUM(timeouts) AS timeouts
133
- FROM ai_usage_daily WHERE usage_date >= ?1 GROUP BY usage_date ORDER BY usage_date
134
- `).bind(fromDate).all<Record<string, unknown>>(),
107
+ const from = `${fromDate}T00:00:00.000Z`;
108
+ const scope = queueScope === null ? null : JSON.stringify(queueScope);
109
+ const visible = `WITH visible_reports AS (SELECT * FROM reports WHERE (?2 IS NULL OR queue_id IN (SELECT value FROM json_each(?2))))`;
110
+ const query = (sql: string) => db.prepare(`${visible} ${sql}`).bind(from, scope);
111
+ const [series, durations, backlog, evidence, actions, notices, appeals, audits] = await Promise.all([
112
+ query(`SELECT substr(received_at,1,10) AS aggregateDate, 'reports_received' AS metricKey, 'source' AS dimensionKey, source AS dimensionValue, COUNT(*) AS value FROM visible_reports WHERE received_at >= ?1 GROUP BY aggregateDate, source
113
+ UNION ALL SELECT substr(received_at,1,10), 'reports_received', 'reason', reason_code, COUNT(*) FROM visible_reports WHERE received_at >= ?1 GROUP BY substr(received_at,1,10), reason_code
114
+ UNION ALL SELECT substr(d.created_at,1,10), 'decisions', 'maker_type', d.maker_type, COUNT(*) FROM decisions d JOIN visible_reports r ON r.id=d.report_id WHERE d.created_at >= ?1 GROUP BY substr(d.created_at,1,10), d.maker_type
115
+ UNION ALL SELECT substr(d.created_at,1,10), 'decisions', 'outcome', d.decision_code, COUNT(*) FROM decisions d JOIN visible_reports r ON r.id=d.report_id WHERE d.created_at >= ?1 GROUP BY substr(d.created_at,1,10), d.decision_code
116
+ ORDER BY 1,2,3,4 LIMIT 10000`).all<Record<string, unknown>>(),
117
+ query(`SELECT CAST((julianday(resolved_at)-julianday(received_at))*86400 AS INTEGER) AS seconds FROM visible_reports WHERE resolved_at IS NOT NULL AND received_at >= ?1 ORDER BY seconds LIMIT 10000`).all<{ seconds: number }>(),
118
+ // Backlog is current, irrespective of the selected reporting period.
119
+ db.prepare(`${visible} SELECT queue_id AS queueId, (SELECT name FROM queue_definitions WHERE id=queue_id) AS queueName, state, COUNT(*) AS count, CAST(MAX((julianday(?1)-julianday(received_at))*86400) AS INTEGER) AS oldestAgeSeconds
120
+ FROM visible_reports WHERE state IN ('received','human_review','ai_review','awaiting_reporter','human_review_requested','appealed') GROUP BY queue_id,state ORDER BY queue_id,state`).bind(generatedAt, scope).all<Record<string, unknown>>(),
121
+ query(`SELECT COUNT(*) AS receivedCases,
122
+ COALESCE(SUM(EXISTS(SELECT 1 FROM findings f WHERE f.report_id=r.id AND f.status='available' AND (f.expires_at IS NULL OR f.expires_at > ?3))),0) AS withRetrievedEvidence,
123
+ COALESCE(SUM(EXISTS(SELECT 1 FROM findings f WHERE f.report_id=r.id AND (f.status IN ('unavailable','invalid','stale') OR (f.status='available' AND f.expires_at <= ?3)))),0) AS withEvidenceGaps,
124
+ COALESCE(SUM(EXISTS(SELECT 1 FROM findings f WHERE f.report_id=r.id AND f.status='available' AND json_array_length(f.uncertainty_json)>0)),0) AS withRecordedUncertainty
125
+ FROM visible_reports r WHERE received_at >= ?1`).bind(from, scope, generatedAt).first<Record<string, number>>(),
126
+ query(`SELECT 'playbook' AS pipeline, CASE WHEN a.accepted_at IS NOT NULL AND a.delivery_terminal = 0 THEN 'awaiting_confirmation' ELSE a.state END AS state, COUNT(*) AS count FROM action_proposals a JOIN visible_reports r ON r.id=a.report_id WHERE a.created_at >= ?1 GROUP BY 2
127
+ UNION ALL SELECT 'legacy', a.status, COUNT(*) FROM decision_action_outbox a JOIN visible_reports r ON r.id=a.report_id WHERE a.created_at >= ?1 GROUP BY a.status`).all<Record<string, unknown>>(),
128
+ query(`SELECT n.audience, n.delivery_state AS state, COUNT(*) AS count FROM notices n JOIN visible_reports r ON r.id=n.report_id WHERE n.created_at >= ?1 GROUP BY n.audience,n.delivery_state`).all<Record<string, unknown>>(),
129
+ query(`SELECT a.state, COUNT(*) AS count FROM appeals a JOIN visible_reports r ON r.id=a.report_id WHERE a.submitted_at >= ?1 GROUP BY a.state`).all<Record<string, unknown>>(),
130
+ query(`SELECT s.sample_reason AS reason, COUNT(*) AS selected, SUM(EXISTS(SELECT 1 FROM quality_reviews q WHERE q.sample_id=s.id)) AS reviewed FROM ai_quality_samples s JOIN visible_reports r ON r.id=s.report_id WHERE s.selected_at >= ?1 GROUP BY s.sample_reason`).all<Record<string, unknown>>(),
135
131
  ]);
136
- const seconds = durations.results.map((row) => row.seconds).filter((value) => Number.isFinite(value));
132
+ const seconds = durations.results.map(row => row.seconds).filter(value => Number.isFinite(value) && value >= 0);
137
133
  return {
138
- generatedAt,
139
- fromDate,
140
- days: boundedDays,
141
- series: series.results,
142
- backlog: backlog.results,
143
- resolutionSeconds: {
144
- count: seconds.length,
145
- median: percentile(seconds, 0.5),
146
- p90: percentile(seconds, 0.9),
147
- p95: percentile(seconds, 0.95),
148
- },
149
- aiUsage: aiUsage.results,
134
+ generatedAt, fromDate, days: boundedDays, queueScope,
135
+ series: series.results, backlog: backlog.results,
136
+ resolutionSeconds: { count: seconds.length, median: percentile(seconds,0.5), p90: percentile(seconds,0.9), p95: percentile(seconds,0.95) },
137
+ evidenceCoverage: evidence, actions: actions.results, notices: notices.results, appeals: appeals.results, audits: audits.results,
150
138
  limitations: [
151
- "Counts describe reports and decisions, not verified real-world incidents.",
152
- "No rate denominator is calculated unless the customer supplies one.",
139
+ "Counts describe recorded cases and decisions, not verified real-world incidents.",
140
+ "Evidence coverage counts stored, available findings; it does not establish that an allegation is true.",
141
+ "Delivered notices were handed to a delivery provider; recipient reading is not measured.",
142
+ "Appeal outcomes and quality samples describe reviewed work, not an overall accuracy score.",
143
+ ...(queueScope !== null ? ["Only cases in your current queue scope are included."] : []),
153
144
  ...(durations.results.length >= 10000 ? ["Resolution percentiles are bounded to the first 10,000 records in the selected period."] : []),
145
+ ...(series.results.length >= 10000 ? ["Daily breakdowns are limited to 10,000 rows; narrow the period for a complete breakdown."] : []),
154
146
  ],
155
147
  };
156
148
  }
@@ -179,6 +171,7 @@ async function anonymizeReport(
179
171
  ]);
180
172
  if (scope === "all_permitted_data") {
181
173
  await db.batch([
174
+ db.prepare(`UPDATE report_handoffs SET note = '[removed by retention]', redacted_at = COALESCE(redacted_at, ?2) WHERE report_id = ?1`).bind(reportId, at),
182
175
  db.prepare(`UPDATE case_messages SET body = '[removed by retention]', sender_id = 'redacted', redacted_at = COALESCE(redacted_at, ?2) WHERE report_id = ?1`).bind(reportId, at),
183
176
  db.prepare(`
184
177
  UPDATE report_targets SET target_reference = 'redacted:' || id, owner_reference = NULL,
@@ -189,7 +182,7 @@ async function anonymizeReport(
189
182
  db.prepare(`UPDATE decisions SET rationale = '[removed by retention]', evidence_references_json = '[]', sensitive_redacted_at = ?2 WHERE report_id = ?1 AND sensitive_redacted_at IS NULL`).bind(reportId, at),
190
183
  db.prepare(`UPDATE notices SET rendered_body = '[removed by retention]', redacted_at = ?2 WHERE report_id = ?1 AND redacted_at IS NULL`).bind(reportId, at),
191
184
  db.prepare(`
192
- UPDATE appeal_decisions SET rationale = '[removed by retention]', rationale_redacted_at = ?2
185
+ UPDATE appeal_decisions SET rationale = '[removed by retention]', rationale_redacted_at = ?2, correction_reference = NULL, correction_note = NULL
193
186
  WHERE appeal_id IN (SELECT id FROM appeals WHERE report_id = ?1) AND rationale_redacted_at IS NULL
194
187
  `).bind(reportId, at),
195
188
  db.prepare(`
@@ -249,6 +242,10 @@ export async function runScheduledRetention(db: D1Database, messageRetentionDays
249
242
  SELECT 1 FROM legal_holds h WHERE h.report_id = notices.report_id AND h.state = 'active'
250
243
  )
251
244
  `).bind(messageCutoff, startedAt).run();
245
+ await db.prepare(`UPDATE report_handoffs SET note = '[removed by retention]', redacted_at = ?2
246
+ WHERE redacted_at IS NULL AND created_at < ?1 AND NOT EXISTS (
247
+ SELECT 1 FROM legal_holds h WHERE h.report_id = report_handoffs.report_id AND h.state = 'active'
248
+ )`).bind(messageCutoff, startedAt).run();
252
249
  const candidates = await db.prepare(`
253
250
  SELECT id FROM reports r
254
251
  WHERE r.updated_at < ?1 AND r.state IN ('resolved_by_human', 'resolved_by_ai', 'closed')