@pegma/support-desk-application 0.1.1 → 0.2.1

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 (86) hide show
  1. package/README.md +237 -7
  2. package/dist/assistant-turn-capacity.d.ts +13 -0
  3. package/dist/assistant-turn-capacity.d.ts.map +1 -0
  4. package/dist/assistant-turn-capacity.js +111 -0
  5. package/dist/assistant-turn-capacity.js.map +1 -0
  6. package/dist/assistant-turn-records.d.ts +68 -0
  7. package/dist/assistant-turn-records.d.ts.map +1 -0
  8. package/dist/assistant-turn-records.js +418 -0
  9. package/dist/assistant-turn-records.js.map +1 -0
  10. package/dist/assistant-turn-types.d.ts +133 -0
  11. package/dist/assistant-turn-types.d.ts.map +1 -0
  12. package/dist/assistant-turn-types.js +2 -0
  13. package/dist/assistant-turn-types.js.map +1 -0
  14. package/dist/assistant-turns.d.ts +42 -0
  15. package/dist/assistant-turns.d.ts.map +1 -0
  16. package/dist/assistant-turns.js +660 -0
  17. package/dist/assistant-turns.js.map +1 -0
  18. package/dist/engineering-evidence.d.ts +20 -0
  19. package/dist/engineering-evidence.d.ts.map +1 -0
  20. package/dist/engineering-evidence.js +899 -0
  21. package/dist/engineering-evidence.js.map +1 -0
  22. package/dist/engineering-followup-internal.d.ts +45 -0
  23. package/dist/engineering-followup-internal.d.ts.map +1 -0
  24. package/dist/engineering-followup-internal.js +2 -0
  25. package/dist/engineering-followup-internal.js.map +1 -0
  26. package/dist/engineering-followup-records.d.ts +209 -0
  27. package/dist/engineering-followup-records.d.ts.map +1 -0
  28. package/dist/engineering-followup-records.js +1434 -0
  29. package/dist/engineering-followup-records.js.map +1 -0
  30. package/dist/engineering-followup-source.d.ts +168 -0
  31. package/dist/engineering-followup-source.d.ts.map +1 -0
  32. package/dist/engineering-followup-source.js +1144 -0
  33. package/dist/engineering-followup-source.js.map +1 -0
  34. package/dist/engineering-followup-types.d.ts +364 -0
  35. package/dist/engineering-followup-types.d.ts.map +1 -0
  36. package/dist/engineering-followup-types.js +2 -0
  37. package/dist/engineering-followup-types.js.map +1 -0
  38. package/dist/engineering-followup-work.d.ts +70 -0
  39. package/dist/engineering-followup-work.d.ts.map +1 -0
  40. package/dist/engineering-followup-work.js +1153 -0
  41. package/dist/engineering-followup-work.js.map +1 -0
  42. package/dist/engineering-internal.d.ts +38 -0
  43. package/dist/engineering-internal.d.ts.map +1 -0
  44. package/dist/engineering-internal.js +11 -0
  45. package/dist/engineering-internal.js.map +1 -0
  46. package/dist/engineering-policy.d.ts +47 -0
  47. package/dist/engineering-policy.d.ts.map +1 -0
  48. package/dist/engineering-policy.js +579 -0
  49. package/dist/engineering-policy.js.map +1 -0
  50. package/dist/engineering-reconciliation.d.ts +6 -0
  51. package/dist/engineering-reconciliation.d.ts.map +1 -0
  52. package/dist/engineering-reconciliation.js +464 -0
  53. package/dist/engineering-reconciliation.js.map +1 -0
  54. package/dist/engineering-records.d.ts +227 -0
  55. package/dist/engineering-records.d.ts.map +1 -0
  56. package/dist/engineering-records.js +1272 -0
  57. package/dist/engineering-records.js.map +1 -0
  58. package/dist/engineering-types.d.ts +505 -0
  59. package/dist/engineering-types.d.ts.map +1 -0
  60. package/dist/engineering-types.js +2 -0
  61. package/dist/engineering-types.js.map +1 -0
  62. package/dist/engineering-work.d.ts +32 -0
  63. package/dist/engineering-work.d.ts.map +1 -0
  64. package/dist/engineering-work.js +1757 -0
  65. package/dist/engineering-work.js.map +1 -0
  66. package/dist/index.d.ts +609 -14
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +3680 -233
  69. package/dist/index.js.map +1 -1
  70. package/dist/resolution-learning-records.d.ts +154 -0
  71. package/dist/resolution-learning-records.d.ts.map +1 -0
  72. package/dist/resolution-learning-records.js +657 -0
  73. package/dist/resolution-learning-records.js.map +1 -0
  74. package/dist/resolution-learning-types.d.ts +356 -0
  75. package/dist/resolution-learning-types.d.ts.map +1 -0
  76. package/dist/resolution-learning-types.js +2 -0
  77. package/dist/resolution-learning-types.js.map +1 -0
  78. package/dist/resolution-learning-worker.d.ts +21 -0
  79. package/dist/resolution-learning-worker.d.ts.map +1 -0
  80. package/dist/resolution-learning-worker.js +1027 -0
  81. package/dist/resolution-learning-worker.js.map +1 -0
  82. package/dist/resolution-learning.d.ts +38 -0
  83. package/dist/resolution-learning.d.ts.map +1 -0
  84. package/dist/resolution-learning.js +860 -0
  85. package/dist/resolution-learning.js.map +1 -0
  86. package/package.json +6 -6
package/dist/index.js CHANGED
@@ -2,8 +2,27 @@ import { auditRecordId, defineAudit } from "@pegma/audit";
2
2
  import { hasPermission } from "@pegma/authorization-core";
3
3
  import { defineMail, maxMailAttempts } from "@pegma/mail";
4
4
  import { noopLogger } from "@pegma/spine";
5
- import { defineCollection, MAX_SCAN_PAGE_SIZE, } from "@pegma/storage-core";
6
- import { applyTicketEvent, createTicket } from "@pegma/support-desk-core";
5
+ import { ConcurrencyError, defineCollection, MAX_SCAN_PAGE_SIZE, } from "@pegma/storage-core";
6
+ import { applyTicketEvent, createTicket, effectiveTicketHandling, } from "@pegma/support-desk-core";
7
+ import { assertAssistantTurnRecord, createAssistantTurnRecord, resolveAssistantTurnOptions, } from "./assistant-turn-records.js";
8
+ import { createAssistantTurnServices, } from "./assistant-turns.js";
9
+ import { createEngineeringEvidenceServices, planEngineeringTicketMutation, } from "./engineering-evidence.js";
10
+ import { assertEngineeringRecord, createEngineeringSource, resolveEngineeringOptions, } from "./engineering-records.js";
11
+ import { createEngineeringPolicyServices } from "./engineering-policy.js";
12
+ import { createEngineeringWorkerServices } from "./engineering-work.js";
13
+ import { createEngineeringReconciliationServices } from "./engineering-reconciliation.js";
14
+ import { assertEngineeringFollowupRecord, resolveEngineeringFollowupOptions, } from "./engineering-followup-records.js";
15
+ import { createEngineeringFollowupSourceServices, planEngineeringFollowupSourceChange, planEngineeringFollowupTicketMutation, } from "./engineering-followup-source.js";
16
+ import { createEngineeringFollowupWorkServices } from "./engineering-followup-work.js";
17
+ import { fields as followupFields, integer as followupInteger, text as followupText, } from "./assistant-turn-records.js";
18
+ import { engineeringFollowupCommandKey, engineeringFollowupHeadKey, safeEngineeringFollowupSnapshot as followupSnapshot, checkEngineeringFollowupRecordCapacity, } from "./engineering-followup-records.js";
19
+ import { engineeringFollowupNow, engineeringFollowupVersionsCurrent, } from "./engineering-followup-source.js";
20
+ import { assertResolutionLearningRecord, resolveResolutionLearningOptions, } from "./resolution-learning-records.js";
21
+ import { createResolutionLearningServices, createResolutionLearningSource, planResolutionLearningTicketMutation, } from "./resolution-learning.js";
22
+ export { resolutionLearningDeliveryBinding, resolutionLearningManifestHash, } from "./resolution-learning-worker.js";
23
+ export { engineeringFollowupModelManifestHash, engineeringFollowupContextHash, engineeringFollowupPublicFactsHash, engineeringFollowupChecksBinding, engineeringFollowupRetestRequestId, engineeringFollowupRetestProductBinding, engineeringFollowupRetestRequestHash, engineeringFollowupRetestSummary, engineeringFollowupModelAttemptCount, } from "./engineering-followup-records.js";
24
+ export { engineeringPolicyRecords, engineeringPolicyAudit, engineeringPolicyAuditActions, createEngineeringPolicyServices, } from "./engineering-policy.js";
25
+ export { engineeringModelManifestHash, engineeringRoutingBinding, engineeringExportChecksBinding, engineeringRoutingBindingHash, engineeringStaffSelectionHash, } from "./engineering-work.js";
7
26
  export const supportPermissions = Object.freeze({
8
27
  create: "support.ticket.create",
9
28
  readOwn: "support.ticket.read.own",
@@ -14,6 +33,32 @@ export const supportPermissions = Object.freeze({
14
33
  assign: "support.ticket.assign",
15
34
  manage: "support.ticket.manage",
16
35
  auditRead: "support.audit.read",
36
+ assistantStart: "support.ticket.assistant.start",
37
+ assistantRead: "support.ticket.assistant.read",
38
+ assistantReply: "support.ticket.assistant.reply",
39
+ assistantEscalate: "support.ticket.assistant.escalate",
40
+ requestPerson: "support.ticket.request_person.own",
41
+ takeOver: "support.ticket.take_over",
42
+ confirmSuccess: "support.ticket.confirm_success.own",
43
+ learningRead: "support.learning.read",
44
+ learningEvidenceSubmit: "support.learning.evidence.submit",
45
+ learningProcess: "support.learning.process",
46
+ learningReview: "support.learning.review",
47
+ learningWithdraw: "support.learning.withdraw",
48
+ engineeringRead: "support.engineering.read",
49
+ engineeringEvidenceSubmit: "support.engineering.evidence.submit",
50
+ engineeringProcess: "support.engineering.process",
51
+ engineeringReview: "support.engineering.review",
52
+ engineeringWithdraw: "support.engineering.withdraw",
53
+ engineeringRestrictedRead: "support.engineering.restricted.read",
54
+ engineeringSecurityReview: "support.engineering.security.review",
55
+ engineeringReconcile: "support.engineering.reconcile",
56
+ engineeringPolicyRead: "support.engineering.policy.read",
57
+ engineeringPolicyManage: "support.engineering.policy.manage",
58
+ engineeringFollowupRead: "support.engineering.followup.read",
59
+ engineeringFollowupProcess: "support.engineering.followup.process",
60
+ engineeringFollowupReview: "support.engineering.followup.review",
61
+ engineeringFollowupEvidenceSubmit: "support.engineering.followup.evidence.submit",
17
62
  });
18
63
  export const defaultSupportDeskLimits = Object.freeze({
19
64
  maxSubjectCharacters: 200,
@@ -42,6 +87,17 @@ export class SupportDeskConflictError extends Error {
42
87
  this.name = "SupportDeskConflictError";
43
88
  }
44
89
  }
90
+ /** Content-safe collision hint, available only after staff authorization. */
91
+ export class SupportDeskStaleRevisionError extends SupportDeskConflictError {
92
+ currentRevision;
93
+ updatedAt;
94
+ constructor(currentRevision, updatedAt) {
95
+ super("The ticket changed since it was loaded");
96
+ this.currentRevision = currentRevision;
97
+ this.updatedAt = updatedAt;
98
+ this.name = "SupportDeskStaleRevisionError";
99
+ }
100
+ }
45
101
  export class SupportDeskLimitError extends Error {
46
102
  field;
47
103
  maximum;
@@ -79,10 +135,65 @@ export const supportTicketAuditActions = Object.freeze({
79
135
  assigned: "support.ticket.assigned",
80
136
  unassigned: "support.ticket.unassigned",
81
137
  priorityChanged: "support.ticket.priority_changed",
138
+ tagsChanged: "support.ticket.tags_changed",
82
139
  resolved: "support.ticket.resolved",
83
140
  closed: "support.ticket.closed",
84
141
  reopened: "support.ticket.reopened",
142
+ assistantReplied: "support.ticket.assistant_replied",
143
+ humanRequested: "support.ticket.human_requested",
144
+ humanEscalated: "support.ticket.human_escalated",
145
+ humanTakenOver: "support.ticket.human_taken_over",
146
+ customerSuccessConfirmed: "support.ticket.customer_success_confirmed",
147
+ learningEnrolled: "support.learning.enrolled",
148
+ learningEvidenceRecorded: "support.learning.evidence_recorded",
149
+ learningEvidenceInvalidated: "support.learning.evidence_invalidated",
150
+ learningWithdrawn: "support.learning.withdrawn",
151
+ learningReviewed: "support.learning.reviewed",
152
+ engineeringEnrolled: "support.engineering.enrolled",
153
+ engineeringEvidenceRecorded: "support.engineering.evidence_recorded",
154
+ engineeringEvidenceInvalidated: "support.engineering.evidence_invalidated",
155
+ engineeringWithdrawn: "support.engineering.withdrawn",
156
+ engineeringRepositorySelected: "support.engineering.repository_selected",
157
+ engineeringProposalPrepared: "support.engineering.proposal_prepared",
158
+ engineeringReviewed: "support.engineering.reviewed",
159
+ engineeringDispatchStarted: "support.engineering.dispatch_started",
160
+ engineeringExportRecorded: "support.engineering.export_recorded",
161
+ engineeringReconciled: "support.engineering.reconciled",
162
+ engineeringFollowupObserved: "support.engineering.followup.observed",
163
+ engineeringFollowupBundlePrepared: "support.engineering.followup.bundle_prepared",
164
+ engineeringFollowupReassessed: "support.engineering.followup.reassessed",
165
+ engineeringFollowupRetried: "support.engineering.followup.retried",
166
+ engineeringFollowupAbandoned: "support.engineering.followup.abandoned",
167
+ engineeringFollowupRejected: "support.engineering.followup.rejected",
168
+ engineeringFollowupNoticePosted: "support.engineering.followup.notice_posted",
169
+ engineeringFollowupRetestRecorded: "support.engineering.followup.retest_recorded",
85
170
  });
171
+ function sameInboundReceiptRoute(left, right) {
172
+ if (left?.kind !== right.kind) {
173
+ return false;
174
+ }
175
+ switch (right.kind) {
176
+ case "suppressed":
177
+ return left.kind === "suppressed" && left.reason === right.reason;
178
+ case "duplicate_message":
179
+ return (left.kind === "duplicate_message" &&
180
+ left.ticketId === right.ticketId &&
181
+ left.messageId === right.messageId);
182
+ case "reply":
183
+ return (left.kind === "reply" &&
184
+ left.ticketId === right.ticketId &&
185
+ left.matchMethod === right.matchMethod);
186
+ case "new_conversation":
187
+ return (left.kind === "new_conversation" &&
188
+ left.ticketId === right.ticketId &&
189
+ left.matchMethod === right.matchMethod &&
190
+ left.association === right.association &&
191
+ left.principalId === right.principalId &&
192
+ left.priority === right.priority &&
193
+ left.category === right.category &&
194
+ left.relatedTicketId === right.relatedTicketId);
195
+ }
196
+ }
86
197
  export const deliveryCallbackDedupeDays = 30;
87
198
  export const maxDeliveryCallbacksPerBucket = 256;
88
199
  export const inboundReceiptDedupeDays = 30;
@@ -97,6 +208,36 @@ function jsonCodec(key) {
97
208
  return {
98
209
  encode(value) {
99
210
  const location = key(value);
211
+ if ([
212
+ "engineering_followup_head",
213
+ "engineering_followup_work",
214
+ "engineering_followup_receipt",
215
+ ].includes(value.kind ?? "") ||
216
+ location.id === "engineering-followup-head" ||
217
+ location.id.startsWith("engineering-followup:") ||
218
+ location.id.startsWith("engineering-followup-command:"))
219
+ assertEngineeringFollowupRecord(value, location);
220
+ if ([
221
+ "engineering_source",
222
+ "engineering_work",
223
+ "engineering_receipt",
224
+ ].includes(value.kind ?? "") ||
225
+ location.id === "engineering-source" ||
226
+ location.id.startsWith("engineering-work:") ||
227
+ location.id.startsWith("engineering-command:"))
228
+ assertEngineeringRecord(value, location);
229
+ if ([
230
+ "resolution_learning_source",
231
+ "resolution_learning_work",
232
+ "resolution_learning_review",
233
+ ].includes(value.kind ?? "") ||
234
+ location.id === "learning-source" ||
235
+ location.id.startsWith("resolution-learning:") ||
236
+ location.id.startsWith("learning-review:"))
237
+ assertResolutionLearningRecord(value, location);
238
+ if (value.kind === "assistant_turn" ||
239
+ location.id.startsWith("assistant-turn:"))
240
+ assertAssistantTurnRecord(value, location);
100
241
  return {
101
242
  partition: location.partition,
102
243
  id: location.id,
@@ -107,7 +248,70 @@ function jsonCodec(key) {
107
248
  if (typeof record.payload !== "string") {
108
249
  throw new TypeError("stored Support Desk record has no JSON payload");
109
250
  }
110
- return JSON.parse(record.payload);
251
+ const decoded = JSON.parse(record.payload);
252
+ if ([
253
+ "engineering_followup_head",
254
+ "engineering_followup_work",
255
+ "engineering_followup_receipt",
256
+ ].includes(decoded?.kind ?? "") ||
257
+ record.id === "engineering-followup-head" ||
258
+ (typeof record.id === "string" &&
259
+ (record.id.startsWith("engineering-followup:") ||
260
+ record.id.startsWith("engineering-followup-command:")))) {
261
+ if (typeof record.partition !== "string" ||
262
+ typeof record.id !== "string")
263
+ throw new TypeError("Follow-up record has invalid physical envelope");
264
+ assertEngineeringFollowupRecord(decoded, {
265
+ partition: record.partition,
266
+ id: record.id,
267
+ });
268
+ }
269
+ if ([
270
+ "engineering_source",
271
+ "engineering_work",
272
+ "engineering_receipt",
273
+ ].includes(decoded?.kind ?? "") ||
274
+ record.id === "engineering-source" ||
275
+ (typeof record.id === "string" &&
276
+ (record.id.startsWith("engineering-work:") ||
277
+ record.id.startsWith("engineering-command:")))) {
278
+ if (typeof record.partition !== "string" ||
279
+ typeof record.id !== "string")
280
+ throw new TypeError("Engineering record has invalid physical envelope");
281
+ assertEngineeringRecord(decoded, {
282
+ partition: record.partition,
283
+ id: record.id,
284
+ });
285
+ }
286
+ if ([
287
+ "resolution_learning_source",
288
+ "resolution_learning_work",
289
+ "resolution_learning_review",
290
+ ].includes(decoded?.kind ?? "") ||
291
+ record.id === "learning-source" ||
292
+ (typeof record.id === "string" &&
293
+ (record.id.startsWith("resolution-learning:") ||
294
+ record.id.startsWith("learning-review:")))) {
295
+ if (typeof record.partition !== "string" ||
296
+ typeof record.id !== "string")
297
+ throw new TypeError("Learning record has invalid physical envelope");
298
+ assertResolutionLearningRecord(decoded, {
299
+ partition: record.partition,
300
+ id: record.id,
301
+ });
302
+ }
303
+ if (decoded?.kind === "assistant_turn" ||
304
+ (typeof record.id === "string" &&
305
+ record.id.startsWith("assistant-turn:"))) {
306
+ if (typeof record.partition !== "string" ||
307
+ typeof record.id !== "string")
308
+ throw new TypeError("stored assistant turn has no string partition/id envelope");
309
+ assertAssistantTurnRecord(decoded, {
310
+ partition: record.partition,
311
+ id: record.id,
312
+ });
313
+ }
314
+ return decoded;
111
315
  },
112
316
  };
113
317
  }
@@ -280,6 +484,7 @@ export const defaultQueueScanBudgets = Object.freeze({
280
484
  maxActiveResults: 1_000,
281
485
  scanPageSize: 100,
282
486
  });
487
+ const replyRoutingTokenPattern = /^[A-Za-z0-9_-]{22,128}$/;
283
488
  export const ticketNumberPartition = "instance";
284
489
  export const ticketNumberRecordId = "ticket-number";
285
490
  const maxNotificationVariables = 32;
@@ -341,9 +546,12 @@ function ticketReservationKey(ticketId) {
341
546
  function commandKey(ticketId, commandId) {
342
547
  return { partition: ticketId, id: `command:${commandId}` };
343
548
  }
344
- function ownDataProperty(source, key, field, optional = false) {
549
+ function ownDataProperty(source, key, field, optional = false, rejectInherited = false) {
345
550
  const descriptor = Object.getOwnPropertyDescriptor(source, key);
346
551
  if (descriptor === undefined) {
552
+ if (rejectInherited && Reflect.has(source, key)) {
553
+ throw new TypeError(`${field} must be an own data property`);
554
+ }
347
555
  if (optional) {
348
556
  return undefined;
349
557
  }
@@ -414,6 +622,12 @@ function snapshotNotification(input) {
414
622
  const subject = readString("subject");
415
623
  const outboundMessageId = readString("outboundMessageId");
416
624
  const maxAttempts = ownDataProperty(input, "maxAttempts", "notification.maxAttempts", true);
625
+ const replyRoutingToken = ownDataProperty(input, "replyRoutingToken", "notification.replyRoutingToken", true);
626
+ if (replyRoutingToken !== undefined &&
627
+ (typeof replyRoutingToken !== "string" ||
628
+ !replyRoutingTokenPattern.test(replyRoutingToken))) {
629
+ throw new TypeError("notification.replyRoutingToken must be 22 to 128 URL-safe base64 characters");
630
+ }
417
631
  requireIdentifier(id, "notification.id");
418
632
  requireIdentifier(recipientRef, "notification.recipientRef");
419
633
  requireIdentifier(templateId, "notification.templateId");
@@ -444,6 +658,9 @@ function snapshotNotification(input) {
444
658
  ...(maxAttempts === undefined
445
659
  ? {}
446
660
  : { maxAttempts: maxAttempts }),
661
+ ...(replyRoutingToken === undefined
662
+ ? {}
663
+ : { replyRoutingToken: replyRoutingToken }),
447
664
  });
448
665
  }
449
666
  function boundaryObject(input, field) {
@@ -462,16 +679,16 @@ function boundaryString(value, field) {
462
679
  const requesterEmailMaximumCharacters = 254;
463
680
  const requesterEmailLocalPart = /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/;
464
681
  const requesterEmailDomain = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
465
- function normalizeRequesterEmail(value) {
682
+ function normalizeRequesterEmail(value, field = "requesterEmail") {
466
683
  if (value === undefined) {
467
684
  return undefined;
468
685
  }
469
686
  if (typeof value !== "string") {
470
- throw new TypeError("requesterEmail must be a string");
687
+ throw new TypeError(`${field} must be a string`);
471
688
  }
472
689
  if (value.length > requesterEmailMaximumCharacters ||
473
690
  /[\u0000-\u001F\u007F<>&"\\]/.test(value)) {
474
- throw new TypeError(`requesterEmail must be a plain email address of at most ${requesterEmailMaximumCharacters} characters`);
691
+ throw new TypeError(`${field} must be a plain email address of at most ${requesterEmailMaximumCharacters} characters`);
475
692
  }
476
693
  const normalized = value.trim();
477
694
  const separator = normalized.indexOf("@");
@@ -488,7 +705,7 @@ function normalizeRequesterEmail(value) {
488
705
  !requesterEmailLocalPart.test(local) ||
489
706
  domain.length > 253 ||
490
707
  !requesterEmailDomain.test(domain)) {
491
- throw new TypeError(`requesterEmail must be a plain email address of at most ${requesterEmailMaximumCharacters} characters`);
708
+ throw new TypeError(`${field} must be a plain email address of at most ${requesterEmailMaximumCharacters} characters`);
492
709
  }
493
710
  return `${local}@${domain.toLowerCase()}`;
494
711
  }
@@ -504,6 +721,111 @@ function snapshotCategory(value, field) {
504
721
  }
505
722
  return value;
506
723
  }
724
+ function normalizedTag(value) {
725
+ if (typeof value !== "string" ||
726
+ value.length > 64 ||
727
+ /[\u0000-\u001F\u007F]/.test(value)) {
728
+ throw new TypeError("tag must be a bounded safe identifier");
729
+ }
730
+ const tag = value.trim().toLowerCase();
731
+ if (!categoryPattern.test(tag))
732
+ throw new TypeError("tag must match the category identifier discipline");
733
+ return tag;
734
+ }
735
+ function snapshotTags(input) {
736
+ if (!Array.isArray(input) || input.length > 32) {
737
+ throw new TypeError("tags must be an array of at most 32 values");
738
+ }
739
+ const tags = new Set();
740
+ for (let index = 0; index < input.length; index += 1) {
741
+ tags.add(normalizedTag(ownDataProperty(input, index, `tags[${index}]`)));
742
+ }
743
+ return Object.freeze([...tags].sort());
744
+ }
745
+ function snapshotRevisionHint(source) {
746
+ const value = ownDataProperty(source, "expectedRevision", "expectedRevision", true);
747
+ if (value === undefined)
748
+ return {};
749
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
750
+ throw new TypeError("expectedRevision must be a positive safe integer");
751
+ }
752
+ return { expectedRevision: value };
753
+ }
754
+ function requireExpectedRevision(ticket, command) {
755
+ if (command.expectedRevision !== undefined &&
756
+ ticket.revision !== command.expectedRevision) {
757
+ throw new SupportDeskStaleRevisionError(ticket.revision, ticket.updatedAt);
758
+ }
759
+ }
760
+ function parseResponseTargetPolicy(input) {
761
+ if (input === undefined)
762
+ return undefined;
763
+ const source = boundaryObject(input, "responseTargetPolicy");
764
+ const method = ownDataProperty(source, "computeTargets", "responseTargetPolicy.computeTargets");
765
+ if (typeof method !== "function")
766
+ throw new TypeError("responseTargetPolicy must provide computeTargets()");
767
+ return Object.freeze({
768
+ computeTargets: (context) => method.call(source, context),
769
+ });
770
+ }
771
+ function staffLifecycleFields(ticket) {
772
+ return {
773
+ ...(ticket.tags === undefined
774
+ ? {}
775
+ : { tags: Object.freeze([...ticket.tags]) }),
776
+ ...(ticket.firstResponseDueAt === undefined
777
+ ? {}
778
+ : { firstResponseDueAt: ticket.firstResponseDueAt }),
779
+ ...(ticket.resolutionDueAt === undefined
780
+ ? {}
781
+ : { resolutionDueAt: ticket.resolutionDueAt }),
782
+ ...(ticket.firstStaffRespondedAt === undefined
783
+ ? {}
784
+ : { firstStaffRespondedAt: ticket.firstStaffRespondedAt }),
785
+ ...(ticket.resolvedAt === undefined
786
+ ? {}
787
+ : { resolvedAt: ticket.resolvedAt }),
788
+ };
789
+ }
790
+ async function applyResponseTargets(ticket, policy, reason) {
791
+ if (policy === undefined)
792
+ return ticket;
793
+ const context = Object.freeze({
794
+ reason,
795
+ ticketId: ticket.id,
796
+ revision: ticket.revision,
797
+ priority: ticket.priority,
798
+ channel: ticket.channel,
799
+ createdAt: ticket.createdAt,
800
+ occurredAt: ticket.updatedAt,
801
+ ...(ticket.category === undefined ? {} : { category: ticket.category }),
802
+ ...(ticket.firstResponseDueAt === undefined
803
+ ? {}
804
+ : { firstResponseDueAt: ticket.firstResponseDueAt }),
805
+ ...(ticket.resolutionDueAt === undefined
806
+ ? {}
807
+ : { resolutionDueAt: ticket.resolutionDueAt }),
808
+ ...(ticket.firstStaffRespondedAt === undefined
809
+ ? {}
810
+ : { firstStaffRespondedAt: ticket.firstStaffRespondedAt }),
811
+ ...(ticket.resolvedAt === undefined
812
+ ? {}
813
+ : { resolvedAt: ticket.resolvedAt }),
814
+ });
815
+ const result = boundaryObject(await policy.computeTargets(context), "response targets");
816
+ const targets = {};
817
+ for (const field of ["firstResponseDueAt", "resolutionDueAt"]) {
818
+ const value = ownDataProperty(result, field, `response targets.${field}`, true);
819
+ if (value !== undefined) {
820
+ if (typeof value !== "string")
821
+ throw new TypeError("response target must be an ISO timestamp");
822
+ canonicalTimestamp(value, `response targets.${field}`);
823
+ targets[field] = value;
824
+ }
825
+ }
826
+ const { firstResponseDueAt: _first, resolutionDueAt: _resolution, ...rest } = ticket;
827
+ return Object.freeze({ ...rest, ...targets });
828
+ }
507
829
  function snapshotCreateCustomerTicketCommand(input) {
508
830
  const source = boundaryObject(input, "create command");
509
831
  const raw = {
@@ -518,6 +840,9 @@ function snapshotCreateCustomerTicketCommand(input) {
518
840
  notification: ownDataProperty(source, "notification", "create command.notification", true),
519
841
  };
520
842
  const category = snapshotCategory(raw.category, "create command.category");
843
+ const initialHandling = ownDataProperty(source, "initialHandling", "create command.initialHandling", true);
844
+ if (initialHandling !== undefined && initialHandling !== "assistant")
845
+ throw new TypeError("initialHandling must be assistant when supplied");
521
846
  const requesterEmail = normalizeRequesterEmail(raw.requesterEmail);
522
847
  const notification = raw.notification === undefined
523
848
  ? undefined
@@ -532,6 +857,7 @@ function snapshotCreateCustomerTicketCommand(input) {
532
857
  ...(category === undefined ? {} : { category }),
533
858
  ...(requesterEmail === undefined ? {} : { requesterEmail }),
534
859
  ...(notification === undefined ? {} : { notification }),
860
+ ...(initialHandling === undefined ? {} : { initialHandling }),
535
861
  });
536
862
  }
537
863
  function snapshotReplyToCustomerTicketCommand(input) {
@@ -577,6 +903,9 @@ function deliveryContent(input) {
577
903
  variables: input.variables,
578
904
  subject: input.subject,
579
905
  outboundMessageId: input.outboundMessageId,
906
+ ...(input.replyRoutingToken === undefined
907
+ ? {}
908
+ : { replyRoutingToken: input.replyRoutingToken }),
580
909
  };
581
910
  }
582
911
  function bindNotificationTicketNumber(input, ticketNumber) {
@@ -640,6 +969,9 @@ function toCustomerTicketSummary(ticket) {
640
969
  channel: ticket.channel,
641
970
  createdAt: ticket.createdAt,
642
971
  customerUpdatedAt,
972
+ ...(ticket.handling === undefined
973
+ ? {}
974
+ : { handling: ticket.handling.state }),
643
975
  });
644
976
  }
645
977
  async function authoritativeView(records, principalId, ticketId, maxRecordsPerTicket) {
@@ -661,19 +993,19 @@ async function authoritativeView(records, principalId, ticketId, maxRecordsPerTi
661
993
  * Validate and copy host-supplied category allowlist into a private Set.
662
994
  * The host array is not retained; the Set is application-owned state.
663
995
  */
664
- function parseAllowedCategories(input) {
996
+ function parseAllowedCategories(input, name = "application options.allowedCategories") {
665
997
  if (input === undefined) {
666
998
  return undefined;
667
999
  }
668
1000
  if (!Array.isArray(input)) {
669
- throw new TypeError("application options.allowedCategories must be an array");
1001
+ throw new TypeError(`${name} must be an array`);
670
1002
  }
671
1003
  if (input.length > maxAllowedCategories) {
672
- throw new TypeError(`application options.allowedCategories may contain at most ${maxAllowedCategories} values`);
1004
+ throw new TypeError(`${name} may contain at most ${maxAllowedCategories} values`);
673
1005
  }
674
1006
  const values = new Set();
675
1007
  for (let index = 0; index < input.length; index += 1) {
676
- const field = `application options.allowedCategories[${index}]`;
1008
+ const field = `${name}[${index}]`;
677
1009
  const value = ownDataProperty(input, index, field);
678
1010
  if (typeof value !== "string") {
679
1011
  throw new TypeError(`${field} must be a string`);
@@ -682,7 +1014,7 @@ function parseAllowedCategories(input) {
682
1014
  throw new TypeError(`${field} must match ${categoryPattern.source} and contain no controls`);
683
1015
  }
684
1016
  if (values.has(value)) {
685
- throw new TypeError("application options.allowedCategories must not contain duplicates");
1017
+ throw new TypeError(`${name} must not contain duplicates`);
686
1018
  }
687
1019
  values.add(value);
688
1020
  }
@@ -694,6 +1026,18 @@ function duplicateMatches(existing, commandType, messageId, requestFingerprint)
694
1026
  existing.messageId === messageId &&
695
1027
  existing.requestFingerprint === requestFingerprint);
696
1028
  }
1029
+ function inboundReplyRerouteFenceMatches(existing, commandId, messageId, requestFingerprint) {
1030
+ return (existing?.kind === "inbound_reply_reroute_fence" &&
1031
+ existing.commandId === commandId &&
1032
+ existing.messageId === messageId &&
1033
+ existing.requestFingerprint === requestFingerprint);
1034
+ }
1035
+ function inboundCreateRerouteFenceMatches(existing, commandId, messageId, requestFingerprint) {
1036
+ return (existing?.kind === "inbound_create_reroute_fence" &&
1037
+ existing.commandId === commandId &&
1038
+ existing.messageId === messageId &&
1039
+ existing.requestFingerprint === requestFingerprint);
1040
+ }
697
1041
  const TICKET_PRIORITIES = [
698
1042
  "low",
699
1043
  "normal",
@@ -721,6 +1065,197 @@ function enforcePartitionRecordLimit(recordCount, maximum, additional = 0) {
721
1065
  throw new SupportDeskLimitError("ticket_partition", maximum);
722
1066
  }
723
1067
  }
1068
+ export const assistantHumanReserveRows = 12;
1069
+ export const assistantHumanReserveMessages = 1;
1070
+ const firstHumanReplyReserveRows = 4;
1071
+ /** Settle unused speculative failures without changing their consumer order. */
1072
+ async function prepareRead(read) {
1073
+ try {
1074
+ return { outcome: "read", value: await read() };
1075
+ }
1076
+ catch (error) {
1077
+ return { outcome: "failed", error };
1078
+ }
1079
+ }
1080
+ function readValue(observation) {
1081
+ if (observation.outcome === "failed")
1082
+ throw observation.error;
1083
+ return observation.value;
1084
+ }
1085
+ async function planTicketDerivationMutation(records, before, after, rows, visibleChange = false, notice, parallel = false) {
1086
+ const learningRead = planResolutionLearningTicketMutation(records, before, after, notice !== undefined && visibleChange);
1087
+ const engineeringRead = parallel && notice === undefined
1088
+ ? prepareRead(() => planEngineeringTicketMutation(records, before, after, {
1089
+ visibleChange,
1090
+ }))
1091
+ : undefined;
1092
+ const learning = await learningRead;
1093
+ const engineering = notice === undefined
1094
+ ? engineeringRead === undefined
1095
+ ? await planEngineeringTicketMutation(records, before, after, {
1096
+ visibleChange,
1097
+ })
1098
+ : readValue(await engineeringRead)
1099
+ : { actions: [], additionalRows: 0 };
1100
+ const reopened = ["resolved", "closed"].includes(before.value.ticket.status) &&
1101
+ !["resolved", "closed"].includes(after.status);
1102
+ const followup = visibleChange || reopened
1103
+ ? await planEngineeringFollowupTicketMutation(records, rows, after.id, notice !== undefined
1104
+ ? "approved_followup_notice"
1105
+ : reopened
1106
+ ? "reopened"
1107
+ : "visible_source_changed", notice?.completedFollowupWorkId)
1108
+ : { actions: [], nextWorkRows: [] };
1109
+ return {
1110
+ ...learning,
1111
+ actions: [...learning.actions, ...engineering.actions, ...followup.actions],
1112
+ additionalRows: learning.additionalRows + engineering.additionalRows,
1113
+ nextWorkRows: followup.nextWorkRows,
1114
+ };
1115
+ }
1116
+ /** Project reserved work transitions without inventing another quota counter. */
1117
+ function projectFollowupRows(rows, nextWorkRows) {
1118
+ if (nextWorkRows.length > 8)
1119
+ throw new SupportDeskConflictError("Follow-up transition bound exceeded");
1120
+ const updates = new Map(nextWorkRows.map((row) => [row.id, row]));
1121
+ if (updates.size !== nextWorkRows.length)
1122
+ throw new SupportDeskConflictError("Duplicate follow-up transition");
1123
+ const projected = rows.map((row) => {
1124
+ if (row.kind !== "engineering_followup_work")
1125
+ return row;
1126
+ const next = updates.get(row.id);
1127
+ updates.delete(row.id);
1128
+ return next ?? row;
1129
+ });
1130
+ return [...projected, ...updates.values()];
1131
+ }
1132
+ function followupMessageSlots(rows) {
1133
+ return rows.reduce((sum, row) => sum + (row.kind === "engineering_followup_work" ? row.messageSlots : 0), 0);
1134
+ }
1135
+ function humanReserveRows(rows) {
1136
+ return (assistantHumanReserveRows +
1137
+ (rows.some((row) => row.kind === "resolution_learning_source") ? 1 : 0));
1138
+ }
1139
+ function derivationReserveRows(rows, consuming = false) {
1140
+ const learning = consuming !== "learning" &&
1141
+ rows.some((row) => row.kind === "resolution_learning_source" && row.state !== "withdrawn")
1142
+ ? 2
1143
+ : 0;
1144
+ const engineering = consuming !== "engineering" &&
1145
+ rows.some((row) => row.kind === "engineering_source" && row.state !== "withdrawn")
1146
+ ? 2
1147
+ : 0;
1148
+ const terminalWorkId = typeof consuming === "object" ? consuming.engineeringTerminalWorkId : null;
1149
+ const terminal = rows.reduce((sum, row) => sum +
1150
+ (row.kind === "engineering_work" && row.id !== terminalWorkId
1151
+ ? row.terminalReserveRows
1152
+ : 0), 0);
1153
+ const followup = rows.reduce((sum, row) => sum + (row.kind === "engineering_followup_work" ? row.completionRows : 0), 0);
1154
+ return learning + engineering + terminal + followup;
1155
+ }
1156
+ /** Reserve is opt-in metadata on the existing quota, never a second counter. */
1157
+ function enforceManagedAdmission(ticket, rows, limits, additionalRows, addsMessage = false, firstHumanReply = false, consumesWithdrawalReserve = false) {
1158
+ const quota = rows.find((row) => row.kind === "ticket_quota");
1159
+ const reserved = quota?.assistantManaged === true &&
1160
+ ticket.firstStaffRespondedAt === undefined &&
1161
+ !firstHumanReply;
1162
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket -
1163
+ (reserved ? humanReserveRows(rows) : 0) -
1164
+ derivationReserveRows(rows, consumesWithdrawalReserve), additionalRows);
1165
+ if (addsMessage &&
1166
+ quota !== undefined &&
1167
+ quota.messageCount >=
1168
+ limits.maxMessagesPerTicket -
1169
+ (reserved ? assistantHumanReserveMessages : 0) -
1170
+ followupMessageSlots(rows))
1171
+ throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
1172
+ }
1173
+ function latestVisibleRecord(rows) {
1174
+ // Reuse the ordinal validation that protects ordinary customer views.
1175
+ customerMessages(rows);
1176
+ return rows
1177
+ .filter((row) => row.kind === "message" && row.message.visibility === "customer")
1178
+ .sort((a, b) => b.ordinal - a.ordinal)[0];
1179
+ }
1180
+ function enforceHandlingControlAdmission(ticket, rows, limits, additionalRows, consumesWithdrawalReserve = false) {
1181
+ const quota = rows.find((row) => row.kind === "ticket_quota");
1182
+ const reserved = quota?.assistantManaged === true &&
1183
+ ticket.firstStaffRespondedAt === undefined;
1184
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket -
1185
+ (reserved ? firstHumanReplyReserveRows : 0) -
1186
+ derivationReserveRows(rows, consumesWithdrawalReserve), additionalRows);
1187
+ if (reserved && quota.messageCount >= limits.maxMessagesPerTicket)
1188
+ throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
1189
+ }
1190
+ function handlingInput(input, fields, optional = []) {
1191
+ const source = boundaryObject(input, "handling command");
1192
+ if (Object.getOwnPropertySymbols(source).length !== 0)
1193
+ throw new TypeError("Handling commands must contain data fields");
1194
+ const allowed = [
1195
+ "commandId",
1196
+ "correlationId",
1197
+ "ticketId",
1198
+ ...fields,
1199
+ ...optional,
1200
+ ];
1201
+ if (Object.getOwnPropertyNames(source).some((key) => !allowed.includes(key)))
1202
+ throw new TypeError("Handling command contains unsupported fields");
1203
+ const result = {};
1204
+ for (const field of allowed) {
1205
+ const value = ownDataProperty(source, field, "handling command." + field, optional.includes(field));
1206
+ if (value !== undefined)
1207
+ result[field] = value;
1208
+ }
1209
+ for (const field of ["commandId", "correlationId", "ticketId"]) {
1210
+ const value = boundaryString(result[field], field);
1211
+ requireIdentifier(value, field);
1212
+ result[field] = value;
1213
+ }
1214
+ return result;
1215
+ }
1216
+ function handlingFence(input) {
1217
+ for (const field of ["expectedRevision", "expectedHandlingGeneration"]) {
1218
+ if (typeof input[field] !== "number" ||
1219
+ !Number.isSafeInteger(input[field]) ||
1220
+ input[field] < (field === "expectedRevision" ? 1 : 0))
1221
+ throw new TypeError("Handling fence must be a safe integer");
1222
+ }
1223
+ return input;
1224
+ }
1225
+ function requireAssistantTurn(ticket, rows, command) {
1226
+ const handling = effectiveTicketHandling(ticket);
1227
+ const latest = latestVisibleRecord(rows);
1228
+ if (ticket.channel !== "web" ||
1229
+ isTerminalTicketStatus(ticket.status) ||
1230
+ handling.state !== "assistant" ||
1231
+ ticket.revision !== command.expectedRevision ||
1232
+ handling.generation !== command.expectedHandlingGeneration ||
1233
+ latest?.message.authorKind !== "customer" ||
1234
+ latest.message.id !== command.triggerMessageId)
1235
+ throw new SupportDeskConflictError("Assistant turn changed or is no longer available");
1236
+ }
1237
+ function handoffWork(input) {
1238
+ const work = {
1239
+ kind: "handoff_work",
1240
+ partition: input.ticket.id,
1241
+ id: "handoff:" + input.commandId,
1242
+ status: "pending",
1243
+ reason: input.reason,
1244
+ ...(input.summary === undefined ? {} : { summary: input.summary }),
1245
+ attemptedGuideIds: input.attemptedGuideIds ?? [],
1246
+ transcript: {
1247
+ ticketId: input.ticket.id,
1248
+ throughRevision: input.ticket.revision,
1249
+ lastVisibleMessageId: input.lastVisibleMessageId,
1250
+ },
1251
+ handlingGeneration: effectiveTicketHandling(input.ticket).generation,
1252
+ createdAt: input.occurredAt,
1253
+ };
1254
+ // Support's existing codec stores one JSON string property, unlike KB chunks.
1255
+ if (JSON.stringify(work).length * 2 > 60_000)
1256
+ throw new SupportDeskLimitError("serialized_record", 60_000);
1257
+ return work;
1258
+ }
724
1259
  async function staffAuthoritativeView(records, ticketId, maxRecordsPerTicket) {
725
1260
  const all = await records.list(ticketId);
726
1261
  enforcePartitionRecordLimit(all.length, maxRecordsPerTicket);
@@ -757,6 +1292,7 @@ function snapshotStaffReplyCommand(input) {
757
1292
  ? undefined
758
1293
  : snapshotNotification(raw.notification);
759
1294
  return Object.freeze({
1295
+ ...snapshotRevisionHint(source),
760
1296
  commandId: boundaryString(raw.commandId, "staff reply command.commandId"),
761
1297
  correlationId: boundaryString(raw.correlationId, "staff reply command.correlationId"),
762
1298
  ticketId: boundaryString(raw.ticketId, "staff reply command.ticketId"),
@@ -775,6 +1311,7 @@ function snapshotAddNoteCommand(input) {
775
1311
  body: ownDataProperty(source, "body", "add note command.body"),
776
1312
  };
777
1313
  return Object.freeze({
1314
+ ...snapshotRevisionHint(source),
778
1315
  commandId: boundaryString(raw.commandId, "add note command.commandId"),
779
1316
  correlationId: boundaryString(raw.correlationId, "add note command.correlationId"),
780
1317
  ticketId: boundaryString(raw.ticketId, "add note command.ticketId"),
@@ -813,6 +1350,7 @@ function snapshotAssignTicketCommand(input) {
813
1350
  }
814
1351
  }
815
1352
  return Object.freeze({
1353
+ ...snapshotRevisionHint(source),
816
1354
  commandId: boundaryString(raw.commandId, "assign command.commandId"),
817
1355
  correlationId: boundaryString(raw.correlationId, "assign command.correlationId"),
818
1356
  ticketId: boundaryString(raw.ticketId, "assign command.ticketId"),
@@ -837,6 +1375,7 @@ function snapshotChangePriorityCommand(input) {
837
1375
  throw new TypeError(`change priority command.priority must be one of: ${TICKET_PRIORITIES.join(", ")}`);
838
1376
  }
839
1377
  return Object.freeze({
1378
+ ...snapshotRevisionHint(source),
840
1379
  commandId: boundaryString(raw.commandId, "change priority command.commandId"),
841
1380
  correlationId: boundaryString(raw.correlationId, "change priority command.correlationId"),
842
1381
  ticketId: boundaryString(raw.ticketId, "change priority command.ticketId"),
@@ -870,6 +1409,7 @@ function snapshotLifecycleTicketCommand(input, field) {
870
1409
  }
871
1410
  }
872
1411
  return Object.freeze({
1412
+ ...snapshotRevisionHint(source),
873
1413
  commandId: boundaryString(raw.commandId, `${field}.commandId`),
874
1414
  correlationId: boundaryString(raw.correlationId, `${field}.correlationId`),
875
1415
  ticketId: boundaryString(raw.ticketId, `${field}.ticketId`),
@@ -910,6 +1450,7 @@ function ticketBeyondTerminalCutoff(ticket, now, terminalRetentionMilliseconds)
910
1450
  function deriveQueueIndexRecord(ticket) {
911
1451
  return {
912
1452
  ticketId: ticket.id,
1453
+ ...staffLifecycleFields(ticket),
913
1454
  id: "queue",
914
1455
  projectedRevision: ticket.revision,
915
1456
  state: isTerminalTicketStatus(ticket.status) ? "inactive" : "active",
@@ -922,6 +1463,9 @@ function deriveQueueIndexRecord(ticket) {
922
1463
  ? {}
923
1464
  : { assignedTo: ticket.assignedTo }),
924
1465
  updatedAt: ticket.updatedAt,
1466
+ ...(ticket.handling === undefined
1467
+ ? {}
1468
+ : { handling: { ...ticket.handling } }),
925
1469
  };
926
1470
  }
927
1471
  function queueIndexContentEqual(left, right) {
@@ -932,10 +1476,16 @@ function queueIndexContentEqual(left, right) {
932
1476
  left.status === right.status &&
933
1477
  left.priority === right.priority &&
934
1478
  left.category === right.category &&
1479
+ JSON.stringify(left.tags ?? []) === JSON.stringify(right.tags ?? []) &&
1480
+ left.firstResponseDueAt === right.firstResponseDueAt &&
1481
+ left.resolutionDueAt === right.resolutionDueAt &&
1482
+ left.firstStaffRespondedAt === right.firstStaffRespondedAt &&
1483
+ left.resolvedAt === right.resolvedAt &&
935
1484
  left.requesterAssociation === right.requesterAssociation &&
936
1485
  left.channel === right.channel &&
937
1486
  left.assignedTo === right.assignedTo &&
938
- left.updatedAt === right.updatedAt);
1487
+ left.updatedAt === right.updatedAt &&
1488
+ JSON.stringify(left.handling) === JSON.stringify(right.handling));
939
1489
  }
940
1490
  function queueProjectionKey(ticketId) {
941
1491
  return { partition: ticketId, id: "queue" };
@@ -971,41 +1521,8 @@ export async function projectTicketToQueue(ticketId, options) {
971
1521
  }
972
1522
  const records = store.collection(supportRecords);
973
1523
  const projections = store.collection(queueIndex);
974
- const ticketRecord = await records.get(ticketKey(ticketId));
975
- if (ticketRecord?.kind !== "ticket") {
976
- const existing = await projections.getVersioned(queueProjectionKey(ticketId));
977
- if (existing === null) {
978
- return "absent";
979
- }
980
- const removed = await projections.deleteIfUnchanged(queueProjectionKey(ticketId), existing.version);
981
- return removed ? "deleted" : "kept";
982
- }
983
- const ticket = ticketRecord.ticket;
984
- if (ticket.id !== ticketId) {
985
- throw new TypeError("stored ticket id does not match its partition");
986
- }
987
- // Sample the host clock only for terminal retention. Active tickets never
988
- // consult wall time, so post-commit projection does not consume sequence
989
- // clocks used by ticket mutations.
990
- if (isTerminalTicketStatus(ticket.status)) {
991
- const now = clock.now();
992
- if (ticketBeyondTerminalCutoff(ticket, now, terminalRetentionMilliseconds)) {
993
- const existing = await projections.getVersioned(queueProjectionKey(ticketId));
994
- if (existing === null) {
995
- return "absent";
996
- }
997
- // Only prune a projection that still matches this terminal revision.
998
- // A concurrent reopen can land a newer active row between the ticket
999
- // read and this delete; that row must be preserved for repair to see.
1000
- if (existing.value.projectedRevision !== ticket.revision) {
1001
- return "kept";
1002
- }
1003
- const removed = await projections.deleteIfUnchanged(queueProjectionKey(ticketId), existing.version);
1004
- return removed ? "deleted" : "kept";
1005
- }
1006
- }
1007
- const derived = deriveQueueIndexRecord(ticket);
1008
- const result = await projections.update(queueProjectionKey(ticketId), (current) => {
1524
+ const key = queueProjectionKey(ticketId);
1525
+ function decide(current, derived) {
1009
1526
  if (current === null) {
1010
1527
  return { action: "write", value: derived };
1011
1528
  }
@@ -1021,13 +1538,72 @@ export async function projectTicketToQueue(ticketId, options) {
1021
1538
  if (queueIndexContentEqual(current, derived)) {
1022
1539
  return { action: "keep" };
1023
1540
  }
1541
+ // Exact pre-SD-01 rows may already contain Task-13 fields but no handling.
1542
+ const { handling: _newHandling, ...beforeHandling } = derived;
1543
+ if (current.handling === undefined &&
1544
+ queueIndexContentEqual(current, beforeHandling))
1545
+ return { action: "write", value: derived };
1546
+ // Pre-Task-13 rows have none of these additive staff fields. Enrich
1547
+ // only that exact legacy shape from freshly loaded authoritative state.
1548
+ const { tags: _tags, firstResponseDueAt: _first, resolutionDueAt: _due, firstStaffRespondedAt: _responded, resolvedAt: _resolved, handling: _handling, ...legacy } = derived;
1549
+ if (queueIndexContentEqual(current, legacy))
1550
+ return { action: "write", value: derived };
1024
1551
  throw new SupportDeskConflictError("Queue projection revision collision with divergent content");
1025
- }, { maxAttempts: maxConflictAttempts });
1026
- return result.written ? "written" : "kept";
1552
+ }
1553
+ for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
1554
+ const ticketRead = records.get(ticketKey(ticketId));
1555
+ const projectionRead = prepareRead(() => projections.getVersioned(key));
1556
+ const ticketRecord = await ticketRead;
1557
+ if (ticketRecord?.kind !== "ticket") {
1558
+ const existing = readValue(await projectionRead);
1559
+ if (existing === null)
1560
+ return "absent";
1561
+ if (await projections.deleteIfUnchanged(key, existing.version))
1562
+ return "deleted";
1563
+ if (attempt + 1 === maxConflictAttempts)
1564
+ return "kept";
1565
+ continue;
1566
+ }
1567
+ const ticket = ticketRecord.ticket;
1568
+ if (ticket.id !== ticketId)
1569
+ throw new TypeError("stored ticket id does not match its partition");
1570
+ // Active tickets never consult wall time. Retry terminal retention only
1571
+ // with the new authoritative Ticket and projection observations together.
1572
+ if (isTerminalTicketStatus(ticket.status)) {
1573
+ const now = clock.now();
1574
+ if (ticketBeyondTerminalCutoff(ticket, now, terminalRetentionMilliseconds)) {
1575
+ const existing = readValue(await projectionRead);
1576
+ if (existing === null)
1577
+ return "absent";
1578
+ // A newer active projection from a concurrent reopen must survive.
1579
+ if (existing.value.projectedRevision !== ticket.revision)
1580
+ return "kept";
1581
+ if (await projections.deleteIfUnchanged(key, existing.version))
1582
+ return "deleted";
1583
+ if (attempt + 1 === maxConflictAttempts)
1584
+ return "kept";
1585
+ continue;
1586
+ }
1587
+ }
1588
+ const derived = deriveQueueIndexRecord(ticket);
1589
+ const existing = readValue(await projectionRead);
1590
+ const decision = decide(existing?.value ?? null, derived);
1591
+ if (decision.action !== "write")
1592
+ return "kept";
1593
+ const written = existing === null
1594
+ ? (await projections.insertIfAbsent(decision.value)).inserted
1595
+ : await projections.putIfUnchanged(decision.value, existing.version);
1596
+ if (written)
1597
+ return "written";
1598
+ // Rebuild both observations; never keep a stale Ticket across a CAS retry.
1599
+ }
1600
+ throw new ConcurrencyError(queueIndex.name, key, maxConflictAttempts);
1027
1601
  }
1028
1602
  function toStaffQueueItem(ticket) {
1029
1603
  return Object.freeze({
1604
+ handling: effectiveTicketHandling(ticket).state,
1030
1605
  ticketId: ticket.id,
1606
+ ...staffLifecycleFields(ticket),
1031
1607
  revision: ticket.revision,
1032
1608
  status: ticket.status,
1033
1609
  priority: ticket.priority,
@@ -1046,7 +1622,23 @@ function normalizeFilterList(value) {
1046
1622
  }
1047
1623
  return new Set(Array.isArray(value) ? value : [value]);
1048
1624
  }
1049
- function staffQueueItemMatches(item, query) {
1625
+ function staffQueueItemMatches(item, query, now) {
1626
+ const handling = query.handling ?? "human";
1627
+ if (handling !== "all" &&
1628
+ (handling === "assistant") !== (item.handling === "assistant"))
1629
+ return false;
1630
+ if (query.tag !== undefined && !(item.tags ?? []).includes(query.tag))
1631
+ return false;
1632
+ if (query.targetState !== undefined) {
1633
+ const due = pendingTargetDue(item, query.target);
1634
+ if (due === undefined || now === undefined)
1635
+ return false;
1636
+ const epoch = canonicalTimestamp(due, "ticket target");
1637
+ if (query.targetState === "overdue"
1638
+ ? epoch >= now
1639
+ : epoch < now || epoch - now > query.dueSoonMilliseconds)
1640
+ return false;
1641
+ }
1050
1642
  const statuses = normalizeFilterList(query.status);
1051
1643
  if (statuses !== undefined && !statuses.has(item.status)) {
1052
1644
  return false;
@@ -1073,6 +1665,15 @@ function staffQueueItemMatches(item, query) {
1073
1665
  }
1074
1666
  return true;
1075
1667
  }
1668
+ function pendingTargetDue(item, target) {
1669
+ return target === "first_response"
1670
+ ? item.firstStaffRespondedAt === undefined
1671
+ ? item.firstResponseDueAt
1672
+ : undefined
1673
+ : item.resolvedAt === undefined
1674
+ ? item.resolutionDueAt
1675
+ : undefined;
1676
+ }
1076
1677
  const QUEUE_FILTER_STATUSES = [
1077
1678
  "open",
1078
1679
  "waiting_on_support",
@@ -1121,6 +1722,12 @@ function snapshotStaffQueueQuery(input) {
1121
1722
  }
1122
1723
  const source = boundaryObject(input, "staff queue query");
1123
1724
  const readOptional = (key) => ownDataProperty(source, key, `staff queue query.${key}`, true);
1725
+ const handling = readOptional("handling");
1726
+ if (handling !== undefined &&
1727
+ handling !== "human" &&
1728
+ handling !== "assistant" &&
1729
+ handling !== "all")
1730
+ throw new TypeError("invalid queue handling view");
1124
1731
  const status = readOptional("status");
1125
1732
  const priority = readOptional("priority");
1126
1733
  const association = readOptional("association");
@@ -1128,6 +1735,29 @@ function snapshotStaffQueueQuery(input) {
1128
1735
  const assignedTo = readOptional("assignedTo");
1129
1736
  const unassignedOnly = readOptional("unassignedOnly");
1130
1737
  const sort = readOptional("sort");
1738
+ const rawTag = readOptional("tag");
1739
+ const tag = rawTag === undefined ? undefined : normalizedTag(rawTag);
1740
+ const target = readOptional("target");
1741
+ const targetState = readOptional("targetState");
1742
+ const dueSoonMilliseconds = readOptional("dueSoonMilliseconds");
1743
+ if (target !== undefined &&
1744
+ target !== "first_response" &&
1745
+ target !== "resolution")
1746
+ throw new TypeError("invalid queue target");
1747
+ if (targetState !== undefined &&
1748
+ targetState !== "overdue" &&
1749
+ targetState !== "due_soon")
1750
+ throw new TypeError("invalid queue targetState");
1751
+ if (dueSoonMilliseconds !== undefined &&
1752
+ (typeof dueSoonMilliseconds !== "number" ||
1753
+ !Number.isSafeInteger(dueSoonMilliseconds) ||
1754
+ dueSoonMilliseconds < 0 ||
1755
+ dueSoonMilliseconds > 30 * 86_400_000))
1756
+ throw new TypeError("dueSoonMilliseconds must be between 0 and 30 days");
1757
+ if (targetState === "due_soon" && dueSoonMilliseconds === undefined)
1758
+ throw new TypeError("due_soon requires dueSoonMilliseconds");
1759
+ if (targetState !== "due_soon" && dueSoonMilliseconds !== undefined)
1760
+ throw new TypeError("dueSoonMilliseconds requires targetState due_soon");
1131
1761
  if (assignedTo !== undefined) {
1132
1762
  if (typeof assignedTo !== "string") {
1133
1763
  throw new TypeError("staff queue query.assignedTo must be a string");
@@ -1139,10 +1769,20 @@ function snapshotStaffQueueQuery(input) {
1139
1769
  }
1140
1770
  if (sort !== undefined &&
1141
1771
  sort !== "updated_newest" &&
1142
- sort !== "updated_oldest") {
1143
- throw new TypeError('staff queue query.sort must be "updated_newest" or "updated_oldest"');
1772
+ sort !== "updated_oldest" &&
1773
+ sort !== "due_soonest" &&
1774
+ sort !== "due_latest") {
1775
+ throw new TypeError("invalid staff queue query.sort");
1144
1776
  }
1145
- const query = {};
1777
+ const query = {
1778
+ ...(handling === undefined ? {} : { handling }),
1779
+ ...(tag === undefined ? {} : { tag }),
1780
+ ...(target === undefined ? {} : { target }),
1781
+ ...(targetState === undefined ? {} : { targetState }),
1782
+ ...(dueSoonMilliseconds === undefined
1783
+ ? {}
1784
+ : { dueSoonMilliseconds: dueSoonMilliseconds }),
1785
+ };
1146
1786
  if (status !== undefined) {
1147
1787
  query.status =
1148
1788
  snapshotEnumOrEnumArray(status, "staff queue query.status", QUEUE_FILTER_STATUSES);
@@ -1293,36 +1933,100 @@ export async function sweepInactiveQueueProjections(options) {
1293
1933
  deleted,
1294
1934
  };
1295
1935
  }
1296
- export function createSupportDeskApplication(options) {
1297
- const source = boundaryObject(options, "application options");
1298
- const store = ownDataProperty(source, "store", "application options.store");
1299
- const clock = ownDataProperty(source, "clock", "application options.clock");
1300
- const rawLogger = ownDataProperty(source, "logger", "application options.logger", true);
1301
- const logger = rawLogger === undefined ? noopLogger : rawLogger;
1302
- const rawLimits = ownDataProperty(source, "limits", "application options.limits", true);
1303
- const rawMaxConflictAttempts = ownDataProperty(source, "maxConflictAttempts", "application options.maxConflictAttempts", true);
1304
- const rawTerminalRetention = ownDataProperty(source, "queueTerminalRetentionMilliseconds", "application options.queueTerminalRetentionMilliseconds", true);
1305
- const rawQueueBudgets = ownDataProperty(source, "queueScanBudgets", "application options.queueScanBudgets", true);
1306
- const allowedCategories = parseAllowedCategories(ownDataProperty(source, "allowedCategories", "application options.allowedCategories", true));
1936
+ const supportDeskLimitMaximums = Object.freeze({
1937
+ maxSubjectCharacters: 1_000,
1938
+ maxMessageCharacters: 20_000,
1939
+ maxTicketsPerPrincipal: 1_000,
1940
+ maxMessagesPerTicket: 100,
1941
+ maxRecordsPerTicket: 2_000,
1942
+ });
1943
+ /** Merge validated host overrides onto the defaults and enforce maxima. */
1944
+ function resolveSupportDeskLimits(rawLimits, field) {
1307
1945
  const limitOverrides = {};
1308
1946
  if (rawLimits !== undefined) {
1309
- const limitSource = boundaryObject(rawLimits, "application options.limits");
1310
- for (const field of [
1947
+ const limitSource = boundaryObject(rawLimits, field);
1948
+ for (const name of [
1311
1949
  "maxSubjectCharacters",
1312
1950
  "maxMessageCharacters",
1313
1951
  "maxTicketsPerPrincipal",
1314
1952
  "maxMessagesPerTicket",
1315
1953
  "maxRecordsPerTicket",
1316
1954
  ]) {
1317
- const value = ownDataProperty(limitSource, field, `application options.limits.${field}`, true);
1955
+ const value = ownDataProperty(limitSource, name, `${field}.${name}`, true);
1318
1956
  if (value !== undefined) {
1319
1957
  if (typeof value !== "number") {
1320
- throw new TypeError(`application options.limits.${field} must be a number`);
1958
+ throw new TypeError(`${field}.${name} must be a number`);
1321
1959
  }
1322
- limitOverrides[field] = value;
1960
+ limitOverrides[name] = value;
1323
1961
  }
1324
1962
  }
1325
1963
  }
1964
+ const limits = { ...defaultSupportDeskLimits, ...limitOverrides };
1965
+ for (const name of Object.keys(limits)) {
1966
+ const value = limits[name];
1967
+ if (!Number.isSafeInteger(value) ||
1968
+ value <= 0 ||
1969
+ value > supportDeskLimitMaximums[name]) {
1970
+ throw new TypeError(`${name} must be a positive safe integer no greater than ${supportDeskLimitMaximums[name]}`);
1971
+ }
1972
+ }
1973
+ return Object.freeze(limits);
1974
+ }
1975
+ /**
1976
+ * Reserve the next instance-scoped ticket number outside any ticket
1977
+ * transaction. A later failure leaves a gap; a duplicate is impossible.
1978
+ */
1979
+ async function reserveNextTicketNumber(numbers, maxConflictAttempts) {
1980
+ const result = await numbers.update({ partition: ticketNumberPartition, id: ticketNumberRecordId }, (current) => {
1981
+ const lastIssued = current?.lastIssued ?? 0;
1982
+ if (!Number.isSafeInteger(lastIssued) || lastIssued < 0) {
1983
+ throw new SupportDeskConflictError("Ticket number counter is corrupt");
1984
+ }
1985
+ if (lastIssued >= Number.MAX_SAFE_INTEGER) {
1986
+ throw new SupportDeskConflictError("Ticket number sequence is exhausted");
1987
+ }
1988
+ return {
1989
+ action: "write",
1990
+ value: { lastIssued: lastIssued + 1 },
1991
+ };
1992
+ }, { maxAttempts: maxConflictAttempts });
1993
+ const issued = result.value?.lastIssued;
1994
+ if (issued === undefined || !Number.isSafeInteger(issued) || issued <= 0) {
1995
+ throw new SupportDeskConflictError("Ticket number was not reserved");
1996
+ }
1997
+ return issued;
1998
+ }
1999
+ export function createSupportDeskApplication(options) {
2000
+ const source = boundaryObject(options, "application options");
2001
+ const engineeringFollowupOptions = resolveEngineeringFollowupOptions(ownDataProperty(source, "engineeringFollowup", "application options.engineeringFollowup", true));
2002
+ const engineeringOptions = resolveEngineeringOptions(ownDataProperty(source, "engineering", "application options.engineering", true));
2003
+ const resolutionLearningOptions = resolveResolutionLearningOptions(ownDataProperty(source, "resolutionLearning", "application options.resolutionLearning", true));
2004
+ const assistantTurnOptions = resolveAssistantTurnOptions(ownDataProperty(source, "assistantTurns", "application options.assistantTurns", true));
2005
+ const rawAssistantEnabled = ownDataProperty(source, "assistantHandlingEnabled", "application options.assistantHandlingEnabled", true);
2006
+ if (rawAssistantEnabled !== undefined &&
2007
+ typeof rawAssistantEnabled !== "boolean")
2008
+ throw new TypeError("assistantHandlingEnabled must be boolean");
2009
+ const assistantHandlingEnabled = rawAssistantEnabled === true;
2010
+ const store = ownDataProperty(source, "store", "application options.store");
2011
+ const clock = ownDataProperty(source, "clock", "application options.clock");
2012
+ const rawLogger = ownDataProperty(source, "logger", "application options.logger", true);
2013
+ const logger = rawLogger === undefined ? noopLogger : rawLogger;
2014
+ const rawLimits = ownDataProperty(source, "limits", "application options.limits", true);
2015
+ const rawMaxConflictAttempts = ownDataProperty(source, "maxConflictAttempts", "application options.maxConflictAttempts", true);
2016
+ const rawTerminalRetention = ownDataProperty(source, "queueTerminalRetentionMilliseconds", "application options.queueTerminalRetentionMilliseconds", true);
2017
+ const rawQueueBudgets = ownDataProperty(source, "queueScanBudgets", "application options.queueScanBudgets", true);
2018
+ const allowedCategories = parseAllowedCategories(ownDataProperty(source, "allowedCategories", "application options.allowedCategories", true));
2019
+ const rawTags = ownDataProperty(source, "allowedTags", "allowedTags", true);
2020
+ const allowedTags = new Set(rawTags === undefined ? [] : snapshotTags(rawTags));
2021
+ const rawMaxTags = ownDataProperty(source, "maxTagsPerTicket", "maxTagsPerTicket", true);
2022
+ const maxTagsPerTicket = rawMaxTags === undefined ? 8 : rawMaxTags;
2023
+ if (typeof maxTagsPerTicket !== "number" ||
2024
+ !Number.isSafeInteger(maxTagsPerTicket) ||
2025
+ maxTagsPerTicket < 1 ||
2026
+ maxTagsPerTicket > 32) {
2027
+ throw new TypeError("maxTagsPerTicket must be between 1 and 32");
2028
+ }
2029
+ const responseTargetPolicy = parseResponseTargetPolicy(ownDataProperty(source, "responseTargetPolicy", "responseTargetPolicy", true));
1326
2030
  if (rawMaxConflictAttempts !== undefined &&
1327
2031
  typeof rawMaxConflictAttempts !== "number") {
1328
2032
  throw new TypeError("application options.maxConflictAttempts must be a number");
@@ -1331,13 +2035,36 @@ export function createSupportDeskApplication(options) {
1331
2035
  const index = store.collection(customerTicketIndex);
1332
2036
  const numbers = store.collection(ticketNumbers);
1333
2037
  const projections = store.collection(queueIndex);
1334
- const limits = { ...defaultSupportDeskLimits, ...limitOverrides };
2038
+ const limits = resolveSupportDeskLimits(rawLimits, "application options.limits");
1335
2039
  const maxConflictAttempts = rawMaxConflictAttempts ?? 4;
1336
2040
  if (!Number.isSafeInteger(maxConflictAttempts) ||
1337
2041
  maxConflictAttempts <= 0 ||
1338
2042
  maxConflictAttempts > 20) {
1339
2043
  throw new TypeError("maxConflictAttempts must be between 1 and 20");
1340
2044
  }
2045
+ const rawEngineeringPolicy = ownDataProperty(source, "engineeringPolicy", "application options.engineeringPolicy", true);
2046
+ const engineeringPolicyOverrides = {};
2047
+ if (rawEngineeringPolicy !== undefined) {
2048
+ const policyOptions = boundaryObject(rawEngineeringPolicy, "engineeringPolicy options");
2049
+ for (const field of [
2050
+ "maxRepositories",
2051
+ "maxRecords",
2052
+ "historyRetentionMilliseconds",
2053
+ ]) {
2054
+ const value = ownDataProperty(policyOptions, field, `engineeringPolicy.${field}`, true);
2055
+ if (value === undefined)
2056
+ continue;
2057
+ if (typeof value !== "number")
2058
+ throw new TypeError(`engineeringPolicy.${field} must be a number`);
2059
+ engineeringPolicyOverrides[field] = value;
2060
+ }
2061
+ }
2062
+ const engineeringPolicy = createEngineeringPolicyServices({
2063
+ store,
2064
+ clock,
2065
+ maxConflictAttempts,
2066
+ ...engineeringPolicyOverrides,
2067
+ });
1341
2068
  const terminalRetentionMilliseconds = rawTerminalRetention === undefined
1342
2069
  ? defaultQueueTerminalRetentionMilliseconds
1343
2070
  : rawTerminalRetention;
@@ -1382,21 +2109,6 @@ export function createSupportDeskApplication(options) {
1382
2109
  throw new TypeError(`queueScanBudgets.${field} must be a positive safe integer`);
1383
2110
  }
1384
2111
  }
1385
- const limitMaximums = {
1386
- maxSubjectCharacters: 1_000,
1387
- maxMessageCharacters: 20_000,
1388
- maxTicketsPerPrincipal: 1_000,
1389
- maxMessagesPerTicket: 100,
1390
- maxRecordsPerTicket: 2_000,
1391
- };
1392
- for (const field of Object.keys(limits)) {
1393
- const value = limits[field];
1394
- if (!Number.isSafeInteger(value) ||
1395
- value <= 0 ||
1396
- value > limitMaximums[field]) {
1397
- throw new TypeError(`${field} must be a positive safe integer no greater than ${limitMaximums[field]}`);
1398
- }
1399
- }
1400
2112
  let projectionHealth = Object.freeze({
1401
2113
  consecutiveFailures: 0,
1402
2114
  });
@@ -1448,13 +2160,65 @@ export function createSupportDeskApplication(options) {
1448
2160
  }
1449
2161
  }
1450
2162
  }
1451
- async function customerViewAfterChange(principalId, ticketId) {
2163
+ /**
2164
+ * Best-effort post-commit threading index writes. Never fails a committed
2165
+ * command; `repairThreadIndexPage` converges anything missed. Does not
2166
+ * sample the host clock.
2167
+ */
2168
+ async function safeIndexThreading(ticketId, threading) {
2169
+ if (threading === undefined ||
2170
+ (threading.messageId === undefined && threading.ticketNumber !== true)) {
2171
+ return;
2172
+ }
2173
+ await indexThreadingAfterCommit(store, ticketId, threading, logger);
2174
+ }
2175
+ async function customerViewAfterChange(principalId, ticketId, threading) {
1452
2176
  await safeProjectQueue(ticketId);
2177
+ await safeIndexThreading(ticketId, threading);
1453
2178
  return authoritativeView(records, principalId, ticketId, limits.maxRecordsPerTicket);
1454
2179
  }
1455
- async function staffViewAfterChange(ticketId) {
2180
+ async function staffCommandResult(ticketId, commandId, threading) {
1456
2181
  await safeProjectQueue(ticketId);
1457
- return staffAuthoritativeView(records, ticketId, limits.maxRecordsPerTicket);
2182
+ await safeIndexThreading(ticketId, threading);
2183
+ const receipt = await records.get(commandKey(ticketId, commandId));
2184
+ if (receipt?.kind !== "command" || receipt.resultTicket === undefined) {
2185
+ // Pre-Task-13 receipts retain their existing replay behavior.
2186
+ return staffAuthoritativeView(records, ticketId, limits.maxRecordsPerTicket);
2187
+ }
2188
+ const rows = await records.list(ticketId);
2189
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
2190
+ return {
2191
+ ticket: receipt.resultTicket,
2192
+ messages: staffMessages(rows.filter((row) => row.kind !== "message" ||
2193
+ row.ordinal <= receipt.resultTicket.revision)),
2194
+ };
2195
+ }
2196
+ async function checkStaffRevision(ticket, command, fingerprint) {
2197
+ if (command.expectedRevision === undefined ||
2198
+ command.expectedRevision === ticket.revision)
2199
+ return false;
2200
+ // A commit may race the first receipt read. Replay wins over a stale hint.
2201
+ const receipt = await records.get(commandKey(command.ticketId, command.commandId));
2202
+ if (receipt?.kind === "command" &&
2203
+ receipt.requestFingerprint === fingerprint)
2204
+ return true;
2205
+ if (receipt !== null)
2206
+ throw new SupportDeskConflictError("Command ID reused with different input");
2207
+ requireExpectedRevision(ticket, command);
2208
+ return false;
2209
+ }
2210
+ async function staffConflictResult(command, fingerprint) {
2211
+ const receipt = await records.get(commandKey(command.ticketId, command.commandId));
2212
+ if (receipt?.kind === "command" &&
2213
+ receipt.requestFingerprint === fingerprint)
2214
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2215
+ if (receipt !== null)
2216
+ throw new SupportDeskConflictError("Command ID reused with different input");
2217
+ const current = await records.get(ticketKey(command.ticketId));
2218
+ if (current?.kind === "ticket" &&
2219
+ (await checkStaffRevision(current.ticket, command, fingerprint)))
2220
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2221
+ throw new SupportDeskConflictError();
1458
2222
  }
1459
2223
  async function reserveCustomerTicket(principalId, ticketId, reservedAt) {
1460
2224
  requireIdentifier(principalId, "principalId");
@@ -1555,80 +2319,863 @@ export function createSupportDeskApplication(options) {
1555
2319
  }
1556
2320
  }
1557
2321
  async function reserveTicketNumber() {
1558
- const result = await numbers.update({ partition: ticketNumberPartition, id: ticketNumberRecordId }, (current) => {
1559
- const lastIssued = current?.lastIssued ?? 0;
1560
- if (!Number.isSafeInteger(lastIssued) || lastIssued < 0) {
1561
- throw new SupportDeskConflictError("Ticket number counter is corrupt");
2322
+ return reserveNextTicketNumber(numbers, maxConflictAttempts);
2323
+ }
2324
+ const resolutionLearning = createResolutionLearningServices({
2325
+ records,
2326
+ clock,
2327
+ limits,
2328
+ options: resolutionLearningOptions,
2329
+ maxConflictAttempts,
2330
+ admission: (ticket, rows, additional) => enforceManagedAdmission(ticket, rows, limits, additional),
2331
+ withdrawalAdmission: (ticket, rows, additional) => enforceHandlingControlAdmission(ticket, rows, limits, additional, "learning"),
2332
+ audit: (command, ticket, actorId, now, action) => ticketAuditAction({
2333
+ commandId: command.commandId,
2334
+ correlationId: command.correlationId,
2335
+ ticketId: ticket.id,
2336
+ revision: ticket.revision,
2337
+ actorId,
2338
+ occurredAt: now,
2339
+ action: action,
2340
+ }),
2341
+ });
2342
+ const engineeringDependencies = {
2343
+ records,
2344
+ clock,
2345
+ limits,
2346
+ options: engineeringOptions,
2347
+ maxConflictAttempts,
2348
+ policy: engineeringPolicy,
2349
+ planFollowupSourceChange: (context) => planEngineeringFollowupSourceChange(records, context),
2350
+ admission: (ticket, rows, additional) => enforceManagedAdmission(ticket, rows, limits, additional),
2351
+ withdrawalAdmission: (ticket, rows, additional) => enforceHandlingControlAdmission(ticket, rows, limits, additional, "engineering"),
2352
+ completionAdmission: (ticket, rows, additional, workId) => {
2353
+ const consumption = { engineeringTerminalWorkId: workId };
2354
+ const current = rows.find((row) => row.kind === "ticket" && row.ticket.id === ticket.id);
2355
+ if (current !== undefined)
2356
+ enforceHandlingControlAdmission(current.ticket, rows, limits, additional, consumption);
2357
+ else
2358
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket - derivationReserveRows(rows, consumption), additional);
2359
+ },
2360
+ audit: (command, ticket, actorId, now, action) => ticketAuditAction({
2361
+ commandId: command.commandId,
2362
+ correlationId: command.correlationId,
2363
+ ticketId: ticket.id,
2364
+ revision: ticket.revision,
2365
+ actorId,
2366
+ occurredAt: now,
2367
+ action: action,
2368
+ }),
2369
+ };
2370
+ const engineeringEvidence = createEngineeringEvidenceServices(engineeringDependencies);
2371
+ const engineeringWorkers = createEngineeringWorkerServices(engineeringDependencies, engineeringEvidence.load);
2372
+ const engineeringReconciliation = createEngineeringReconciliationServices(engineeringDependencies);
2373
+ const followupDependencies = {
2374
+ records,
2375
+ clock,
2376
+ limits,
2377
+ options: engineeringFollowupOptions,
2378
+ maxConflictAttempts,
2379
+ audit: (command, ticket, actorId, now, action) => ticketAuditAction({
2380
+ commandId: command.commandId,
2381
+ correlationId: command.correlationId,
2382
+ ticketId: ticket.id,
2383
+ revision: ticket.revision,
2384
+ actorId,
2385
+ occurredAt: now,
2386
+ action: action,
2387
+ }),
2388
+ admission: (ticket, rows, nextWorkRows, addedPhysicalRows, addsMessage = false) => {
2389
+ const projected = projectFollowupRows(rows, nextWorkRows);
2390
+ const newlyProjected = projected.length - rows.length;
2391
+ if (addedPhysicalRows < newlyProjected)
2392
+ throw new SupportDeskConflictError("Follow-up admission uses a stale partition view");
2393
+ const quota = projected.find((row) => row.kind === "ticket_quota");
2394
+ if (followupMessageSlots(projected) > 0) {
2395
+ if (quota === undefined)
2396
+ throw new SupportDeskConflictError("Follow-up quota metadata is missing");
2397
+ const humanSlot = quota.assistantManaged === true &&
2398
+ ticket.firstStaffRespondedAt === undefined
2399
+ ? assistantHumanReserveMessages
2400
+ : 0;
2401
+ if (quota.messageCount + followupMessageSlots(projected) + humanSlot >
2402
+ limits.maxMessagesPerTicket)
2403
+ throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
1562
2404
  }
1563
- if (lastIssued >= Number.MAX_SAFE_INTEGER) {
1564
- throw new SupportDeskConflictError("Ticket number sequence is exhausted");
2405
+ enforceManagedAdmission(ticket, projected, limits, addedPhysicalRows - newlyProjected, addsMessage);
2406
+ },
2407
+ };
2408
+ const engineeringFollowupSource = createEngineeringFollowupSourceServices(followupDependencies);
2409
+ const engineeringFollowupWork = createEngineeringFollowupWorkServices(followupDependencies, engineeringFollowupSource.internal.load);
2410
+ const approveAndPostEngineeringFollowupWebUpdate = async (access, input, guard) => {
2411
+ const actorId = access.principalId;
2412
+ const authorize = () => {
2413
+ requireIdentifier(access.principalId, "principalId");
2414
+ if (access.principalId !== actorId)
2415
+ throw new SupportDeskAuthorizationError(supportPermissions.engineeringFollowupReview);
2416
+ requirePermission(access, supportPermissions.engineeringFollowupReview);
2417
+ requirePermission(access, supportPermissions.queueRead);
2418
+ requirePermission(access, supportPermissions.replyAny);
2419
+ };
2420
+ authorize();
2421
+ const command = followupSnapshot(followupFields(input, "follow-up web update", [
2422
+ "ticketId",
2423
+ "workId",
2424
+ "commandId",
2425
+ "correlationId",
2426
+ "expectedWorkGeneration",
2427
+ "expectedTicketRevision",
2428
+ "bundleHash",
2429
+ "messageId",
2430
+ ]));
2431
+ for (const name of [
2432
+ "ticketId",
2433
+ "workId",
2434
+ "commandId",
2435
+ "correlationId",
2436
+ "bundleHash",
2437
+ "messageId",
2438
+ ])
2439
+ followupText(command[name], name);
2440
+ followupInteger(command.expectedWorkGeneration, "expectedWorkGeneration", 1);
2441
+ followupInteger(command.expectedTicketRevision, "expectedTicketRevision", 1);
2442
+ if (!/^[0-9a-f]{64}$/.test(command.bundleHash))
2443
+ throw new TypeError("Invalid follow-up bundle hash");
2444
+ if (typeof guard !== "function")
2445
+ throw new TypeError("Follow-up posting requires a current-proof guard");
2446
+ const hash = await requestFingerprint({
2447
+ operation: "engineering_followup_web_update",
2448
+ actorId,
2449
+ command,
2450
+ });
2451
+ const receiptKey = engineeringFollowupCommandKey(command.ticketId, command.commandId);
2452
+ const now = engineeringFollowupNow(followupDependencies);
2453
+ for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
2454
+ const prior = await records.get(receiptKey);
2455
+ authorize();
2456
+ if (prior !== null) {
2457
+ if (prior.kind !== "engineering_followup_receipt" ||
2458
+ prior.fingerprint !== hash ||
2459
+ !("messageId" in prior.result) ||
2460
+ prior.result.messageId === undefined ||
2461
+ !("bundleHash" in prior.result) ||
2462
+ prior.result.bundleHash !== command.bundleHash ||
2463
+ !("ticketRevision" in prior.result) ||
2464
+ prior.result.ticketRevision === undefined ||
2465
+ !("workId" in prior.result) ||
2466
+ prior.result.workId !== command.workId)
2467
+ throw new SupportDeskConflictError("Follow-up posting command changed");
2468
+ return followupSnapshot({
2469
+ ticketId: command.ticketId,
2470
+ workId: command.workId,
2471
+ messageId: prior.result.messageId,
2472
+ ticketRevision: prior.result.ticketRevision,
2473
+ bundleHash: prior.result.bundleHash,
2474
+ });
1565
2475
  }
1566
- return {
1567
- action: "write",
1568
- value: { lastIssued: lastIssued + 1 },
2476
+ if (!engineeringFollowupOptions.enabled)
2477
+ throw new SupportDeskConflictError("Follow-up is disabled");
2478
+ const current = await engineeringFollowupWork.internal.current({ ticketId: command.ticketId, workId: command.workId }, now);
2479
+ authorize();
2480
+ const work = current.work.value;
2481
+ const prepared = work.preparedBundle;
2482
+ const ticket = current.loaded.ticket.value.ticket;
2483
+ if (prepared === undefined ||
2484
+ current.loaded.head === null ||
2485
+ work.workGeneration !== command.expectedWorkGeneration ||
2486
+ ticket.revision !== command.expectedTicketRevision ||
2487
+ prepared.bundleHash !== command.bundleHash ||
2488
+ work.completionRows !== 3 ||
2489
+ work.messageSlots !== 1 ||
2490
+ now() >= Date.parse(work.deadline))
2491
+ throw new SupportDeskConflictError("Follow-up exact prepared work or ticket changed");
2492
+ const checked = await engineeringFollowupWork.internal.verifyBundle(current, now);
2493
+ const body = prepared.bundle.customerDraft.body;
2494
+ requireNonempty(body, "body");
2495
+ enforceLimit(body, "body", limits.maxMessageCharacters);
2496
+ const bodyHash = await requestFingerprint({ body });
2497
+ await guard();
2498
+ authorize();
2499
+ const occurredAt = clampTicketTime(clock.now(), ticket.updatedAt);
2500
+ const updated = applyTicketEvent(ticket, {
2501
+ type: "customer_notice_added",
2502
+ actorId,
2503
+ occurredAt,
2504
+ });
2505
+ const derivations = await planTicketDerivationMutation(records, current.loaded.ticket, updated, current.loaded.rows, true, { completedFollowupWorkId: work.workId });
2506
+ const { claim: _claim, leaseUntil: _lease, retryAt: _retry, candidate: _candidate, candidateHash: _candidateHash, preparedBundle: _prepared, ...remaining } = work;
2507
+ const next = {
2508
+ ...remaining,
2509
+ stage: "delivered",
2510
+ status: "delivered",
2511
+ workGeneration: work.workGeneration + 1,
2512
+ completionRows: 0,
2513
+ messageSlots: 0,
2514
+ modelAttempts: [],
2515
+ usageTombstones: [
2516
+ ...new Map([
2517
+ ...(work.usageTombstones ?? []).map((entry) => [entry.stepId, entry]),
2518
+ ...work.modelAttempts.map((entry) => [
2519
+ entry.stepId,
2520
+ {
2521
+ stepId: entry.stepId,
2522
+ requestHash: entry.requestHash,
2523
+ usageReservationId: entry.binding.usageReservationId,
2524
+ accounting: entry.accounting,
2525
+ },
2526
+ ]),
2527
+ ]).values(),
2528
+ ],
2529
+ delivered: {
2530
+ messageId: command.messageId,
2531
+ ticketRevision: updated.revision,
2532
+ bodyHash,
2533
+ bundleHash: prepared.bundleHash,
2534
+ commandId: command.commandId,
2535
+ postedBy: actorId,
2536
+ postedAt: occurredAt,
2537
+ },
2538
+ handoff: {
2539
+ kbProposal: prepared.bundle.kbProposal,
2540
+ retestRequests: prepared.bundle.retestRequests,
2541
+ },
1569
2542
  };
1570
- }, { maxAttempts: maxConflictAttempts });
1571
- const issued = result.value?.lastIssued;
1572
- if (issued === undefined || !Number.isSafeInteger(issued) || issued <= 0) {
1573
- throw new SupportDeskConflictError("Ticket number was not reserved");
1574
- }
1575
- return issued;
1576
- }
1577
- return {
1578
- async createCustomerTicket(access, input) {
1579
- requirePermission(access, supportPermissions.create);
1580
- requireIdentifier(access.principalId, "principalId");
1581
- const command = snapshotCreateCustomerTicketCommand(input);
1582
- requireIdentifier(command.ticketId, "ticketId");
1583
- requireIdentifier(command.messageId, "messageId");
1584
- requireIdentifier(command.commandId, "commandId");
1585
- requireIdentifier(command.correlationId, "correlationId");
1586
- enforceLimit(command.subject, "subject", limits.maxSubjectCharacters);
1587
- enforceLimit(command.body, "body", limits.maxMessageCharacters);
1588
- const fingerprintPayload = {
1589
- type: "create_customer_ticket",
2543
+ checkEngineeringFollowupRecordCapacity(next);
2544
+ const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
2545
+ authorize();
2546
+ if (quota?.value.kind !== "ticket_quota")
2547
+ throw new SupportDeskConflictError("Follow-up quota metadata is missing");
2548
+ const admissionRows = current.loaded.rows.map((row) => row.kind === "ticket_quota" ? quota.value : row);
2549
+ followupDependencies.admission(updated, admissionRows, [...derivations.nextWorkRows, next], 3 + derivations.additionalRows, true);
2550
+ const result = {
1590
2551
  ticketId: command.ticketId,
2552
+ workId: command.workId,
1591
2553
  messageId: command.messageId,
1592
- subject: command.subject,
1593
- body: command.body,
1594
- // Omit absent category so pre-category receipts still match retries.
1595
- ...(command.category === undefined
1596
- ? {}
1597
- : { category: command.category }),
1598
- requesterEmail: command.requesterEmail ?? null,
1599
- notification: stableNotification(command.notification),
2554
+ ticketRevision: updated.revision,
2555
+ bundleHash: prepared.bundleHash,
1600
2556
  };
1601
- const fingerprint = await requestFingerprint(fingerprintPayload);
1602
- // Replay an already committed create before host allowlist checks so a
1603
- // later config change cannot break idempotent retries of the same command.
1604
- // Replay never reserves another instance ticket number.
1605
- const existingCreate = await records.get(commandKey(command.ticketId, command.commandId));
1606
- let createIsReplay = duplicateMatches(existingCreate, "create_customer_ticket", command.messageId, fingerprint);
1607
- if (!createIsReplay &&
1608
- existingCreate?.kind === "command" &&
1609
- existingCreate.commandType === "create_customer_ticket" &&
1610
- existingCreate.messageId === command.messageId) {
1611
- // Pre-Task-3 receipts included ticketNumber in the fingerprint.
1612
- const existingTicket = await records.get(ticketKey(command.ticketId));
1613
- if (existingTicket?.kind === "ticket") {
1614
- // Pre-Task-3 hashed ticketNumber immediately after ticketId.
1615
- const legacyFingerprint = await requestFingerprint({
1616
- type: fingerprintPayload.type,
1617
- ticketId: fingerprintPayload.ticketId,
1618
- ticketNumber: existingTicket.ticket.number,
1619
- messageId: fingerprintPayload.messageId,
1620
- subject: fingerprintPayload.subject,
1621
- body: fingerprintPayload.body,
1622
- ...(command.category === undefined
1623
- ? {}
1624
- : { category: command.category }),
1625
- requesterEmail: fingerprintPayload.requesterEmail,
1626
- notification: fingerprintPayload.notification,
1627
- });
1628
- createIsReplay = duplicateMatches(existingCreate, "create_customer_ticket", command.messageId, legacyFingerprint);
1629
- }
1630
- }
2557
+ const receipt = {
2558
+ kind: "engineering_followup_receipt",
2559
+ partition: command.ticketId,
2560
+ id: receiptKey.id,
2561
+ fingerprint: hash,
2562
+ result,
2563
+ };
2564
+ checkEngineeringFollowupRecordCapacity(receipt);
2565
+ if (!(await engineeringFollowupVersionsCurrent(followupDependencies, current.loaded, current.work, current.engineeringWork)))
2566
+ continue;
2567
+ authorize();
2568
+ if (now() >=
2569
+ Math.min(Date.parse(work.deadline), checked.deadline, Date.parse(current.proof.attestation.validUntil)))
2570
+ throw new SupportDeskConflictError("Follow-up posting proof expired");
2571
+ const outcome = await records.transact(command.ticketId, [
2572
+ ...derivations.actions,
2573
+ {
2574
+ action: "putIfUnchanged",
2575
+ version: current.loaded.source.version,
2576
+ value: current.loaded.source.value,
2577
+ },
2578
+ {
2579
+ action: "putIfUnchanged",
2580
+ version: current.engineeringWork.version,
2581
+ value: current.engineeringWork.value,
2582
+ },
2583
+ {
2584
+ action: "putIfUnchanged",
2585
+ version: current.loaded.head.version,
2586
+ value: current.loaded.head.value,
2587
+ },
2588
+ {
2589
+ action: "putIfUnchanged",
2590
+ version: current.work.version,
2591
+ value: next,
2592
+ },
2593
+ {
2594
+ action: "putIfUnchanged",
2595
+ version: quota.version,
2596
+ value: {
2597
+ ...quota.value,
2598
+ messageCount: quota.value.messageCount + 1,
2599
+ },
2600
+ },
2601
+ {
2602
+ action: "insert",
2603
+ value: {
2604
+ kind: "message",
2605
+ partition: command.ticketId,
2606
+ id: `message:${command.messageId}`,
2607
+ ordinal: updated.revision,
2608
+ message: {
2609
+ id: command.messageId,
2610
+ ticketId: command.ticketId,
2611
+ authorKind: "staff",
2612
+ authorPrincipalId: actorId,
2613
+ channel: "web",
2614
+ visibility: "customer",
2615
+ format: "plain_text",
2616
+ body,
2617
+ createdAt: occurredAt,
2618
+ },
2619
+ },
2620
+ },
2621
+ ticketAuditAction({
2622
+ commandId: command.commandId,
2623
+ correlationId: command.correlationId,
2624
+ ticketId: command.ticketId,
2625
+ revision: updated.revision,
2626
+ actorId,
2627
+ occurredAt,
2628
+ action: supportTicketAuditActions.engineeringFollowupNoticePosted,
2629
+ }),
2630
+ { action: "insert", value: receipt },
2631
+ ]);
2632
+ if (outcome.committed) {
2633
+ authorize();
2634
+ return result;
2635
+ }
2636
+ }
2637
+ throw new SupportDeskConflictError();
2638
+ };
2639
+ function owns(ticket, principalId) {
2640
+ return (ticket.requester.association === "authenticated" &&
2641
+ ticket.requester.principalId === principalId);
2642
+ }
2643
+ function handlingAck(ticket, messageId) {
2644
+ return {
2645
+ ticketId: ticket.id,
2646
+ revision: ticket.revision,
2647
+ handlingGeneration: effectiveTicketHandling(ticket).generation,
2648
+ ...(messageId === undefined ? {} : { messageId }),
2649
+ };
2650
+ }
2651
+ /** Only the new handling commands share this small executor; legacy fingerprints stay untouched. */
2652
+ async function mutateHandling(access, command, operation, assistantTurnCommit) {
2653
+ requireIdentifier(access.principalId, "principalId");
2654
+ const customer = operation === "request_person" ||
2655
+ operation === "confirm_customer_success";
2656
+ const fingerprint = await requestFingerprint({
2657
+ type: operation,
2658
+ actorId: access.principalId,
2659
+ ...Object.fromEntries(Object.entries(command).filter(([key]) => key !== "commandId" && key !== "correlationId")),
2660
+ ...(assistantTurnCommit === undefined
2661
+ ? {}
2662
+ : { assistantTurnFingerprint: assistantTurnCommit.fingerprint }),
2663
+ });
2664
+ const completion = assistantTurnCommit;
2665
+ const parallel = completion?.kind === "complete";
2666
+ for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
2667
+ const ticketRead = records.getVersioned(ticketKey(command.ticketId));
2668
+ const duplicateRead = parallel
2669
+ ? prepareRead(() => records.get(commandKey(command.ticketId, command.commandId)))
2670
+ : undefined;
2671
+ const versioned = await ticketRead;
2672
+ if (versioned?.value.kind !== "ticket" ||
2673
+ (customer && !owns(versioned.value.ticket, access.principalId)))
2674
+ throw new SupportDeskNotFoundError();
2675
+ const ticket = versioned.value.ticket;
2676
+ const duplicate = duplicateRead === undefined
2677
+ ? await records.get(commandKey(command.ticketId, command.commandId))
2678
+ : readValue(await duplicateRead);
2679
+ if (duplicateMatches(duplicate, operation, command.messageId, fingerprint)) {
2680
+ if (duplicate?.kind !== "command" ||
2681
+ duplicate.resultTicket === undefined)
2682
+ throw new SupportDeskConflictError("Handling receipt is incomplete");
2683
+ return duplicate.resultTicket;
2684
+ }
2685
+ if (duplicate !== null)
2686
+ throw new SupportDeskConflictError("Command ID reused with different input");
2687
+ if (parallel &&
2688
+ (ticket.channel !== "web" ||
2689
+ isTerminalTicketStatus(ticket.status) ||
2690
+ effectiveTicketHandling(ticket).state !== "assistant" ||
2691
+ ticket.revision !== command.expectedRevision ||
2692
+ effectiveTicketHandling(ticket).generation !==
2693
+ command.expectedHandlingGeneration)) {
2694
+ // The speculative receipt may predate a concurrent exact completion
2695
+ // that this Ticket observation already contains. Recover its receipt
2696
+ // before rejecting the now-stale live fence; ordinary paired reads
2697
+ // retain their existing cost and original whole-attempt CAS versions.
2698
+ const accepted = await records.get(commandKey(command.ticketId, command.commandId));
2699
+ if (duplicateMatches(accepted, operation, command.messageId, fingerprint)) {
2700
+ if (accepted?.kind !== "command" ||
2701
+ accepted.resultTicket === undefined)
2702
+ throw new SupportDeskConflictError("Handling receipt is incomplete");
2703
+ return accepted.resultTicket;
2704
+ }
2705
+ if (accepted !== null)
2706
+ throw new SupportDeskConflictError("Command ID reused with different input");
2707
+ }
2708
+ if (operation === "assistant_reply" && !assistantHandlingEnabled)
2709
+ throw new SupportDeskConflictError("Assistant handling is disabled");
2710
+ const rows = await records.list(command.ticketId);
2711
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
2712
+ const latest = latestVisibleRecord(rows);
2713
+ if (operation === "assistant_reply" || operation === "assistant_escalate")
2714
+ requireAssistantTurn(ticket, rows, command);
2715
+ if (operation === "request_person" &&
2716
+ !isTerminalTicketStatus(ticket.status) &&
2717
+ effectiveTicketHandling(ticket).state !== "assistant")
2718
+ return ticket;
2719
+ if (operation === "confirm_customer_success") {
2720
+ if (latest?.message.id !== command.lastSeenVisibleMessageId)
2721
+ throw new SupportDeskConflictError("Visible conversation changed");
2722
+ if (ticket.status === "resolved" &&
2723
+ rows.some((row) => row.kind === "customer_success" &&
2724
+ row.lastSeenVisibleMessageId ===
2725
+ command.lastSeenVisibleMessageId &&
2726
+ (rows.find((record) => record.kind === "resolution_learning_source") === undefined ||
2727
+ row.resolutionCycle ===
2728
+ rows.find((record) => record.kind === "resolution_learning_source")?.resolutionCycle)))
2729
+ return ticket;
2730
+ }
2731
+ const now = clampTicketTime(clock.now(), ticket.updatedAt);
2732
+ const common = { actorId: access.principalId, occurredAt: now };
2733
+ const fence = {
2734
+ expectedRevision: command.expectedRevision,
2735
+ expectedHandlingGeneration: command.expectedHandlingGeneration,
2736
+ };
2737
+ const updated = operation === "assistant_reply"
2738
+ ? applyTicketEvent(ticket, {
2739
+ type: "assistant_replied",
2740
+ ...common,
2741
+ ...fence,
2742
+ })
2743
+ : operation === "assistant_escalate"
2744
+ ? applyTicketEvent(ticket, {
2745
+ type: "human_escalated",
2746
+ ...common,
2747
+ ...fence,
2748
+ })
2749
+ : operation === "take_over"
2750
+ ? applyTicketEvent(ticket, {
2751
+ type: "human_taken_over",
2752
+ ...common,
2753
+ ...fence,
2754
+ })
2755
+ : operation === "request_person"
2756
+ ? applyTicketEvent(ticket, {
2757
+ type: "human_requested",
2758
+ ...common,
2759
+ })
2760
+ : applyTicketEvent(ticket, {
2761
+ type: "customer_success_confirmed",
2762
+ ...common,
2763
+ });
2764
+ if (updated.revision === ticket.revision)
2765
+ return ticket;
2766
+ const originalTicket = {
2767
+ value: versioned.value,
2768
+ version: versioned.version,
2769
+ };
2770
+ const planMutation = () => planTicketDerivationMutation(records, originalTicket, updated, rows, operation === "assistant_reply", undefined, parallel);
2771
+ const mutationRead = parallel ? prepareRead(planMutation) : undefined;
2772
+ const quotaRead = parallel && operation === "assistant_reply"
2773
+ ? prepareRead(() => records.getVersioned(ticketQuotaKey(command.ticketId)))
2774
+ : undefined;
2775
+ const assistantRead = parallel && completion !== undefined
2776
+ ? prepareRead(() => assistantTurns.completionAction(ticket, rows, updated, completion))
2777
+ : undefined;
2778
+ const learningMutation = mutationRead === undefined
2779
+ ? await planMutation()
2780
+ : readValue(await mutationRead);
2781
+ const learningConfirmation = operation === "confirm_customer_success"
2782
+ ? await resolutionLearning.customerConfirmation(learningMutation.source, updated, access.principalId, command.lastSeenVisibleMessageId, command.commandId, rows)
2783
+ : { actions: [], additionalRows: 0 };
2784
+ const newRows = (operation === "take_over" ? 2 : 3) +
2785
+ learningMutation.additionalRows +
2786
+ learningConfirmation.additionalRows;
2787
+ if (operation === "assistant_reply")
2788
+ enforceManagedAdmission(ticket, projectFollowupRows(rows, learningMutation.nextWorkRows), limits, newRows, true);
2789
+ else
2790
+ enforceHandlingControlAdmission(ticket, projectFollowupRows(rows, learningMutation.nextWorkRows), limits, newRows);
2791
+ const actions = [
2792
+ ...learningMutation.actions,
2793
+ ...learningConfirmation.actions,
2794
+ ];
2795
+ if (operation === "assistant_reply") {
2796
+ const quota = quotaRead === undefined
2797
+ ? await records.getVersioned(ticketQuotaKey(command.ticketId))
2798
+ : readValue(await quotaRead);
2799
+ if (quota?.value.kind !== "ticket_quota")
2800
+ throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
2801
+ if (quota.value.messageCount >= limits.maxMessagesPerTicket)
2802
+ throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
2803
+ const assistantMessage = {
2804
+ kind: "message",
2805
+ partition: ticket.id,
2806
+ id: "message:" + command.messageId,
2807
+ ordinal: updated.revision,
2808
+ message: {
2809
+ id: command.messageId,
2810
+ ticketId: ticket.id,
2811
+ authorKind: "assistant",
2812
+ authorPrincipalId: access.principalId,
2813
+ channel: "web",
2814
+ visibility: "customer",
2815
+ format: "plain_text",
2816
+ body: command.body,
2817
+ createdAt: now,
2818
+ },
2819
+ };
2820
+ if (JSON.stringify(assistantMessage).length * 2 > 60_000)
2821
+ throw new SupportDeskLimitError("serialized_record", 60_000);
2822
+ actions.push({
2823
+ action: "putIfUnchanged",
2824
+ version: quota.version,
2825
+ value: {
2826
+ ...quota.value,
2827
+ messageCount: quota.value.messageCount + 1,
2828
+ },
2829
+ }, { action: "insert", value: assistantMessage });
2830
+ }
2831
+ else if (operation === "request_person" ||
2832
+ operation === "assistant_escalate") {
2833
+ if (latest === undefined)
2834
+ throw new SupportDeskConflictError("Ticket conversation is unavailable");
2835
+ actions.push({
2836
+ action: "insert",
2837
+ value: handoffWork({
2838
+ ticket: updated,
2839
+ commandId: command.commandId,
2840
+ occurredAt: now,
2841
+ lastVisibleMessageId: latest.message.id,
2842
+ reason: operation === "request_person"
2843
+ ? "customer_requested"
2844
+ : (command.reason ?? "other"),
2845
+ ...(command.summary === undefined
2846
+ ? {}
2847
+ : { summary: command.summary }),
2848
+ ...(command.attemptedGuideIds === undefined
2849
+ ? {}
2850
+ : { attemptedGuideIds: command.attemptedGuideIds }),
2851
+ }),
2852
+ });
2853
+ }
2854
+ else if (operation === "confirm_customer_success") {
2855
+ actions.push({
2856
+ action: "insert",
2857
+ value: {
2858
+ kind: "customer_success",
2859
+ partition: ticket.id,
2860
+ id: "success:" + command.commandId,
2861
+ commandId: command.commandId,
2862
+ ticketId: ticket.id,
2863
+ confirmedBy: access.principalId,
2864
+ confirmedAt: now,
2865
+ lastSeenVisibleMessageId: command.lastSeenVisibleMessageId,
2866
+ revision: updated.revision,
2867
+ handlingGeneration: effectiveTicketHandling(updated).generation,
2868
+ ...(learningMutation.source === null
2869
+ ? {}
2870
+ : {
2871
+ resolutionCycle: learningMutation.source.value.resolutionCycle,
2872
+ }),
2873
+ },
2874
+ });
2875
+ }
2876
+ const auditAction = operation === "assistant_reply"
2877
+ ? supportTicketAuditActions.assistantReplied
2878
+ : operation === "assistant_escalate"
2879
+ ? supportTicketAuditActions.humanEscalated
2880
+ : operation === "request_person"
2881
+ ? supportTicketAuditActions.humanRequested
2882
+ : operation === "take_over"
2883
+ ? supportTicketAuditActions.humanTakenOver
2884
+ : supportTicketAuditActions.customerSuccessConfirmed;
2885
+ actions.push(ticketAuditAction({
2886
+ commandId: command.commandId,
2887
+ correlationId: command.correlationId,
2888
+ ticketId: ticket.id,
2889
+ revision: updated.revision,
2890
+ actorId: access.principalId,
2891
+ occurredAt: now,
2892
+ action: auditAction,
2893
+ }), {
2894
+ action: "insert",
2895
+ value: {
2896
+ kind: "command",
2897
+ partition: ticket.id,
2898
+ id: "command:" + command.commandId,
2899
+ commandId: command.commandId,
2900
+ commandType: operation,
2901
+ ...(command.messageId === undefined
2902
+ ? {}
2903
+ : { messageId: command.messageId }),
2904
+ requestFingerprint: fingerprint,
2905
+ completedAt: now,
2906
+ resultTicket: updated,
2907
+ },
2908
+ });
2909
+ const assistantCommit = assistantTurnCommit === undefined
2910
+ ? undefined
2911
+ : assistantRead === undefined
2912
+ ? await assistantTurns.completionAction(ticket, rows, updated, assistantTurnCommit)
2913
+ : readValue(await assistantRead);
2914
+ if (assistantCommit !== undefined) {
2915
+ actions.push(assistantCommit.action);
2916
+ await assistantCommit.verifyBeforeCommit();
2917
+ }
2918
+ if ((await records.transact(ticket.id, actions)).committed) {
2919
+ await safeProjectQueue(ticket.id);
2920
+ return updated;
2921
+ }
2922
+ }
2923
+ const current = await records.get(ticketKey(command.ticketId));
2924
+ if (current?.kind !== "ticket" ||
2925
+ (customer && !owns(current.ticket, access.principalId)))
2926
+ throw new SupportDeskNotFoundError();
2927
+ const receipt = await records.get(commandKey(command.ticketId, command.commandId));
2928
+ if (duplicateMatches(receipt, operation, command.messageId, fingerprint) &&
2929
+ receipt?.kind === "command" &&
2930
+ receipt.resultTicket !== undefined)
2931
+ return receipt.resultTicket;
2932
+ throw new SupportDeskConflictError();
2933
+ }
2934
+ const assistantTurns = createAssistantTurnServices({
2935
+ records,
2936
+ clock,
2937
+ options: assistantTurnOptions,
2938
+ assistantHandlingEnabled,
2939
+ limits,
2940
+ maxConflictAttempts,
2941
+ conversation(ticket, rows) {
2942
+ return {
2943
+ ticket: toCustomerTicketSummary(ticket),
2944
+ messages: customerMessages(rows),
2945
+ revision: ticket.revision,
2946
+ handling: { ...effectiveTicketHandling(ticket) },
2947
+ ...(latestVisibleRecord(rows) === undefined
2948
+ ? {}
2949
+ : { triggerMessageId: latestVisibleRecord(rows).message.id }),
2950
+ };
2951
+ },
2952
+ mutateHandling,
2953
+ });
2954
+ return {
2955
+ ...resolutionLearning.application,
2956
+ ...engineeringEvidence.application,
2957
+ ...engineeringWorkers,
2958
+ ...engineeringReconciliation,
2959
+ ...engineeringPolicy.application,
2960
+ ...engineeringFollowupSource.application,
2961
+ ...engineeringFollowupWork.application,
2962
+ approveAndPostEngineeringFollowupWebUpdate,
2963
+ scanAssistantTurnCandidates: assistantTurns.scanAssistantTurnCandidates,
2964
+ claimAssistantTurn: assistantTurns.claimAssistantTurn,
2965
+ prepareAssistantTurnAttempt: assistantTurns.prepareAssistantTurnAttempt,
2966
+ completeAssistantTurn: assistantTurns.completeAssistantTurn,
2967
+ finishAssistantTurnAttempt: assistantTurns.finishAssistantTurnAttempt,
2968
+ async readHandoffWork(access, ticketId) {
2969
+ requirePermission(access, supportPermissions.queueRead);
2970
+ requireIdentifier(access.principalId, "principalId");
2971
+ requireIdentifier(ticketId, "ticketId");
2972
+ const rows = await records.list(ticketId);
2973
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
2974
+ if (!rows.some((row) => row.kind === "ticket"))
2975
+ throw new SupportDeskNotFoundError();
2976
+ return rows
2977
+ .filter((row) => row.kind === "handoff_work")
2978
+ .sort((a, b) => a.transcript.throughRevision - b.transcript.throughRevision ||
2979
+ a.id.localeCompare(b.id))
2980
+ .map((row) => Object.freeze({
2981
+ ticketId,
2982
+ workId: row.id,
2983
+ status: row.status,
2984
+ reason: row.reason,
2985
+ ...(row.summary === undefined ? {} : { summary: row.summary }),
2986
+ attemptedGuideIds: Object.freeze([...row.attemptedGuideIds]),
2987
+ transcript: Object.freeze({ ...row.transcript }),
2988
+ handlingGeneration: row.handlingGeneration,
2989
+ createdAt: row.createdAt,
2990
+ }));
2991
+ },
2992
+ async readAssistantConversation(access, ticketId) {
2993
+ requirePermission(access, supportPermissions.assistantRead);
2994
+ requireIdentifier(access.principalId, "principalId");
2995
+ requireIdentifier(ticketId, "ticketId");
2996
+ const rows = await records.list(ticketId);
2997
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
2998
+ const ticket = rows.find((row) => row.kind === "ticket")?.ticket;
2999
+ if (ticket === undefined)
3000
+ throw new SupportDeskNotFoundError();
3001
+ if (ticket.channel !== "web" ||
3002
+ isTerminalTicketStatus(ticket.status) ||
3003
+ effectiveTicketHandling(ticket).state !== "assistant")
3004
+ throw new SupportDeskConflictError("Assistant conversation is unavailable");
3005
+ const latest = latestVisibleRecord(rows);
3006
+ return {
3007
+ ticket: toCustomerTicketSummary(ticket),
3008
+ messages: customerMessages(rows),
3009
+ revision: ticket.revision,
3010
+ handling: { ...effectiveTicketHandling(ticket) },
3011
+ ...(latest?.message.authorKind === "customer"
3012
+ ? { triggerMessageId: latest.message.id }
3013
+ : {}),
3014
+ };
3015
+ },
3016
+ async replyAsAssistant(access, input) {
3017
+ requirePermission(access, supportPermissions.assistantReply);
3018
+ const raw = handlingInput(input, [
3019
+ "expectedRevision",
3020
+ "expectedHandlingGeneration",
3021
+ "triggerMessageId",
3022
+ "messageId",
3023
+ "body",
3024
+ ]);
3025
+ handlingFence(raw);
3026
+ for (const key of ["triggerMessageId", "messageId"])
3027
+ requireIdentifier(boundaryString(raw[key], key), key);
3028
+ const body = boundaryString(raw.body, "body");
3029
+ enforceLimit(body, "body", limits.maxMessageCharacters);
3030
+ const command = raw;
3031
+ return handlingAck(await mutateHandling(access, command, "assistant_reply"), command.messageId);
3032
+ },
3033
+ async escalateToHuman(access, input) {
3034
+ requirePermission(access, supportPermissions.assistantEscalate);
3035
+ const raw = handlingInput(input, ["expectedRevision", "expectedHandlingGeneration", "triggerMessageId"], ["reason", "summary", "attemptedGuideIds"]);
3036
+ handlingFence(raw);
3037
+ requireIdentifier(boundaryString(raw.triggerMessageId, "triggerMessageId"), "triggerMessageId");
3038
+ if (raw.reason !== undefined &&
3039
+ ![
3040
+ "no_guidance",
3041
+ "unsuccessful",
3042
+ "capacity",
3043
+ "provider_failure",
3044
+ "other",
3045
+ ].includes(raw.reason))
3046
+ throw new TypeError("Invalid escalation reason");
3047
+ if (raw.summary !== undefined) {
3048
+ const summary = boundaryString(raw.summary, "summary");
3049
+ requireNonempty(summary, "handoff_summary");
3050
+ if (summary.length > 2_000)
3051
+ throw new SupportDeskLimitError("handoff_summary", 2_000);
3052
+ }
3053
+ if (raw.attemptedGuideIds !== undefined) {
3054
+ if (!Array.isArray(raw.attemptedGuideIds) ||
3055
+ raw.attemptedGuideIds.length > 16)
3056
+ throw new TypeError("Too many attempted guide IDs");
3057
+ const guides = raw.attemptedGuideIds;
3058
+ if (Object.keys(guides).length !== guides.length ||
3059
+ Object.getOwnPropertySymbols(guides).length !== 0)
3060
+ throw new TypeError("Guide IDs must be dense data");
3061
+ raw.attemptedGuideIds = Object.freeze(Array.from({ length: guides.length }, (_, index) => {
3062
+ const value = ownDataProperty(guides, String(index), "attemptedGuideIds");
3063
+ const id = boundaryString(value, "guideId");
3064
+ requireIdentifier(id, "guideId");
3065
+ if (id.length > 200 || id.includes("://"))
3066
+ throw new TypeError("Guide IDs must be bounded opaque references");
3067
+ return id;
3068
+ }));
3069
+ }
3070
+ return handlingAck(await mutateHandling(access, raw, "assistant_escalate"));
3071
+ },
3072
+ async requestPerson(access, input) {
3073
+ requirePermission(access, supportPermissions.requestPerson);
3074
+ const command = handlingInput(input, []);
3075
+ await mutateHandling(access, command, "request_person");
3076
+ return customerViewAfterChange(access.principalId, command.ticketId);
3077
+ },
3078
+ async takeOverTicket(access, input) {
3079
+ requireStaffMutationAccess(access, supportPermissions.takeOver);
3080
+ const command = handlingFence(handlingInput(input, [
3081
+ "expectedRevision",
3082
+ "expectedHandlingGeneration",
3083
+ ]));
3084
+ await mutateHandling(access, command, "take_over");
3085
+ return staffCommandResult(command.ticketId, command.commandId);
3086
+ },
3087
+ async confirmCustomerSuccess(access, input) {
3088
+ requirePermission(access, supportPermissions.confirmSuccess);
3089
+ const raw = handlingInput(input, ["lastSeenVisibleMessageId"]);
3090
+ requireIdentifier(boundaryString(raw.lastSeenVisibleMessageId, "lastSeenVisibleMessageId"), "lastSeenVisibleMessageId");
3091
+ const command = raw;
3092
+ await mutateHandling(access, command, "confirm_customer_success");
3093
+ return customerViewAfterChange(access.principalId, command.ticketId);
3094
+ },
3095
+ async readCustomerSuccessConfirmations(access, ticketId) {
3096
+ requireStaffMutationAccess(access, supportPermissions.auditRead);
3097
+ requireIdentifier(access.principalId, "principalId");
3098
+ requireIdentifier(ticketId, "ticketId");
3099
+ const rows = await records.list(ticketId);
3100
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
3101
+ if (!rows.some((row) => row.kind === "ticket"))
3102
+ throw new SupportDeskNotFoundError();
3103
+ return rows
3104
+ .filter((row) => row.kind === "customer_success")
3105
+ .sort((a, b) => a.revision - b.revision || a.commandId.localeCompare(b.commandId))
3106
+ .map(({ kind: _kind, partition: _partition, id: _id, ...proof }) => Object.freeze(proof));
3107
+ },
3108
+ async createCustomerTicket(access, input) {
3109
+ requirePermission(access, supportPermissions.create);
3110
+ requireIdentifier(access.principalId, "principalId");
3111
+ const command = snapshotCreateCustomerTicketCommand(input);
3112
+ if (command.initialHandling === "assistant") {
3113
+ requirePermission(access, supportPermissions.assistantStart);
3114
+ }
3115
+ requireIdentifier(command.ticketId, "ticketId");
3116
+ requireIdentifier(command.messageId, "messageId");
3117
+ requireIdentifier(command.commandId, "commandId");
3118
+ requireIdentifier(command.correlationId, "correlationId");
3119
+ enforceLimit(command.subject, "subject", limits.maxSubjectCharacters);
3120
+ enforceLimit(command.body, "body", limits.maxMessageCharacters);
3121
+ const fingerprintPayload = {
3122
+ type: "create_customer_ticket",
3123
+ ticketId: command.ticketId,
3124
+ messageId: command.messageId,
3125
+ subject: command.subject,
3126
+ body: command.body,
3127
+ // Omit absent category so pre-category receipts still match retries.
3128
+ ...(command.category === undefined
3129
+ ? {}
3130
+ : { category: command.category }),
3131
+ requesterEmail: command.requesterEmail ?? null,
3132
+ notification: stableNotification(command.notification),
3133
+ ...(command.initialHandling === undefined
3134
+ ? {}
3135
+ : { initialHandling: command.initialHandling }),
3136
+ };
3137
+ const fingerprint = await requestFingerprint(fingerprintPayload);
3138
+ const createThreading = {
3139
+ ticketNumber: true,
3140
+ ...(command.notification === undefined
3141
+ ? {}
3142
+ : { messageId: command.messageId }),
3143
+ };
3144
+ // Replay an already committed create before host allowlist checks so a
3145
+ // later config change cannot break idempotent retries of the same command.
3146
+ // Replay never reserves another instance ticket number.
3147
+ const existingCreate = await records.get(commandKey(command.ticketId, command.commandId));
3148
+ let createIsReplay = duplicateMatches(existingCreate, "create_customer_ticket", command.messageId, fingerprint);
3149
+ if (!createIsReplay &&
3150
+ command.initialHandling === undefined &&
3151
+ existingCreate?.kind === "command" &&
3152
+ existingCreate.commandType === "create_customer_ticket" &&
3153
+ existingCreate.messageId === command.messageId) {
3154
+ // Pre-Task-3 receipts included ticketNumber in the fingerprint.
3155
+ const existingTicket = await records.get(ticketKey(command.ticketId));
3156
+ if (existingTicket?.kind === "ticket") {
3157
+ // Pre-Task-3 hashed ticketNumber immediately after ticketId.
3158
+ const legacyFingerprint = await requestFingerprint({
3159
+ type: fingerprintPayload.type,
3160
+ ticketId: fingerprintPayload.ticketId,
3161
+ ticketNumber: existingTicket.ticket.number,
3162
+ messageId: fingerprintPayload.messageId,
3163
+ subject: fingerprintPayload.subject,
3164
+ body: fingerprintPayload.body,
3165
+ ...(command.category === undefined
3166
+ ? {}
3167
+ : { category: command.category }),
3168
+ requesterEmail: fingerprintPayload.requesterEmail,
3169
+ notification: fingerprintPayload.notification,
3170
+ });
3171
+ createIsReplay = duplicateMatches(existingCreate, "create_customer_ticket", command.messageId, legacyFingerprint);
3172
+ }
3173
+ }
1631
3174
  if (createIsReplay) {
3175
+ const current = await records.get(ticketKey(command.ticketId));
3176
+ if (current?.kind !== "ticket" ||
3177
+ !owns(current.ticket, access.principalId))
3178
+ throw new SupportDeskNotFoundError();
1632
3179
  // A crash after ticket commit and before index confirmation must still
1633
3180
  // converge the principal hint on retry.
1634
3181
  const fence = await records.get(ticketReservationKey(command.ticketId));
@@ -1636,8 +3183,10 @@ export function createSupportDeskApplication(options) {
1636
3183
  fence.state === "committed") {
1637
3184
  await confirmCustomerTicket(access.principalId, command.ticketId, fence.token, fence.generation);
1638
3185
  }
1639
- return customerViewAfterChange(access.principalId, command.ticketId);
3186
+ return customerViewAfterChange(access.principalId, command.ticketId, createThreading);
1640
3187
  }
3188
+ if (command.initialHandling === "assistant" && !assistantHandlingEnabled)
3189
+ throw new SupportDeskConflictError("Assistant handling is disabled");
1641
3190
  if (command.category !== undefined) {
1642
3191
  if (allowedCategories === undefined ||
1643
3192
  !allowedCategories.has(command.category)) {
@@ -1648,8 +3197,23 @@ export function createSupportDeskApplication(options) {
1648
3197
  // partition budget cannot exhaust the principal ticket index with
1649
3198
  // reserved-but-never-created tickets.
1650
3199
  const estimatedCreateRows = 6 + // ticket, quota, reservation, message, audit, command
1651
- (command.notification === undefined ? 0 : 1);
1652
- enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, estimatedCreateRows);
3200
+ (resolutionLearningOptions.enabled ? 1 : 0) +
3201
+ (engineeringOptions.enabled ? 1 : 0) +
3202
+ (command.notification === undefined ? 0 : 1) +
3203
+ (assistantTurnOptions !== undefined &&
3204
+ command.initialHandling === "assistant"
3205
+ ? 1
3206
+ : 0);
3207
+ enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, estimatedCreateRows +
3208
+ (resolutionLearningOptions.enabled ? 2 : 0) +
3209
+ (engineeringOptions.enabled ? 2 : 0) +
3210
+ (command.initialHandling === "assistant"
3211
+ ? assistantHumanReserveRows +
3212
+ (resolutionLearningOptions.enabled ? 1 : 0)
3213
+ : 0));
3214
+ if (command.initialHandling === "assistant" &&
3215
+ limits.maxMessagesPerTicket < 1 + assistantHumanReserveMessages)
3216
+ throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
1653
3217
  // Reserve outside the ticket transaction; gaps are accepted if create fails.
1654
3218
  const ticketNumber = await reserveTicketNumber();
1655
3219
  // Bind host-supplied notification content to the reserved number so mail
@@ -1658,11 +3222,14 @@ export function createSupportDeskApplication(options) {
1658
3222
  ? undefined
1659
3223
  : bindNotificationTicketNumber(command.notification, ticketNumber);
1660
3224
  const now = clock.now();
1661
- const ticket = createTicket({
3225
+ const ticket = await applyResponseTargets(createTicket({
1662
3226
  id: command.ticketId,
1663
3227
  number: ticketNumber,
1664
3228
  subject: command.subject,
1665
3229
  channel: "web",
3230
+ ...(command.initialHandling === undefined
3231
+ ? {}
3232
+ : { initialHandling: command.initialHandling }),
1666
3233
  ...(command.category === undefined
1667
3234
  ? {}
1668
3235
  : { category: command.category }),
@@ -1674,7 +3241,7 @@ export function createSupportDeskApplication(options) {
1674
3241
  : { email: command.requesterEmail }),
1675
3242
  },
1676
3243
  createdAt: now,
1677
- });
3244
+ }), responseTargetPolicy, "created");
1678
3245
  const message = {
1679
3246
  id: command.messageId,
1680
3247
  ticketId: command.ticketId,
@@ -1686,6 +3253,10 @@ export function createSupportDeskApplication(options) {
1686
3253
  body: command.body,
1687
3254
  createdAt: now,
1688
3255
  };
3256
+ const assistantTurn = assistantTurnOptions !== undefined &&
3257
+ command.initialHandling === "assistant"
3258
+ ? await createAssistantTurnRecord(ticket, message, command.correlationId, assistantTurnOptions)
3259
+ : undefined;
1689
3260
  const notificationJob = notification === undefined
1690
3261
  ? undefined
1691
3262
  : deliveryJobAction(command.ticketId, command.messageId, now, notification);
@@ -1728,6 +3299,22 @@ export function createSupportDeskApplication(options) {
1728
3299
  ]
1729
3300
  : [];
1730
3301
  const actions = [
3302
+ ...(engineeringOptions.enabled
3303
+ ? [
3304
+ {
3305
+ action: "insert",
3306
+ value: await createEngineeringSource(ticket),
3307
+ },
3308
+ ]
3309
+ : []),
3310
+ ...(resolutionLearningOptions.enabled
3311
+ ? [
3312
+ {
3313
+ action: "insert",
3314
+ value: await createResolutionLearningSource(ticket),
3315
+ },
3316
+ ]
3317
+ : []),
1731
3318
  {
1732
3319
  action: "insert",
1733
3320
  value: {
@@ -1744,6 +3331,9 @@ export function createSupportDeskApplication(options) {
1744
3331
  partition: command.ticketId,
1745
3332
  id: "quota",
1746
3333
  messageCount: 1,
3334
+ ...(command.initialHandling === "assistant"
3335
+ ? { assistantManaged: true }
3336
+ : {}),
1747
3337
  },
1748
3338
  },
1749
3339
  ...reservationActions,
@@ -1783,6 +3373,9 @@ export function createSupportDeskApplication(options) {
1783
3373
  },
1784
3374
  },
1785
3375
  ...(notificationJob === undefined ? [] : [notificationJob]),
3376
+ ...(assistantTurn === undefined
3377
+ ? []
3378
+ : [{ action: "insert", value: assistantTurn }]),
1786
3379
  ];
1787
3380
  // Create starts an empty partition; fail closed if the first write would
1788
3381
  // exceed the configured physical-record budget.
@@ -1795,7 +3388,7 @@ export function createSupportDeskApplication(options) {
1795
3388
  }
1796
3389
  }
1797
3390
  await confirmCustomerTicket(access.principalId, command.ticketId, reservation.reservationToken, reservation.reservationGeneration);
1798
- return customerViewAfterChange(access.principalId, command.ticketId);
3391
+ return customerViewAfterChange(access.principalId, command.ticketId, createThreading);
1799
3392
  },
1800
3393
  async replyToCustomerTicket(access, input) {
1801
3394
  requirePermission(access, supportPermissions.replyOwn);
@@ -1816,7 +3409,7 @@ export function createSupportDeskApplication(options) {
1816
3409
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
1817
3410
  const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
1818
3411
  if (duplicateMatches(duplicate, "reply_customer_ticket", command.messageId, fingerprint)) {
1819
- return customerViewAfterChange(access.principalId, command.ticketId);
3412
+ return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
1820
3413
  }
1821
3414
  const versioned = await records.getVersioned(ticketKey(command.ticketId));
1822
3415
  if (versioned?.value.kind !== "ticket" ||
@@ -1825,7 +3418,10 @@ export function createSupportDeskApplication(options) {
1825
3418
  throw new SupportDeskNotFoundError();
1826
3419
  }
1827
3420
  const partitionRows = await records.list(command.ticketId);
1828
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, command.notification === undefined ? 3 : 4);
3421
+ const durableAssistantIngress = assistantTurnOptions !== undefined &&
3422
+ effectiveTicketHandling(versioned.value.ticket).state === "assistant";
3423
+ const addedReplyRows = (command.notification === undefined ? 3 : 4) +
3424
+ (durableAssistantIngress ? 1 : 0);
1829
3425
  const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
1830
3426
  if (quota?.value.kind !== "ticket_quota") {
1831
3427
  throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
@@ -1839,11 +3435,25 @@ export function createSupportDeskApplication(options) {
1839
3435
  const now = sampledEpoch < storedEpoch
1840
3436
  ? versioned.value.ticket.updatedAt
1841
3437
  : sampledNow;
1842
- const updated = applyTicketEvent(versioned.value.ticket, {
3438
+ let updated = await applyResponseTargets(applyTicketEvent(versioned.value.ticket, {
1843
3439
  type: "customer_replied",
1844
3440
  actorId: access.principalId,
1845
3441
  occurredAt: now,
1846
- });
3442
+ }), responseTargetPolicy, "customer_replied");
3443
+ if (durableAssistantIngress && !assistantHandlingEnabled) {
3444
+ // Configuration can disable generation while an older assistant
3445
+ // conversation exists. The causal reply itself commits human work.
3446
+ updated = applyTicketEvent(updated, {
3447
+ type: "human_escalated",
3448
+ actorId: access.principalId,
3449
+ occurredAt: now,
3450
+ expectedRevision: updated.revision,
3451
+ expectedHandlingGeneration: effectiveTicketHandling(updated).generation,
3452
+ });
3453
+ }
3454
+ const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
3455
+ enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedReplyRows + learningMutation.additionalRows);
3456
+ enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, addedReplyRows + learningMutation.additionalRows, true);
1847
3457
  const message = {
1848
3458
  id: command.messageId,
1849
3459
  ticketId: command.ticketId,
@@ -1856,15 +3466,22 @@ export function createSupportDeskApplication(options) {
1856
3466
  createdAt: now,
1857
3467
  };
1858
3468
  const notificationContent = deliveryContent(command.notification);
3469
+ const assistantIngress = !durableAssistantIngress
3470
+ ? undefined
3471
+ : assistantHandlingEnabled
3472
+ ? await createAssistantTurnRecord(updated, message, command.correlationId, assistantTurnOptions)
3473
+ : handoffWork({
3474
+ ticket: updated,
3475
+ commandId: command.commandId,
3476
+ occurredAt: now,
3477
+ lastVisibleMessageId: command.messageId,
3478
+ reason: "other",
3479
+ });
1859
3480
  const notificationJob = command.notification === undefined
1860
3481
  ? undefined
1861
3482
  : deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
1862
3483
  const outcome = await records.transact(command.ticketId, [
1863
- {
1864
- action: "putIfUnchanged",
1865
- version: versioned.version,
1866
- value: { ...versioned.value, ticket: updated },
1867
- },
3484
+ ...learningMutation.actions,
1868
3485
  {
1869
3486
  action: "putIfUnchanged",
1870
3487
  version: quota.version,
@@ -1909,13 +3526,16 @@ export function createSupportDeskApplication(options) {
1909
3526
  },
1910
3527
  },
1911
3528
  ...(notificationJob === undefined ? [] : [notificationJob]),
3529
+ ...(assistantIngress === undefined
3530
+ ? []
3531
+ : [{ action: "insert", value: assistantIngress }]),
1912
3532
  ]);
1913
3533
  if (outcome.committed) {
1914
- return customerViewAfterChange(access.principalId, command.ticketId);
3534
+ return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
1915
3535
  }
1916
3536
  const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
1917
3537
  if (duplicateMatches(nowDuplicate, "reply_customer_ticket", command.messageId, fingerprint)) {
1918
- return customerViewAfterChange(access.principalId, command.ticketId);
3538
+ return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
1919
3539
  }
1920
3540
  }
1921
3541
  throw new SupportDeskConflictError();
@@ -1956,6 +3576,10 @@ export function createSupportDeskApplication(options) {
1956
3576
  requirePermission(access, supportPermissions.queueRead);
1957
3577
  requireIdentifier(access.principalId, "principalId");
1958
3578
  const query = snapshotStaffQueueQuery(input);
3579
+ // One trusted instant for the entire bounded request; no clock read for old queries.
3580
+ const targetNow = query.targetState === undefined
3581
+ ? undefined
3582
+ : canonicalTimestamp(clock.now(), "clock.now()");
1959
3583
  // Request-local cursor only: every online queue read starts without one
1960
3584
  // and consumes a single complete projection scan cycle.
1961
3585
  let cursor;
@@ -2016,10 +3640,17 @@ export function createSupportDeskApplication(options) {
2016
3640
  }
2017
3641
  const ticket = ticketRecord.ticket;
2018
3642
  if (ticket.id !== projection.ticketId ||
2019
- ticket.revision !== projection.projectedRevision ||
3643
+ !Number.isSafeInteger(projection.projectedRevision) ||
3644
+ projection.projectedRevision <= 0 ||
3645
+ projection.projectedRevision > ticket.revision ||
2020
3646
  isTerminalTicketStatus(ticket.status)) {
2021
3647
  continue;
2022
3648
  }
3649
+ const handlingView = query.handling ?? "human";
3650
+ if (handlingView !== "all" &&
3651
+ (handlingView === "assistant") !==
3652
+ (effectiveTicketHandling(ticket).state === "assistant"))
3653
+ continue;
2023
3654
  if (seenTicketIds.has(ticket.id)) {
2024
3655
  // Scan pages may repeat rows; de-duplicate after confirmation.
2025
3656
  continue;
@@ -2035,9 +3666,33 @@ export function createSupportDeskApplication(options) {
2035
3666
  }
2036
3667
  cursor = page.nextCursor;
2037
3668
  }
2038
- const filtered = confirmed.filter((item) => staffQueueItemMatches(item, query));
3669
+ const filtered = confirmed.filter((item) => staffQueueItemMatches(item, query, targetNow));
2039
3670
  const sort = query.sort ?? "updated_newest";
3671
+ // Parse each pending due date once per read, not once per comparison.
3672
+ const dueEpochs = new Map();
3673
+ if (sort === "due_soonest" || sort === "due_latest") {
3674
+ for (const item of filtered) {
3675
+ const due = pendingTargetDue(item, query.target);
3676
+ if (due !== undefined)
3677
+ dueEpochs.set(item, canonicalTimestamp(due, "ticket target"));
3678
+ }
3679
+ }
2040
3680
  filtered.sort((left, right) => {
3681
+ if (sort === "due_soonest" || sort === "due_latest") {
3682
+ const leftDue = dueEpochs.get(left);
3683
+ const rightDue = dueEpochs.get(right);
3684
+ // Completed and missing targets sort last in both directions.
3685
+ if (leftDue === undefined && rightDue !== undefined)
3686
+ return 1;
3687
+ if (rightDue === undefined && leftDue !== undefined)
3688
+ return -1;
3689
+ const difference = leftDue === undefined || rightDue === undefined
3690
+ ? 0
3691
+ : sort === "due_soonest"
3692
+ ? leftDue - rightDue
3693
+ : rightDue - leftDue;
3694
+ return difference || left.ticketId.localeCompare(right.ticketId);
3695
+ }
2041
3696
  const byTime = sort === "updated_oldest"
2042
3697
  ? left.updatedAt.localeCompare(right.updatedAt)
2043
3698
  : right.updatedAt.localeCompare(left.updatedAt);
@@ -2061,6 +3716,7 @@ export function createSupportDeskApplication(options) {
2061
3716
  requireIdentifier(command.correlationId, "correlationId");
2062
3717
  enforceLimit(command.body, "body", limits.maxMessageCharacters);
2063
3718
  const fingerprint = await requestFingerprint({
3719
+ ...snapshotRevisionHint(command),
2064
3720
  type: "staff_reply",
2065
3721
  ticketId: command.ticketId,
2066
3722
  messageId: command.messageId,
@@ -2070,14 +3726,17 @@ export function createSupportDeskApplication(options) {
2070
3726
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
2071
3727
  const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
2072
3728
  if (duplicateMatches(duplicate, "staff_reply", command.messageId, fingerprint)) {
2073
- return staffViewAfterChange(command.ticketId);
3729
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2074
3730
  }
3731
+ if (duplicate !== null)
3732
+ throw new SupportDeskConflictError("Command ID reused with different input");
2075
3733
  const versioned = await records.getVersioned(ticketKey(command.ticketId));
2076
3734
  if (versioned?.value.kind !== "ticket") {
2077
3735
  throw new SupportDeskNotFoundError();
2078
3736
  }
3737
+ if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
3738
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2079
3739
  const partitionRows = await records.list(command.ticketId);
2080
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, command.notification === undefined ? 3 : 4);
2081
3740
  const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
2082
3741
  if (quota?.value.kind !== "ticket_quota") {
2083
3742
  throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
@@ -2091,6 +3750,11 @@ export function createSupportDeskApplication(options) {
2091
3750
  actorId: access.principalId,
2092
3751
  occurredAt: now,
2093
3752
  });
3753
+ const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
3754
+ enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, (command.notification === undefined ? 3 : 4) +
3755
+ learningMutation.additionalRows);
3756
+ enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, (command.notification === undefined ? 3 : 4) +
3757
+ learningMutation.additionalRows, true, true);
2094
3758
  const message = {
2095
3759
  id: command.messageId,
2096
3760
  ticketId: command.ticketId,
@@ -2107,11 +3771,7 @@ export function createSupportDeskApplication(options) {
2107
3771
  ? undefined
2108
3772
  : deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
2109
3773
  const outcome = await records.transact(command.ticketId, [
2110
- {
2111
- action: "putIfUnchanged",
2112
- version: versioned.version,
2113
- value: { ...versioned.value, ticket: updated },
2114
- },
3774
+ ...learningMutation.actions,
2115
3775
  {
2116
3776
  action: "putIfUnchanged",
2117
3777
  version: quota.version,
@@ -2153,19 +3813,20 @@ export function createSupportDeskApplication(options) {
2153
3813
  messageId: command.messageId,
2154
3814
  requestFingerprint: fingerprint,
2155
3815
  completedAt: now,
3816
+ resultTicket: updated,
2156
3817
  },
2157
3818
  },
2158
3819
  ...(notificationJob === undefined ? [] : [notificationJob]),
2159
3820
  ]);
2160
3821
  if (outcome.committed) {
2161
- return staffViewAfterChange(command.ticketId);
3822
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2162
3823
  }
2163
3824
  const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
2164
3825
  if (duplicateMatches(nowDuplicate, "staff_reply", command.messageId, fingerprint)) {
2165
- return staffViewAfterChange(command.ticketId);
3826
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2166
3827
  }
2167
3828
  }
2168
- throw new SupportDeskConflictError();
3829
+ return staffConflictResult(command, fingerprint);
2169
3830
  },
2170
3831
  async addNote(access, input) {
2171
3832
  requireStaffMutationAccess(access, supportPermissions.note);
@@ -2177,6 +3838,7 @@ export function createSupportDeskApplication(options) {
2177
3838
  requireIdentifier(command.correlationId, "correlationId");
2178
3839
  enforceLimit(command.body, "body", limits.maxMessageCharacters);
2179
3840
  const fingerprint = await requestFingerprint({
3841
+ ...snapshotRevisionHint(command),
2180
3842
  type: "add_note",
2181
3843
  ticketId: command.ticketId,
2182
3844
  messageId: command.messageId,
@@ -2185,14 +3847,19 @@ export function createSupportDeskApplication(options) {
2185
3847
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
2186
3848
  const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
2187
3849
  if (duplicateMatches(duplicate, "add_note", command.messageId, fingerprint)) {
2188
- return staffViewAfterChange(command.ticketId);
3850
+ return staffCommandResult(command.ticketId, command.commandId);
2189
3851
  }
3852
+ if (duplicate !== null)
3853
+ throw new SupportDeskConflictError("Command ID reused with different input");
2190
3854
  const versioned = await records.getVersioned(ticketKey(command.ticketId));
2191
3855
  if (versioned?.value.kind !== "ticket") {
2192
3856
  throw new SupportDeskNotFoundError();
2193
3857
  }
3858
+ if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
3859
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2194
3860
  const partitionRows = await records.list(command.ticketId);
2195
3861
  enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 3);
3862
+ enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, 3, true);
2196
3863
  const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
2197
3864
  if (quota?.value.kind !== "ticket_quota") {
2198
3865
  throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
@@ -2268,18 +3935,19 @@ export function createSupportDeskApplication(options) {
2268
3935
  messageId: command.messageId,
2269
3936
  requestFingerprint: fingerprint,
2270
3937
  completedAt: now,
3938
+ resultTicket: updated,
2271
3939
  },
2272
3940
  },
2273
3941
  ]);
2274
3942
  if (outcome.committed) {
2275
- return staffViewAfterChange(command.ticketId);
3943
+ return staffCommandResult(command.ticketId, command.commandId);
2276
3944
  }
2277
3945
  const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
2278
3946
  if (duplicateMatches(nowDuplicate, "add_note", command.messageId, fingerprint)) {
2279
- return staffViewAfterChange(command.ticketId);
3947
+ return staffCommandResult(command.ticketId, command.commandId);
2280
3948
  }
2281
3949
  }
2282
- throw new SupportDeskConflictError();
3950
+ return staffConflictResult(command, fingerprint);
2283
3951
  },
2284
3952
  async assignTicket(access, input) {
2285
3953
  requireStaffMutationAccess(access, supportPermissions.assign);
@@ -2298,6 +3966,7 @@ export function createSupportDeskApplication(options) {
2298
3966
  enforceLimit(command.body, "body", limits.maxMessageCharacters);
2299
3967
  }
2300
3968
  const fingerprint = await requestFingerprint({
3969
+ ...snapshotRevisionHint(command),
2301
3970
  type: "assign_ticket",
2302
3971
  ticketId: command.ticketId,
2303
3972
  assigneeId: command.assigneeId,
@@ -2308,8 +3977,10 @@ export function createSupportDeskApplication(options) {
2308
3977
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
2309
3978
  const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
2310
3979
  if (duplicateMatches(duplicate, "assign_ticket", command.messageId, fingerprint)) {
2311
- return staffViewAfterChange(command.ticketId);
3980
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2312
3981
  }
3982
+ if (duplicate !== null)
3983
+ throw new SupportDeskConflictError("Command ID reused with different input");
2313
3984
  const versioned = await records.getVersioned(ticketKey(command.ticketId));
2314
3985
  if (versioned?.value.kind !== "ticket") {
2315
3986
  throw new SupportDeskNotFoundError();
@@ -2319,8 +3990,11 @@ export function createSupportDeskApplication(options) {
2319
3990
  : command.notification === undefined
2320
3991
  ? 3
2321
3992
  : 4;
3993
+ if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
3994
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2322
3995
  const partitionRows = await records.list(command.ticketId);
2323
3996
  enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords);
3997
+ enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, addedRecords, command.messageId !== undefined);
2324
3998
  let quotaVersion = null;
2325
3999
  if (command.messageId !== undefined) {
2326
4000
  quotaVersion = await records.getVersioned(ticketQuotaKey(command.ticketId));
@@ -2377,9 +4051,7 @@ export function createSupportDeskApplication(options) {
2377
4051
  action: "putIfUnchanged",
2378
4052
  version: quotaVersion.version,
2379
4053
  value: {
2380
- kind: "ticket_quota",
2381
- partition: command.ticketId,
2382
- id: "quota",
4054
+ ...quotaVersion.value,
2383
4055
  messageCount: quotaVersion.value.messageCount + 1,
2384
4056
  },
2385
4057
  },
@@ -2428,19 +4100,20 @@ export function createSupportDeskApplication(options) {
2428
4100
  : { messageId: command.messageId }),
2429
4101
  requestFingerprint: fingerprint,
2430
4102
  completedAt: now,
4103
+ resultTicket: updated,
2431
4104
  },
2432
4105
  },
2433
4106
  ...(notificationJob === undefined ? [] : [notificationJob]),
2434
4107
  ]);
2435
4108
  if (outcome.committed) {
2436
- return staffViewAfterChange(command.ticketId);
4109
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2437
4110
  }
2438
4111
  const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
2439
4112
  if (duplicateMatches(nowDuplicate, "assign_ticket", command.messageId, fingerprint)) {
2440
- return staffViewAfterChange(command.ticketId);
4113
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2441
4114
  }
2442
4115
  }
2443
- throw new SupportDeskConflictError();
4116
+ return staffConflictResult(command, fingerprint);
2444
4117
  },
2445
4118
  async changePriority(access, input) {
2446
4119
  requireStaffMutationAccess(access, supportPermissions.manage);
@@ -2450,6 +4123,7 @@ export function createSupportDeskApplication(options) {
2450
4123
  requireIdentifier(command.commandId, "commandId");
2451
4124
  requireIdentifier(command.correlationId, "correlationId");
2452
4125
  const fingerprint = await requestFingerprint({
4126
+ ...snapshotRevisionHint(command),
2453
4127
  type: "change_priority",
2454
4128
  ticketId: command.ticketId,
2455
4129
  priority: command.priority,
@@ -2457,23 +4131,28 @@ export function createSupportDeskApplication(options) {
2457
4131
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
2458
4132
  const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
2459
4133
  if (duplicateMatches(duplicate, "change_priority", undefined, fingerprint)) {
2460
- return staffViewAfterChange(command.ticketId);
4134
+ return staffCommandResult(command.ticketId, command.commandId);
2461
4135
  }
4136
+ if (duplicate !== null)
4137
+ throw new SupportDeskConflictError("Command ID reused with different input");
2462
4138
  const versioned = await records.getVersioned(ticketKey(command.ticketId));
2463
4139
  if (versioned?.value.kind !== "ticket") {
2464
4140
  throw new SupportDeskNotFoundError();
2465
4141
  }
4142
+ if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
4143
+ return staffCommandResult(command.ticketId, command.commandId);
2466
4144
  const partitionRows = await records.list(command.ticketId);
2467
4145
  enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 2);
4146
+ enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, 2);
2468
4147
  const statusBefore = versioned.value.ticket.status;
2469
4148
  const customerUpdatedBefore = versioned.value.ticket.customerUpdatedAt;
2470
4149
  const now = clampTicketTime(clock.now(), versioned.value.ticket.updatedAt);
2471
- const updated = applyTicketEvent(versioned.value.ticket, {
4150
+ const updated = await applyResponseTargets(applyTicketEvent(versioned.value.ticket, {
2472
4151
  type: "priority_changed",
2473
4152
  actorId: access.principalId,
2474
4153
  priority: command.priority,
2475
4154
  occurredAt: now,
2476
- });
4155
+ }), responseTargetPolicy, "priority_changed");
2477
4156
  if (updated.status !== statusBefore ||
2478
4157
  updated.customerUpdatedAt !== customerUpdatedBefore) {
2479
4158
  throw new SupportDeskConflictError("Priority change unexpectedly changed customer-visible ticket state");
@@ -2504,35 +4183,115 @@ export function createSupportDeskApplication(options) {
2504
4183
  commandType: "change_priority",
2505
4184
  requestFingerprint: fingerprint,
2506
4185
  completedAt: now,
4186
+ resultTicket: updated,
2507
4187
  },
2508
4188
  },
2509
4189
  ]);
2510
4190
  if (outcome.committed) {
2511
- return staffViewAfterChange(command.ticketId);
4191
+ return staffCommandResult(command.ticketId, command.commandId);
2512
4192
  }
2513
4193
  const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
2514
4194
  if (duplicateMatches(nowDuplicate, "change_priority", undefined, fingerprint)) {
2515
- return staffViewAfterChange(command.ticketId);
4195
+ return staffCommandResult(command.ticketId, command.commandId);
2516
4196
  }
2517
4197
  }
2518
- throw new SupportDeskConflictError();
4198
+ return staffConflictResult(command, fingerprint);
2519
4199
  },
2520
- async resolveTicket(access, input) {
2521
- return mutateLifecycle(access, input, {
2522
- commandType: "resolve_ticket",
2523
- eventType: "resolved",
2524
- auditAction: supportTicketAuditActions.resolved,
2525
- field: "resolve command",
4200
+ async setTags(access, input) {
4201
+ requireStaffMutationAccess(access, supportPermissions.manage);
4202
+ requireIdentifier(access.principalId, "principalId");
4203
+ const source = boundaryObject(input, "set tags command");
4204
+ const command = Object.freeze({
4205
+ ...snapshotRevisionHint(source),
4206
+ commandId: boundaryString(ownDataProperty(source, "commandId", "commandId"), "commandId"),
4207
+ correlationId: boundaryString(ownDataProperty(source, "correlationId", "correlationId"), "correlationId"),
4208
+ ticketId: boundaryString(ownDataProperty(source, "ticketId", "ticketId"), "ticketId"),
4209
+ tags: snapshotTags(ownDataProperty(source, "tags", "tags")),
2526
4210
  });
2527
- },
2528
- async closeTicket(access, input) {
2529
- return mutateLifecycle(access, input, {
2530
- commandType: "close_ticket",
2531
- eventType: "closed",
2532
- auditAction: supportTicketAuditActions.closed,
2533
- field: "close command",
4211
+ requireIdentifier(command.ticketId, "ticketId");
4212
+ requireIdentifier(command.commandId, "commandId");
4213
+ requireIdentifier(command.correlationId, "correlationId");
4214
+ const fingerprint = await requestFingerprint({
4215
+ type: "set_tags",
4216
+ ticketId: command.ticketId,
4217
+ tags: command.tags,
4218
+ ...snapshotRevisionHint(command),
2534
4219
  });
2535
- },
4220
+ for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
4221
+ const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
4222
+ if (duplicateMatches(duplicate, "set_tags", undefined, fingerprint))
4223
+ return staffCommandResult(command.ticketId, command.commandId);
4224
+ if (duplicate !== null)
4225
+ throw new SupportDeskConflictError("Command ID reused with different input");
4226
+ const versioned = await records.getVersioned(ticketKey(command.ticketId));
4227
+ if (versioned?.value.kind !== "ticket")
4228
+ throw new SupportDeskNotFoundError();
4229
+ if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
4230
+ return staffCommandResult(command.ticketId, command.commandId);
4231
+ if (command.tags.length > maxTagsPerTicket)
4232
+ throw new TypeError("tags exceed maxTagsPerTicket");
4233
+ if (command.tags.some((tag) => !allowedTags.has(tag)))
4234
+ throw new TypeError("tags must be on the host allowlist");
4235
+ const rows = await records.list(command.ticketId);
4236
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket, 2);
4237
+ enforceManagedAdmission(versioned.value.ticket, rows, limits, 2);
4238
+ const now = clampTicketTime(clock.now(), versioned.value.ticket.updatedAt);
4239
+ const updated = applyTicketEvent(versioned.value.ticket, {
4240
+ type: "tags_changed",
4241
+ actorId: access.principalId,
4242
+ occurredAt: now,
4243
+ tags: command.tags,
4244
+ });
4245
+ const outcome = await records.transact(command.ticketId, [
4246
+ {
4247
+ action: "putIfUnchanged",
4248
+ version: versioned.version,
4249
+ value: { ...versioned.value, ticket: updated },
4250
+ },
4251
+ ticketAuditAction({
4252
+ commandId: command.commandId,
4253
+ correlationId: command.correlationId,
4254
+ ticketId: command.ticketId,
4255
+ revision: updated.revision,
4256
+ actorId: access.principalId,
4257
+ occurredAt: now,
4258
+ action: supportTicketAuditActions.tagsChanged,
4259
+ }),
4260
+ {
4261
+ action: "insert",
4262
+ value: {
4263
+ kind: "command",
4264
+ partition: command.ticketId,
4265
+ id: `command:${command.commandId}`,
4266
+ commandId: command.commandId,
4267
+ commandType: "set_tags",
4268
+ requestFingerprint: fingerprint,
4269
+ completedAt: now,
4270
+ resultTicket: updated,
4271
+ },
4272
+ },
4273
+ ]);
4274
+ if (outcome.committed)
4275
+ return staffCommandResult(command.ticketId, command.commandId);
4276
+ }
4277
+ return staffConflictResult(command, fingerprint);
4278
+ },
4279
+ async resolveTicket(access, input) {
4280
+ return mutateLifecycle(access, input, {
4281
+ commandType: "resolve_ticket",
4282
+ eventType: "resolved",
4283
+ auditAction: supportTicketAuditActions.resolved,
4284
+ field: "resolve command",
4285
+ });
4286
+ },
4287
+ async closeTicket(access, input) {
4288
+ return mutateLifecycle(access, input, {
4289
+ commandType: "close_ticket",
4290
+ eventType: "closed",
4291
+ auditAction: supportTicketAuditActions.closed,
4292
+ field: "close command",
4293
+ });
4294
+ },
2536
4295
  async reopenTicket(access, input) {
2537
4296
  return mutateLifecycle(access, input, {
2538
4297
  commandType: "reopen_ticket",
@@ -2568,6 +4327,7 @@ export function createSupportDeskApplication(options) {
2568
4327
  enforceLimit(command.body, "body", limits.maxMessageCharacters);
2569
4328
  }
2570
4329
  const fingerprint = await requestFingerprint({
4330
+ ...snapshotRevisionHint(command),
2571
4331
  type: spec.commandType,
2572
4332
  ticketId: command.ticketId,
2573
4333
  messageId: command.messageId ?? null,
@@ -2577,19 +4337,25 @@ export function createSupportDeskApplication(options) {
2577
4337
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
2578
4338
  const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
2579
4339
  if (duplicateMatches(duplicate, spec.commandType, command.messageId, fingerprint)) {
2580
- return staffViewAfterChange(command.ticketId);
4340
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2581
4341
  }
4342
+ if (duplicate !== null)
4343
+ throw new SupportDeskConflictError("Command ID reused with different input");
2582
4344
  const versioned = await records.getVersioned(ticketKey(command.ticketId));
2583
4345
  if (versioned?.value.kind !== "ticket") {
2584
4346
  throw new SupportDeskNotFoundError();
2585
4347
  }
4348
+ if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
4349
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2586
4350
  const partitionRows = await records.list(command.ticketId);
2587
- const addedRecords = command.messageId === undefined
4351
+ const needsReopenHandoff = assistantTurnOptions !== undefined &&
4352
+ spec.eventType === "reopened" &&
4353
+ effectiveTicketHandling(versioned.value.ticket).state === "assistant";
4354
+ const addedRecords = (command.messageId === undefined
2588
4355
  ? 2 // audit + command
2589
4356
  : command.notification === undefined
2590
4357
  ? 3 // message + audit + command
2591
- : 4; // message + audit + command + delivery
2592
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords);
4358
+ : 4) + (needsReopenHandoff ? 1 : 0); // optional atomic human handoff
2593
4359
  let quotaVersion = null;
2594
4360
  if (command.messageId !== undefined) {
2595
4361
  quotaVersion = await records.getVersioned(ticketQuotaKey(command.ticketId));
@@ -2606,6 +4372,9 @@ export function createSupportDeskApplication(options) {
2606
4372
  actorId: access.principalId,
2607
4373
  occurredAt: now,
2608
4374
  });
4375
+ const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, command.messageId !== undefined);
4376
+ enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords + learningMutation.additionalRows);
4377
+ enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, addedRecords + learningMutation.additionalRows, command.messageId !== undefined);
2609
4378
  // Optional system message + mail commit with the lifecycle change so a
2610
4379
  // resolution notification cannot be lost after the ticket is resolved.
2611
4380
  const systemMessage = command.messageId === undefined || command.body === undefined
@@ -2625,11 +4394,7 @@ export function createSupportDeskApplication(options) {
2625
4394
  ? undefined
2626
4395
  : deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
2627
4396
  const outcome = await records.transact(command.ticketId, [
2628
- {
2629
- action: "putIfUnchanged",
2630
- version: versioned.version,
2631
- value: { ...versioned.value, ticket: updated },
2632
- },
4397
+ ...learningMutation.actions,
2633
4398
  ...(quotaVersion === null ||
2634
4399
  systemMessage === undefined ||
2635
4400
  quotaVersion.value.kind !== "ticket_quota"
@@ -2639,9 +4404,7 @@ export function createSupportDeskApplication(options) {
2639
4404
  action: "putIfUnchanged",
2640
4405
  version: quotaVersion.version,
2641
4406
  value: {
2642
- kind: "ticket_quota",
2643
- partition: command.ticketId,
2644
- id: "quota",
4407
+ ...quotaVersion.value,
2645
4408
  messageCount: quotaVersion.value.messageCount + 1,
2646
4409
  },
2647
4410
  },
@@ -2685,19 +4448,35 @@ export function createSupportDeskApplication(options) {
2685
4448
  : { messageId: command.messageId }),
2686
4449
  requestFingerprint: fingerprint,
2687
4450
  completedAt: now,
4451
+ resultTicket: updated,
2688
4452
  },
2689
4453
  },
2690
4454
  ...(notificationJob === undefined ? [] : [notificationJob]),
4455
+ ...(needsReopenHandoff
4456
+ ? [
4457
+ {
4458
+ action: "insert",
4459
+ value: handoffWork({
4460
+ ticket: updated,
4461
+ commandId: command.commandId,
4462
+ occurredAt: now,
4463
+ lastVisibleMessageId: systemMessage?.id ??
4464
+ latestVisibleRecord(partitionRows).message.id,
4465
+ reason: "other",
4466
+ }),
4467
+ },
4468
+ ]
4469
+ : []),
2691
4470
  ]);
2692
4471
  if (outcome.committed) {
2693
- return staffViewAfterChange(command.ticketId);
4472
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2694
4473
  }
2695
4474
  const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
2696
4475
  if (duplicateMatches(nowDuplicate, spec.commandType, command.messageId, fingerprint)) {
2697
- return staffViewAfterChange(command.ticketId);
4476
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2698
4477
  }
2699
4478
  }
2700
- throw new SupportDeskConflictError();
4479
+ return staffConflictResult(command, fingerprint);
2701
4480
  }
2702
4481
  }
2703
4482
  function canonicalTimestamp(value, field) {
@@ -2922,7 +4701,8 @@ export async function sweepInboundReceipts(store, clock, input) {
2922
4701
  }
2923
4702
  const receipts = store.collection(inboundReceipts);
2924
4703
  const candidates = (await receipts.listVersioned(bucket))
2925
- .filter((versioned) => versioned.value.status !== "processing" &&
4704
+ .filter((versioned) => (versioned.value.status === "processed" ||
4705
+ versioned.value.status === "rejected") &&
2926
4706
  versioned.value.processedAt !== undefined &&
2927
4707
  versioned.value.processedAt <= processedBefore &&
2928
4708
  versioned.value.processedAt < horizonBefore)
@@ -3045,4 +4825,1671 @@ export async function pruneCustomerTicketIndex(store, principalId, input) {
3045
4825
  });
3046
4826
  return result.value?.entries.map((entry) => entry.ticketId) ?? [];
3047
4827
  }
4828
+ const threadIndexKey = (record) => ({
4829
+ partition: record.key,
4830
+ id: record.id,
4831
+ });
4832
+ export const threadIndex = defineCollection({
4833
+ name: "support-desk.thread-index.v1",
4834
+ key: threadIndexKey,
4835
+ codec: jsonCodec(threadIndexKey),
4836
+ });
4837
+ /**
4838
+ * Default lifetime of `Message-ID` and reply-token threading rows. Lookups,
4839
+ * repair, and sweeping all treat older rows as absent; ticket-number rows
4840
+ * live as long as their ticket.
4841
+ */
4842
+ export const defaultThreadIndexRetentionMilliseconds = 365 * 86_400_000;
4843
+ /** Actor recorded on Audit events accepted from verified inbound mail. */
4844
+ export const inboundEmailSystemActorId = "support-desk.inbound-email";
4845
+ function notificationThreading(command) {
4846
+ return command.notification === undefined || command.messageId === undefined
4847
+ ? undefined
4848
+ : { messageId: command.messageId };
4849
+ }
4850
+ function messageKey(ticketId, messageId) {
4851
+ return { partition: ticketId, id: `message:${messageId}` };
4852
+ }
4853
+ async function sha256Hex(text) {
4854
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
4855
+ return [...new Uint8Array(digest)]
4856
+ .map((byte) => byte.toString(16).padStart(2, "0"))
4857
+ .join("");
4858
+ }
4859
+ async function messageIdIndexKey(value) {
4860
+ return `mid:${await sha256Hex(`message-id\u0000${value}`)}`;
4861
+ }
4862
+ async function replyTokenIndexKey(token) {
4863
+ return `rt:${await sha256Hex(`reply-token\u0000${token}`)}`;
4864
+ }
4865
+ function ticketNumberIndexKey(ticketNumber) {
4866
+ return `number:${ticketNumber}`;
4867
+ }
4868
+ const inboundMessageIdPattern = /^<[^<>\s@]+@[^<>\s@]+>$/;
4869
+ const maxInboundMessageIdCharacters = 512;
4870
+ /**
4871
+ * Accept a header-shaped `<local@domain>` identifier or nothing. Malformed
4872
+ * sender-controlled identifiers are dropped rather than stored or matched.
4873
+ */
4874
+ function normalizeInboundMessageId(value) {
4875
+ if (typeof value !== "string") {
4876
+ return undefined;
4877
+ }
4878
+ const trimmed = value.trim();
4879
+ if (trimmed.length === 0 ||
4880
+ trimmed.length > maxInboundMessageIdCharacters ||
4881
+ /[\u0000-\u001F\u007F]/.test(trimmed) ||
4882
+ !inboundMessageIdPattern.test(trimmed)) {
4883
+ return undefined;
4884
+ }
4885
+ return trimmed;
4886
+ }
4887
+ /** Derive every thread-index row an authoritative record implies. */
4888
+ async function threadIndexEntriesFor(record) {
4889
+ if (record.kind === "ticket") {
4890
+ const ticket = record.ticket;
4891
+ if (ticket.id !== record.partition ||
4892
+ !Number.isSafeInteger(ticket.number) ||
4893
+ ticket.number <= 0) {
4894
+ return [];
4895
+ }
4896
+ return [
4897
+ {
4898
+ key: ticketNumberIndexKey(ticket.number),
4899
+ id: "thread",
4900
+ kind: "ticket_number",
4901
+ ticketId: ticket.id,
4902
+ issuedAt: ticket.createdAt,
4903
+ },
4904
+ ];
4905
+ }
4906
+ if (record.kind !== "message") {
4907
+ return [];
4908
+ }
4909
+ const message = record.message;
4910
+ if (message.ticketId !== record.partition ||
4911
+ record.id !== `message:${message.id}`) {
4912
+ return [];
4913
+ }
4914
+ const entries = [];
4915
+ const content = record.deliveryContent;
4916
+ if (content !== undefined) {
4917
+ entries.push({
4918
+ key: await messageIdIndexKey(content.outboundMessageId),
4919
+ id: "thread",
4920
+ kind: "message_id",
4921
+ ticketId: message.ticketId,
4922
+ messageId: message.id,
4923
+ source: "outbound",
4924
+ issuedAt: message.createdAt,
4925
+ });
4926
+ if (content.replyRoutingToken !== undefined) {
4927
+ entries.push({
4928
+ key: await replyTokenIndexKey(content.replyRoutingToken),
4929
+ id: "thread",
4930
+ kind: "reply_token",
4931
+ ticketId: message.ticketId,
4932
+ messageId: message.id,
4933
+ issuedAt: message.createdAt,
4934
+ });
4935
+ }
4936
+ }
4937
+ if (message.channel === "email" &&
4938
+ message.authorKind === "customer" &&
4939
+ message.externalMessageId !== undefined &&
4940
+ message.externalMessageId !== content?.outboundMessageId &&
4941
+ normalizeInboundMessageId(message.externalMessageId) ===
4942
+ message.externalMessageId) {
4943
+ entries.push({
4944
+ key: await messageIdIndexKey(message.externalMessageId),
4945
+ id: "thread",
4946
+ kind: "message_id",
4947
+ ticketId: message.ticketId,
4948
+ messageId: message.id,
4949
+ source: "inbound",
4950
+ issuedAt: message.createdAt,
4951
+ });
4952
+ }
4953
+ return entries;
4954
+ }
4955
+ /** First writer wins; a different mapping for the same key is a collision. */
4956
+ async function putThreadIndexEntry(index, entry) {
4957
+ const result = await index.insertIfAbsent(entry);
4958
+ if (result.inserted) {
4959
+ return "written";
4960
+ }
4961
+ const existing = result.value;
4962
+ return existing.kind === entry.kind &&
4963
+ existing.ticketId === entry.ticketId &&
4964
+ existing.messageId === entry.messageId &&
4965
+ existing.source === entry.source
4966
+ ? "kept"
4967
+ : "collision";
4968
+ }
4969
+ function safeLog(logger, level, event, fields) {
4970
+ try {
4971
+ logger.log(level, event, fields);
4972
+ }
4973
+ catch {
4974
+ // ignore logger transport failures
4975
+ }
4976
+ }
4977
+ /**
4978
+ * Best-effort post-commit threading writes for one ticket. Failures are
4979
+ * logged and later converged by {@link repairThreadIndexPage}; they never
4980
+ * fail the command that already committed. Does not sample the clock.
4981
+ */
4982
+ async function indexThreadingAfterCommit(store, ticketId, hint, logger) {
4983
+ try {
4984
+ const records = store.collection(supportRecords);
4985
+ const index = store.collection(threadIndex);
4986
+ const sources = [];
4987
+ if (hint.ticketNumber === true) {
4988
+ const ticket = await records.get(ticketKey(ticketId));
4989
+ if (ticket !== null) {
4990
+ sources.push(ticket);
4991
+ }
4992
+ }
4993
+ if (hint.messageId !== undefined) {
4994
+ const message = await records.get(messageKey(ticketId, hint.messageId));
4995
+ if (message !== null) {
4996
+ sources.push(message);
4997
+ }
4998
+ }
4999
+ for (const source of sources) {
5000
+ for (const entry of await threadIndexEntriesFor(source)) {
5001
+ if ((await putThreadIndexEntry(index, entry)) === "collision") {
5002
+ safeLog(logger, "warn", "support-desk.thread_index.collision", {
5003
+ ticketId,
5004
+ kind: entry.kind,
5005
+ });
5006
+ }
5007
+ }
5008
+ }
5009
+ }
5010
+ catch (error) {
5011
+ safeLog(logger, "error", "support-desk.thread_index.write_failed", {
5012
+ ticketId,
5013
+ message: error instanceof Error ? error.message : "thread index write failed",
5014
+ });
5015
+ }
5016
+ }
5017
+ /**
5018
+ * Re-derive a row from its authoritative ticket or message. Returns the
5019
+ * current ticket only when the row still describes that record and, for
5020
+ * message rows, is inside the retention window.
5021
+ */
5022
+ async function confirmThreadEntry(records, entry, nowEpoch, retentionMilliseconds) {
5023
+ const ticketRecord = await records.get(ticketKey(entry.ticketId));
5024
+ if (ticketRecord?.kind !== "ticket" ||
5025
+ ticketRecord.ticket.id !== entry.ticketId) {
5026
+ return null;
5027
+ }
5028
+ let authoritative = ticketRecord;
5029
+ if (entry.kind !== "ticket_number") {
5030
+ if (entry.messageId === undefined) {
5031
+ return null;
5032
+ }
5033
+ const message = await records.get(messageKey(entry.ticketId, entry.messageId));
5034
+ if (message === null) {
5035
+ return null;
5036
+ }
5037
+ authoritative = message;
5038
+ }
5039
+ const derived = (await threadIndexEntriesFor(authoritative)).find((candidate) => candidate.key === entry.key && candidate.kind === entry.kind);
5040
+ if (derived === undefined ||
5041
+ derived.ticketId !== entry.ticketId ||
5042
+ derived.messageId !== entry.messageId ||
5043
+ derived.source !== entry.source) {
5044
+ return null;
5045
+ }
5046
+ if (derived.kind !== "ticket_number" &&
5047
+ nowEpoch - canonicalTimestamp(derived.issuedAt, "thread index issuedAt") >=
5048
+ retentionMilliseconds) {
5049
+ return null;
5050
+ }
5051
+ return ticketRecord.ticket;
5052
+ }
5053
+ async function lookupThreadEntry(records, index, kind, key, nowEpoch, retentionMilliseconds) {
5054
+ const entry = await index.get({ partition: key, id: "thread" });
5055
+ if (entry === null || entry.key !== key || entry.kind !== kind) {
5056
+ return null;
5057
+ }
5058
+ const ticket = await confirmThreadEntry(records, entry, nowEpoch, retentionMilliseconds);
5059
+ return ticket === null ? null : { ticket, entry };
5060
+ }
5061
+ function parseRetentionMilliseconds(value, field) {
5062
+ if (typeof value !== "number") {
5063
+ throw new TypeError(`${field} must be a number`);
5064
+ }
5065
+ if (!Number.isSafeInteger(value) || value <= 0) {
5066
+ throw new TypeError(`${field} must be a positive safe integer`);
5067
+ }
5068
+ return value;
5069
+ }
5070
+ function parseThreadIndexWorkerOptions(options, field) {
5071
+ const source = boundaryObject(options, field);
5072
+ const store = ownDataProperty(source, "store", `${field}.store`);
5073
+ const clock = ownDataProperty(source, "clock", `${field}.clock`);
5074
+ const retentionMilliseconds = parseRetentionMilliseconds(ownDataProperty(source, "threadIndexRetentionMilliseconds", `${field}.threadIndexRetentionMilliseconds`), `${field}.threadIndexRetentionMilliseconds`);
5075
+ const rawCursor = ownDataProperty(source, "cursor", `${field}.cursor`, true);
5076
+ const rawLimit = ownDataProperty(source, "limit", `${field}.limit`, true);
5077
+ if (rawCursor !== undefined && typeof rawCursor !== "string") {
5078
+ throw new TypeError(`${field}.cursor must be a string`);
5079
+ }
5080
+ if (rawLimit !== undefined && typeof rawLimit !== "number") {
5081
+ throw new TypeError(`${field}.limit must be between 1 and ${MAX_SCAN_PAGE_SIZE}`);
5082
+ }
5083
+ const limit = rawLimit ?? 100;
5084
+ if (!Number.isSafeInteger(limit) ||
5085
+ limit <= 0 ||
5086
+ limit > MAX_SCAN_PAGE_SIZE) {
5087
+ throw new TypeError(`${field}.limit must be between 1 and ${MAX_SCAN_PAGE_SIZE}`);
5088
+ }
5089
+ return {
5090
+ store,
5091
+ clock,
5092
+ retentionMilliseconds,
5093
+ ...(rawCursor === undefined ? {} : { cursor: rawCursor }),
5094
+ limit,
5095
+ };
5096
+ }
5097
+ /**
5098
+ * Scan one page of authoritative `support-desk.records.v1` rows and insert
5099
+ * every thread-index row they imply (ticket numbers, outbound `Message-ID`s,
5100
+ * reply tokens, and inbound `Message-ID`s). Rows past retention are not
5101
+ * recreated. Hosts persist `nextCursor` only after the whole page succeeds
5102
+ * and repeat complete cycles.
5103
+ */
5104
+ export async function repairThreadIndexPage(options) {
5105
+ const { store, clock, retentionMilliseconds, cursor, limit } = parseThreadIndexWorkerOptions(options, "thread index repair options");
5106
+ const records = store.collection(supportRecords);
5107
+ const index = store.collection(threadIndex);
5108
+ const page = await records.scan({
5109
+ limit,
5110
+ ...(cursor === undefined ? {} : { cursor }),
5111
+ });
5112
+ const nowEpoch = canonicalTimestamp(clock.now(), "clock.now()");
5113
+ let indexed = 0;
5114
+ let collisions = 0;
5115
+ for (const entry of page.records) {
5116
+ if (entry.key.partition !== entry.value.partition ||
5117
+ entry.key.id !== entry.value.id) {
5118
+ continue;
5119
+ }
5120
+ for (const candidate of await threadIndexEntriesFor(entry.value)) {
5121
+ if (candidate.kind !== "ticket_number" &&
5122
+ nowEpoch -
5123
+ canonicalTimestamp(candidate.issuedAt, "thread index issuedAt") >=
5124
+ retentionMilliseconds) {
5125
+ continue;
5126
+ }
5127
+ const outcome = await putThreadIndexEntry(index, candidate);
5128
+ if (outcome === "written") {
5129
+ indexed += 1;
5130
+ }
5131
+ else if (outcome === "collision") {
5132
+ collisions += 1;
5133
+ }
5134
+ }
5135
+ }
5136
+ return {
5137
+ nextCursor: page.nextCursor,
5138
+ physicalRows: page.records.length,
5139
+ indexed,
5140
+ collisions,
5141
+ };
5142
+ }
5143
+ /**
5144
+ * Scan one page of thread-index rows and conditionally delete every row whose
5145
+ * authoritative target is gone, no longer derives it, or is past retention.
5146
+ * Uses `deleteIfUnchanged` with the scanned version.
5147
+ */
5148
+ export async function sweepThreadIndexPage(options) {
5149
+ const { store, clock, retentionMilliseconds, cursor, limit } = parseThreadIndexWorkerOptions(options, "thread index sweep options");
5150
+ const records = store.collection(supportRecords);
5151
+ const index = store.collection(threadIndex);
5152
+ const page = await index.scan({
5153
+ limit,
5154
+ ...(cursor === undefined ? {} : { cursor }),
5155
+ });
5156
+ const nowEpoch = canonicalTimestamp(clock.now(), "clock.now()");
5157
+ let deleted = 0;
5158
+ for (const entry of page.records) {
5159
+ if (entry.key.partition !== entry.value.key || entry.key.id !== "thread") {
5160
+ continue;
5161
+ }
5162
+ if ((await confirmThreadEntry(records, entry.value, nowEpoch, retentionMilliseconds)) !== null) {
5163
+ continue;
5164
+ }
5165
+ if (await index.deleteIfUnchanged(entry.key, entry.version)) {
5166
+ deleted += 1;
5167
+ }
5168
+ }
5169
+ return {
5170
+ nextCursor: page.nextCursor,
5171
+ physicalRows: page.records.length,
5172
+ deleted,
5173
+ };
5174
+ }
5175
+ const maxInboundReferences = 100;
5176
+ const maxThreadingLookups = 20;
5177
+ const maxLoopSignalCharacters = 998;
5178
+ const maxInboundDisplayNameCharacters = 200;
5179
+ const maxSupportAddresses = 32;
5180
+ const subjectMarkerPrefixPattern = /^[A-Z][A-Z0-9]{0,15}$/;
5181
+ const inboundSuppressingPrecedence = new Set([
5182
+ "bulk",
5183
+ "junk",
5184
+ "list",
5185
+ "auto_reply",
5186
+ "auto-reply",
5187
+ ]);
5188
+ function collapseHeaderText(value) {
5189
+ return value
5190
+ .replace(/[\u0000-\u001F\u007F]+/g, " ")
5191
+ .replace(/\s+/g, " ")
5192
+ .trim();
5193
+ }
5194
+ function normalizeInboundSubject(value, maximum) {
5195
+ const collapsed = collapseHeaderText(value);
5196
+ const text = collapsed.length === 0 ? "(no subject)" : collapsed;
5197
+ const points = [...text];
5198
+ return points.length > maximum
5199
+ ? points.slice(0, maximum).join("").trimEnd()
5200
+ : text;
5201
+ }
5202
+ function normalizeInboundBody(value) {
5203
+ return value
5204
+ .replace(/\r\n?/g, "\n")
5205
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
5206
+ }
5207
+ function normalizeInboundDisplayName(value) {
5208
+ const collapsed = collapseHeaderText(value);
5209
+ if (collapsed.length === 0) {
5210
+ return undefined;
5211
+ }
5212
+ return [...collapsed].slice(0, maxInboundDisplayNameCharacters).join("");
5213
+ }
5214
+ function snapshotLoopSignals(input) {
5215
+ if (input === undefined) {
5216
+ return Object.freeze({});
5217
+ }
5218
+ const source = boundaryObject(input, "inbound email.loopSignals");
5219
+ const signals = {};
5220
+ for (const key of [
5221
+ "autoSubmitted",
5222
+ "precedence",
5223
+ "autoResponseSuppress",
5224
+ "listId",
5225
+ "contentType",
5226
+ "envelopeSender",
5227
+ ]) {
5228
+ const value = ownDataProperty(source, key, `inbound email.loopSignals.${key}`, true);
5229
+ if (value === undefined) {
5230
+ continue;
5231
+ }
5232
+ if (typeof value !== "string") {
5233
+ throw new TypeError(`inbound email.loopSignals.${key} must be a string`);
5234
+ }
5235
+ signals[key] = value.slice(0, maxLoopSignalCharacters);
5236
+ }
5237
+ const autoReply = ownDataProperty(source, "autoReplyHeaderPresent", "inbound email.loopSignals.autoReplyHeaderPresent", true);
5238
+ if (autoReply !== undefined) {
5239
+ if (typeof autoReply !== "boolean") {
5240
+ throw new TypeError("inbound email.loopSignals.autoReplyHeaderPresent must be a boolean");
5241
+ }
5242
+ signals.autoReplyHeaderPresent = autoReply;
5243
+ }
5244
+ return Object.freeze(signals);
5245
+ }
5246
+ function snapshotInboundSenderAuthentication(input) {
5247
+ if (input === undefined) {
5248
+ return undefined;
5249
+ }
5250
+ const field = "inbound email.senderAuthentication";
5251
+ const source = boundaryObject(input, field);
5252
+ const method = ownDataProperty(source, "method", `${field}.method`);
5253
+ if (method !== "dmarc" && method !== "host_identity") {
5254
+ throw new TypeError(`${field}.method must be dmarc or host_identity`);
5255
+ }
5256
+ const result = ownDataProperty(source, "result", `${field}.result`);
5257
+ if (result !== "pass" && result !== "fail") {
5258
+ throw new TypeError(`${field}.result must be pass or fail`);
5259
+ }
5260
+ const email = normalizeRequesterEmail(boundaryString(ownDataProperty(source, "email", `${field}.email`), `${field}.email`), `${field}.email`);
5261
+ if (method === "host_identity") {
5262
+ for (const key of ["dkimAligned", "spfAligned"]) {
5263
+ ownDataProperty(source, key, `${field}.${key}`, true, true);
5264
+ if (Object.hasOwn(source, key)) {
5265
+ throw new TypeError(`${field}.${key} is not valid when method is host_identity`);
5266
+ }
5267
+ }
5268
+ return Object.freeze({ method, result, email });
5269
+ }
5270
+ const dkimAligned = ownDataProperty(source, "dkimAligned", `${field}.dkimAligned`);
5271
+ if (typeof dkimAligned !== "boolean") {
5272
+ throw new TypeError(`${field}.dkimAligned must be a boolean`);
5273
+ }
5274
+ const spfAligned = ownDataProperty(source, "spfAligned", `${field}.spfAligned`);
5275
+ if (typeof spfAligned !== "boolean") {
5276
+ throw new TypeError(`${field}.spfAligned must be a boolean`);
5277
+ }
5278
+ return Object.freeze({
5279
+ method,
5280
+ result,
5281
+ email,
5282
+ dkimAligned,
5283
+ spfAligned,
5284
+ });
5285
+ }
5286
+ function snapshotInboundEmailInput(input, limits) {
5287
+ const source = boundaryObject(input, "inbound email");
5288
+ const field = (key) => `inbound email.${key}`;
5289
+ const readString = (key) => boundaryString(ownDataProperty(source, key, field(key)), field(key));
5290
+ const readIdentifier = (key) => {
5291
+ const value = readString(key);
5292
+ requireIdentifier(value, field(key));
5293
+ return value;
5294
+ };
5295
+ const readOptional = (key) => ownDataProperty(source, key, field(key), true);
5296
+ const channelId = readIdentifier("channelId");
5297
+ const providerEventId = readIdentifier("providerEventId");
5298
+ const ticketId = readIdentifier("ticketId");
5299
+ const messageId = readIdentifier("messageId");
5300
+ const correlationId = readIdentifier("correlationId");
5301
+ const from = normalizeRequesterEmail(readString("from"), field("from"));
5302
+ const rawSenderAuthentication = ownDataProperty(source, "senderAuthentication", field("senderAuthentication"), true, true);
5303
+ const senderAuthentication = snapshotInboundSenderAuthentication(rawSenderAuthentication);
5304
+ const rawName = readOptional("fromName");
5305
+ const fromName = rawName === undefined
5306
+ ? undefined
5307
+ : normalizeInboundDisplayName(boundaryString(rawName, field("fromName")));
5308
+ const subject = normalizeInboundSubject(readString("subject"), limits.maxSubjectCharacters);
5309
+ const body = normalizeInboundBody(readString("body"));
5310
+ enforceLimit(body, "body", limits.maxMessageCharacters);
5311
+ const rawFormat = readOptional("format");
5312
+ if (rawFormat !== undefined &&
5313
+ rawFormat !== "plain_text" &&
5314
+ rawFormat !== "markdown") {
5315
+ throw new TypeError(`${field("format")} must be plain_text or markdown`);
5316
+ }
5317
+ const format = rawFormat ?? "plain_text";
5318
+ const externalMessageId = normalizeInboundMessageId(readOptional("externalMessageId"));
5319
+ const inReplyTo = normalizeInboundMessageId(readOptional("inReplyTo"));
5320
+ const rawReferences = readOptional("references");
5321
+ const references = [];
5322
+ if (rawReferences !== undefined) {
5323
+ if (!Array.isArray(rawReferences)) {
5324
+ throw new TypeError(`${field("references")} must be an array`);
5325
+ }
5326
+ const start = Math.max(0, rawReferences.length - maxInboundReferences);
5327
+ for (let position = start; position < rawReferences.length; position += 1) {
5328
+ const normalized = normalizeInboundMessageId(ownDataProperty(rawReferences, position, `${field("references")}[${position}]`));
5329
+ if (normalized !== undefined && !references.includes(normalized)) {
5330
+ references.push(normalized);
5331
+ }
5332
+ }
5333
+ }
5334
+ const rawToken = readOptional("replyRoutingToken");
5335
+ if (rawToken !== undefined && typeof rawToken !== "string") {
5336
+ throw new TypeError(`${field("replyRoutingToken")} must be a string`);
5337
+ }
5338
+ const token = rawToken?.trim();
5339
+ const replyRoutingToken = token !== undefined && replyRoutingTokenPattern.test(token)
5340
+ ? token
5341
+ : undefined;
5342
+ const loopSignals = snapshotLoopSignals(readOptional("loopSignals"));
5343
+ return Object.freeze({
5344
+ channelId,
5345
+ providerEventId,
5346
+ ticketId,
5347
+ messageId,
5348
+ correlationId,
5349
+ from,
5350
+ ...(senderAuthentication === undefined ? {} : { senderAuthentication }),
5351
+ ...(fromName === undefined ? {} : { fromName }),
5352
+ subject,
5353
+ body,
5354
+ format,
5355
+ ...(externalMessageId === undefined ? {} : { externalMessageId }),
5356
+ ...(inReplyTo === undefined ? {} : { inReplyTo }),
5357
+ references: Object.freeze(references),
5358
+ ...(replyRoutingToken === undefined ? {} : { replyRoutingToken }),
5359
+ loopSignals,
5360
+ });
5361
+ }
5362
+ /** Content fingerprint for one provider event; excludes host-minted IDs. */
5363
+ function inboundFingerprintPayload(input, format = "current") {
5364
+ const canonicalSender = format === "current";
5365
+ const includeSenderResult = format !== "pre_sender_result";
5366
+ return {
5367
+ type: "inbound_email",
5368
+ channelId: input.channelId,
5369
+ providerEventId: input.providerEventId,
5370
+ from: canonicalSender ? input.from.toLowerCase() : input.from,
5371
+ ...(!includeSenderResult || input.senderAuthentication === undefined
5372
+ ? {}
5373
+ : {
5374
+ senderAuthentication: canonicalSender
5375
+ ? {
5376
+ ...input.senderAuthentication,
5377
+ email: input.senderAuthentication.email.toLowerCase(),
5378
+ }
5379
+ : input.senderAuthentication,
5380
+ }),
5381
+ fromName: input.fromName ?? null,
5382
+ subject: input.subject,
5383
+ body: input.body,
5384
+ format: input.format,
5385
+ externalMessageId: input.externalMessageId ?? null,
5386
+ inReplyTo: input.inReplyTo ?? null,
5387
+ references: input.references,
5388
+ replyRoutingToken: input.replyRoutingToken ?? null,
5389
+ loopSignals: input.loopSignals,
5390
+ };
5391
+ }
5392
+ function inboundSuppressionReason(input, supportAddresses) {
5393
+ const signals = input.loopSignals;
5394
+ const autoSubmitted = signals.autoSubmitted
5395
+ ?.split(";")[0]
5396
+ ?.trim()
5397
+ .toLowerCase();
5398
+ if (autoSubmitted !== undefined &&
5399
+ autoSubmitted.length > 0 &&
5400
+ autoSubmitted !== "no") {
5401
+ return "auto_submitted";
5402
+ }
5403
+ const precedence = signals.precedence?.trim().toLowerCase();
5404
+ if (precedence !== undefined &&
5405
+ inboundSuppressingPrecedence.has(precedence)) {
5406
+ return "precedence";
5407
+ }
5408
+ if (signals.autoReplyHeaderPresent === true) {
5409
+ return "auto_reply_header";
5410
+ }
5411
+ if (signals.envelopeSender !== undefined) {
5412
+ const envelope = signals.envelopeSender.trim();
5413
+ if (envelope.length === 0 || envelope === "<>") {
5414
+ return "bounce";
5415
+ }
5416
+ }
5417
+ const localPart = input.from.slice(0, input.from.indexOf("@")).toLowerCase();
5418
+ if (localPart === "mailer-daemon") {
5419
+ return "bounce";
5420
+ }
5421
+ const contentType = signals.contentType?.trim().toLowerCase();
5422
+ if (contentType !== undefined &&
5423
+ (contentType.startsWith("multipart/report") ||
5424
+ contentType.startsWith("message/delivery-status") ||
5425
+ contentType.startsWith("message/disposition-notification"))) {
5426
+ return "delivery_report";
5427
+ }
5428
+ if (supportAddresses.has(input.from.toLowerCase())) {
5429
+ return "support_address_loop";
5430
+ }
5431
+ return undefined;
5432
+ }
5433
+ function inboundSenderAcknowledgementAllowed(input) {
5434
+ const suppress = (input.loopSignals.autoResponseSuppress ?? "")
5435
+ .split(",")
5436
+ .map((value) => value.trim().toLowerCase());
5437
+ if (suppress.some((value) => value === "all" || value === "oof" || value === "autoreply")) {
5438
+ return false;
5439
+ }
5440
+ return (input.loopSignals.listId ?? "").trim().length === 0;
5441
+ }
5442
+ function subjectMarkerNumbers(subject, prefix) {
5443
+ const pattern = new RegExp(`\\[${prefix}-([1-9][0-9]{0,15})\\]`, "g");
5444
+ const found = [];
5445
+ for (const match of subject.matchAll(pattern)) {
5446
+ const value = Number(match[1]);
5447
+ if (Number.isSafeInteger(value) && !found.includes(value)) {
5448
+ found.push(value);
5449
+ }
5450
+ }
5451
+ return found;
5452
+ }
5453
+ function threadingHeaderCandidates(input) {
5454
+ const ordered = [
5455
+ ...(input.inReplyTo === undefined ? [] : [input.inReplyTo]),
5456
+ ...[...input.references].reverse(),
5457
+ ];
5458
+ const unique = [];
5459
+ for (const candidate of ordered) {
5460
+ if (candidate !== input.externalMessageId && !unique.includes(candidate)) {
5461
+ unique.push(candidate);
5462
+ }
5463
+ if (unique.length >= maxThreadingLookups) {
5464
+ break;
5465
+ }
5466
+ }
5467
+ return unique;
5468
+ }
5469
+ function sameAddress(left, right) {
5470
+ return left.toLowerCase() === right.toLowerCase();
5471
+ }
5472
+ /**
5473
+ * A thread match with a recorded requester address requires both exact
5474
+ * case-insensitive address equality and a passing host-evaluated sender result
5475
+ * bound to that normalized address. Without a recorded address, only an
5476
+ * opaque routing token can join; a `Message-ID` or subject marker alone is
5477
+ * never enough.
5478
+ */
5479
+ function threadJoinIsSafe(ticket, method, input) {
5480
+ const email = ticket.requester.email;
5481
+ if (email === undefined) {
5482
+ return method === "routing_token";
5483
+ }
5484
+ const authentication = input.senderAuthentication;
5485
+ if (authentication === undefined ||
5486
+ authentication.result !== "pass" ||
5487
+ !sameAddress(email, input.from) ||
5488
+ !sameAddress(authentication.email, input.from)) {
5489
+ return false;
5490
+ }
5491
+ return (authentication.method === "host_identity" ||
5492
+ authentication.dkimAligned ||
5493
+ authentication.spfAligned);
5494
+ }
5495
+ function snapshotSenderMatch(value) {
5496
+ const source = boundaryObject(value, "account matcher result");
5497
+ const matches = ownDataProperty(source, "matches", "account matcher result.matches");
5498
+ if (matches === "none" || matches === "many") {
5499
+ return Object.freeze({ matches });
5500
+ }
5501
+ if (matches === "one") {
5502
+ const principalId = boundaryString(ownDataProperty(source, "principalId", "account matcher result.principalId"), "account matcher result.principalId");
5503
+ requireIdentifier(principalId, "account matcher result.principalId");
5504
+ return Object.freeze({ matches, principalId });
5505
+ }
5506
+ throw new TypeError("account matcher result.matches must be none, one, or many");
5507
+ }
5508
+ function snapshotRoutingDecision(value, allowedCategories) {
5509
+ const source = boundaryObject(value, "inbound routing decision");
5510
+ const priority = ownDataProperty(source, "priority", "inbound routing decision.priority");
5511
+ if (priority !== "low" && priority !== "normal" && priority !== "high") {
5512
+ throw new TypeError("inbound routing decision.priority must be low, normal, or high; inbound mail is never automatically urgent");
5513
+ }
5514
+ const category = snapshotCategory(ownDataProperty(source, "category", "inbound routing decision.category", true), "inbound routing decision.category");
5515
+ if (category !== undefined &&
5516
+ (allowedCategories === undefined || !allowedCategories.has(category))) {
5517
+ throw new TypeError("inbound routing decision.category is not configured for this Support Desk instance");
5518
+ }
5519
+ return Object.freeze({
5520
+ priority,
5521
+ ...(category === undefined ? {} : { category }),
5522
+ });
5523
+ }
5524
+ function inboundAuditAction(input) {
5525
+ return supportAudit.action({
5526
+ id: input.commandId,
5527
+ occurredAt: input.occurredAt,
5528
+ actor: { kind: "system", systemId: inboundEmailSystemActorId },
5529
+ action: input.action,
5530
+ subject: input.ticketId,
5531
+ sequence: input.revision,
5532
+ details: {
5533
+ commandId: input.commandId,
5534
+ correlationId: input.correlationId,
5535
+ channel: "email",
5536
+ matchMethod: input.matchMethod,
5537
+ ...(input.association === undefined
5538
+ ? {}
5539
+ : { association: input.association }),
5540
+ },
5541
+ });
5542
+ }
5543
+ function inboundMessage(input, ticketId, messageId, now) {
5544
+ // No authorPrincipalId: a sender address never authenticates authorship.
5545
+ return {
5546
+ id: messageId,
5547
+ ticketId,
5548
+ authorKind: "customer",
5549
+ channel: "email",
5550
+ visibility: "customer",
5551
+ format: input.format,
5552
+ body: input.body,
5553
+ createdAt: now,
5554
+ ...(input.externalMessageId === undefined
5555
+ ? {}
5556
+ : { externalMessageId: input.externalMessageId }),
5557
+ ...(input.inReplyTo === undefined
5558
+ ? {}
5559
+ : { inReplyToExternalMessageId: input.inReplyTo }),
5560
+ };
5561
+ }
5562
+ function inboundResult(receipt, duplicateDelivery) {
5563
+ const route = receipt.route;
5564
+ return Object.freeze({
5565
+ duplicateDelivery,
5566
+ outcome: receipt.outcome ?? "rejected",
5567
+ ...(receipt.ticketId === undefined ? {} : { ticketId: receipt.ticketId }),
5568
+ ...(receipt.messageId === undefined
5569
+ ? {}
5570
+ : { messageId: receipt.messageId }),
5571
+ ...(route?.kind === "reply" || route?.kind === "new_conversation"
5572
+ ? { matchMethod: route.matchMethod }
5573
+ : {}),
5574
+ ...(route?.kind === "new_conversation"
5575
+ ? {
5576
+ association: route.association,
5577
+ ...(route.relatedTicketId === undefined
5578
+ ? {}
5579
+ : { relatedTicketId: route.relatedTicketId }),
5580
+ }
5581
+ : {}),
5582
+ ...(receipt.diagnostic === undefined ? {} : { reason: receipt.diagnostic }),
5583
+ });
5584
+ }
5585
+ function parseSupportAddresses(input) {
5586
+ const field = "inbound mail processor options.supportAddresses";
5587
+ if (input === undefined) {
5588
+ return new Set();
5589
+ }
5590
+ if (!Array.isArray(input)) {
5591
+ throw new TypeError(`${field} must be an array`);
5592
+ }
5593
+ if (input.length > maxSupportAddresses) {
5594
+ throw new TypeError(`${field} may contain at most ${maxSupportAddresses} values`);
5595
+ }
5596
+ const addresses = new Set();
5597
+ for (let position = 0; position < input.length; position += 1) {
5598
+ const entry = `${field}[${position}]`;
5599
+ const value = normalizeRequesterEmail(boundaryString(ownDataProperty(input, position, entry), entry), entry);
5600
+ addresses.add(value.toLowerCase());
5601
+ }
5602
+ return addresses;
5603
+ }
5604
+ /**
5605
+ * Create the inbound mail processor a host's mail worker calls after it has
5606
+ * verified provider authenticity and normalized bounded MIME content.
5607
+ *
5608
+ * Processing order: reserve the provider-event receipt with `insertIfAbsent`
5609
+ * before any ticket work; suppress bounce and auto-reply loops; persist one
5610
+ * routing decision on the receipt (routing token, then `In-Reply-To` /
5611
+ * `References`, then subject marker, each confirmed against authoritative
5612
+ * records); then create or reply in one ticket-partition transaction with
5613
+ * its Audit event, command receipt, and optional notification; finally mark
5614
+ * the receipt terminal and refresh the queue and threading projections.
5615
+ */
5616
+ export function createInboundMailProcessor(options) {
5617
+ const field = "inbound mail processor options";
5618
+ const source = boundaryObject(options, field);
5619
+ const read = (key, optional = false) => ownDataProperty(source, key, `${field}.${key}`, optional);
5620
+ const store = read("store");
5621
+ const clock = read("clock");
5622
+ const rawLogger = read("logger", true);
5623
+ const logger = rawLogger === undefined ? noopLogger : rawLogger;
5624
+ const accountMatcher = read("accountMatcher");
5625
+ const routingPolicy = read("routingPolicy");
5626
+ const notificationPolicy = read("notificationPolicy", true);
5627
+ if (accountMatcher === undefined ||
5628
+ accountMatcher === null ||
5629
+ typeof accountMatcher.matchSenderEmail !== "function") {
5630
+ throw new TypeError(`${field}.accountMatcher must provide matchSenderEmail()`);
5631
+ }
5632
+ if (routingPolicy === undefined ||
5633
+ routingPolicy === null ||
5634
+ typeof routingPolicy.routeNewConversation !== "function") {
5635
+ throw new TypeError(`${field}.routingPolicy must provide routeNewConversation()`);
5636
+ }
5637
+ if (notificationPolicy !== undefined &&
5638
+ (notificationPolicy === null ||
5639
+ typeof notificationPolicy.notificationFor !== "function")) {
5640
+ throw new TypeError(`${field}.notificationPolicy must provide notificationFor()`);
5641
+ }
5642
+ const limits = resolveSupportDeskLimits(read("limits", true), `${field}.limits`);
5643
+ const rawAttempts = read("maxConflictAttempts", true);
5644
+ if (rawAttempts !== undefined && typeof rawAttempts !== "number") {
5645
+ throw new TypeError("maxConflictAttempts must be between 1 and 20");
5646
+ }
5647
+ const maxConflictAttempts = rawAttempts ?? 4;
5648
+ if (!Number.isSafeInteger(maxConflictAttempts) ||
5649
+ maxConflictAttempts <= 0 ||
5650
+ maxConflictAttempts > 20) {
5651
+ throw new TypeError("maxConflictAttempts must be between 1 and 20");
5652
+ }
5653
+ const rawTerminalRetention = read("queueTerminalRetentionMilliseconds", true);
5654
+ if (rawTerminalRetention !== undefined &&
5655
+ typeof rawTerminalRetention !== "number") {
5656
+ throw new TypeError("queueTerminalRetentionMilliseconds must be a non-negative safe integer");
5657
+ }
5658
+ const terminalRetentionMilliseconds = rawTerminalRetention ??
5659
+ defaultQueueTerminalRetentionMilliseconds;
5660
+ if (!Number.isSafeInteger(terminalRetentionMilliseconds) ||
5661
+ terminalRetentionMilliseconds < 0) {
5662
+ throw new TypeError("queueTerminalRetentionMilliseconds must be a non-negative safe integer");
5663
+ }
5664
+ const rawThreadRetention = read("threadIndexRetentionMilliseconds", true);
5665
+ const threadIndexRetentionMilliseconds = rawThreadRetention === undefined
5666
+ ? defaultThreadIndexRetentionMilliseconds
5667
+ : parseRetentionMilliseconds(rawThreadRetention, `${field}.threadIndexRetentionMilliseconds`);
5668
+ const responseTargetPolicy = parseResponseTargetPolicy(read("responseTargetPolicy", true));
5669
+ const allowedCategories = parseAllowedCategories(read("allowedCategories", true), `${field}.allowedCategories`);
5670
+ const rawPrefix = read("subjectMarkerPrefix", true);
5671
+ if (rawPrefix !== undefined &&
5672
+ (typeof rawPrefix !== "string" ||
5673
+ !subjectMarkerPrefixPattern.test(rawPrefix))) {
5674
+ throw new TypeError(`${field}.subjectMarkerPrefix must match ${subjectMarkerPrefixPattern.source}`);
5675
+ }
5676
+ const subjectMarkerPrefix = rawPrefix;
5677
+ const supportAddresses = parseSupportAddresses(read("supportAddresses", true));
5678
+ const records = store.collection(supportRecords);
5679
+ const receipts = store.collection(inboundReceipts);
5680
+ const numbers = store.collection(ticketNumbers);
5681
+ const index = store.collection(threadIndex);
5682
+ async function bindInboundReplayFingerprint(key, stored, input, replayFingerprint) {
5683
+ if (stored.replayFingerprint !== undefined) {
5684
+ if (stored.replayFingerprint !== replayFingerprint) {
5685
+ throw new SupportDeskConflictError("An inbound provider event ID was reused for different content");
5686
+ }
5687
+ return stored;
5688
+ }
5689
+ const compatibleFingerprints = new Set([replayFingerprint]);
5690
+ if (!compatibleFingerprints.has(stored.payloadFingerprint)) {
5691
+ const priorFingerprints = await Promise.all([
5692
+ requestFingerprint(inboundFingerprintPayload(input, "case_preserving_sender")),
5693
+ requestFingerprint(inboundFingerprintPayload(input, "pre_sender_result")),
5694
+ ]);
5695
+ for (const prior of priorFingerprints) {
5696
+ compatibleFingerprints.add(prior);
5697
+ }
5698
+ }
5699
+ if (!compatibleFingerprints.has(stored.payloadFingerprint)) {
5700
+ throw new SupportDeskConflictError("An inbound provider event ID was reused for different content");
5701
+ }
5702
+ const bound = await receipts.update(key, (current) => {
5703
+ if (current === null) {
5704
+ throw new SupportDeskConflictError("Inbound receipt disappeared before replay binding");
5705
+ }
5706
+ if (current.replayFingerprint !== undefined) {
5707
+ return { action: "keep" };
5708
+ }
5709
+ if (!compatibleFingerprints.has(current.payloadFingerprint)) {
5710
+ return { action: "keep" };
5711
+ }
5712
+ return {
5713
+ action: "write",
5714
+ value: { ...current, replayFingerprint },
5715
+ };
5716
+ }, { maxAttempts: maxConflictAttempts });
5717
+ if (bound.value === null) {
5718
+ throw new SupportDeskConflictError("Inbound receipt disappeared before replay binding");
5719
+ }
5720
+ if (bound.value.replayFingerprint !== replayFingerprint ||
5721
+ !compatibleFingerprints.has(bound.value.payloadFingerprint)) {
5722
+ throw new SupportDeskConflictError("An inbound provider event ID was reused for different content");
5723
+ }
5724
+ return bound.value;
5725
+ }
5726
+ async function finishReceipt(work, terminal) {
5727
+ const processedAt = clock.now();
5728
+ canonicalTimestamp(processedAt, "clock.now()");
5729
+ const result = await receipts.update(work.key, (current) => {
5730
+ if (current === null) {
5731
+ throw new SupportDeskConflictError("Inbound receipt disappeared before it was finalized");
5732
+ }
5733
+ if (current.status !== "processing" && current.status !== "rerouting") {
5734
+ return { action: "keep" };
5735
+ }
5736
+ if (!sameInboundReceiptRoute(current.route, work.route)) {
5737
+ throw new SupportDeskConflictError("Inbound route changed before the receipt was finalized");
5738
+ }
5739
+ return {
5740
+ action: "write",
5741
+ value: {
5742
+ ...current,
5743
+ status: terminal.status,
5744
+ outcome: terminal.outcome,
5745
+ processedAt,
5746
+ ...(terminal.ticketId === undefined
5747
+ ? {}
5748
+ : { ticketId: terminal.ticketId }),
5749
+ ...(terminal.messageId === undefined
5750
+ ? {}
5751
+ : { messageId: terminal.messageId }),
5752
+ ...(terminal.diagnostic === undefined
5753
+ ? {}
5754
+ : { diagnostic: terminal.diagnostic }),
5755
+ },
5756
+ };
5757
+ }, { maxAttempts: maxConflictAttempts });
5758
+ if (result.value === null) {
5759
+ throw new SupportDeskConflictError("Inbound receipt disappeared before it was finalized");
5760
+ }
5761
+ return inboundResult(result.value, work.duplicateDelivery);
5762
+ }
5763
+ async function afterInboundCommit(ticketId, messageId, ticketNumber) {
5764
+ try {
5765
+ await projectTicketToQueue(ticketId, {
5766
+ store,
5767
+ clock,
5768
+ terminalRetentionMilliseconds,
5769
+ maxConflictAttempts,
5770
+ });
5771
+ }
5772
+ catch (error) {
5773
+ safeLog(logger, "error", "support-desk.queue.projection_failed", {
5774
+ ticketId,
5775
+ message: error instanceof Error ? error.message : "queue projection failed",
5776
+ });
5777
+ }
5778
+ await indexThreadingAfterCommit(store, ticketId, { messageId, ticketNumber }, logger);
5779
+ }
5780
+ async function inboundNotification(context) {
5781
+ if (notificationPolicy === undefined) {
5782
+ return undefined;
5783
+ }
5784
+ const proposed = await notificationPolicy.notificationFor(Object.freeze({ ...context }));
5785
+ if (proposed === undefined) {
5786
+ return undefined;
5787
+ }
5788
+ return bindNotificationTicketNumber(snapshotNotification(proposed), context.ticketNumber);
5789
+ }
5790
+ async function proposeNewConversation(input, reservedTicketId, matchMethod, relatedTicketId, forceUnverified = false) {
5791
+ const match = relatedTicketId === undefined && !forceUnverified
5792
+ ? snapshotSenderMatch(await accountMatcher.matchSenderEmail(Object.freeze({ channelId: input.channelId, email: input.from })))
5793
+ : { matches: "none" };
5794
+ const association = match.matches === "one" ? "matched_email" : "unverified";
5795
+ const decision = snapshotRoutingDecision(await routingPolicy.routeNewConversation(Object.freeze({
5796
+ channelId: input.channelId,
5797
+ association,
5798
+ ...(match.matches === "one"
5799
+ ? { principalId: match.principalId }
5800
+ : {}),
5801
+ ...(relatedTicketId === undefined ? {} : { relatedTicketId }),
5802
+ })), allowedCategories);
5803
+ return {
5804
+ kind: "new_conversation",
5805
+ ticketId: reservedTicketId,
5806
+ matchMethod,
5807
+ association,
5808
+ ...(match.matches === "one" ? { principalId: match.principalId } : {}),
5809
+ priority: decision.priority,
5810
+ ...(decision.category === undefined
5811
+ ? {}
5812
+ : { category: decision.category }),
5813
+ ...(relatedTicketId === undefined ? {} : { relatedTicketId }),
5814
+ };
5815
+ }
5816
+ async function claimRouteForRerouting(key, expected) {
5817
+ const claimed = await receipts.update(key, (current) => {
5818
+ if (current === null) {
5819
+ throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
5820
+ }
5821
+ if (current.status !== "processing" ||
5822
+ !sameInboundReceiptRoute(current.route, expected)) {
5823
+ return { action: "keep" };
5824
+ }
5825
+ return {
5826
+ action: "write",
5827
+ value: { ...current, status: "rerouting" },
5828
+ };
5829
+ }, { maxAttempts: maxConflictAttempts });
5830
+ if (claimed.value === null) {
5831
+ throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
5832
+ }
5833
+ return claimed.value;
5834
+ }
5835
+ async function replaceReroutingRoute(key, expected, proposed) {
5836
+ const replaced = await receipts.update(key, (current) => {
5837
+ if (current === null) {
5838
+ throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
5839
+ }
5840
+ if (current.status !== "rerouting" ||
5841
+ !sameInboundReceiptRoute(current.route, expected)) {
5842
+ return { action: "keep" };
5843
+ }
5844
+ return { action: "write", value: { ...current, route: proposed } };
5845
+ }, { maxAttempts: maxConflictAttempts });
5846
+ if (replaced.value === null) {
5847
+ throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
5848
+ }
5849
+ return replaced.value;
5850
+ }
5851
+ async function reserveReplyRerouteFence(input) {
5852
+ const location = commandKey(input.ticketId, input.commandId);
5853
+ for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
5854
+ const existing = await records.get(location);
5855
+ if (inboundReplyRerouteFenceMatches(existing, input.commandId, input.messageId, input.fingerprint)) {
5856
+ return true;
5857
+ }
5858
+ if (existing !== null) {
5859
+ return false;
5860
+ }
5861
+ const ticket = await records.getVersioned(ticketKey(input.ticketId));
5862
+ if (ticket?.value.kind !== "ticket") {
5863
+ return false;
5864
+ }
5865
+ const quota = await records.getVersioned(ticketQuotaKey(input.ticketId));
5866
+ if (quota?.value.kind !== "ticket_quota") {
5867
+ throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
5868
+ }
5869
+ const partitionRows = await records.list(input.ticketId);
5870
+ enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 1);
5871
+ enforceManagedAdmission(ticket.value.ticket, partitionRows, limits, 1);
5872
+ const outcome = await records.transact(input.ticketId, [
5873
+ {
5874
+ action: "putIfUnchanged",
5875
+ version: ticket.version,
5876
+ value: ticket.value,
5877
+ },
5878
+ {
5879
+ action: "putIfUnchanged",
5880
+ version: quota.version,
5881
+ value: quota.value,
5882
+ },
5883
+ {
5884
+ action: "insert",
5885
+ value: {
5886
+ kind: "inbound_reply_reroute_fence",
5887
+ partition: input.ticketId,
5888
+ id: location.id,
5889
+ commandId: input.commandId,
5890
+ messageId: input.messageId,
5891
+ requestFingerprint: input.fingerprint,
5892
+ recordedAt: input.recordedAt,
5893
+ },
5894
+ },
5895
+ ]);
5896
+ if (outcome.committed) {
5897
+ return true;
5898
+ }
5899
+ }
5900
+ const existing = await records.get(location);
5901
+ if (inboundReplyRerouteFenceMatches(existing, input.commandId, input.messageId, input.fingerprint)) {
5902
+ return true;
5903
+ }
5904
+ if (existing !== null) {
5905
+ return false;
5906
+ }
5907
+ throw new SupportDeskConflictError("Concurrent ticket updates exhausted retries; the provider may retry the same event. The inbound receipt remains processing and is safe to resume.");
5908
+ }
5909
+ async function reserveCreateRerouteFence(input) {
5910
+ const location = commandKey(input.ticketId, input.commandId);
5911
+ const existing = await records.get(location);
5912
+ if (inboundCreateRerouteFenceMatches(existing, input.commandId, input.messageId, input.fingerprint)) {
5913
+ return true;
5914
+ }
5915
+ if (existing !== null ||
5916
+ (await records.get(ticketKey(input.ticketId))) !== null) {
5917
+ return false;
5918
+ }
5919
+ enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, 1);
5920
+ const inserted = await records.insertIfAbsent({
5921
+ kind: "inbound_create_reroute_fence",
5922
+ partition: input.ticketId,
5923
+ id: location.id,
5924
+ commandId: input.commandId,
5925
+ messageId: input.messageId,
5926
+ requestFingerprint: input.fingerprint,
5927
+ recordedAt: input.recordedAt,
5928
+ });
5929
+ return (inserted.inserted ||
5930
+ inboundCreateRerouteFenceMatches(inserted.value, input.commandId, input.messageId, input.fingerprint));
5931
+ }
5932
+ async function proposeRoute(input, reservedTicketId) {
5933
+ const reason = inboundSuppressionReason(input, supportAddresses);
5934
+ if (reason !== undefined) {
5935
+ return { kind: "suppressed", reason };
5936
+ }
5937
+ const nowEpoch = canonicalTimestamp(clock.now(), "clock.now()");
5938
+ const lookup = (kind, key) => lookupThreadEntry(records, index, kind, key, nowEpoch, threadIndexRetentionMilliseconds);
5939
+ // The same Message-ID delivered under another provider event is the same
5940
+ // message only when the sender satisfies the existing-ticket join rule.
5941
+ let repeatedMessageTicket;
5942
+ if (input.externalMessageId !== undefined) {
5943
+ const own = await lookup("message_id", await messageIdIndexKey(input.externalMessageId));
5944
+ if (own !== null &&
5945
+ own.entry.source === "inbound" &&
5946
+ own.entry.messageId !== undefined) {
5947
+ if (threadJoinIsSafe(own.ticket, "message_id", input)) {
5948
+ return {
5949
+ kind: "duplicate_message",
5950
+ ticketId: own.ticket.id,
5951
+ messageId: own.entry.messageId,
5952
+ };
5953
+ }
5954
+ repeatedMessageTicket = own.ticket;
5955
+ }
5956
+ }
5957
+ let candidate;
5958
+ let ambiguous = false;
5959
+ if (input.replyRoutingToken !== undefined) {
5960
+ const hit = await lookup("reply_token", await replyTokenIndexKey(input.replyRoutingToken));
5961
+ if (hit !== null) {
5962
+ candidate = { ticket: hit.ticket, method: "routing_token" };
5963
+ }
5964
+ }
5965
+ if (candidate === undefined) {
5966
+ const found = [];
5967
+ for (const header of threadingHeaderCandidates(input)) {
5968
+ const hit = await lookup("message_id", await messageIdIndexKey(header));
5969
+ if (hit !== null &&
5970
+ !found.some((ticket) => ticket.id === hit.ticket.id)) {
5971
+ found.push(hit.ticket);
5972
+ }
5973
+ }
5974
+ const first = found[0];
5975
+ if (first !== undefined) {
5976
+ candidate = { ticket: first, method: "message_id" };
5977
+ ambiguous = found.length > 1;
5978
+ }
5979
+ }
5980
+ // Keep a repeated inbound Message-ID ahead of the display-only subject
5981
+ // marker when a stronger token or threading-header locator did not match.
5982
+ if (candidate === undefined && repeatedMessageTicket !== undefined) {
5983
+ candidate = {
5984
+ ticket: repeatedMessageTicket,
5985
+ method: "message_id",
5986
+ };
5987
+ }
5988
+ if (candidate === undefined && subjectMarkerPrefix !== undefined) {
5989
+ const markers = subjectMarkerNumbers(input.subject, subjectMarkerPrefix);
5990
+ ambiguous = markers.length > 1;
5991
+ for (const marker of markers) {
5992
+ const hit = await lookup("ticket_number", ticketNumberIndexKey(marker));
5993
+ if (hit !== null && candidate === undefined) {
5994
+ candidate = { ticket: hit.ticket, method: "subject_marker" };
5995
+ }
5996
+ }
5997
+ }
5998
+ if (candidate !== undefined &&
5999
+ !ambiguous &&
6000
+ candidate.ticket.id !== reservedTicketId &&
6001
+ threadJoinIsSafe(candidate.ticket, candidate.method, input)) {
6002
+ return {
6003
+ kind: "reply",
6004
+ ticketId: candidate.ticket.id,
6005
+ matchMethod: candidate.method,
6006
+ };
6007
+ }
6008
+ const relatedTicketId = candidate !== undefined && candidate.ticket.id !== reservedTicketId
6009
+ ? candidate.ticket.id
6010
+ : undefined;
6011
+ return proposeNewConversation(input, reservedTicketId, candidate?.method ?? "none", relatedTicketId, ambiguous);
6012
+ }
6013
+ async function decideRoute(key, stored, input, reservedTicketId, commandId, messageId, fingerprint) {
6014
+ if (stored.status !== "processing" && stored.status !== "rerouting") {
6015
+ return stored;
6016
+ }
6017
+ if (stored.route !== undefined) {
6018
+ const route = stored.route;
6019
+ if (route.kind === "suppressed") {
6020
+ return stored;
6021
+ }
6022
+ if (route.kind === "duplicate_message") {
6023
+ const target = await records.get(ticketKey(route.ticketId));
6024
+ if (target?.kind === "ticket" &&
6025
+ threadJoinIsSafe(target.ticket, "message_id", input)) {
6026
+ return stored;
6027
+ }
6028
+ if (stored.status === "processing") {
6029
+ return decideRoute(key, await claimRouteForRerouting(key, route), input, reservedTicketId, commandId, messageId, fingerprint);
6030
+ }
6031
+ return replaceReroutingRoute(key, route, await proposeNewConversation(input, reservedTicketId, "message_id", route.ticketId, true));
6032
+ }
6033
+ if (route.kind === "new_conversation") {
6034
+ if (route.relatedTicketId === undefined ||
6035
+ (route.association === "unverified" &&
6036
+ route.principalId === undefined)) {
6037
+ return stored;
6038
+ }
6039
+ const existing = await records.get(commandKey(route.ticketId, commandId));
6040
+ const alreadyFenced = inboundCreateRerouteFenceMatches(existing, commandId, messageId, fingerprint);
6041
+ // A committed create remains authoritative for receipt replay.
6042
+ if (existing !== null && !alreadyFenced) {
6043
+ return stored;
6044
+ }
6045
+ if (stored.status === "processing") {
6046
+ return decideRoute(key, await claimRouteForRerouting(key, route), input, reservedTicketId, commandId, messageId, fingerprint);
6047
+ }
6048
+ if (!alreadyFenced &&
6049
+ !(await reserveCreateRerouteFence({
6050
+ ticketId: route.ticketId,
6051
+ commandId,
6052
+ messageId,
6053
+ fingerprint,
6054
+ recordedAt: stored.receivedAt,
6055
+ }))) {
6056
+ return stored;
6057
+ }
6058
+ return replaceReroutingRoute(key, route, await proposeNewConversation(input, reservedTicketId, route.matchMethod, route.relatedTicketId, true));
6059
+ }
6060
+ const commandLocation = commandKey(route.ticketId, commandId);
6061
+ const existing = await records.get(commandLocation);
6062
+ const alreadyFenced = inboundReplyRerouteFenceMatches(existing, commandId, messageId, fingerprint);
6063
+ if (existing !== null && !alreadyFenced) {
6064
+ return stored;
6065
+ }
6066
+ const target = await records.get(ticketKey(route.ticketId));
6067
+ if (!alreadyFenced &&
6068
+ (target?.kind !== "ticket" ||
6069
+ threadJoinIsSafe(target.ticket, route.matchMethod, input))) {
6070
+ return stored;
6071
+ }
6072
+ if (stored.status === "processing") {
6073
+ return decideRoute(key, await claimRouteForRerouting(key, route), input, reservedTicketId, commandId, messageId, fingerprint);
6074
+ }
6075
+ if (!alreadyFenced) {
6076
+ const fenced = await reserveReplyRerouteFence({
6077
+ ticketId: route.ticketId,
6078
+ commandId,
6079
+ messageId,
6080
+ fingerprint,
6081
+ recordedAt: stored.receivedAt,
6082
+ });
6083
+ if (!fenced) {
6084
+ return stored;
6085
+ }
6086
+ }
6087
+ return replaceReroutingRoute(key, route, await proposeNewConversation(input, reservedTicketId, route.matchMethod, route.ticketId, true));
6088
+ }
6089
+ const proposed = await proposeRoute(input, reservedTicketId);
6090
+ const result = await receipts.update(key, (current) => {
6091
+ if (current === null) {
6092
+ throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
6093
+ }
6094
+ if (current.status !== "processing" || current.route !== undefined) {
6095
+ return { action: "keep" };
6096
+ }
6097
+ return { action: "write", value: { ...current, route: proposed } };
6098
+ }, { maxAttempts: maxConflictAttempts });
6099
+ if (result.value === null ||
6100
+ ((result.value.status === "processing" ||
6101
+ result.value.status === "rerouting") &&
6102
+ result.value.route === undefined)) {
6103
+ throw new SupportDeskConflictError("Inbound routing decision was not stored");
6104
+ }
6105
+ return result.value;
6106
+ }
6107
+ async function createConversation(work, route) {
6108
+ const { input, messageId, commandId, fingerprint } = work;
6109
+ const ticketId = route.ticketId;
6110
+ const commandLocation = commandKey(ticketId, commandId);
6111
+ const existingVersioned = await records.getVersioned(commandLocation);
6112
+ const existing = existingVersioned?.value ?? null;
6113
+ const createFence = existingVersioned !== null &&
6114
+ inboundCreateRerouteFenceMatches(existingVersioned.value, commandId, messageId, fingerprint)
6115
+ ? existingVersioned
6116
+ : null;
6117
+ if (existing !== null && createFence === null) {
6118
+ if (!duplicateMatches(existing, "inbound_email_create", messageId, fingerprint)) {
6119
+ throw new SupportDeskConflictError("An inbound ticket command ID was reused for different content");
6120
+ }
6121
+ }
6122
+ else {
6123
+ // ticket, quota, message, audit, command, and an optional delivery job.
6124
+ enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, 6);
6125
+ // Reserved outside the ticket transaction; a failed create leaves a gap.
6126
+ const ticketNumber = await reserveNextTicketNumber(numbers, maxConflictAttempts);
6127
+ const now = clock.now();
6128
+ const displayName = input.fromName === undefined ? {} : { displayName: input.fromName };
6129
+ const ticket = await applyResponseTargets(createTicket({
6130
+ id: ticketId,
6131
+ number: ticketNumber,
6132
+ subject: input.subject,
6133
+ channel: "email",
6134
+ priority: route.priority,
6135
+ ...(route.category === undefined ? {} : { category: route.category }),
6136
+ requester: route.association === "matched_email"
6137
+ ? {
6138
+ association: "matched_email",
6139
+ principalId: route.principalId,
6140
+ email: input.from,
6141
+ ...displayName,
6142
+ }
6143
+ : {
6144
+ association: "unverified",
6145
+ email: input.from,
6146
+ ...displayName,
6147
+ },
6148
+ createdAt: now,
6149
+ ...(route.relatedTicketId === undefined
6150
+ ? {}
6151
+ : { relatedTicketId: route.relatedTicketId }),
6152
+ }), responseTargetPolicy, "created");
6153
+ const notification = await inboundNotification({
6154
+ outcome: "created",
6155
+ channelId: input.channelId,
6156
+ ticketId,
6157
+ ticketNumber,
6158
+ messageId,
6159
+ association: route.association,
6160
+ matchMethod: route.matchMethod,
6161
+ senderAcknowledgementAllowed: inboundSenderAcknowledgementAllowed(input),
6162
+ });
6163
+ const notificationContent = deliveryContent(notification);
6164
+ const commandRecord = {
6165
+ kind: "command",
6166
+ partition: ticketId,
6167
+ id: commandLocation.id,
6168
+ commandId,
6169
+ commandType: "inbound_email_create",
6170
+ messageId,
6171
+ requestFingerprint: fingerprint,
6172
+ completedAt: now,
6173
+ };
6174
+ const actions = [
6175
+ {
6176
+ action: "insert",
6177
+ value: { kind: "ticket", partition: ticketId, id: "ticket", ticket },
6178
+ },
6179
+ {
6180
+ action: "insert",
6181
+ value: {
6182
+ kind: "ticket_quota",
6183
+ partition: ticketId,
6184
+ id: "quota",
6185
+ messageCount: 1,
6186
+ },
6187
+ },
6188
+ {
6189
+ action: "insert",
6190
+ value: {
6191
+ kind: "message",
6192
+ partition: ticketId,
6193
+ id: `message:${messageId}`,
6194
+ ordinal: ticket.revision,
6195
+ message: inboundMessage(input, ticketId, messageId, now),
6196
+ ...(notificationContent === undefined
6197
+ ? {}
6198
+ : { deliveryContent: notificationContent }),
6199
+ },
6200
+ },
6201
+ inboundAuditAction({
6202
+ commandId,
6203
+ correlationId: work.correlationId,
6204
+ ticketId,
6205
+ revision: ticket.revision,
6206
+ occurredAt: now,
6207
+ action: supportTicketAuditActions.created,
6208
+ matchMethod: route.matchMethod,
6209
+ association: route.association,
6210
+ }),
6211
+ ...(createFence === null
6212
+ ? [{ action: "insert", value: commandRecord }]
6213
+ : [
6214
+ {
6215
+ action: "putIfUnchanged",
6216
+ version: createFence.version,
6217
+ value: commandRecord,
6218
+ },
6219
+ ]),
6220
+ ...(notification === undefined
6221
+ ? []
6222
+ : [deliveryJobAction(ticketId, messageId, now, notification)]),
6223
+ ];
6224
+ enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, actions.length);
6225
+ const outcome = await records.transact(ticketId, actions);
6226
+ if (!outcome.committed) {
6227
+ const raced = await records.get(commandKey(ticketId, commandId));
6228
+ if (!duplicateMatches(raced, "inbound_email_create", messageId, fingerprint)) {
6229
+ if (raced !== null &&
6230
+ !inboundCreateRerouteFenceMatches(raced, commandId, messageId, fingerprint)) {
6231
+ throw new SupportDeskConflictError("An inbound ticket command ID was reused for different content");
6232
+ }
6233
+ throw new SupportDeskConflictError("Concurrent ticket updates exhausted retries; the provider may retry the same event. The inbound receipt remains processing and is safe to resume.");
6234
+ }
6235
+ }
6236
+ }
6237
+ const result = await finishReceipt(work, {
6238
+ status: "processed",
6239
+ outcome: "created",
6240
+ ticketId,
6241
+ messageId,
6242
+ });
6243
+ await afterInboundCommit(ticketId, messageId, true);
6244
+ return result;
6245
+ }
6246
+ async function appendReply(work, route) {
6247
+ const { input, messageId, commandId, fingerprint } = work;
6248
+ const ticketId = route.ticketId;
6249
+ const completed = async () => {
6250
+ const result = await finishReceipt(work, {
6251
+ status: "processed",
6252
+ outcome: "replied",
6253
+ ticketId,
6254
+ messageId,
6255
+ });
6256
+ await afterInboundCommit(ticketId, messageId, false);
6257
+ return result;
6258
+ };
6259
+ const rejected = (diagnostic) => finishReceipt(work, {
6260
+ status: "rejected",
6261
+ outcome: "rejected",
6262
+ diagnostic,
6263
+ });
6264
+ for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
6265
+ const duplicate = await records.get(commandKey(ticketId, commandId));
6266
+ if (duplicate !== null) {
6267
+ if (!duplicateMatches(duplicate, "inbound_email_reply", messageId, fingerprint)) {
6268
+ throw new SupportDeskConflictError("An inbound ticket command ID was reused for different content");
6269
+ }
6270
+ return completed();
6271
+ }
6272
+ const versioned = await records.getVersioned(ticketKey(ticketId));
6273
+ if (versioned?.value.kind !== "ticket") {
6274
+ return rejected("ticket_unavailable");
6275
+ }
6276
+ const ticket = versioned.value.ticket;
6277
+ const partitionRows = await records.list(ticketId);
6278
+ if (input.externalMessageId !== undefined) {
6279
+ const prior = partitionRows.find((row) => row.kind === "message" &&
6280
+ row.message.channel === "email" &&
6281
+ row.message.authorKind === "customer" &&
6282
+ row.message.externalMessageId === input.externalMessageId);
6283
+ if (prior !== undefined) {
6284
+ return finishReceipt(work, {
6285
+ status: "processed",
6286
+ outcome: "duplicate_message",
6287
+ ticketId,
6288
+ messageId: prior.message.id,
6289
+ });
6290
+ }
6291
+ }
6292
+ const quota = await records.getVersioned(ticketQuotaKey(ticketId));
6293
+ if (quota?.value.kind !== "ticket_quota") {
6294
+ throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
6295
+ }
6296
+ if (quota.value.messageCount >= limits.maxMessagesPerTicket) {
6297
+ return rejected("ticket_messages_limit");
6298
+ }
6299
+ const notification = await inboundNotification({
6300
+ outcome: "replied",
6301
+ channelId: input.channelId,
6302
+ ticketId,
6303
+ ticketNumber: ticket.number,
6304
+ messageId,
6305
+ association: ticket.requester.association,
6306
+ matchMethod: route.matchMethod,
6307
+ ...(ticket.assignedTo === undefined
6308
+ ? {}
6309
+ : { assignedTo: ticket.assignedTo }),
6310
+ senderAcknowledgementAllowed: inboundSenderAcknowledgementAllowed(input),
6311
+ });
6312
+ const requiresHandoff = effectiveTicketHandling(ticket).state === "assistant";
6313
+ const now = clampTicketTime(clock.now(), ticket.updatedAt);
6314
+ // Customer-reply semantics: any status, including resolved or closed,
6315
+ // moves to waiting_on_support. No actor: email is not authentication.
6316
+ const updated = await applyResponseTargets(applyTicketEvent(ticket, {
6317
+ type: "customer_replied",
6318
+ occurredAt: now,
6319
+ channel: "email",
6320
+ }), responseTargetPolicy, "customer_replied");
6321
+ const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
6322
+ try {
6323
+ enforceManagedAdmission(ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, (notification === undefined ? 3 : 4) +
6324
+ (requiresHandoff ? 1 : 0) +
6325
+ learningMutation.additionalRows, true);
6326
+ }
6327
+ catch (error) {
6328
+ if (!(error instanceof SupportDeskLimitError))
6329
+ throw error;
6330
+ return rejected(error.field === "ticket_messages"
6331
+ ? "ticket_messages_limit"
6332
+ : "ticket_partition_limit");
6333
+ }
6334
+ const notificationContent = deliveryContent(notification);
6335
+ const outcome = await records.transact(ticketId, [
6336
+ ...learningMutation.actions,
6337
+ {
6338
+ action: "putIfUnchanged",
6339
+ version: quota.version,
6340
+ value: {
6341
+ ...quota.value,
6342
+ messageCount: quota.value.messageCount + 1,
6343
+ },
6344
+ },
6345
+ {
6346
+ action: "insert",
6347
+ value: {
6348
+ kind: "message",
6349
+ partition: ticketId,
6350
+ id: `message:${messageId}`,
6351
+ ordinal: updated.revision,
6352
+ message: inboundMessage(input, ticketId, messageId, now),
6353
+ ...(notificationContent === undefined
6354
+ ? {}
6355
+ : { deliveryContent: notificationContent }),
6356
+ },
6357
+ },
6358
+ inboundAuditAction({
6359
+ commandId,
6360
+ correlationId: work.correlationId,
6361
+ ticketId,
6362
+ revision: updated.revision,
6363
+ occurredAt: now,
6364
+ action: supportTicketAuditActions.customerReplied,
6365
+ matchMethod: route.matchMethod,
6366
+ }),
6367
+ {
6368
+ action: "insert",
6369
+ value: {
6370
+ kind: "command",
6371
+ partition: ticketId,
6372
+ id: `command:${commandId}`,
6373
+ commandId,
6374
+ commandType: "inbound_email_reply",
6375
+ messageId,
6376
+ requestFingerprint: fingerprint,
6377
+ completedAt: now,
6378
+ },
6379
+ },
6380
+ ...(notification === undefined
6381
+ ? []
6382
+ : [deliveryJobAction(ticketId, messageId, now, notification)]),
6383
+ ...(requiresHandoff
6384
+ ? [
6385
+ {
6386
+ action: "insert",
6387
+ value: handoffWork({
6388
+ ticket: updated,
6389
+ commandId,
6390
+ occurredAt: now,
6391
+ lastVisibleMessageId: messageId,
6392
+ reason: "email_reply",
6393
+ }),
6394
+ },
6395
+ ]
6396
+ : []),
6397
+ ]);
6398
+ if (outcome.committed) {
6399
+ return completed();
6400
+ }
6401
+ }
6402
+ const last = await records.get(commandKey(ticketId, commandId));
6403
+ if (duplicateMatches(last, "inbound_email_reply", messageId, fingerprint)) {
6404
+ return completed();
6405
+ }
6406
+ if (last !== null) {
6407
+ throw new SupportDeskConflictError("An inbound ticket command ID was reused for different content");
6408
+ }
6409
+ throw new SupportDeskConflictError("Concurrent ticket updates exhausted retries; the provider may retry the same event. The inbound receipt remains processing and is safe to resume.");
6410
+ }
6411
+ return Object.freeze({
6412
+ async processInboundEmail(rawInput) {
6413
+ const input = snapshotInboundEmailInput(rawInput, limits);
6414
+ const replayFingerprint = await requestFingerprint(inboundFingerprintPayload(input));
6415
+ const location = await inboundReceiptLocation(input.channelId, input.providerEventId);
6416
+ const key = {
6417
+ partition: location.bucket,
6418
+ id: receiptSlotId(location.slot, "inbound receipt slot"),
6419
+ };
6420
+ const receivedAt = new Date(canonicalTimestamp(clock.now(), "clock.now()")).toISOString();
6421
+ const inserted = await receipts.insertIfAbsent({
6422
+ bucket: location.bucket,
6423
+ slot: location.slot,
6424
+ channelId: input.channelId,
6425
+ providerEventId: input.providerEventId,
6426
+ ...(input.externalMessageId === undefined
6427
+ ? {}
6428
+ : { externalMessageId: input.externalMessageId }),
6429
+ payloadFingerprint: replayFingerprint,
6430
+ replayFingerprint,
6431
+ status: "processing",
6432
+ receivedAt,
6433
+ reservedTicketId: input.ticketId,
6434
+ reservedMessageId: input.messageId,
6435
+ correlationId: input.correlationId,
6436
+ });
6437
+ let stored = inserted.value;
6438
+ if (stored.channelId !== input.channelId ||
6439
+ stored.providerEventId !== input.providerEventId) {
6440
+ throw new SupportDeskConflictError("An inbound receipt hash slot collided with a different provider event");
6441
+ }
6442
+ stored = await bindInboundReplayFingerprint(key, stored, input, replayFingerprint);
6443
+ const fingerprint = stored.payloadFingerprint;
6444
+ const duplicateDelivery = !inserted.inserted;
6445
+ if (stored.status !== "processing" && stored.status !== "rerouting") {
6446
+ return inboundResult(stored, duplicateDelivery);
6447
+ }
6448
+ const reservedTicketId = stored.reservedTicketId;
6449
+ const reservedMessageId = stored.reservedMessageId;
6450
+ const correlationId = stored.correlationId;
6451
+ if (reservedTicketId === undefined ||
6452
+ reservedMessageId === undefined ||
6453
+ correlationId === undefined) {
6454
+ throw new SupportDeskConflictError("Inbound receipt is missing its reserved identifiers");
6455
+ }
6456
+ // Stable per provider event and independent of retry identifiers.
6457
+ const commandId = `inbound:${location.bucket.slice(-30)}${location.slot}`;
6458
+ const decided = await decideRoute(key, stored, input, reservedTicketId, commandId, reservedMessageId, fingerprint);
6459
+ if (decided.status !== "processing" && decided.status !== "rerouting") {
6460
+ return inboundResult(decided, duplicateDelivery);
6461
+ }
6462
+ const route = decided.route;
6463
+ const work = {
6464
+ key,
6465
+ input,
6466
+ route,
6467
+ messageId: reservedMessageId,
6468
+ correlationId,
6469
+ commandId,
6470
+ fingerprint,
6471
+ duplicateDelivery,
6472
+ };
6473
+ switch (route.kind) {
6474
+ case "suppressed":
6475
+ return finishReceipt(work, {
6476
+ status: "rejected",
6477
+ outcome: "suppressed",
6478
+ diagnostic: route.reason,
6479
+ });
6480
+ case "duplicate_message":
6481
+ return finishReceipt(work, {
6482
+ status: "processed",
6483
+ outcome: "duplicate_message",
6484
+ ticketId: route.ticketId,
6485
+ messageId: route.messageId,
6486
+ });
6487
+ case "new_conversation":
6488
+ return createConversation(work, route);
6489
+ case "reply":
6490
+ return appendReply(work, route);
6491
+ }
6492
+ },
6493
+ });
6494
+ }
3048
6495
  //# sourceMappingURL=index.js.map