@pegma/support-desk-application 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +228 -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 +640 -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 +3568 -204
  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 +8 -8
package/dist/index.js CHANGED
@@ -3,7 +3,26 @@ import { hasPermission } from "@pegma/authorization-core";
3
3
  import { defineMail, maxMailAttempts } from "@pegma/mail";
4
4
  import { noopLogger } from "@pegma/spine";
5
5
  import { defineCollection, MAX_SCAN_PAGE_SIZE, } from "@pegma/storage-core";
6
- import { applyTicketEvent, createTicket } from "@pegma/support-desk-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,175 @@ 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
+ async function planTicketDerivationMutation(records, before, after, rows, visibleChange = false, notice) {
1072
+ const learning = await planResolutionLearningTicketMutation(records, before, after, notice !== undefined && visibleChange);
1073
+ const engineering = notice === undefined
1074
+ ? await planEngineeringTicketMutation(records, before, after, {
1075
+ visibleChange,
1076
+ })
1077
+ : { actions: [], additionalRows: 0 };
1078
+ const reopened = ["resolved", "closed"].includes(before.value.ticket.status) &&
1079
+ !["resolved", "closed"].includes(after.status);
1080
+ const followup = visibleChange || reopened
1081
+ ? await planEngineeringFollowupTicketMutation(records, rows, after.id, notice !== undefined
1082
+ ? "approved_followup_notice"
1083
+ : reopened
1084
+ ? "reopened"
1085
+ : "visible_source_changed", notice?.completedFollowupWorkId)
1086
+ : { actions: [], nextWorkRows: [] };
1087
+ return {
1088
+ ...learning,
1089
+ actions: [...learning.actions, ...engineering.actions, ...followup.actions],
1090
+ additionalRows: learning.additionalRows + engineering.additionalRows,
1091
+ nextWorkRows: followup.nextWorkRows,
1092
+ };
1093
+ }
1094
+ /** Project reserved work transitions without inventing another quota counter. */
1095
+ function projectFollowupRows(rows, nextWorkRows) {
1096
+ if (nextWorkRows.length > 8)
1097
+ throw new SupportDeskConflictError("Follow-up transition bound exceeded");
1098
+ const updates = new Map(nextWorkRows.map((row) => [row.id, row]));
1099
+ if (updates.size !== nextWorkRows.length)
1100
+ throw new SupportDeskConflictError("Duplicate follow-up transition");
1101
+ const projected = rows.map((row) => {
1102
+ if (row.kind !== "engineering_followup_work")
1103
+ return row;
1104
+ const next = updates.get(row.id);
1105
+ updates.delete(row.id);
1106
+ return next ?? row;
1107
+ });
1108
+ return [...projected, ...updates.values()];
1109
+ }
1110
+ function followupMessageSlots(rows) {
1111
+ return rows.reduce((sum, row) => sum + (row.kind === "engineering_followup_work" ? row.messageSlots : 0), 0);
1112
+ }
1113
+ function humanReserveRows(rows) {
1114
+ return (assistantHumanReserveRows +
1115
+ (rows.some((row) => row.kind === "resolution_learning_source") ? 1 : 0));
1116
+ }
1117
+ function derivationReserveRows(rows, consuming = false) {
1118
+ const learning = consuming !== "learning" &&
1119
+ rows.some((row) => row.kind === "resolution_learning_source" && row.state !== "withdrawn")
1120
+ ? 2
1121
+ : 0;
1122
+ const engineering = consuming !== "engineering" &&
1123
+ rows.some((row) => row.kind === "engineering_source" && row.state !== "withdrawn")
1124
+ ? 2
1125
+ : 0;
1126
+ const terminalWorkId = typeof consuming === "object" ? consuming.engineeringTerminalWorkId : null;
1127
+ const terminal = rows.reduce((sum, row) => sum +
1128
+ (row.kind === "engineering_work" && row.id !== terminalWorkId
1129
+ ? row.terminalReserveRows
1130
+ : 0), 0);
1131
+ const followup = rows.reduce((sum, row) => sum + (row.kind === "engineering_followup_work" ? row.completionRows : 0), 0);
1132
+ return learning + engineering + terminal + followup;
1133
+ }
1134
+ /** Reserve is opt-in metadata on the existing quota, never a second counter. */
1135
+ function enforceManagedAdmission(ticket, rows, limits, additionalRows, addsMessage = false, firstHumanReply = false, consumesWithdrawalReserve = false) {
1136
+ const quota = rows.find((row) => row.kind === "ticket_quota");
1137
+ const reserved = quota?.assistantManaged === true &&
1138
+ ticket.firstStaffRespondedAt === undefined &&
1139
+ !firstHumanReply;
1140
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket -
1141
+ (reserved ? humanReserveRows(rows) : 0) -
1142
+ derivationReserveRows(rows, consumesWithdrawalReserve), additionalRows);
1143
+ if (addsMessage &&
1144
+ quota !== undefined &&
1145
+ quota.messageCount >=
1146
+ limits.maxMessagesPerTicket -
1147
+ (reserved ? assistantHumanReserveMessages : 0) -
1148
+ followupMessageSlots(rows))
1149
+ throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
1150
+ }
1151
+ function latestVisibleRecord(rows) {
1152
+ // Reuse the ordinal validation that protects ordinary customer views.
1153
+ customerMessages(rows);
1154
+ return rows
1155
+ .filter((row) => row.kind === "message" && row.message.visibility === "customer")
1156
+ .sort((a, b) => b.ordinal - a.ordinal)[0];
1157
+ }
1158
+ function enforceHandlingControlAdmission(ticket, rows, limits, additionalRows, consumesWithdrawalReserve = false) {
1159
+ const quota = rows.find((row) => row.kind === "ticket_quota");
1160
+ const reserved = quota?.assistantManaged === true &&
1161
+ ticket.firstStaffRespondedAt === undefined;
1162
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket -
1163
+ (reserved ? firstHumanReplyReserveRows : 0) -
1164
+ derivationReserveRows(rows, consumesWithdrawalReserve), additionalRows);
1165
+ if (reserved && quota.messageCount >= limits.maxMessagesPerTicket)
1166
+ throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
1167
+ }
1168
+ function handlingInput(input, fields, optional = []) {
1169
+ const source = boundaryObject(input, "handling command");
1170
+ if (Object.getOwnPropertySymbols(source).length !== 0)
1171
+ throw new TypeError("Handling commands must contain data fields");
1172
+ const allowed = [
1173
+ "commandId",
1174
+ "correlationId",
1175
+ "ticketId",
1176
+ ...fields,
1177
+ ...optional,
1178
+ ];
1179
+ if (Object.getOwnPropertyNames(source).some((key) => !allowed.includes(key)))
1180
+ throw new TypeError("Handling command contains unsupported fields");
1181
+ const result = {};
1182
+ for (const field of allowed) {
1183
+ const value = ownDataProperty(source, field, "handling command." + field, optional.includes(field));
1184
+ if (value !== undefined)
1185
+ result[field] = value;
1186
+ }
1187
+ for (const field of ["commandId", "correlationId", "ticketId"]) {
1188
+ const value = boundaryString(result[field], field);
1189
+ requireIdentifier(value, field);
1190
+ result[field] = value;
1191
+ }
1192
+ return result;
1193
+ }
1194
+ function handlingFence(input) {
1195
+ for (const field of ["expectedRevision", "expectedHandlingGeneration"]) {
1196
+ if (typeof input[field] !== "number" ||
1197
+ !Number.isSafeInteger(input[field]) ||
1198
+ input[field] < (field === "expectedRevision" ? 1 : 0))
1199
+ throw new TypeError("Handling fence must be a safe integer");
1200
+ }
1201
+ return input;
1202
+ }
1203
+ function requireAssistantTurn(ticket, rows, command) {
1204
+ const handling = effectiveTicketHandling(ticket);
1205
+ const latest = latestVisibleRecord(rows);
1206
+ if (ticket.channel !== "web" ||
1207
+ isTerminalTicketStatus(ticket.status) ||
1208
+ handling.state !== "assistant" ||
1209
+ ticket.revision !== command.expectedRevision ||
1210
+ handling.generation !== command.expectedHandlingGeneration ||
1211
+ latest?.message.authorKind !== "customer" ||
1212
+ latest.message.id !== command.triggerMessageId)
1213
+ throw new SupportDeskConflictError("Assistant turn changed or is no longer available");
1214
+ }
1215
+ function handoffWork(input) {
1216
+ const work = {
1217
+ kind: "handoff_work",
1218
+ partition: input.ticket.id,
1219
+ id: "handoff:" + input.commandId,
1220
+ status: "pending",
1221
+ reason: input.reason,
1222
+ ...(input.summary === undefined ? {} : { summary: input.summary }),
1223
+ attemptedGuideIds: input.attemptedGuideIds ?? [],
1224
+ transcript: {
1225
+ ticketId: input.ticket.id,
1226
+ throughRevision: input.ticket.revision,
1227
+ lastVisibleMessageId: input.lastVisibleMessageId,
1228
+ },
1229
+ handlingGeneration: effectiveTicketHandling(input.ticket).generation,
1230
+ createdAt: input.occurredAt,
1231
+ };
1232
+ // Support's existing codec stores one JSON string property, unlike KB chunks.
1233
+ if (JSON.stringify(work).length * 2 > 60_000)
1234
+ throw new SupportDeskLimitError("serialized_record", 60_000);
1235
+ return work;
1236
+ }
724
1237
  async function staffAuthoritativeView(records, ticketId, maxRecordsPerTicket) {
725
1238
  const all = await records.list(ticketId);
726
1239
  enforcePartitionRecordLimit(all.length, maxRecordsPerTicket);
@@ -757,6 +1270,7 @@ function snapshotStaffReplyCommand(input) {
757
1270
  ? undefined
758
1271
  : snapshotNotification(raw.notification);
759
1272
  return Object.freeze({
1273
+ ...snapshotRevisionHint(source),
760
1274
  commandId: boundaryString(raw.commandId, "staff reply command.commandId"),
761
1275
  correlationId: boundaryString(raw.correlationId, "staff reply command.correlationId"),
762
1276
  ticketId: boundaryString(raw.ticketId, "staff reply command.ticketId"),
@@ -775,6 +1289,7 @@ function snapshotAddNoteCommand(input) {
775
1289
  body: ownDataProperty(source, "body", "add note command.body"),
776
1290
  };
777
1291
  return Object.freeze({
1292
+ ...snapshotRevisionHint(source),
778
1293
  commandId: boundaryString(raw.commandId, "add note command.commandId"),
779
1294
  correlationId: boundaryString(raw.correlationId, "add note command.correlationId"),
780
1295
  ticketId: boundaryString(raw.ticketId, "add note command.ticketId"),
@@ -813,6 +1328,7 @@ function snapshotAssignTicketCommand(input) {
813
1328
  }
814
1329
  }
815
1330
  return Object.freeze({
1331
+ ...snapshotRevisionHint(source),
816
1332
  commandId: boundaryString(raw.commandId, "assign command.commandId"),
817
1333
  correlationId: boundaryString(raw.correlationId, "assign command.correlationId"),
818
1334
  ticketId: boundaryString(raw.ticketId, "assign command.ticketId"),
@@ -837,6 +1353,7 @@ function snapshotChangePriorityCommand(input) {
837
1353
  throw new TypeError(`change priority command.priority must be one of: ${TICKET_PRIORITIES.join(", ")}`);
838
1354
  }
839
1355
  return Object.freeze({
1356
+ ...snapshotRevisionHint(source),
840
1357
  commandId: boundaryString(raw.commandId, "change priority command.commandId"),
841
1358
  correlationId: boundaryString(raw.correlationId, "change priority command.correlationId"),
842
1359
  ticketId: boundaryString(raw.ticketId, "change priority command.ticketId"),
@@ -870,6 +1387,7 @@ function snapshotLifecycleTicketCommand(input, field) {
870
1387
  }
871
1388
  }
872
1389
  return Object.freeze({
1390
+ ...snapshotRevisionHint(source),
873
1391
  commandId: boundaryString(raw.commandId, `${field}.commandId`),
874
1392
  correlationId: boundaryString(raw.correlationId, `${field}.correlationId`),
875
1393
  ticketId: boundaryString(raw.ticketId, `${field}.ticketId`),
@@ -910,6 +1428,7 @@ function ticketBeyondTerminalCutoff(ticket, now, terminalRetentionMilliseconds)
910
1428
  function deriveQueueIndexRecord(ticket) {
911
1429
  return {
912
1430
  ticketId: ticket.id,
1431
+ ...staffLifecycleFields(ticket),
913
1432
  id: "queue",
914
1433
  projectedRevision: ticket.revision,
915
1434
  state: isTerminalTicketStatus(ticket.status) ? "inactive" : "active",
@@ -922,6 +1441,9 @@ function deriveQueueIndexRecord(ticket) {
922
1441
  ? {}
923
1442
  : { assignedTo: ticket.assignedTo }),
924
1443
  updatedAt: ticket.updatedAt,
1444
+ ...(ticket.handling === undefined
1445
+ ? {}
1446
+ : { handling: { ...ticket.handling } }),
925
1447
  };
926
1448
  }
927
1449
  function queueIndexContentEqual(left, right) {
@@ -932,10 +1454,16 @@ function queueIndexContentEqual(left, right) {
932
1454
  left.status === right.status &&
933
1455
  left.priority === right.priority &&
934
1456
  left.category === right.category &&
1457
+ JSON.stringify(left.tags ?? []) === JSON.stringify(right.tags ?? []) &&
1458
+ left.firstResponseDueAt === right.firstResponseDueAt &&
1459
+ left.resolutionDueAt === right.resolutionDueAt &&
1460
+ left.firstStaffRespondedAt === right.firstStaffRespondedAt &&
1461
+ left.resolvedAt === right.resolvedAt &&
935
1462
  left.requesterAssociation === right.requesterAssociation &&
936
1463
  left.channel === right.channel &&
937
1464
  left.assignedTo === right.assignedTo &&
938
- left.updatedAt === right.updatedAt);
1465
+ left.updatedAt === right.updatedAt &&
1466
+ JSON.stringify(left.handling) === JSON.stringify(right.handling));
939
1467
  }
940
1468
  function queueProjectionKey(ticketId) {
941
1469
  return { partition: ticketId, id: "queue" };
@@ -1021,13 +1549,25 @@ export async function projectTicketToQueue(ticketId, options) {
1021
1549
  if (queueIndexContentEqual(current, derived)) {
1022
1550
  return { action: "keep" };
1023
1551
  }
1552
+ // Exact pre-SD-01 rows may already contain Task-13 fields but no handling.
1553
+ const { handling: _newHandling, ...beforeHandling } = derived;
1554
+ if (current.handling === undefined &&
1555
+ queueIndexContentEqual(current, beforeHandling))
1556
+ return { action: "write", value: derived };
1557
+ // Pre-Task-13 rows have none of these additive staff fields. Enrich
1558
+ // only that exact legacy shape from freshly loaded authoritative state.
1559
+ const { tags: _tags, firstResponseDueAt: _first, resolutionDueAt: _due, firstStaffRespondedAt: _responded, resolvedAt: _resolved, handling: _handling, ...legacy } = derived;
1560
+ if (queueIndexContentEqual(current, legacy))
1561
+ return { action: "write", value: derived };
1024
1562
  throw new SupportDeskConflictError("Queue projection revision collision with divergent content");
1025
1563
  }, { maxAttempts: maxConflictAttempts });
1026
1564
  return result.written ? "written" : "kept";
1027
1565
  }
1028
1566
  function toStaffQueueItem(ticket) {
1029
1567
  return Object.freeze({
1568
+ handling: effectiveTicketHandling(ticket).state,
1030
1569
  ticketId: ticket.id,
1570
+ ...staffLifecycleFields(ticket),
1031
1571
  revision: ticket.revision,
1032
1572
  status: ticket.status,
1033
1573
  priority: ticket.priority,
@@ -1046,7 +1586,23 @@ function normalizeFilterList(value) {
1046
1586
  }
1047
1587
  return new Set(Array.isArray(value) ? value : [value]);
1048
1588
  }
1049
- function staffQueueItemMatches(item, query) {
1589
+ function staffQueueItemMatches(item, query, now) {
1590
+ const handling = query.handling ?? "human";
1591
+ if (handling !== "all" &&
1592
+ (handling === "assistant") !== (item.handling === "assistant"))
1593
+ return false;
1594
+ if (query.tag !== undefined && !(item.tags ?? []).includes(query.tag))
1595
+ return false;
1596
+ if (query.targetState !== undefined) {
1597
+ const due = pendingTargetDue(item, query.target);
1598
+ if (due === undefined || now === undefined)
1599
+ return false;
1600
+ const epoch = canonicalTimestamp(due, "ticket target");
1601
+ if (query.targetState === "overdue"
1602
+ ? epoch >= now
1603
+ : epoch < now || epoch - now > query.dueSoonMilliseconds)
1604
+ return false;
1605
+ }
1050
1606
  const statuses = normalizeFilterList(query.status);
1051
1607
  if (statuses !== undefined && !statuses.has(item.status)) {
1052
1608
  return false;
@@ -1073,6 +1629,15 @@ function staffQueueItemMatches(item, query) {
1073
1629
  }
1074
1630
  return true;
1075
1631
  }
1632
+ function pendingTargetDue(item, target) {
1633
+ return target === "first_response"
1634
+ ? item.firstStaffRespondedAt === undefined
1635
+ ? item.firstResponseDueAt
1636
+ : undefined
1637
+ : item.resolvedAt === undefined
1638
+ ? item.resolutionDueAt
1639
+ : undefined;
1640
+ }
1076
1641
  const QUEUE_FILTER_STATUSES = [
1077
1642
  "open",
1078
1643
  "waiting_on_support",
@@ -1121,6 +1686,12 @@ function snapshotStaffQueueQuery(input) {
1121
1686
  }
1122
1687
  const source = boundaryObject(input, "staff queue query");
1123
1688
  const readOptional = (key) => ownDataProperty(source, key, `staff queue query.${key}`, true);
1689
+ const handling = readOptional("handling");
1690
+ if (handling !== undefined &&
1691
+ handling !== "human" &&
1692
+ handling !== "assistant" &&
1693
+ handling !== "all")
1694
+ throw new TypeError("invalid queue handling view");
1124
1695
  const status = readOptional("status");
1125
1696
  const priority = readOptional("priority");
1126
1697
  const association = readOptional("association");
@@ -1128,6 +1699,29 @@ function snapshotStaffQueueQuery(input) {
1128
1699
  const assignedTo = readOptional("assignedTo");
1129
1700
  const unassignedOnly = readOptional("unassignedOnly");
1130
1701
  const sort = readOptional("sort");
1702
+ const rawTag = readOptional("tag");
1703
+ const tag = rawTag === undefined ? undefined : normalizedTag(rawTag);
1704
+ const target = readOptional("target");
1705
+ const targetState = readOptional("targetState");
1706
+ const dueSoonMilliseconds = readOptional("dueSoonMilliseconds");
1707
+ if (target !== undefined &&
1708
+ target !== "first_response" &&
1709
+ target !== "resolution")
1710
+ throw new TypeError("invalid queue target");
1711
+ if (targetState !== undefined &&
1712
+ targetState !== "overdue" &&
1713
+ targetState !== "due_soon")
1714
+ throw new TypeError("invalid queue targetState");
1715
+ if (dueSoonMilliseconds !== undefined &&
1716
+ (typeof dueSoonMilliseconds !== "number" ||
1717
+ !Number.isSafeInteger(dueSoonMilliseconds) ||
1718
+ dueSoonMilliseconds < 0 ||
1719
+ dueSoonMilliseconds > 30 * 86_400_000))
1720
+ throw new TypeError("dueSoonMilliseconds must be between 0 and 30 days");
1721
+ if (targetState === "due_soon" && dueSoonMilliseconds === undefined)
1722
+ throw new TypeError("due_soon requires dueSoonMilliseconds");
1723
+ if (targetState !== "due_soon" && dueSoonMilliseconds !== undefined)
1724
+ throw new TypeError("dueSoonMilliseconds requires targetState due_soon");
1131
1725
  if (assignedTo !== undefined) {
1132
1726
  if (typeof assignedTo !== "string") {
1133
1727
  throw new TypeError("staff queue query.assignedTo must be a string");
@@ -1139,10 +1733,20 @@ function snapshotStaffQueueQuery(input) {
1139
1733
  }
1140
1734
  if (sort !== undefined &&
1141
1735
  sort !== "updated_newest" &&
1142
- sort !== "updated_oldest") {
1143
- throw new TypeError('staff queue query.sort must be "updated_newest" or "updated_oldest"');
1736
+ sort !== "updated_oldest" &&
1737
+ sort !== "due_soonest" &&
1738
+ sort !== "due_latest") {
1739
+ throw new TypeError("invalid staff queue query.sort");
1144
1740
  }
1145
- const query = {};
1741
+ const query = {
1742
+ ...(handling === undefined ? {} : { handling }),
1743
+ ...(tag === undefined ? {} : { tag }),
1744
+ ...(target === undefined ? {} : { target }),
1745
+ ...(targetState === undefined ? {} : { targetState }),
1746
+ ...(dueSoonMilliseconds === undefined
1747
+ ? {}
1748
+ : { dueSoonMilliseconds: dueSoonMilliseconds }),
1749
+ };
1146
1750
  if (status !== undefined) {
1147
1751
  query.status =
1148
1752
  snapshotEnumOrEnumArray(status, "staff queue query.status", QUEUE_FILTER_STATUSES);
@@ -1293,36 +1897,100 @@ export async function sweepInactiveQueueProjections(options) {
1293
1897
  deleted,
1294
1898
  };
1295
1899
  }
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));
1900
+ const supportDeskLimitMaximums = Object.freeze({
1901
+ maxSubjectCharacters: 1_000,
1902
+ maxMessageCharacters: 20_000,
1903
+ maxTicketsPerPrincipal: 1_000,
1904
+ maxMessagesPerTicket: 100,
1905
+ maxRecordsPerTicket: 2_000,
1906
+ });
1907
+ /** Merge validated host overrides onto the defaults and enforce maxima. */
1908
+ function resolveSupportDeskLimits(rawLimits, field) {
1307
1909
  const limitOverrides = {};
1308
1910
  if (rawLimits !== undefined) {
1309
- const limitSource = boundaryObject(rawLimits, "application options.limits");
1310
- for (const field of [
1911
+ const limitSource = boundaryObject(rawLimits, field);
1912
+ for (const name of [
1311
1913
  "maxSubjectCharacters",
1312
1914
  "maxMessageCharacters",
1313
1915
  "maxTicketsPerPrincipal",
1314
1916
  "maxMessagesPerTicket",
1315
1917
  "maxRecordsPerTicket",
1316
1918
  ]) {
1317
- const value = ownDataProperty(limitSource, field, `application options.limits.${field}`, true);
1919
+ const value = ownDataProperty(limitSource, name, `${field}.${name}`, true);
1318
1920
  if (value !== undefined) {
1319
1921
  if (typeof value !== "number") {
1320
- throw new TypeError(`application options.limits.${field} must be a number`);
1922
+ throw new TypeError(`${field}.${name} must be a number`);
1321
1923
  }
1322
- limitOverrides[field] = value;
1924
+ limitOverrides[name] = value;
1323
1925
  }
1324
1926
  }
1325
1927
  }
1928
+ const limits = { ...defaultSupportDeskLimits, ...limitOverrides };
1929
+ for (const name of Object.keys(limits)) {
1930
+ const value = limits[name];
1931
+ if (!Number.isSafeInteger(value) ||
1932
+ value <= 0 ||
1933
+ value > supportDeskLimitMaximums[name]) {
1934
+ throw new TypeError(`${name} must be a positive safe integer no greater than ${supportDeskLimitMaximums[name]}`);
1935
+ }
1936
+ }
1937
+ return Object.freeze(limits);
1938
+ }
1939
+ /**
1940
+ * Reserve the next instance-scoped ticket number outside any ticket
1941
+ * transaction. A later failure leaves a gap; a duplicate is impossible.
1942
+ */
1943
+ async function reserveNextTicketNumber(numbers, maxConflictAttempts) {
1944
+ const result = await numbers.update({ partition: ticketNumberPartition, id: ticketNumberRecordId }, (current) => {
1945
+ const lastIssued = current?.lastIssued ?? 0;
1946
+ if (!Number.isSafeInteger(lastIssued) || lastIssued < 0) {
1947
+ throw new SupportDeskConflictError("Ticket number counter is corrupt");
1948
+ }
1949
+ if (lastIssued >= Number.MAX_SAFE_INTEGER) {
1950
+ throw new SupportDeskConflictError("Ticket number sequence is exhausted");
1951
+ }
1952
+ return {
1953
+ action: "write",
1954
+ value: { lastIssued: lastIssued + 1 },
1955
+ };
1956
+ }, { maxAttempts: maxConflictAttempts });
1957
+ const issued = result.value?.lastIssued;
1958
+ if (issued === undefined || !Number.isSafeInteger(issued) || issued <= 0) {
1959
+ throw new SupportDeskConflictError("Ticket number was not reserved");
1960
+ }
1961
+ return issued;
1962
+ }
1963
+ export function createSupportDeskApplication(options) {
1964
+ const source = boundaryObject(options, "application options");
1965
+ const engineeringFollowupOptions = resolveEngineeringFollowupOptions(ownDataProperty(source, "engineeringFollowup", "application options.engineeringFollowup", true));
1966
+ const engineeringOptions = resolveEngineeringOptions(ownDataProperty(source, "engineering", "application options.engineering", true));
1967
+ const resolutionLearningOptions = resolveResolutionLearningOptions(ownDataProperty(source, "resolutionLearning", "application options.resolutionLearning", true));
1968
+ const assistantTurnOptions = resolveAssistantTurnOptions(ownDataProperty(source, "assistantTurns", "application options.assistantTurns", true));
1969
+ const rawAssistantEnabled = ownDataProperty(source, "assistantHandlingEnabled", "application options.assistantHandlingEnabled", true);
1970
+ if (rawAssistantEnabled !== undefined &&
1971
+ typeof rawAssistantEnabled !== "boolean")
1972
+ throw new TypeError("assistantHandlingEnabled must be boolean");
1973
+ const assistantHandlingEnabled = rawAssistantEnabled === true;
1974
+ const store = ownDataProperty(source, "store", "application options.store");
1975
+ const clock = ownDataProperty(source, "clock", "application options.clock");
1976
+ const rawLogger = ownDataProperty(source, "logger", "application options.logger", true);
1977
+ const logger = rawLogger === undefined ? noopLogger : rawLogger;
1978
+ const rawLimits = ownDataProperty(source, "limits", "application options.limits", true);
1979
+ const rawMaxConflictAttempts = ownDataProperty(source, "maxConflictAttempts", "application options.maxConflictAttempts", true);
1980
+ const rawTerminalRetention = ownDataProperty(source, "queueTerminalRetentionMilliseconds", "application options.queueTerminalRetentionMilliseconds", true);
1981
+ const rawQueueBudgets = ownDataProperty(source, "queueScanBudgets", "application options.queueScanBudgets", true);
1982
+ const allowedCategories = parseAllowedCategories(ownDataProperty(source, "allowedCategories", "application options.allowedCategories", true));
1983
+ const rawTags = ownDataProperty(source, "allowedTags", "allowedTags", true);
1984
+ const allowedTags = new Set(rawTags === undefined ? [] : snapshotTags(rawTags));
1985
+ const rawMaxTags = ownDataProperty(source, "maxTagsPerTicket", "maxTagsPerTicket", true);
1986
+ const maxTagsPerTicket = rawMaxTags === undefined ? 8 : rawMaxTags;
1987
+ if (typeof maxTagsPerTicket !== "number" ||
1988
+ !Number.isSafeInteger(maxTagsPerTicket) ||
1989
+ maxTagsPerTicket < 1 ||
1990
+ maxTagsPerTicket > 32) {
1991
+ throw new TypeError("maxTagsPerTicket must be between 1 and 32");
1992
+ }
1993
+ const responseTargetPolicy = parseResponseTargetPolicy(ownDataProperty(source, "responseTargetPolicy", "responseTargetPolicy", true));
1326
1994
  if (rawMaxConflictAttempts !== undefined &&
1327
1995
  typeof rawMaxConflictAttempts !== "number") {
1328
1996
  throw new TypeError("application options.maxConflictAttempts must be a number");
@@ -1331,13 +1999,36 @@ export function createSupportDeskApplication(options) {
1331
1999
  const index = store.collection(customerTicketIndex);
1332
2000
  const numbers = store.collection(ticketNumbers);
1333
2001
  const projections = store.collection(queueIndex);
1334
- const limits = { ...defaultSupportDeskLimits, ...limitOverrides };
2002
+ const limits = resolveSupportDeskLimits(rawLimits, "application options.limits");
1335
2003
  const maxConflictAttempts = rawMaxConflictAttempts ?? 4;
1336
2004
  if (!Number.isSafeInteger(maxConflictAttempts) ||
1337
2005
  maxConflictAttempts <= 0 ||
1338
2006
  maxConflictAttempts > 20) {
1339
2007
  throw new TypeError("maxConflictAttempts must be between 1 and 20");
1340
2008
  }
2009
+ const rawEngineeringPolicy = ownDataProperty(source, "engineeringPolicy", "application options.engineeringPolicy", true);
2010
+ const engineeringPolicyOverrides = {};
2011
+ if (rawEngineeringPolicy !== undefined) {
2012
+ const policyOptions = boundaryObject(rawEngineeringPolicy, "engineeringPolicy options");
2013
+ for (const field of [
2014
+ "maxRepositories",
2015
+ "maxRecords",
2016
+ "historyRetentionMilliseconds",
2017
+ ]) {
2018
+ const value = ownDataProperty(policyOptions, field, `engineeringPolicy.${field}`, true);
2019
+ if (value === undefined)
2020
+ continue;
2021
+ if (typeof value !== "number")
2022
+ throw new TypeError(`engineeringPolicy.${field} must be a number`);
2023
+ engineeringPolicyOverrides[field] = value;
2024
+ }
2025
+ }
2026
+ const engineeringPolicy = createEngineeringPolicyServices({
2027
+ store,
2028
+ clock,
2029
+ maxConflictAttempts,
2030
+ ...engineeringPolicyOverrides,
2031
+ });
1341
2032
  const terminalRetentionMilliseconds = rawTerminalRetention === undefined
1342
2033
  ? defaultQueueTerminalRetentionMilliseconds
1343
2034
  : rawTerminalRetention;
@@ -1382,21 +2073,6 @@ export function createSupportDeskApplication(options) {
1382
2073
  throw new TypeError(`queueScanBudgets.${field} must be a positive safe integer`);
1383
2074
  }
1384
2075
  }
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
2076
  let projectionHealth = Object.freeze({
1401
2077
  consecutiveFailures: 0,
1402
2078
  });
@@ -1448,13 +2124,65 @@ export function createSupportDeskApplication(options) {
1448
2124
  }
1449
2125
  }
1450
2126
  }
1451
- async function customerViewAfterChange(principalId, ticketId) {
2127
+ /**
2128
+ * Best-effort post-commit threading index writes. Never fails a committed
2129
+ * command; `repairThreadIndexPage` converges anything missed. Does not
2130
+ * sample the host clock.
2131
+ */
2132
+ async function safeIndexThreading(ticketId, threading) {
2133
+ if (threading === undefined ||
2134
+ (threading.messageId === undefined && threading.ticketNumber !== true)) {
2135
+ return;
2136
+ }
2137
+ await indexThreadingAfterCommit(store, ticketId, threading, logger);
2138
+ }
2139
+ async function customerViewAfterChange(principalId, ticketId, threading) {
1452
2140
  await safeProjectQueue(ticketId);
2141
+ await safeIndexThreading(ticketId, threading);
1453
2142
  return authoritativeView(records, principalId, ticketId, limits.maxRecordsPerTicket);
1454
2143
  }
1455
- async function staffViewAfterChange(ticketId) {
2144
+ async function staffCommandResult(ticketId, commandId, threading) {
1456
2145
  await safeProjectQueue(ticketId);
1457
- return staffAuthoritativeView(records, ticketId, limits.maxRecordsPerTicket);
2146
+ await safeIndexThreading(ticketId, threading);
2147
+ const receipt = await records.get(commandKey(ticketId, commandId));
2148
+ if (receipt?.kind !== "command" || receipt.resultTicket === undefined) {
2149
+ // Pre-Task-13 receipts retain their existing replay behavior.
2150
+ return staffAuthoritativeView(records, ticketId, limits.maxRecordsPerTicket);
2151
+ }
2152
+ const rows = await records.list(ticketId);
2153
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
2154
+ return {
2155
+ ticket: receipt.resultTicket,
2156
+ messages: staffMessages(rows.filter((row) => row.kind !== "message" ||
2157
+ row.ordinal <= receipt.resultTicket.revision)),
2158
+ };
2159
+ }
2160
+ async function checkStaffRevision(ticket, command, fingerprint) {
2161
+ if (command.expectedRevision === undefined ||
2162
+ command.expectedRevision === ticket.revision)
2163
+ return false;
2164
+ // A commit may race the first receipt read. Replay wins over a stale hint.
2165
+ const receipt = await records.get(commandKey(command.ticketId, command.commandId));
2166
+ if (receipt?.kind === "command" &&
2167
+ receipt.requestFingerprint === fingerprint)
2168
+ return true;
2169
+ if (receipt !== null)
2170
+ throw new SupportDeskConflictError("Command ID reused with different input");
2171
+ requireExpectedRevision(ticket, command);
2172
+ return false;
2173
+ }
2174
+ async function staffConflictResult(command, fingerprint) {
2175
+ const receipt = await records.get(commandKey(command.ticketId, command.commandId));
2176
+ if (receipt?.kind === "command" &&
2177
+ receipt.requestFingerprint === fingerprint)
2178
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2179
+ if (receipt !== null)
2180
+ throw new SupportDeskConflictError("Command ID reused with different input");
2181
+ const current = await records.get(ticketKey(command.ticketId));
2182
+ if (current?.kind === "ticket" &&
2183
+ (await checkStaffRevision(current.ticket, command, fingerprint)))
2184
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2185
+ throw new SupportDeskConflictError();
1458
2186
  }
1459
2187
  async function reserveCustomerTicket(principalId, ticketId, reservedAt) {
1460
2188
  requireIdentifier(principalId, "principalId");
@@ -1555,114 +2283,870 @@ export function createSupportDeskApplication(options) {
1555
2283
  }
1556
2284
  }
1557
2285
  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");
2286
+ return reserveNextTicketNumber(numbers, maxConflictAttempts);
2287
+ }
2288
+ const resolutionLearning = createResolutionLearningServices({
2289
+ records,
2290
+ clock,
2291
+ limits,
2292
+ options: resolutionLearningOptions,
2293
+ maxConflictAttempts,
2294
+ admission: (ticket, rows, additional) => enforceManagedAdmission(ticket, rows, limits, additional),
2295
+ withdrawalAdmission: (ticket, rows, additional) => enforceHandlingControlAdmission(ticket, rows, limits, additional, "learning"),
2296
+ audit: (command, ticket, actorId, now, action) => ticketAuditAction({
2297
+ commandId: command.commandId,
2298
+ correlationId: command.correlationId,
2299
+ ticketId: ticket.id,
2300
+ revision: ticket.revision,
2301
+ actorId,
2302
+ occurredAt: now,
2303
+ action: action,
2304
+ }),
2305
+ });
2306
+ const engineeringDependencies = {
2307
+ records,
2308
+ clock,
2309
+ limits,
2310
+ options: engineeringOptions,
2311
+ maxConflictAttempts,
2312
+ policy: engineeringPolicy,
2313
+ planFollowupSourceChange: (context) => planEngineeringFollowupSourceChange(records, context),
2314
+ admission: (ticket, rows, additional) => enforceManagedAdmission(ticket, rows, limits, additional),
2315
+ withdrawalAdmission: (ticket, rows, additional) => enforceHandlingControlAdmission(ticket, rows, limits, additional, "engineering"),
2316
+ completionAdmission: (ticket, rows, additional, workId) => {
2317
+ const consumption = { engineeringTerminalWorkId: workId };
2318
+ const current = rows.find((row) => row.kind === "ticket" && row.ticket.id === ticket.id);
2319
+ if (current !== undefined)
2320
+ enforceHandlingControlAdmission(current.ticket, rows, limits, additional, consumption);
2321
+ else
2322
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket - derivationReserveRows(rows, consumption), additional);
2323
+ },
2324
+ audit: (command, ticket, actorId, now, action) => ticketAuditAction({
2325
+ commandId: command.commandId,
2326
+ correlationId: command.correlationId,
2327
+ ticketId: ticket.id,
2328
+ revision: ticket.revision,
2329
+ actorId,
2330
+ occurredAt: now,
2331
+ action: action,
2332
+ }),
2333
+ };
2334
+ const engineeringEvidence = createEngineeringEvidenceServices(engineeringDependencies);
2335
+ const engineeringWorkers = createEngineeringWorkerServices(engineeringDependencies, engineeringEvidence.load);
2336
+ const engineeringReconciliation = createEngineeringReconciliationServices(engineeringDependencies);
2337
+ const followupDependencies = {
2338
+ records,
2339
+ clock,
2340
+ limits,
2341
+ options: engineeringFollowupOptions,
2342
+ maxConflictAttempts,
2343
+ audit: (command, ticket, actorId, now, action) => ticketAuditAction({
2344
+ commandId: command.commandId,
2345
+ correlationId: command.correlationId,
2346
+ ticketId: ticket.id,
2347
+ revision: ticket.revision,
2348
+ actorId,
2349
+ occurredAt: now,
2350
+ action: action,
2351
+ }),
2352
+ admission: (ticket, rows, nextWorkRows, addedPhysicalRows, addsMessage = false) => {
2353
+ const projected = projectFollowupRows(rows, nextWorkRows);
2354
+ const newlyProjected = projected.length - rows.length;
2355
+ if (addedPhysicalRows < newlyProjected)
2356
+ throw new SupportDeskConflictError("Follow-up admission uses a stale partition view");
2357
+ const quota = projected.find((row) => row.kind === "ticket_quota");
2358
+ if (followupMessageSlots(projected) > 0) {
2359
+ if (quota === undefined)
2360
+ throw new SupportDeskConflictError("Follow-up quota metadata is missing");
2361
+ const humanSlot = quota.assistantManaged === true &&
2362
+ ticket.firstStaffRespondedAt === undefined
2363
+ ? assistantHumanReserveMessages
2364
+ : 0;
2365
+ if (quota.messageCount + followupMessageSlots(projected) + humanSlot >
2366
+ limits.maxMessagesPerTicket)
2367
+ throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
1562
2368
  }
1563
- if (lastIssued >= Number.MAX_SAFE_INTEGER) {
1564
- throw new SupportDeskConflictError("Ticket number sequence is exhausted");
2369
+ enforceManagedAdmission(ticket, projected, limits, addedPhysicalRows - newlyProjected, addsMessage);
2370
+ },
2371
+ };
2372
+ const engineeringFollowupSource = createEngineeringFollowupSourceServices(followupDependencies);
2373
+ const engineeringFollowupWork = createEngineeringFollowupWorkServices(followupDependencies, engineeringFollowupSource.internal.load);
2374
+ const approveAndPostEngineeringFollowupWebUpdate = async (access, input, guard) => {
2375
+ const actorId = access.principalId;
2376
+ const authorize = () => {
2377
+ requireIdentifier(access.principalId, "principalId");
2378
+ if (access.principalId !== actorId)
2379
+ throw new SupportDeskAuthorizationError(supportPermissions.engineeringFollowupReview);
2380
+ requirePermission(access, supportPermissions.engineeringFollowupReview);
2381
+ requirePermission(access, supportPermissions.queueRead);
2382
+ requirePermission(access, supportPermissions.replyAny);
2383
+ };
2384
+ authorize();
2385
+ const command = followupSnapshot(followupFields(input, "follow-up web update", [
2386
+ "ticketId",
2387
+ "workId",
2388
+ "commandId",
2389
+ "correlationId",
2390
+ "expectedWorkGeneration",
2391
+ "expectedTicketRevision",
2392
+ "bundleHash",
2393
+ "messageId",
2394
+ ]));
2395
+ for (const name of [
2396
+ "ticketId",
2397
+ "workId",
2398
+ "commandId",
2399
+ "correlationId",
2400
+ "bundleHash",
2401
+ "messageId",
2402
+ ])
2403
+ followupText(command[name], name);
2404
+ followupInteger(command.expectedWorkGeneration, "expectedWorkGeneration", 1);
2405
+ followupInteger(command.expectedTicketRevision, "expectedTicketRevision", 1);
2406
+ if (!/^[0-9a-f]{64}$/.test(command.bundleHash))
2407
+ throw new TypeError("Invalid follow-up bundle hash");
2408
+ if (typeof guard !== "function")
2409
+ throw new TypeError("Follow-up posting requires a current-proof guard");
2410
+ const hash = await requestFingerprint({
2411
+ operation: "engineering_followup_web_update",
2412
+ actorId,
2413
+ command,
2414
+ });
2415
+ const receiptKey = engineeringFollowupCommandKey(command.ticketId, command.commandId);
2416
+ const now = engineeringFollowupNow(followupDependencies);
2417
+ for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
2418
+ const prior = await records.get(receiptKey);
2419
+ authorize();
2420
+ if (prior !== null) {
2421
+ if (prior.kind !== "engineering_followup_receipt" ||
2422
+ prior.fingerprint !== hash ||
2423
+ !("messageId" in prior.result) ||
2424
+ prior.result.messageId === undefined ||
2425
+ !("bundleHash" in prior.result) ||
2426
+ prior.result.bundleHash !== command.bundleHash ||
2427
+ !("ticketRevision" in prior.result) ||
2428
+ prior.result.ticketRevision === undefined ||
2429
+ !("workId" in prior.result) ||
2430
+ prior.result.workId !== command.workId)
2431
+ throw new SupportDeskConflictError("Follow-up posting command changed");
2432
+ return followupSnapshot({
2433
+ ticketId: command.ticketId,
2434
+ workId: command.workId,
2435
+ messageId: prior.result.messageId,
2436
+ ticketRevision: prior.result.ticketRevision,
2437
+ bundleHash: prior.result.bundleHash,
2438
+ });
1565
2439
  }
1566
- return {
1567
- action: "write",
1568
- value: { lastIssued: lastIssued + 1 },
2440
+ if (!engineeringFollowupOptions.enabled)
2441
+ throw new SupportDeskConflictError("Follow-up is disabled");
2442
+ const current = await engineeringFollowupWork.internal.current({ ticketId: command.ticketId, workId: command.workId }, now);
2443
+ authorize();
2444
+ const work = current.work.value;
2445
+ const prepared = work.preparedBundle;
2446
+ const ticket = current.loaded.ticket.value.ticket;
2447
+ if (prepared === undefined ||
2448
+ current.loaded.head === null ||
2449
+ work.workGeneration !== command.expectedWorkGeneration ||
2450
+ ticket.revision !== command.expectedTicketRevision ||
2451
+ prepared.bundleHash !== command.bundleHash ||
2452
+ work.completionRows !== 3 ||
2453
+ work.messageSlots !== 1 ||
2454
+ now() >= Date.parse(work.deadline))
2455
+ throw new SupportDeskConflictError("Follow-up exact prepared work or ticket changed");
2456
+ const checked = await engineeringFollowupWork.internal.verifyBundle(current, now);
2457
+ const body = prepared.bundle.customerDraft.body;
2458
+ requireNonempty(body, "body");
2459
+ enforceLimit(body, "body", limits.maxMessageCharacters);
2460
+ const bodyHash = await requestFingerprint({ body });
2461
+ await guard();
2462
+ authorize();
2463
+ const occurredAt = clampTicketTime(clock.now(), ticket.updatedAt);
2464
+ const updated = applyTicketEvent(ticket, {
2465
+ type: "customer_notice_added",
2466
+ actorId,
2467
+ occurredAt,
2468
+ });
2469
+ const derivations = await planTicketDerivationMutation(records, current.loaded.ticket, updated, current.loaded.rows, true, { completedFollowupWorkId: work.workId });
2470
+ const { claim: _claim, leaseUntil: _lease, retryAt: _retry, candidate: _candidate, candidateHash: _candidateHash, preparedBundle: _prepared, ...remaining } = work;
2471
+ const next = {
2472
+ ...remaining,
2473
+ stage: "delivered",
2474
+ status: "delivered",
2475
+ workGeneration: work.workGeneration + 1,
2476
+ completionRows: 0,
2477
+ messageSlots: 0,
2478
+ modelAttempts: [],
2479
+ usageTombstones: [
2480
+ ...new Map([
2481
+ ...(work.usageTombstones ?? []).map((entry) => [entry.stepId, entry]),
2482
+ ...work.modelAttempts.map((entry) => [
2483
+ entry.stepId,
2484
+ {
2485
+ stepId: entry.stepId,
2486
+ requestHash: entry.requestHash,
2487
+ usageReservationId: entry.binding.usageReservationId,
2488
+ accounting: entry.accounting,
2489
+ },
2490
+ ]),
2491
+ ]).values(),
2492
+ ],
2493
+ delivered: {
2494
+ messageId: command.messageId,
2495
+ ticketRevision: updated.revision,
2496
+ bodyHash,
2497
+ bundleHash: prepared.bundleHash,
2498
+ commandId: command.commandId,
2499
+ postedBy: actorId,
2500
+ postedAt: occurredAt,
2501
+ },
2502
+ handoff: {
2503
+ kbProposal: prepared.bundle.kbProposal,
2504
+ retestRequests: prepared.bundle.retestRequests,
2505
+ },
1569
2506
  };
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",
2507
+ checkEngineeringFollowupRecordCapacity(next);
2508
+ const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
2509
+ authorize();
2510
+ if (quota?.value.kind !== "ticket_quota")
2511
+ throw new SupportDeskConflictError("Follow-up quota metadata is missing");
2512
+ const admissionRows = current.loaded.rows.map((row) => row.kind === "ticket_quota" ? quota.value : row);
2513
+ followupDependencies.admission(updated, admissionRows, [...derivations.nextWorkRows, next], 3 + derivations.additionalRows, true);
2514
+ const result = {
1590
2515
  ticketId: command.ticketId,
2516
+ workId: command.workId,
1591
2517
  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),
2518
+ ticketRevision: updated.revision,
2519
+ bundleHash: prepared.bundleHash,
1600
2520
  };
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
- }
1631
- if (createIsReplay) {
1632
- // A crash after ticket commit and before index confirmation must still
1633
- // converge the principal hint on retry.
1634
- const fence = await records.get(ticketReservationKey(command.ticketId));
1635
- if (fence?.kind === "ticket_reservation" &&
1636
- fence.state === "committed") {
1637
- await confirmCustomerTicket(access.principalId, command.ticketId, fence.token, fence.generation);
1638
- }
1639
- return customerViewAfterChange(access.principalId, command.ticketId);
1640
- }
1641
- if (command.category !== undefined) {
1642
- if (allowedCategories === undefined ||
1643
- !allowedCategories.has(command.category)) {
1644
- throw new TypeError("create command.category is not configured for this Support Desk instance");
1645
- }
1646
- }
1647
- // Fail closed before durable number/index reservation so a too-small
1648
- // partition budget cannot exhaust the principal ticket index with
1649
- // reserved-but-never-created tickets.
1650
- const estimatedCreateRows = 6 + // ticket, quota, reservation, message, audit, command
1651
- (command.notification === undefined ? 0 : 1);
1652
- enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, estimatedCreateRows);
1653
- // Reserve outside the ticket transaction; gaps are accepted if create fails.
1654
- const ticketNumber = await reserveTicketNumber();
1655
- // Bind host-supplied notification content to the reserved number so mail
1656
- // cannot advertise a guessed ticket number.
2521
+ const receipt = {
2522
+ kind: "engineering_followup_receipt",
2523
+ partition: command.ticketId,
2524
+ id: receiptKey.id,
2525
+ fingerprint: hash,
2526
+ result,
2527
+ };
2528
+ checkEngineeringFollowupRecordCapacity(receipt);
2529
+ if (!(await engineeringFollowupVersionsCurrent(followupDependencies, current.loaded, current.work, current.engineeringWork)))
2530
+ continue;
2531
+ authorize();
2532
+ if (now() >=
2533
+ Math.min(Date.parse(work.deadline), checked.deadline, Date.parse(current.proof.attestation.validUntil)))
2534
+ throw new SupportDeskConflictError("Follow-up posting proof expired");
2535
+ const outcome = await records.transact(command.ticketId, [
2536
+ ...derivations.actions,
2537
+ {
2538
+ action: "putIfUnchanged",
2539
+ version: current.loaded.source.version,
2540
+ value: current.loaded.source.value,
2541
+ },
2542
+ {
2543
+ action: "putIfUnchanged",
2544
+ version: current.engineeringWork.version,
2545
+ value: current.engineeringWork.value,
2546
+ },
2547
+ {
2548
+ action: "putIfUnchanged",
2549
+ version: current.loaded.head.version,
2550
+ value: current.loaded.head.value,
2551
+ },
2552
+ {
2553
+ action: "putIfUnchanged",
2554
+ version: current.work.version,
2555
+ value: next,
2556
+ },
2557
+ {
2558
+ action: "putIfUnchanged",
2559
+ version: quota.version,
2560
+ value: {
2561
+ ...quota.value,
2562
+ messageCount: quota.value.messageCount + 1,
2563
+ },
2564
+ },
2565
+ {
2566
+ action: "insert",
2567
+ value: {
2568
+ kind: "message",
2569
+ partition: command.ticketId,
2570
+ id: `message:${command.messageId}`,
2571
+ ordinal: updated.revision,
2572
+ message: {
2573
+ id: command.messageId,
2574
+ ticketId: command.ticketId,
2575
+ authorKind: "staff",
2576
+ authorPrincipalId: actorId,
2577
+ channel: "web",
2578
+ visibility: "customer",
2579
+ format: "plain_text",
2580
+ body,
2581
+ createdAt: occurredAt,
2582
+ },
2583
+ },
2584
+ },
2585
+ ticketAuditAction({
2586
+ commandId: command.commandId,
2587
+ correlationId: command.correlationId,
2588
+ ticketId: command.ticketId,
2589
+ revision: updated.revision,
2590
+ actorId,
2591
+ occurredAt,
2592
+ action: supportTicketAuditActions.engineeringFollowupNoticePosted,
2593
+ }),
2594
+ { action: "insert", value: receipt },
2595
+ ]);
2596
+ if (outcome.committed) {
2597
+ authorize();
2598
+ return result;
2599
+ }
2600
+ }
2601
+ throw new SupportDeskConflictError();
2602
+ };
2603
+ function owns(ticket, principalId) {
2604
+ return (ticket.requester.association === "authenticated" &&
2605
+ ticket.requester.principalId === principalId);
2606
+ }
2607
+ function handlingAck(ticket, messageId) {
2608
+ return {
2609
+ ticketId: ticket.id,
2610
+ revision: ticket.revision,
2611
+ handlingGeneration: effectiveTicketHandling(ticket).generation,
2612
+ ...(messageId === undefined ? {} : { messageId }),
2613
+ };
2614
+ }
2615
+ /** Only the new handling commands share this small executor; legacy fingerprints stay untouched. */
2616
+ async function mutateHandling(access, command, operation, assistantTurnCommit) {
2617
+ requireIdentifier(access.principalId, "principalId");
2618
+ const customer = operation === "request_person" ||
2619
+ operation === "confirm_customer_success";
2620
+ const fingerprint = await requestFingerprint({
2621
+ type: operation,
2622
+ actorId: access.principalId,
2623
+ ...Object.fromEntries(Object.entries(command).filter(([key]) => key !== "commandId" && key !== "correlationId")),
2624
+ ...(assistantTurnCommit === undefined
2625
+ ? {}
2626
+ : { assistantTurnFingerprint: assistantTurnCommit.fingerprint }),
2627
+ });
2628
+ for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
2629
+ const versioned = await records.getVersioned(ticketKey(command.ticketId));
2630
+ if (versioned?.value.kind !== "ticket" ||
2631
+ (customer && !owns(versioned.value.ticket, access.principalId)))
2632
+ throw new SupportDeskNotFoundError();
2633
+ const ticket = versioned.value.ticket;
2634
+ const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
2635
+ if (duplicateMatches(duplicate, operation, command.messageId, fingerprint)) {
2636
+ if (duplicate?.kind !== "command" ||
2637
+ duplicate.resultTicket === undefined)
2638
+ throw new SupportDeskConflictError("Handling receipt is incomplete");
2639
+ return duplicate.resultTicket;
2640
+ }
2641
+ if (duplicate !== null)
2642
+ throw new SupportDeskConflictError("Command ID reused with different input");
2643
+ if (operation === "assistant_reply" && !assistantHandlingEnabled)
2644
+ throw new SupportDeskConflictError("Assistant handling is disabled");
2645
+ const rows = await records.list(command.ticketId);
2646
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
2647
+ const latest = latestVisibleRecord(rows);
2648
+ if (operation === "assistant_reply" || operation === "assistant_escalate")
2649
+ requireAssistantTurn(ticket, rows, command);
2650
+ if (operation === "request_person" &&
2651
+ !isTerminalTicketStatus(ticket.status) &&
2652
+ effectiveTicketHandling(ticket).state !== "assistant")
2653
+ return ticket;
2654
+ if (operation === "confirm_customer_success") {
2655
+ if (latest?.message.id !== command.lastSeenVisibleMessageId)
2656
+ throw new SupportDeskConflictError("Visible conversation changed");
2657
+ if (ticket.status === "resolved" &&
2658
+ rows.some((row) => row.kind === "customer_success" &&
2659
+ row.lastSeenVisibleMessageId ===
2660
+ command.lastSeenVisibleMessageId &&
2661
+ (rows.find((record) => record.kind === "resolution_learning_source") === undefined ||
2662
+ row.resolutionCycle ===
2663
+ rows.find((record) => record.kind === "resolution_learning_source")?.resolutionCycle)))
2664
+ return ticket;
2665
+ }
2666
+ const now = clampTicketTime(clock.now(), ticket.updatedAt);
2667
+ const common = { actorId: access.principalId, occurredAt: now };
2668
+ const fence = {
2669
+ expectedRevision: command.expectedRevision,
2670
+ expectedHandlingGeneration: command.expectedHandlingGeneration,
2671
+ };
2672
+ const updated = operation === "assistant_reply"
2673
+ ? applyTicketEvent(ticket, {
2674
+ type: "assistant_replied",
2675
+ ...common,
2676
+ ...fence,
2677
+ })
2678
+ : operation === "assistant_escalate"
2679
+ ? applyTicketEvent(ticket, {
2680
+ type: "human_escalated",
2681
+ ...common,
2682
+ ...fence,
2683
+ })
2684
+ : operation === "take_over"
2685
+ ? applyTicketEvent(ticket, {
2686
+ type: "human_taken_over",
2687
+ ...common,
2688
+ ...fence,
2689
+ })
2690
+ : operation === "request_person"
2691
+ ? applyTicketEvent(ticket, {
2692
+ type: "human_requested",
2693
+ ...common,
2694
+ })
2695
+ : applyTicketEvent(ticket, {
2696
+ type: "customer_success_confirmed",
2697
+ ...common,
2698
+ });
2699
+ if (updated.revision === ticket.revision)
2700
+ return ticket;
2701
+ const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, rows, operation === "assistant_reply");
2702
+ const learningConfirmation = operation === "confirm_customer_success"
2703
+ ? await resolutionLearning.customerConfirmation(learningMutation.source, updated, access.principalId, command.lastSeenVisibleMessageId, command.commandId, rows)
2704
+ : { actions: [], additionalRows: 0 };
2705
+ const newRows = (operation === "take_over" ? 2 : 3) +
2706
+ learningMutation.additionalRows +
2707
+ learningConfirmation.additionalRows;
2708
+ if (operation === "assistant_reply")
2709
+ enforceManagedAdmission(ticket, projectFollowupRows(rows, learningMutation.nextWorkRows), limits, newRows, true);
2710
+ else
2711
+ enforceHandlingControlAdmission(ticket, projectFollowupRows(rows, learningMutation.nextWorkRows), limits, newRows);
2712
+ const actions = [
2713
+ ...learningMutation.actions,
2714
+ ...learningConfirmation.actions,
2715
+ ];
2716
+ if (operation === "assistant_reply") {
2717
+ const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
2718
+ if (quota?.value.kind !== "ticket_quota")
2719
+ throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
2720
+ if (quota.value.messageCount >= limits.maxMessagesPerTicket)
2721
+ throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
2722
+ const assistantMessage = {
2723
+ kind: "message",
2724
+ partition: ticket.id,
2725
+ id: "message:" + command.messageId,
2726
+ ordinal: updated.revision,
2727
+ message: {
2728
+ id: command.messageId,
2729
+ ticketId: ticket.id,
2730
+ authorKind: "assistant",
2731
+ authorPrincipalId: access.principalId,
2732
+ channel: "web",
2733
+ visibility: "customer",
2734
+ format: "plain_text",
2735
+ body: command.body,
2736
+ createdAt: now,
2737
+ },
2738
+ };
2739
+ if (JSON.stringify(assistantMessage).length * 2 > 60_000)
2740
+ throw new SupportDeskLimitError("serialized_record", 60_000);
2741
+ actions.push({
2742
+ action: "putIfUnchanged",
2743
+ version: quota.version,
2744
+ value: {
2745
+ ...quota.value,
2746
+ messageCount: quota.value.messageCount + 1,
2747
+ },
2748
+ }, { action: "insert", value: assistantMessage });
2749
+ }
2750
+ else if (operation === "request_person" ||
2751
+ operation === "assistant_escalate") {
2752
+ if (latest === undefined)
2753
+ throw new SupportDeskConflictError("Ticket conversation is unavailable");
2754
+ actions.push({
2755
+ action: "insert",
2756
+ value: handoffWork({
2757
+ ticket: updated,
2758
+ commandId: command.commandId,
2759
+ occurredAt: now,
2760
+ lastVisibleMessageId: latest.message.id,
2761
+ reason: operation === "request_person"
2762
+ ? "customer_requested"
2763
+ : (command.reason ?? "other"),
2764
+ ...(command.summary === undefined
2765
+ ? {}
2766
+ : { summary: command.summary }),
2767
+ ...(command.attemptedGuideIds === undefined
2768
+ ? {}
2769
+ : { attemptedGuideIds: command.attemptedGuideIds }),
2770
+ }),
2771
+ });
2772
+ }
2773
+ else if (operation === "confirm_customer_success") {
2774
+ actions.push({
2775
+ action: "insert",
2776
+ value: {
2777
+ kind: "customer_success",
2778
+ partition: ticket.id,
2779
+ id: "success:" + command.commandId,
2780
+ commandId: command.commandId,
2781
+ ticketId: ticket.id,
2782
+ confirmedBy: access.principalId,
2783
+ confirmedAt: now,
2784
+ lastSeenVisibleMessageId: command.lastSeenVisibleMessageId,
2785
+ revision: updated.revision,
2786
+ handlingGeneration: effectiveTicketHandling(updated).generation,
2787
+ ...(learningMutation.source === null
2788
+ ? {}
2789
+ : {
2790
+ resolutionCycle: learningMutation.source.value.resolutionCycle,
2791
+ }),
2792
+ },
2793
+ });
2794
+ }
2795
+ const auditAction = operation === "assistant_reply"
2796
+ ? supportTicketAuditActions.assistantReplied
2797
+ : operation === "assistant_escalate"
2798
+ ? supportTicketAuditActions.humanEscalated
2799
+ : operation === "request_person"
2800
+ ? supportTicketAuditActions.humanRequested
2801
+ : operation === "take_over"
2802
+ ? supportTicketAuditActions.humanTakenOver
2803
+ : supportTicketAuditActions.customerSuccessConfirmed;
2804
+ actions.push(ticketAuditAction({
2805
+ commandId: command.commandId,
2806
+ correlationId: command.correlationId,
2807
+ ticketId: ticket.id,
2808
+ revision: updated.revision,
2809
+ actorId: access.principalId,
2810
+ occurredAt: now,
2811
+ action: auditAction,
2812
+ }), {
2813
+ action: "insert",
2814
+ value: {
2815
+ kind: "command",
2816
+ partition: ticket.id,
2817
+ id: "command:" + command.commandId,
2818
+ commandId: command.commandId,
2819
+ commandType: operation,
2820
+ ...(command.messageId === undefined
2821
+ ? {}
2822
+ : { messageId: command.messageId }),
2823
+ requestFingerprint: fingerprint,
2824
+ completedAt: now,
2825
+ resultTicket: updated,
2826
+ },
2827
+ });
2828
+ const assistantCommit = assistantTurnCommit === undefined
2829
+ ? undefined
2830
+ : await assistantTurns.completionAction(ticket, rows, updated, assistantTurnCommit);
2831
+ if (assistantCommit !== undefined) {
2832
+ actions.push(assistantCommit.action);
2833
+ await assistantCommit.verifyBeforeCommit();
2834
+ }
2835
+ if ((await records.transact(ticket.id, actions)).committed) {
2836
+ await safeProjectQueue(ticket.id);
2837
+ return updated;
2838
+ }
2839
+ }
2840
+ const current = await records.get(ticketKey(command.ticketId));
2841
+ if (current?.kind !== "ticket" ||
2842
+ (customer && !owns(current.ticket, access.principalId)))
2843
+ throw new SupportDeskNotFoundError();
2844
+ const receipt = await records.get(commandKey(command.ticketId, command.commandId));
2845
+ if (duplicateMatches(receipt, operation, command.messageId, fingerprint) &&
2846
+ receipt?.kind === "command" &&
2847
+ receipt.resultTicket !== undefined)
2848
+ return receipt.resultTicket;
2849
+ throw new SupportDeskConflictError();
2850
+ }
2851
+ const assistantTurns = createAssistantTurnServices({
2852
+ records,
2853
+ clock,
2854
+ options: assistantTurnOptions,
2855
+ assistantHandlingEnabled,
2856
+ limits,
2857
+ maxConflictAttempts,
2858
+ conversation(ticket, rows) {
2859
+ return {
2860
+ ticket: toCustomerTicketSummary(ticket),
2861
+ messages: customerMessages(rows),
2862
+ revision: ticket.revision,
2863
+ handling: { ...effectiveTicketHandling(ticket) },
2864
+ ...(latestVisibleRecord(rows) === undefined
2865
+ ? {}
2866
+ : { triggerMessageId: latestVisibleRecord(rows).message.id }),
2867
+ };
2868
+ },
2869
+ mutateHandling,
2870
+ });
2871
+ return {
2872
+ ...resolutionLearning.application,
2873
+ ...engineeringEvidence.application,
2874
+ ...engineeringWorkers,
2875
+ ...engineeringReconciliation,
2876
+ ...engineeringPolicy.application,
2877
+ ...engineeringFollowupSource.application,
2878
+ ...engineeringFollowupWork.application,
2879
+ approveAndPostEngineeringFollowupWebUpdate,
2880
+ scanAssistantTurnCandidates: assistantTurns.scanAssistantTurnCandidates,
2881
+ claimAssistantTurn: assistantTurns.claimAssistantTurn,
2882
+ prepareAssistantTurnAttempt: assistantTurns.prepareAssistantTurnAttempt,
2883
+ completeAssistantTurn: assistantTurns.completeAssistantTurn,
2884
+ finishAssistantTurnAttempt: assistantTurns.finishAssistantTurnAttempt,
2885
+ async readHandoffWork(access, ticketId) {
2886
+ requirePermission(access, supportPermissions.queueRead);
2887
+ requireIdentifier(access.principalId, "principalId");
2888
+ requireIdentifier(ticketId, "ticketId");
2889
+ const rows = await records.list(ticketId);
2890
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
2891
+ if (!rows.some((row) => row.kind === "ticket"))
2892
+ throw new SupportDeskNotFoundError();
2893
+ return rows
2894
+ .filter((row) => row.kind === "handoff_work")
2895
+ .sort((a, b) => a.transcript.throughRevision - b.transcript.throughRevision ||
2896
+ a.id.localeCompare(b.id))
2897
+ .map((row) => Object.freeze({
2898
+ ticketId,
2899
+ workId: row.id,
2900
+ status: row.status,
2901
+ reason: row.reason,
2902
+ ...(row.summary === undefined ? {} : { summary: row.summary }),
2903
+ attemptedGuideIds: Object.freeze([...row.attemptedGuideIds]),
2904
+ transcript: Object.freeze({ ...row.transcript }),
2905
+ handlingGeneration: row.handlingGeneration,
2906
+ createdAt: row.createdAt,
2907
+ }));
2908
+ },
2909
+ async readAssistantConversation(access, ticketId) {
2910
+ requirePermission(access, supportPermissions.assistantRead);
2911
+ requireIdentifier(access.principalId, "principalId");
2912
+ requireIdentifier(ticketId, "ticketId");
2913
+ const rows = await records.list(ticketId);
2914
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
2915
+ const ticket = rows.find((row) => row.kind === "ticket")?.ticket;
2916
+ if (ticket === undefined)
2917
+ throw new SupportDeskNotFoundError();
2918
+ if (ticket.channel !== "web" ||
2919
+ isTerminalTicketStatus(ticket.status) ||
2920
+ effectiveTicketHandling(ticket).state !== "assistant")
2921
+ throw new SupportDeskConflictError("Assistant conversation is unavailable");
2922
+ const latest = latestVisibleRecord(rows);
2923
+ return {
2924
+ ticket: toCustomerTicketSummary(ticket),
2925
+ messages: customerMessages(rows),
2926
+ revision: ticket.revision,
2927
+ handling: { ...effectiveTicketHandling(ticket) },
2928
+ ...(latest?.message.authorKind === "customer"
2929
+ ? { triggerMessageId: latest.message.id }
2930
+ : {}),
2931
+ };
2932
+ },
2933
+ async replyAsAssistant(access, input) {
2934
+ requirePermission(access, supportPermissions.assistantReply);
2935
+ const raw = handlingInput(input, [
2936
+ "expectedRevision",
2937
+ "expectedHandlingGeneration",
2938
+ "triggerMessageId",
2939
+ "messageId",
2940
+ "body",
2941
+ ]);
2942
+ handlingFence(raw);
2943
+ for (const key of ["triggerMessageId", "messageId"])
2944
+ requireIdentifier(boundaryString(raw[key], key), key);
2945
+ const body = boundaryString(raw.body, "body");
2946
+ enforceLimit(body, "body", limits.maxMessageCharacters);
2947
+ const command = raw;
2948
+ return handlingAck(await mutateHandling(access, command, "assistant_reply"), command.messageId);
2949
+ },
2950
+ async escalateToHuman(access, input) {
2951
+ requirePermission(access, supportPermissions.assistantEscalate);
2952
+ const raw = handlingInput(input, ["expectedRevision", "expectedHandlingGeneration", "triggerMessageId"], ["reason", "summary", "attemptedGuideIds"]);
2953
+ handlingFence(raw);
2954
+ requireIdentifier(boundaryString(raw.triggerMessageId, "triggerMessageId"), "triggerMessageId");
2955
+ if (raw.reason !== undefined &&
2956
+ ![
2957
+ "no_guidance",
2958
+ "unsuccessful",
2959
+ "capacity",
2960
+ "provider_failure",
2961
+ "other",
2962
+ ].includes(raw.reason))
2963
+ throw new TypeError("Invalid escalation reason");
2964
+ if (raw.summary !== undefined) {
2965
+ const summary = boundaryString(raw.summary, "summary");
2966
+ requireNonempty(summary, "handoff_summary");
2967
+ if (summary.length > 2_000)
2968
+ throw new SupportDeskLimitError("handoff_summary", 2_000);
2969
+ }
2970
+ if (raw.attemptedGuideIds !== undefined) {
2971
+ if (!Array.isArray(raw.attemptedGuideIds) ||
2972
+ raw.attemptedGuideIds.length > 16)
2973
+ throw new TypeError("Too many attempted guide IDs");
2974
+ const guides = raw.attemptedGuideIds;
2975
+ if (Object.keys(guides).length !== guides.length ||
2976
+ Object.getOwnPropertySymbols(guides).length !== 0)
2977
+ throw new TypeError("Guide IDs must be dense data");
2978
+ raw.attemptedGuideIds = Object.freeze(Array.from({ length: guides.length }, (_, index) => {
2979
+ const value = ownDataProperty(guides, String(index), "attemptedGuideIds");
2980
+ const id = boundaryString(value, "guideId");
2981
+ requireIdentifier(id, "guideId");
2982
+ if (id.length > 200 || id.includes("://"))
2983
+ throw new TypeError("Guide IDs must be bounded opaque references");
2984
+ return id;
2985
+ }));
2986
+ }
2987
+ return handlingAck(await mutateHandling(access, raw, "assistant_escalate"));
2988
+ },
2989
+ async requestPerson(access, input) {
2990
+ requirePermission(access, supportPermissions.requestPerson);
2991
+ const command = handlingInput(input, []);
2992
+ await mutateHandling(access, command, "request_person");
2993
+ return customerViewAfterChange(access.principalId, command.ticketId);
2994
+ },
2995
+ async takeOverTicket(access, input) {
2996
+ requireStaffMutationAccess(access, supportPermissions.takeOver);
2997
+ const command = handlingFence(handlingInput(input, [
2998
+ "expectedRevision",
2999
+ "expectedHandlingGeneration",
3000
+ ]));
3001
+ await mutateHandling(access, command, "take_over");
3002
+ return staffCommandResult(command.ticketId, command.commandId);
3003
+ },
3004
+ async confirmCustomerSuccess(access, input) {
3005
+ requirePermission(access, supportPermissions.confirmSuccess);
3006
+ const raw = handlingInput(input, ["lastSeenVisibleMessageId"]);
3007
+ requireIdentifier(boundaryString(raw.lastSeenVisibleMessageId, "lastSeenVisibleMessageId"), "lastSeenVisibleMessageId");
3008
+ const command = raw;
3009
+ await mutateHandling(access, command, "confirm_customer_success");
3010
+ return customerViewAfterChange(access.principalId, command.ticketId);
3011
+ },
3012
+ async readCustomerSuccessConfirmations(access, ticketId) {
3013
+ requireStaffMutationAccess(access, supportPermissions.auditRead);
3014
+ requireIdentifier(access.principalId, "principalId");
3015
+ requireIdentifier(ticketId, "ticketId");
3016
+ const rows = await records.list(ticketId);
3017
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
3018
+ if (!rows.some((row) => row.kind === "ticket"))
3019
+ throw new SupportDeskNotFoundError();
3020
+ return rows
3021
+ .filter((row) => row.kind === "customer_success")
3022
+ .sort((a, b) => a.revision - b.revision || a.commandId.localeCompare(b.commandId))
3023
+ .map(({ kind: _kind, partition: _partition, id: _id, ...proof }) => Object.freeze(proof));
3024
+ },
3025
+ async createCustomerTicket(access, input) {
3026
+ requirePermission(access, supportPermissions.create);
3027
+ requireIdentifier(access.principalId, "principalId");
3028
+ const command = snapshotCreateCustomerTicketCommand(input);
3029
+ if (command.initialHandling === "assistant") {
3030
+ requirePermission(access, supportPermissions.assistantStart);
3031
+ }
3032
+ requireIdentifier(command.ticketId, "ticketId");
3033
+ requireIdentifier(command.messageId, "messageId");
3034
+ requireIdentifier(command.commandId, "commandId");
3035
+ requireIdentifier(command.correlationId, "correlationId");
3036
+ enforceLimit(command.subject, "subject", limits.maxSubjectCharacters);
3037
+ enforceLimit(command.body, "body", limits.maxMessageCharacters);
3038
+ const fingerprintPayload = {
3039
+ type: "create_customer_ticket",
3040
+ ticketId: command.ticketId,
3041
+ messageId: command.messageId,
3042
+ subject: command.subject,
3043
+ body: command.body,
3044
+ // Omit absent category so pre-category receipts still match retries.
3045
+ ...(command.category === undefined
3046
+ ? {}
3047
+ : { category: command.category }),
3048
+ requesterEmail: command.requesterEmail ?? null,
3049
+ notification: stableNotification(command.notification),
3050
+ ...(command.initialHandling === undefined
3051
+ ? {}
3052
+ : { initialHandling: command.initialHandling }),
3053
+ };
3054
+ const fingerprint = await requestFingerprint(fingerprintPayload);
3055
+ const createThreading = {
3056
+ ticketNumber: true,
3057
+ ...(command.notification === undefined
3058
+ ? {}
3059
+ : { messageId: command.messageId }),
3060
+ };
3061
+ // Replay an already committed create before host allowlist checks so a
3062
+ // later config change cannot break idempotent retries of the same command.
3063
+ // Replay never reserves another instance ticket number.
3064
+ const existingCreate = await records.get(commandKey(command.ticketId, command.commandId));
3065
+ let createIsReplay = duplicateMatches(existingCreate, "create_customer_ticket", command.messageId, fingerprint);
3066
+ if (!createIsReplay &&
3067
+ command.initialHandling === undefined &&
3068
+ existingCreate?.kind === "command" &&
3069
+ existingCreate.commandType === "create_customer_ticket" &&
3070
+ existingCreate.messageId === command.messageId) {
3071
+ // Pre-Task-3 receipts included ticketNumber in the fingerprint.
3072
+ const existingTicket = await records.get(ticketKey(command.ticketId));
3073
+ if (existingTicket?.kind === "ticket") {
3074
+ // Pre-Task-3 hashed ticketNumber immediately after ticketId.
3075
+ const legacyFingerprint = await requestFingerprint({
3076
+ type: fingerprintPayload.type,
3077
+ ticketId: fingerprintPayload.ticketId,
3078
+ ticketNumber: existingTicket.ticket.number,
3079
+ messageId: fingerprintPayload.messageId,
3080
+ subject: fingerprintPayload.subject,
3081
+ body: fingerprintPayload.body,
3082
+ ...(command.category === undefined
3083
+ ? {}
3084
+ : { category: command.category }),
3085
+ requesterEmail: fingerprintPayload.requesterEmail,
3086
+ notification: fingerprintPayload.notification,
3087
+ });
3088
+ createIsReplay = duplicateMatches(existingCreate, "create_customer_ticket", command.messageId, legacyFingerprint);
3089
+ }
3090
+ }
3091
+ if (createIsReplay) {
3092
+ const current = await records.get(ticketKey(command.ticketId));
3093
+ if (current?.kind !== "ticket" ||
3094
+ !owns(current.ticket, access.principalId))
3095
+ throw new SupportDeskNotFoundError();
3096
+ // A crash after ticket commit and before index confirmation must still
3097
+ // converge the principal hint on retry.
3098
+ const fence = await records.get(ticketReservationKey(command.ticketId));
3099
+ if (fence?.kind === "ticket_reservation" &&
3100
+ fence.state === "committed") {
3101
+ await confirmCustomerTicket(access.principalId, command.ticketId, fence.token, fence.generation);
3102
+ }
3103
+ return customerViewAfterChange(access.principalId, command.ticketId, createThreading);
3104
+ }
3105
+ if (command.initialHandling === "assistant" && !assistantHandlingEnabled)
3106
+ throw new SupportDeskConflictError("Assistant handling is disabled");
3107
+ if (command.category !== undefined) {
3108
+ if (allowedCategories === undefined ||
3109
+ !allowedCategories.has(command.category)) {
3110
+ throw new TypeError("create command.category is not configured for this Support Desk instance");
3111
+ }
3112
+ }
3113
+ // Fail closed before durable number/index reservation so a too-small
3114
+ // partition budget cannot exhaust the principal ticket index with
3115
+ // reserved-but-never-created tickets.
3116
+ const estimatedCreateRows = 6 + // ticket, quota, reservation, message, audit, command
3117
+ (resolutionLearningOptions.enabled ? 1 : 0) +
3118
+ (engineeringOptions.enabled ? 1 : 0) +
3119
+ (command.notification === undefined ? 0 : 1) +
3120
+ (assistantTurnOptions !== undefined &&
3121
+ command.initialHandling === "assistant"
3122
+ ? 1
3123
+ : 0);
3124
+ enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, estimatedCreateRows +
3125
+ (resolutionLearningOptions.enabled ? 2 : 0) +
3126
+ (engineeringOptions.enabled ? 2 : 0) +
3127
+ (command.initialHandling === "assistant"
3128
+ ? assistantHumanReserveRows +
3129
+ (resolutionLearningOptions.enabled ? 1 : 0)
3130
+ : 0));
3131
+ if (command.initialHandling === "assistant" &&
3132
+ limits.maxMessagesPerTicket < 1 + assistantHumanReserveMessages)
3133
+ throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
3134
+ // Reserve outside the ticket transaction; gaps are accepted if create fails.
3135
+ const ticketNumber = await reserveTicketNumber();
3136
+ // Bind host-supplied notification content to the reserved number so mail
3137
+ // cannot advertise a guessed ticket number.
1657
3138
  const notification = command.notification === undefined
1658
3139
  ? undefined
1659
3140
  : bindNotificationTicketNumber(command.notification, ticketNumber);
1660
3141
  const now = clock.now();
1661
- const ticket = createTicket({
3142
+ const ticket = await applyResponseTargets(createTicket({
1662
3143
  id: command.ticketId,
1663
3144
  number: ticketNumber,
1664
3145
  subject: command.subject,
1665
3146
  channel: "web",
3147
+ ...(command.initialHandling === undefined
3148
+ ? {}
3149
+ : { initialHandling: command.initialHandling }),
1666
3150
  ...(command.category === undefined
1667
3151
  ? {}
1668
3152
  : { category: command.category }),
@@ -1674,7 +3158,7 @@ export function createSupportDeskApplication(options) {
1674
3158
  : { email: command.requesterEmail }),
1675
3159
  },
1676
3160
  createdAt: now,
1677
- });
3161
+ }), responseTargetPolicy, "created");
1678
3162
  const message = {
1679
3163
  id: command.messageId,
1680
3164
  ticketId: command.ticketId,
@@ -1686,6 +3170,10 @@ export function createSupportDeskApplication(options) {
1686
3170
  body: command.body,
1687
3171
  createdAt: now,
1688
3172
  };
3173
+ const assistantTurn = assistantTurnOptions !== undefined &&
3174
+ command.initialHandling === "assistant"
3175
+ ? await createAssistantTurnRecord(ticket, message, command.correlationId, assistantTurnOptions)
3176
+ : undefined;
1689
3177
  const notificationJob = notification === undefined
1690
3178
  ? undefined
1691
3179
  : deliveryJobAction(command.ticketId, command.messageId, now, notification);
@@ -1728,6 +3216,22 @@ export function createSupportDeskApplication(options) {
1728
3216
  ]
1729
3217
  : [];
1730
3218
  const actions = [
3219
+ ...(engineeringOptions.enabled
3220
+ ? [
3221
+ {
3222
+ action: "insert",
3223
+ value: await createEngineeringSource(ticket),
3224
+ },
3225
+ ]
3226
+ : []),
3227
+ ...(resolutionLearningOptions.enabled
3228
+ ? [
3229
+ {
3230
+ action: "insert",
3231
+ value: await createResolutionLearningSource(ticket),
3232
+ },
3233
+ ]
3234
+ : []),
1731
3235
  {
1732
3236
  action: "insert",
1733
3237
  value: {
@@ -1744,6 +3248,9 @@ export function createSupportDeskApplication(options) {
1744
3248
  partition: command.ticketId,
1745
3249
  id: "quota",
1746
3250
  messageCount: 1,
3251
+ ...(command.initialHandling === "assistant"
3252
+ ? { assistantManaged: true }
3253
+ : {}),
1747
3254
  },
1748
3255
  },
1749
3256
  ...reservationActions,
@@ -1783,6 +3290,9 @@ export function createSupportDeskApplication(options) {
1783
3290
  },
1784
3291
  },
1785
3292
  ...(notificationJob === undefined ? [] : [notificationJob]),
3293
+ ...(assistantTurn === undefined
3294
+ ? []
3295
+ : [{ action: "insert", value: assistantTurn }]),
1786
3296
  ];
1787
3297
  // Create starts an empty partition; fail closed if the first write would
1788
3298
  // exceed the configured physical-record budget.
@@ -1795,7 +3305,7 @@ export function createSupportDeskApplication(options) {
1795
3305
  }
1796
3306
  }
1797
3307
  await confirmCustomerTicket(access.principalId, command.ticketId, reservation.reservationToken, reservation.reservationGeneration);
1798
- return customerViewAfterChange(access.principalId, command.ticketId);
3308
+ return customerViewAfterChange(access.principalId, command.ticketId, createThreading);
1799
3309
  },
1800
3310
  async replyToCustomerTicket(access, input) {
1801
3311
  requirePermission(access, supportPermissions.replyOwn);
@@ -1816,7 +3326,7 @@ export function createSupportDeskApplication(options) {
1816
3326
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
1817
3327
  const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
1818
3328
  if (duplicateMatches(duplicate, "reply_customer_ticket", command.messageId, fingerprint)) {
1819
- return customerViewAfterChange(access.principalId, command.ticketId);
3329
+ return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
1820
3330
  }
1821
3331
  const versioned = await records.getVersioned(ticketKey(command.ticketId));
1822
3332
  if (versioned?.value.kind !== "ticket" ||
@@ -1825,7 +3335,10 @@ export function createSupportDeskApplication(options) {
1825
3335
  throw new SupportDeskNotFoundError();
1826
3336
  }
1827
3337
  const partitionRows = await records.list(command.ticketId);
1828
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, command.notification === undefined ? 3 : 4);
3338
+ const durableAssistantIngress = assistantTurnOptions !== undefined &&
3339
+ effectiveTicketHandling(versioned.value.ticket).state === "assistant";
3340
+ const addedReplyRows = (command.notification === undefined ? 3 : 4) +
3341
+ (durableAssistantIngress ? 1 : 0);
1829
3342
  const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
1830
3343
  if (quota?.value.kind !== "ticket_quota") {
1831
3344
  throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
@@ -1839,11 +3352,25 @@ export function createSupportDeskApplication(options) {
1839
3352
  const now = sampledEpoch < storedEpoch
1840
3353
  ? versioned.value.ticket.updatedAt
1841
3354
  : sampledNow;
1842
- const updated = applyTicketEvent(versioned.value.ticket, {
3355
+ let updated = await applyResponseTargets(applyTicketEvent(versioned.value.ticket, {
1843
3356
  type: "customer_replied",
1844
3357
  actorId: access.principalId,
1845
3358
  occurredAt: now,
1846
- });
3359
+ }), responseTargetPolicy, "customer_replied");
3360
+ if (durableAssistantIngress && !assistantHandlingEnabled) {
3361
+ // Configuration can disable generation while an older assistant
3362
+ // conversation exists. The causal reply itself commits human work.
3363
+ updated = applyTicketEvent(updated, {
3364
+ type: "human_escalated",
3365
+ actorId: access.principalId,
3366
+ occurredAt: now,
3367
+ expectedRevision: updated.revision,
3368
+ expectedHandlingGeneration: effectiveTicketHandling(updated).generation,
3369
+ });
3370
+ }
3371
+ const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
3372
+ enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedReplyRows + learningMutation.additionalRows);
3373
+ enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, addedReplyRows + learningMutation.additionalRows, true);
1847
3374
  const message = {
1848
3375
  id: command.messageId,
1849
3376
  ticketId: command.ticketId,
@@ -1856,15 +3383,22 @@ export function createSupportDeskApplication(options) {
1856
3383
  createdAt: now,
1857
3384
  };
1858
3385
  const notificationContent = deliveryContent(command.notification);
3386
+ const assistantIngress = !durableAssistantIngress
3387
+ ? undefined
3388
+ : assistantHandlingEnabled
3389
+ ? await createAssistantTurnRecord(updated, message, command.correlationId, assistantTurnOptions)
3390
+ : handoffWork({
3391
+ ticket: updated,
3392
+ commandId: command.commandId,
3393
+ occurredAt: now,
3394
+ lastVisibleMessageId: command.messageId,
3395
+ reason: "other",
3396
+ });
1859
3397
  const notificationJob = command.notification === undefined
1860
3398
  ? undefined
1861
3399
  : deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
1862
3400
  const outcome = await records.transact(command.ticketId, [
1863
- {
1864
- action: "putIfUnchanged",
1865
- version: versioned.version,
1866
- value: { ...versioned.value, ticket: updated },
1867
- },
3401
+ ...learningMutation.actions,
1868
3402
  {
1869
3403
  action: "putIfUnchanged",
1870
3404
  version: quota.version,
@@ -1909,13 +3443,16 @@ export function createSupportDeskApplication(options) {
1909
3443
  },
1910
3444
  },
1911
3445
  ...(notificationJob === undefined ? [] : [notificationJob]),
3446
+ ...(assistantIngress === undefined
3447
+ ? []
3448
+ : [{ action: "insert", value: assistantIngress }]),
1912
3449
  ]);
1913
3450
  if (outcome.committed) {
1914
- return customerViewAfterChange(access.principalId, command.ticketId);
3451
+ return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
1915
3452
  }
1916
3453
  const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
1917
3454
  if (duplicateMatches(nowDuplicate, "reply_customer_ticket", command.messageId, fingerprint)) {
1918
- return customerViewAfterChange(access.principalId, command.ticketId);
3455
+ return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
1919
3456
  }
1920
3457
  }
1921
3458
  throw new SupportDeskConflictError();
@@ -1956,6 +3493,10 @@ export function createSupportDeskApplication(options) {
1956
3493
  requirePermission(access, supportPermissions.queueRead);
1957
3494
  requireIdentifier(access.principalId, "principalId");
1958
3495
  const query = snapshotStaffQueueQuery(input);
3496
+ // One trusted instant for the entire bounded request; no clock read for old queries.
3497
+ const targetNow = query.targetState === undefined
3498
+ ? undefined
3499
+ : canonicalTimestamp(clock.now(), "clock.now()");
1959
3500
  // Request-local cursor only: every online queue read starts without one
1960
3501
  // and consumes a single complete projection scan cycle.
1961
3502
  let cursor;
@@ -2016,10 +3557,17 @@ export function createSupportDeskApplication(options) {
2016
3557
  }
2017
3558
  const ticket = ticketRecord.ticket;
2018
3559
  if (ticket.id !== projection.ticketId ||
2019
- ticket.revision !== projection.projectedRevision ||
3560
+ !Number.isSafeInteger(projection.projectedRevision) ||
3561
+ projection.projectedRevision <= 0 ||
3562
+ projection.projectedRevision > ticket.revision ||
2020
3563
  isTerminalTicketStatus(ticket.status)) {
2021
3564
  continue;
2022
3565
  }
3566
+ const handlingView = query.handling ?? "human";
3567
+ if (handlingView !== "all" &&
3568
+ (handlingView === "assistant") !==
3569
+ (effectiveTicketHandling(ticket).state === "assistant"))
3570
+ continue;
2023
3571
  if (seenTicketIds.has(ticket.id)) {
2024
3572
  // Scan pages may repeat rows; de-duplicate after confirmation.
2025
3573
  continue;
@@ -2035,9 +3583,33 @@ export function createSupportDeskApplication(options) {
2035
3583
  }
2036
3584
  cursor = page.nextCursor;
2037
3585
  }
2038
- const filtered = confirmed.filter((item) => staffQueueItemMatches(item, query));
3586
+ const filtered = confirmed.filter((item) => staffQueueItemMatches(item, query, targetNow));
2039
3587
  const sort = query.sort ?? "updated_newest";
3588
+ // Parse each pending due date once per read, not once per comparison.
3589
+ const dueEpochs = new Map();
3590
+ if (sort === "due_soonest" || sort === "due_latest") {
3591
+ for (const item of filtered) {
3592
+ const due = pendingTargetDue(item, query.target);
3593
+ if (due !== undefined)
3594
+ dueEpochs.set(item, canonicalTimestamp(due, "ticket target"));
3595
+ }
3596
+ }
2040
3597
  filtered.sort((left, right) => {
3598
+ if (sort === "due_soonest" || sort === "due_latest") {
3599
+ const leftDue = dueEpochs.get(left);
3600
+ const rightDue = dueEpochs.get(right);
3601
+ // Completed and missing targets sort last in both directions.
3602
+ if (leftDue === undefined && rightDue !== undefined)
3603
+ return 1;
3604
+ if (rightDue === undefined && leftDue !== undefined)
3605
+ return -1;
3606
+ const difference = leftDue === undefined || rightDue === undefined
3607
+ ? 0
3608
+ : sort === "due_soonest"
3609
+ ? leftDue - rightDue
3610
+ : rightDue - leftDue;
3611
+ return difference || left.ticketId.localeCompare(right.ticketId);
3612
+ }
2041
3613
  const byTime = sort === "updated_oldest"
2042
3614
  ? left.updatedAt.localeCompare(right.updatedAt)
2043
3615
  : right.updatedAt.localeCompare(left.updatedAt);
@@ -2061,6 +3633,7 @@ export function createSupportDeskApplication(options) {
2061
3633
  requireIdentifier(command.correlationId, "correlationId");
2062
3634
  enforceLimit(command.body, "body", limits.maxMessageCharacters);
2063
3635
  const fingerprint = await requestFingerprint({
3636
+ ...snapshotRevisionHint(command),
2064
3637
  type: "staff_reply",
2065
3638
  ticketId: command.ticketId,
2066
3639
  messageId: command.messageId,
@@ -2070,14 +3643,17 @@ export function createSupportDeskApplication(options) {
2070
3643
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
2071
3644
  const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
2072
3645
  if (duplicateMatches(duplicate, "staff_reply", command.messageId, fingerprint)) {
2073
- return staffViewAfterChange(command.ticketId);
3646
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2074
3647
  }
3648
+ if (duplicate !== null)
3649
+ throw new SupportDeskConflictError("Command ID reused with different input");
2075
3650
  const versioned = await records.getVersioned(ticketKey(command.ticketId));
2076
3651
  if (versioned?.value.kind !== "ticket") {
2077
3652
  throw new SupportDeskNotFoundError();
2078
3653
  }
3654
+ if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
3655
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2079
3656
  const partitionRows = await records.list(command.ticketId);
2080
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, command.notification === undefined ? 3 : 4);
2081
3657
  const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
2082
3658
  if (quota?.value.kind !== "ticket_quota") {
2083
3659
  throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
@@ -2091,6 +3667,11 @@ export function createSupportDeskApplication(options) {
2091
3667
  actorId: access.principalId,
2092
3668
  occurredAt: now,
2093
3669
  });
3670
+ const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
3671
+ enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, (command.notification === undefined ? 3 : 4) +
3672
+ learningMutation.additionalRows);
3673
+ enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, (command.notification === undefined ? 3 : 4) +
3674
+ learningMutation.additionalRows, true, true);
2094
3675
  const message = {
2095
3676
  id: command.messageId,
2096
3677
  ticketId: command.ticketId,
@@ -2107,11 +3688,7 @@ export function createSupportDeskApplication(options) {
2107
3688
  ? undefined
2108
3689
  : deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
2109
3690
  const outcome = await records.transact(command.ticketId, [
2110
- {
2111
- action: "putIfUnchanged",
2112
- version: versioned.version,
2113
- value: { ...versioned.value, ticket: updated },
2114
- },
3691
+ ...learningMutation.actions,
2115
3692
  {
2116
3693
  action: "putIfUnchanged",
2117
3694
  version: quota.version,
@@ -2153,19 +3730,20 @@ export function createSupportDeskApplication(options) {
2153
3730
  messageId: command.messageId,
2154
3731
  requestFingerprint: fingerprint,
2155
3732
  completedAt: now,
3733
+ resultTicket: updated,
2156
3734
  },
2157
3735
  },
2158
3736
  ...(notificationJob === undefined ? [] : [notificationJob]),
2159
3737
  ]);
2160
3738
  if (outcome.committed) {
2161
- return staffViewAfterChange(command.ticketId);
3739
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2162
3740
  }
2163
3741
  const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
2164
3742
  if (duplicateMatches(nowDuplicate, "staff_reply", command.messageId, fingerprint)) {
2165
- return staffViewAfterChange(command.ticketId);
3743
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2166
3744
  }
2167
3745
  }
2168
- throw new SupportDeskConflictError();
3746
+ return staffConflictResult(command, fingerprint);
2169
3747
  },
2170
3748
  async addNote(access, input) {
2171
3749
  requireStaffMutationAccess(access, supportPermissions.note);
@@ -2177,6 +3755,7 @@ export function createSupportDeskApplication(options) {
2177
3755
  requireIdentifier(command.correlationId, "correlationId");
2178
3756
  enforceLimit(command.body, "body", limits.maxMessageCharacters);
2179
3757
  const fingerprint = await requestFingerprint({
3758
+ ...snapshotRevisionHint(command),
2180
3759
  type: "add_note",
2181
3760
  ticketId: command.ticketId,
2182
3761
  messageId: command.messageId,
@@ -2185,14 +3764,19 @@ export function createSupportDeskApplication(options) {
2185
3764
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
2186
3765
  const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
2187
3766
  if (duplicateMatches(duplicate, "add_note", command.messageId, fingerprint)) {
2188
- return staffViewAfterChange(command.ticketId);
3767
+ return staffCommandResult(command.ticketId, command.commandId);
2189
3768
  }
3769
+ if (duplicate !== null)
3770
+ throw new SupportDeskConflictError("Command ID reused with different input");
2190
3771
  const versioned = await records.getVersioned(ticketKey(command.ticketId));
2191
3772
  if (versioned?.value.kind !== "ticket") {
2192
3773
  throw new SupportDeskNotFoundError();
2193
3774
  }
3775
+ if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
3776
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2194
3777
  const partitionRows = await records.list(command.ticketId);
2195
3778
  enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 3);
3779
+ enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, 3, true);
2196
3780
  const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
2197
3781
  if (quota?.value.kind !== "ticket_quota") {
2198
3782
  throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
@@ -2268,18 +3852,19 @@ export function createSupportDeskApplication(options) {
2268
3852
  messageId: command.messageId,
2269
3853
  requestFingerprint: fingerprint,
2270
3854
  completedAt: now,
3855
+ resultTicket: updated,
2271
3856
  },
2272
3857
  },
2273
3858
  ]);
2274
3859
  if (outcome.committed) {
2275
- return staffViewAfterChange(command.ticketId);
3860
+ return staffCommandResult(command.ticketId, command.commandId);
2276
3861
  }
2277
3862
  const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
2278
3863
  if (duplicateMatches(nowDuplicate, "add_note", command.messageId, fingerprint)) {
2279
- return staffViewAfterChange(command.ticketId);
3864
+ return staffCommandResult(command.ticketId, command.commandId);
2280
3865
  }
2281
3866
  }
2282
- throw new SupportDeskConflictError();
3867
+ return staffConflictResult(command, fingerprint);
2283
3868
  },
2284
3869
  async assignTicket(access, input) {
2285
3870
  requireStaffMutationAccess(access, supportPermissions.assign);
@@ -2298,6 +3883,7 @@ export function createSupportDeskApplication(options) {
2298
3883
  enforceLimit(command.body, "body", limits.maxMessageCharacters);
2299
3884
  }
2300
3885
  const fingerprint = await requestFingerprint({
3886
+ ...snapshotRevisionHint(command),
2301
3887
  type: "assign_ticket",
2302
3888
  ticketId: command.ticketId,
2303
3889
  assigneeId: command.assigneeId,
@@ -2308,8 +3894,10 @@ export function createSupportDeskApplication(options) {
2308
3894
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
2309
3895
  const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
2310
3896
  if (duplicateMatches(duplicate, "assign_ticket", command.messageId, fingerprint)) {
2311
- return staffViewAfterChange(command.ticketId);
3897
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2312
3898
  }
3899
+ if (duplicate !== null)
3900
+ throw new SupportDeskConflictError("Command ID reused with different input");
2313
3901
  const versioned = await records.getVersioned(ticketKey(command.ticketId));
2314
3902
  if (versioned?.value.kind !== "ticket") {
2315
3903
  throw new SupportDeskNotFoundError();
@@ -2319,8 +3907,11 @@ export function createSupportDeskApplication(options) {
2319
3907
  : command.notification === undefined
2320
3908
  ? 3
2321
3909
  : 4;
3910
+ if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
3911
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2322
3912
  const partitionRows = await records.list(command.ticketId);
2323
3913
  enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords);
3914
+ enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, addedRecords, command.messageId !== undefined);
2324
3915
  let quotaVersion = null;
2325
3916
  if (command.messageId !== undefined) {
2326
3917
  quotaVersion = await records.getVersioned(ticketQuotaKey(command.ticketId));
@@ -2377,9 +3968,7 @@ export function createSupportDeskApplication(options) {
2377
3968
  action: "putIfUnchanged",
2378
3969
  version: quotaVersion.version,
2379
3970
  value: {
2380
- kind: "ticket_quota",
2381
- partition: command.ticketId,
2382
- id: "quota",
3971
+ ...quotaVersion.value,
2383
3972
  messageCount: quotaVersion.value.messageCount + 1,
2384
3973
  },
2385
3974
  },
@@ -2428,19 +4017,20 @@ export function createSupportDeskApplication(options) {
2428
4017
  : { messageId: command.messageId }),
2429
4018
  requestFingerprint: fingerprint,
2430
4019
  completedAt: now,
4020
+ resultTicket: updated,
2431
4021
  },
2432
4022
  },
2433
4023
  ...(notificationJob === undefined ? [] : [notificationJob]),
2434
4024
  ]);
2435
4025
  if (outcome.committed) {
2436
- return staffViewAfterChange(command.ticketId);
4026
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2437
4027
  }
2438
4028
  const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
2439
4029
  if (duplicateMatches(nowDuplicate, "assign_ticket", command.messageId, fingerprint)) {
2440
- return staffViewAfterChange(command.ticketId);
4030
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2441
4031
  }
2442
4032
  }
2443
- throw new SupportDeskConflictError();
4033
+ return staffConflictResult(command, fingerprint);
2444
4034
  },
2445
4035
  async changePriority(access, input) {
2446
4036
  requireStaffMutationAccess(access, supportPermissions.manage);
@@ -2450,6 +4040,7 @@ export function createSupportDeskApplication(options) {
2450
4040
  requireIdentifier(command.commandId, "commandId");
2451
4041
  requireIdentifier(command.correlationId, "correlationId");
2452
4042
  const fingerprint = await requestFingerprint({
4043
+ ...snapshotRevisionHint(command),
2453
4044
  type: "change_priority",
2454
4045
  ticketId: command.ticketId,
2455
4046
  priority: command.priority,
@@ -2457,23 +4048,28 @@ export function createSupportDeskApplication(options) {
2457
4048
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
2458
4049
  const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
2459
4050
  if (duplicateMatches(duplicate, "change_priority", undefined, fingerprint)) {
2460
- return staffViewAfterChange(command.ticketId);
4051
+ return staffCommandResult(command.ticketId, command.commandId);
2461
4052
  }
4053
+ if (duplicate !== null)
4054
+ throw new SupportDeskConflictError("Command ID reused with different input");
2462
4055
  const versioned = await records.getVersioned(ticketKey(command.ticketId));
2463
4056
  if (versioned?.value.kind !== "ticket") {
2464
4057
  throw new SupportDeskNotFoundError();
2465
4058
  }
4059
+ if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
4060
+ return staffCommandResult(command.ticketId, command.commandId);
2466
4061
  const partitionRows = await records.list(command.ticketId);
2467
4062
  enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 2);
4063
+ enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, 2);
2468
4064
  const statusBefore = versioned.value.ticket.status;
2469
4065
  const customerUpdatedBefore = versioned.value.ticket.customerUpdatedAt;
2470
4066
  const now = clampTicketTime(clock.now(), versioned.value.ticket.updatedAt);
2471
- const updated = applyTicketEvent(versioned.value.ticket, {
4067
+ const updated = await applyResponseTargets(applyTicketEvent(versioned.value.ticket, {
2472
4068
  type: "priority_changed",
2473
4069
  actorId: access.principalId,
2474
4070
  priority: command.priority,
2475
4071
  occurredAt: now,
2476
- });
4072
+ }), responseTargetPolicy, "priority_changed");
2477
4073
  if (updated.status !== statusBefore ||
2478
4074
  updated.customerUpdatedAt !== customerUpdatedBefore) {
2479
4075
  throw new SupportDeskConflictError("Priority change unexpectedly changed customer-visible ticket state");
@@ -2504,18 +4100,98 @@ export function createSupportDeskApplication(options) {
2504
4100
  commandType: "change_priority",
2505
4101
  requestFingerprint: fingerprint,
2506
4102
  completedAt: now,
4103
+ resultTicket: updated,
2507
4104
  },
2508
4105
  },
2509
4106
  ]);
2510
4107
  if (outcome.committed) {
2511
- return staffViewAfterChange(command.ticketId);
4108
+ return staffCommandResult(command.ticketId, command.commandId);
2512
4109
  }
2513
4110
  const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
2514
4111
  if (duplicateMatches(nowDuplicate, "change_priority", undefined, fingerprint)) {
2515
- return staffViewAfterChange(command.ticketId);
4112
+ return staffCommandResult(command.ticketId, command.commandId);
2516
4113
  }
2517
4114
  }
2518
- throw new SupportDeskConflictError();
4115
+ return staffConflictResult(command, fingerprint);
4116
+ },
4117
+ async setTags(access, input) {
4118
+ requireStaffMutationAccess(access, supportPermissions.manage);
4119
+ requireIdentifier(access.principalId, "principalId");
4120
+ const source = boundaryObject(input, "set tags command");
4121
+ const command = Object.freeze({
4122
+ ...snapshotRevisionHint(source),
4123
+ commandId: boundaryString(ownDataProperty(source, "commandId", "commandId"), "commandId"),
4124
+ correlationId: boundaryString(ownDataProperty(source, "correlationId", "correlationId"), "correlationId"),
4125
+ ticketId: boundaryString(ownDataProperty(source, "ticketId", "ticketId"), "ticketId"),
4126
+ tags: snapshotTags(ownDataProperty(source, "tags", "tags")),
4127
+ });
4128
+ requireIdentifier(command.ticketId, "ticketId");
4129
+ requireIdentifier(command.commandId, "commandId");
4130
+ requireIdentifier(command.correlationId, "correlationId");
4131
+ const fingerprint = await requestFingerprint({
4132
+ type: "set_tags",
4133
+ ticketId: command.ticketId,
4134
+ tags: command.tags,
4135
+ ...snapshotRevisionHint(command),
4136
+ });
4137
+ for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
4138
+ const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
4139
+ if (duplicateMatches(duplicate, "set_tags", undefined, fingerprint))
4140
+ return staffCommandResult(command.ticketId, command.commandId);
4141
+ if (duplicate !== null)
4142
+ throw new SupportDeskConflictError("Command ID reused with different input");
4143
+ const versioned = await records.getVersioned(ticketKey(command.ticketId));
4144
+ if (versioned?.value.kind !== "ticket")
4145
+ throw new SupportDeskNotFoundError();
4146
+ if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
4147
+ return staffCommandResult(command.ticketId, command.commandId);
4148
+ if (command.tags.length > maxTagsPerTicket)
4149
+ throw new TypeError("tags exceed maxTagsPerTicket");
4150
+ if (command.tags.some((tag) => !allowedTags.has(tag)))
4151
+ throw new TypeError("tags must be on the host allowlist");
4152
+ const rows = await records.list(command.ticketId);
4153
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket, 2);
4154
+ enforceManagedAdmission(versioned.value.ticket, rows, limits, 2);
4155
+ const now = clampTicketTime(clock.now(), versioned.value.ticket.updatedAt);
4156
+ const updated = applyTicketEvent(versioned.value.ticket, {
4157
+ type: "tags_changed",
4158
+ actorId: access.principalId,
4159
+ occurredAt: now,
4160
+ tags: command.tags,
4161
+ });
4162
+ const outcome = await records.transact(command.ticketId, [
4163
+ {
4164
+ action: "putIfUnchanged",
4165
+ version: versioned.version,
4166
+ value: { ...versioned.value, ticket: updated },
4167
+ },
4168
+ ticketAuditAction({
4169
+ commandId: command.commandId,
4170
+ correlationId: command.correlationId,
4171
+ ticketId: command.ticketId,
4172
+ revision: updated.revision,
4173
+ actorId: access.principalId,
4174
+ occurredAt: now,
4175
+ action: supportTicketAuditActions.tagsChanged,
4176
+ }),
4177
+ {
4178
+ action: "insert",
4179
+ value: {
4180
+ kind: "command",
4181
+ partition: command.ticketId,
4182
+ id: `command:${command.commandId}`,
4183
+ commandId: command.commandId,
4184
+ commandType: "set_tags",
4185
+ requestFingerprint: fingerprint,
4186
+ completedAt: now,
4187
+ resultTicket: updated,
4188
+ },
4189
+ },
4190
+ ]);
4191
+ if (outcome.committed)
4192
+ return staffCommandResult(command.ticketId, command.commandId);
4193
+ }
4194
+ return staffConflictResult(command, fingerprint);
2519
4195
  },
2520
4196
  async resolveTicket(access, input) {
2521
4197
  return mutateLifecycle(access, input, {
@@ -2568,6 +4244,7 @@ export function createSupportDeskApplication(options) {
2568
4244
  enforceLimit(command.body, "body", limits.maxMessageCharacters);
2569
4245
  }
2570
4246
  const fingerprint = await requestFingerprint({
4247
+ ...snapshotRevisionHint(command),
2571
4248
  type: spec.commandType,
2572
4249
  ticketId: command.ticketId,
2573
4250
  messageId: command.messageId ?? null,
@@ -2577,19 +4254,25 @@ export function createSupportDeskApplication(options) {
2577
4254
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
2578
4255
  const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
2579
4256
  if (duplicateMatches(duplicate, spec.commandType, command.messageId, fingerprint)) {
2580
- return staffViewAfterChange(command.ticketId);
4257
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2581
4258
  }
4259
+ if (duplicate !== null)
4260
+ throw new SupportDeskConflictError("Command ID reused with different input");
2582
4261
  const versioned = await records.getVersioned(ticketKey(command.ticketId));
2583
4262
  if (versioned?.value.kind !== "ticket") {
2584
4263
  throw new SupportDeskNotFoundError();
2585
4264
  }
4265
+ if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
4266
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2586
4267
  const partitionRows = await records.list(command.ticketId);
2587
- const addedRecords = command.messageId === undefined
4268
+ const needsReopenHandoff = assistantTurnOptions !== undefined &&
4269
+ spec.eventType === "reopened" &&
4270
+ effectiveTicketHandling(versioned.value.ticket).state === "assistant";
4271
+ const addedRecords = (command.messageId === undefined
2588
4272
  ? 2 // audit + command
2589
4273
  : command.notification === undefined
2590
4274
  ? 3 // message + audit + command
2591
- : 4; // message + audit + command + delivery
2592
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords);
4275
+ : 4) + (needsReopenHandoff ? 1 : 0); // optional atomic human handoff
2593
4276
  let quotaVersion = null;
2594
4277
  if (command.messageId !== undefined) {
2595
4278
  quotaVersion = await records.getVersioned(ticketQuotaKey(command.ticketId));
@@ -2606,6 +4289,9 @@ export function createSupportDeskApplication(options) {
2606
4289
  actorId: access.principalId,
2607
4290
  occurredAt: now,
2608
4291
  });
4292
+ const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, command.messageId !== undefined);
4293
+ enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords + learningMutation.additionalRows);
4294
+ enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, addedRecords + learningMutation.additionalRows, command.messageId !== undefined);
2609
4295
  // Optional system message + mail commit with the lifecycle change so a
2610
4296
  // resolution notification cannot be lost after the ticket is resolved.
2611
4297
  const systemMessage = command.messageId === undefined || command.body === undefined
@@ -2625,11 +4311,7 @@ export function createSupportDeskApplication(options) {
2625
4311
  ? undefined
2626
4312
  : deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
2627
4313
  const outcome = await records.transact(command.ticketId, [
2628
- {
2629
- action: "putIfUnchanged",
2630
- version: versioned.version,
2631
- value: { ...versioned.value, ticket: updated },
2632
- },
4314
+ ...learningMutation.actions,
2633
4315
  ...(quotaVersion === null ||
2634
4316
  systemMessage === undefined ||
2635
4317
  quotaVersion.value.kind !== "ticket_quota"
@@ -2639,9 +4321,7 @@ export function createSupportDeskApplication(options) {
2639
4321
  action: "putIfUnchanged",
2640
4322
  version: quotaVersion.version,
2641
4323
  value: {
2642
- kind: "ticket_quota",
2643
- partition: command.ticketId,
2644
- id: "quota",
4324
+ ...quotaVersion.value,
2645
4325
  messageCount: quotaVersion.value.messageCount + 1,
2646
4326
  },
2647
4327
  },
@@ -2685,19 +4365,35 @@ export function createSupportDeskApplication(options) {
2685
4365
  : { messageId: command.messageId }),
2686
4366
  requestFingerprint: fingerprint,
2687
4367
  completedAt: now,
4368
+ resultTicket: updated,
2688
4369
  },
2689
4370
  },
2690
4371
  ...(notificationJob === undefined ? [] : [notificationJob]),
4372
+ ...(needsReopenHandoff
4373
+ ? [
4374
+ {
4375
+ action: "insert",
4376
+ value: handoffWork({
4377
+ ticket: updated,
4378
+ commandId: command.commandId,
4379
+ occurredAt: now,
4380
+ lastVisibleMessageId: systemMessage?.id ??
4381
+ latestVisibleRecord(partitionRows).message.id,
4382
+ reason: "other",
4383
+ }),
4384
+ },
4385
+ ]
4386
+ : []),
2691
4387
  ]);
2692
4388
  if (outcome.committed) {
2693
- return staffViewAfterChange(command.ticketId);
4389
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2694
4390
  }
2695
4391
  const nowDuplicate = await records.get(commandKey(command.ticketId, command.commandId));
2696
4392
  if (duplicateMatches(nowDuplicate, spec.commandType, command.messageId, fingerprint)) {
2697
- return staffViewAfterChange(command.ticketId);
4393
+ return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
2698
4394
  }
2699
4395
  }
2700
- throw new SupportDeskConflictError();
4396
+ return staffConflictResult(command, fingerprint);
2701
4397
  }
2702
4398
  }
2703
4399
  function canonicalTimestamp(value, field) {
@@ -2922,7 +4618,8 @@ export async function sweepInboundReceipts(store, clock, input) {
2922
4618
  }
2923
4619
  const receipts = store.collection(inboundReceipts);
2924
4620
  const candidates = (await receipts.listVersioned(bucket))
2925
- .filter((versioned) => versioned.value.status !== "processing" &&
4621
+ .filter((versioned) => (versioned.value.status === "processed" ||
4622
+ versioned.value.status === "rejected") &&
2926
4623
  versioned.value.processedAt !== undefined &&
2927
4624
  versioned.value.processedAt <= processedBefore &&
2928
4625
  versioned.value.processedAt < horizonBefore)
@@ -3045,4 +4742,1671 @@ export async function pruneCustomerTicketIndex(store, principalId, input) {
3045
4742
  });
3046
4743
  return result.value?.entries.map((entry) => entry.ticketId) ?? [];
3047
4744
  }
4745
+ const threadIndexKey = (record) => ({
4746
+ partition: record.key,
4747
+ id: record.id,
4748
+ });
4749
+ export const threadIndex = defineCollection({
4750
+ name: "support-desk.thread-index.v1",
4751
+ key: threadIndexKey,
4752
+ codec: jsonCodec(threadIndexKey),
4753
+ });
4754
+ /**
4755
+ * Default lifetime of `Message-ID` and reply-token threading rows. Lookups,
4756
+ * repair, and sweeping all treat older rows as absent; ticket-number rows
4757
+ * live as long as their ticket.
4758
+ */
4759
+ export const defaultThreadIndexRetentionMilliseconds = 365 * 86_400_000;
4760
+ /** Actor recorded on Audit events accepted from verified inbound mail. */
4761
+ export const inboundEmailSystemActorId = "support-desk.inbound-email";
4762
+ function notificationThreading(command) {
4763
+ return command.notification === undefined || command.messageId === undefined
4764
+ ? undefined
4765
+ : { messageId: command.messageId };
4766
+ }
4767
+ function messageKey(ticketId, messageId) {
4768
+ return { partition: ticketId, id: `message:${messageId}` };
4769
+ }
4770
+ async function sha256Hex(text) {
4771
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
4772
+ return [...new Uint8Array(digest)]
4773
+ .map((byte) => byte.toString(16).padStart(2, "0"))
4774
+ .join("");
4775
+ }
4776
+ async function messageIdIndexKey(value) {
4777
+ return `mid:${await sha256Hex(`message-id\u0000${value}`)}`;
4778
+ }
4779
+ async function replyTokenIndexKey(token) {
4780
+ return `rt:${await sha256Hex(`reply-token\u0000${token}`)}`;
4781
+ }
4782
+ function ticketNumberIndexKey(ticketNumber) {
4783
+ return `number:${ticketNumber}`;
4784
+ }
4785
+ const inboundMessageIdPattern = /^<[^<>\s@]+@[^<>\s@]+>$/;
4786
+ const maxInboundMessageIdCharacters = 512;
4787
+ /**
4788
+ * Accept a header-shaped `<local@domain>` identifier or nothing. Malformed
4789
+ * sender-controlled identifiers are dropped rather than stored or matched.
4790
+ */
4791
+ function normalizeInboundMessageId(value) {
4792
+ if (typeof value !== "string") {
4793
+ return undefined;
4794
+ }
4795
+ const trimmed = value.trim();
4796
+ if (trimmed.length === 0 ||
4797
+ trimmed.length > maxInboundMessageIdCharacters ||
4798
+ /[\u0000-\u001F\u007F]/.test(trimmed) ||
4799
+ !inboundMessageIdPattern.test(trimmed)) {
4800
+ return undefined;
4801
+ }
4802
+ return trimmed;
4803
+ }
4804
+ /** Derive every thread-index row an authoritative record implies. */
4805
+ async function threadIndexEntriesFor(record) {
4806
+ if (record.kind === "ticket") {
4807
+ const ticket = record.ticket;
4808
+ if (ticket.id !== record.partition ||
4809
+ !Number.isSafeInteger(ticket.number) ||
4810
+ ticket.number <= 0) {
4811
+ return [];
4812
+ }
4813
+ return [
4814
+ {
4815
+ key: ticketNumberIndexKey(ticket.number),
4816
+ id: "thread",
4817
+ kind: "ticket_number",
4818
+ ticketId: ticket.id,
4819
+ issuedAt: ticket.createdAt,
4820
+ },
4821
+ ];
4822
+ }
4823
+ if (record.kind !== "message") {
4824
+ return [];
4825
+ }
4826
+ const message = record.message;
4827
+ if (message.ticketId !== record.partition ||
4828
+ record.id !== `message:${message.id}`) {
4829
+ return [];
4830
+ }
4831
+ const entries = [];
4832
+ const content = record.deliveryContent;
4833
+ if (content !== undefined) {
4834
+ entries.push({
4835
+ key: await messageIdIndexKey(content.outboundMessageId),
4836
+ id: "thread",
4837
+ kind: "message_id",
4838
+ ticketId: message.ticketId,
4839
+ messageId: message.id,
4840
+ source: "outbound",
4841
+ issuedAt: message.createdAt,
4842
+ });
4843
+ if (content.replyRoutingToken !== undefined) {
4844
+ entries.push({
4845
+ key: await replyTokenIndexKey(content.replyRoutingToken),
4846
+ id: "thread",
4847
+ kind: "reply_token",
4848
+ ticketId: message.ticketId,
4849
+ messageId: message.id,
4850
+ issuedAt: message.createdAt,
4851
+ });
4852
+ }
4853
+ }
4854
+ if (message.channel === "email" &&
4855
+ message.authorKind === "customer" &&
4856
+ message.externalMessageId !== undefined &&
4857
+ message.externalMessageId !== content?.outboundMessageId &&
4858
+ normalizeInboundMessageId(message.externalMessageId) ===
4859
+ message.externalMessageId) {
4860
+ entries.push({
4861
+ key: await messageIdIndexKey(message.externalMessageId),
4862
+ id: "thread",
4863
+ kind: "message_id",
4864
+ ticketId: message.ticketId,
4865
+ messageId: message.id,
4866
+ source: "inbound",
4867
+ issuedAt: message.createdAt,
4868
+ });
4869
+ }
4870
+ return entries;
4871
+ }
4872
+ /** First writer wins; a different mapping for the same key is a collision. */
4873
+ async function putThreadIndexEntry(index, entry) {
4874
+ const result = await index.insertIfAbsent(entry);
4875
+ if (result.inserted) {
4876
+ return "written";
4877
+ }
4878
+ const existing = result.value;
4879
+ return existing.kind === entry.kind &&
4880
+ existing.ticketId === entry.ticketId &&
4881
+ existing.messageId === entry.messageId &&
4882
+ existing.source === entry.source
4883
+ ? "kept"
4884
+ : "collision";
4885
+ }
4886
+ function safeLog(logger, level, event, fields) {
4887
+ try {
4888
+ logger.log(level, event, fields);
4889
+ }
4890
+ catch {
4891
+ // ignore logger transport failures
4892
+ }
4893
+ }
4894
+ /**
4895
+ * Best-effort post-commit threading writes for one ticket. Failures are
4896
+ * logged and later converged by {@link repairThreadIndexPage}; they never
4897
+ * fail the command that already committed. Does not sample the clock.
4898
+ */
4899
+ async function indexThreadingAfterCommit(store, ticketId, hint, logger) {
4900
+ try {
4901
+ const records = store.collection(supportRecords);
4902
+ const index = store.collection(threadIndex);
4903
+ const sources = [];
4904
+ if (hint.ticketNumber === true) {
4905
+ const ticket = await records.get(ticketKey(ticketId));
4906
+ if (ticket !== null) {
4907
+ sources.push(ticket);
4908
+ }
4909
+ }
4910
+ if (hint.messageId !== undefined) {
4911
+ const message = await records.get(messageKey(ticketId, hint.messageId));
4912
+ if (message !== null) {
4913
+ sources.push(message);
4914
+ }
4915
+ }
4916
+ for (const source of sources) {
4917
+ for (const entry of await threadIndexEntriesFor(source)) {
4918
+ if ((await putThreadIndexEntry(index, entry)) === "collision") {
4919
+ safeLog(logger, "warn", "support-desk.thread_index.collision", {
4920
+ ticketId,
4921
+ kind: entry.kind,
4922
+ });
4923
+ }
4924
+ }
4925
+ }
4926
+ }
4927
+ catch (error) {
4928
+ safeLog(logger, "error", "support-desk.thread_index.write_failed", {
4929
+ ticketId,
4930
+ message: error instanceof Error ? error.message : "thread index write failed",
4931
+ });
4932
+ }
4933
+ }
4934
+ /**
4935
+ * Re-derive a row from its authoritative ticket or message. Returns the
4936
+ * current ticket only when the row still describes that record and, for
4937
+ * message rows, is inside the retention window.
4938
+ */
4939
+ async function confirmThreadEntry(records, entry, nowEpoch, retentionMilliseconds) {
4940
+ const ticketRecord = await records.get(ticketKey(entry.ticketId));
4941
+ if (ticketRecord?.kind !== "ticket" ||
4942
+ ticketRecord.ticket.id !== entry.ticketId) {
4943
+ return null;
4944
+ }
4945
+ let authoritative = ticketRecord;
4946
+ if (entry.kind !== "ticket_number") {
4947
+ if (entry.messageId === undefined) {
4948
+ return null;
4949
+ }
4950
+ const message = await records.get(messageKey(entry.ticketId, entry.messageId));
4951
+ if (message === null) {
4952
+ return null;
4953
+ }
4954
+ authoritative = message;
4955
+ }
4956
+ const derived = (await threadIndexEntriesFor(authoritative)).find((candidate) => candidate.key === entry.key && candidate.kind === entry.kind);
4957
+ if (derived === undefined ||
4958
+ derived.ticketId !== entry.ticketId ||
4959
+ derived.messageId !== entry.messageId ||
4960
+ derived.source !== entry.source) {
4961
+ return null;
4962
+ }
4963
+ if (derived.kind !== "ticket_number" &&
4964
+ nowEpoch - canonicalTimestamp(derived.issuedAt, "thread index issuedAt") >=
4965
+ retentionMilliseconds) {
4966
+ return null;
4967
+ }
4968
+ return ticketRecord.ticket;
4969
+ }
4970
+ async function lookupThreadEntry(records, index, kind, key, nowEpoch, retentionMilliseconds) {
4971
+ const entry = await index.get({ partition: key, id: "thread" });
4972
+ if (entry === null || entry.key !== key || entry.kind !== kind) {
4973
+ return null;
4974
+ }
4975
+ const ticket = await confirmThreadEntry(records, entry, nowEpoch, retentionMilliseconds);
4976
+ return ticket === null ? null : { ticket, entry };
4977
+ }
4978
+ function parseRetentionMilliseconds(value, field) {
4979
+ if (typeof value !== "number") {
4980
+ throw new TypeError(`${field} must be a number`);
4981
+ }
4982
+ if (!Number.isSafeInteger(value) || value <= 0) {
4983
+ throw new TypeError(`${field} must be a positive safe integer`);
4984
+ }
4985
+ return value;
4986
+ }
4987
+ function parseThreadIndexWorkerOptions(options, field) {
4988
+ const source = boundaryObject(options, field);
4989
+ const store = ownDataProperty(source, "store", `${field}.store`);
4990
+ const clock = ownDataProperty(source, "clock", `${field}.clock`);
4991
+ const retentionMilliseconds = parseRetentionMilliseconds(ownDataProperty(source, "threadIndexRetentionMilliseconds", `${field}.threadIndexRetentionMilliseconds`), `${field}.threadIndexRetentionMilliseconds`);
4992
+ const rawCursor = ownDataProperty(source, "cursor", `${field}.cursor`, true);
4993
+ const rawLimit = ownDataProperty(source, "limit", `${field}.limit`, true);
4994
+ if (rawCursor !== undefined && typeof rawCursor !== "string") {
4995
+ throw new TypeError(`${field}.cursor must be a string`);
4996
+ }
4997
+ if (rawLimit !== undefined && typeof rawLimit !== "number") {
4998
+ throw new TypeError(`${field}.limit must be between 1 and ${MAX_SCAN_PAGE_SIZE}`);
4999
+ }
5000
+ const limit = rawLimit ?? 100;
5001
+ if (!Number.isSafeInteger(limit) ||
5002
+ limit <= 0 ||
5003
+ limit > MAX_SCAN_PAGE_SIZE) {
5004
+ throw new TypeError(`${field}.limit must be between 1 and ${MAX_SCAN_PAGE_SIZE}`);
5005
+ }
5006
+ return {
5007
+ store,
5008
+ clock,
5009
+ retentionMilliseconds,
5010
+ ...(rawCursor === undefined ? {} : { cursor: rawCursor }),
5011
+ limit,
5012
+ };
5013
+ }
5014
+ /**
5015
+ * Scan one page of authoritative `support-desk.records.v1` rows and insert
5016
+ * every thread-index row they imply (ticket numbers, outbound `Message-ID`s,
5017
+ * reply tokens, and inbound `Message-ID`s). Rows past retention are not
5018
+ * recreated. Hosts persist `nextCursor` only after the whole page succeeds
5019
+ * and repeat complete cycles.
5020
+ */
5021
+ export async function repairThreadIndexPage(options) {
5022
+ const { store, clock, retentionMilliseconds, cursor, limit } = parseThreadIndexWorkerOptions(options, "thread index repair options");
5023
+ const records = store.collection(supportRecords);
5024
+ const index = store.collection(threadIndex);
5025
+ const page = await records.scan({
5026
+ limit,
5027
+ ...(cursor === undefined ? {} : { cursor }),
5028
+ });
5029
+ const nowEpoch = canonicalTimestamp(clock.now(), "clock.now()");
5030
+ let indexed = 0;
5031
+ let collisions = 0;
5032
+ for (const entry of page.records) {
5033
+ if (entry.key.partition !== entry.value.partition ||
5034
+ entry.key.id !== entry.value.id) {
5035
+ continue;
5036
+ }
5037
+ for (const candidate of await threadIndexEntriesFor(entry.value)) {
5038
+ if (candidate.kind !== "ticket_number" &&
5039
+ nowEpoch -
5040
+ canonicalTimestamp(candidate.issuedAt, "thread index issuedAt") >=
5041
+ retentionMilliseconds) {
5042
+ continue;
5043
+ }
5044
+ const outcome = await putThreadIndexEntry(index, candidate);
5045
+ if (outcome === "written") {
5046
+ indexed += 1;
5047
+ }
5048
+ else if (outcome === "collision") {
5049
+ collisions += 1;
5050
+ }
5051
+ }
5052
+ }
5053
+ return {
5054
+ nextCursor: page.nextCursor,
5055
+ physicalRows: page.records.length,
5056
+ indexed,
5057
+ collisions,
5058
+ };
5059
+ }
5060
+ /**
5061
+ * Scan one page of thread-index rows and conditionally delete every row whose
5062
+ * authoritative target is gone, no longer derives it, or is past retention.
5063
+ * Uses `deleteIfUnchanged` with the scanned version.
5064
+ */
5065
+ export async function sweepThreadIndexPage(options) {
5066
+ const { store, clock, retentionMilliseconds, cursor, limit } = parseThreadIndexWorkerOptions(options, "thread index sweep options");
5067
+ const records = store.collection(supportRecords);
5068
+ const index = store.collection(threadIndex);
5069
+ const page = await index.scan({
5070
+ limit,
5071
+ ...(cursor === undefined ? {} : { cursor }),
5072
+ });
5073
+ const nowEpoch = canonicalTimestamp(clock.now(), "clock.now()");
5074
+ let deleted = 0;
5075
+ for (const entry of page.records) {
5076
+ if (entry.key.partition !== entry.value.key || entry.key.id !== "thread") {
5077
+ continue;
5078
+ }
5079
+ if ((await confirmThreadEntry(records, entry.value, nowEpoch, retentionMilliseconds)) !== null) {
5080
+ continue;
5081
+ }
5082
+ if (await index.deleteIfUnchanged(entry.key, entry.version)) {
5083
+ deleted += 1;
5084
+ }
5085
+ }
5086
+ return {
5087
+ nextCursor: page.nextCursor,
5088
+ physicalRows: page.records.length,
5089
+ deleted,
5090
+ };
5091
+ }
5092
+ const maxInboundReferences = 100;
5093
+ const maxThreadingLookups = 20;
5094
+ const maxLoopSignalCharacters = 998;
5095
+ const maxInboundDisplayNameCharacters = 200;
5096
+ const maxSupportAddresses = 32;
5097
+ const subjectMarkerPrefixPattern = /^[A-Z][A-Z0-9]{0,15}$/;
5098
+ const inboundSuppressingPrecedence = new Set([
5099
+ "bulk",
5100
+ "junk",
5101
+ "list",
5102
+ "auto_reply",
5103
+ "auto-reply",
5104
+ ]);
5105
+ function collapseHeaderText(value) {
5106
+ return value
5107
+ .replace(/[\u0000-\u001F\u007F]+/g, " ")
5108
+ .replace(/\s+/g, " ")
5109
+ .trim();
5110
+ }
5111
+ function normalizeInboundSubject(value, maximum) {
5112
+ const collapsed = collapseHeaderText(value);
5113
+ const text = collapsed.length === 0 ? "(no subject)" : collapsed;
5114
+ const points = [...text];
5115
+ return points.length > maximum
5116
+ ? points.slice(0, maximum).join("").trimEnd()
5117
+ : text;
5118
+ }
5119
+ function normalizeInboundBody(value) {
5120
+ return value
5121
+ .replace(/\r\n?/g, "\n")
5122
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
5123
+ }
5124
+ function normalizeInboundDisplayName(value) {
5125
+ const collapsed = collapseHeaderText(value);
5126
+ if (collapsed.length === 0) {
5127
+ return undefined;
5128
+ }
5129
+ return [...collapsed].slice(0, maxInboundDisplayNameCharacters).join("");
5130
+ }
5131
+ function snapshotLoopSignals(input) {
5132
+ if (input === undefined) {
5133
+ return Object.freeze({});
5134
+ }
5135
+ const source = boundaryObject(input, "inbound email.loopSignals");
5136
+ const signals = {};
5137
+ for (const key of [
5138
+ "autoSubmitted",
5139
+ "precedence",
5140
+ "autoResponseSuppress",
5141
+ "listId",
5142
+ "contentType",
5143
+ "envelopeSender",
5144
+ ]) {
5145
+ const value = ownDataProperty(source, key, `inbound email.loopSignals.${key}`, true);
5146
+ if (value === undefined) {
5147
+ continue;
5148
+ }
5149
+ if (typeof value !== "string") {
5150
+ throw new TypeError(`inbound email.loopSignals.${key} must be a string`);
5151
+ }
5152
+ signals[key] = value.slice(0, maxLoopSignalCharacters);
5153
+ }
5154
+ const autoReply = ownDataProperty(source, "autoReplyHeaderPresent", "inbound email.loopSignals.autoReplyHeaderPresent", true);
5155
+ if (autoReply !== undefined) {
5156
+ if (typeof autoReply !== "boolean") {
5157
+ throw new TypeError("inbound email.loopSignals.autoReplyHeaderPresent must be a boolean");
5158
+ }
5159
+ signals.autoReplyHeaderPresent = autoReply;
5160
+ }
5161
+ return Object.freeze(signals);
5162
+ }
5163
+ function snapshotInboundSenderAuthentication(input) {
5164
+ if (input === undefined) {
5165
+ return undefined;
5166
+ }
5167
+ const field = "inbound email.senderAuthentication";
5168
+ const source = boundaryObject(input, field);
5169
+ const method = ownDataProperty(source, "method", `${field}.method`);
5170
+ if (method !== "dmarc" && method !== "host_identity") {
5171
+ throw new TypeError(`${field}.method must be dmarc or host_identity`);
5172
+ }
5173
+ const result = ownDataProperty(source, "result", `${field}.result`);
5174
+ if (result !== "pass" && result !== "fail") {
5175
+ throw new TypeError(`${field}.result must be pass or fail`);
5176
+ }
5177
+ const email = normalizeRequesterEmail(boundaryString(ownDataProperty(source, "email", `${field}.email`), `${field}.email`), `${field}.email`);
5178
+ if (method === "host_identity") {
5179
+ for (const key of ["dkimAligned", "spfAligned"]) {
5180
+ ownDataProperty(source, key, `${field}.${key}`, true, true);
5181
+ if (Object.hasOwn(source, key)) {
5182
+ throw new TypeError(`${field}.${key} is not valid when method is host_identity`);
5183
+ }
5184
+ }
5185
+ return Object.freeze({ method, result, email });
5186
+ }
5187
+ const dkimAligned = ownDataProperty(source, "dkimAligned", `${field}.dkimAligned`);
5188
+ if (typeof dkimAligned !== "boolean") {
5189
+ throw new TypeError(`${field}.dkimAligned must be a boolean`);
5190
+ }
5191
+ const spfAligned = ownDataProperty(source, "spfAligned", `${field}.spfAligned`);
5192
+ if (typeof spfAligned !== "boolean") {
5193
+ throw new TypeError(`${field}.spfAligned must be a boolean`);
5194
+ }
5195
+ return Object.freeze({
5196
+ method,
5197
+ result,
5198
+ email,
5199
+ dkimAligned,
5200
+ spfAligned,
5201
+ });
5202
+ }
5203
+ function snapshotInboundEmailInput(input, limits) {
5204
+ const source = boundaryObject(input, "inbound email");
5205
+ const field = (key) => `inbound email.${key}`;
5206
+ const readString = (key) => boundaryString(ownDataProperty(source, key, field(key)), field(key));
5207
+ const readIdentifier = (key) => {
5208
+ const value = readString(key);
5209
+ requireIdentifier(value, field(key));
5210
+ return value;
5211
+ };
5212
+ const readOptional = (key) => ownDataProperty(source, key, field(key), true);
5213
+ const channelId = readIdentifier("channelId");
5214
+ const providerEventId = readIdentifier("providerEventId");
5215
+ const ticketId = readIdentifier("ticketId");
5216
+ const messageId = readIdentifier("messageId");
5217
+ const correlationId = readIdentifier("correlationId");
5218
+ const from = normalizeRequesterEmail(readString("from"), field("from"));
5219
+ const rawSenderAuthentication = ownDataProperty(source, "senderAuthentication", field("senderAuthentication"), true, true);
5220
+ const senderAuthentication = snapshotInboundSenderAuthentication(rawSenderAuthentication);
5221
+ const rawName = readOptional("fromName");
5222
+ const fromName = rawName === undefined
5223
+ ? undefined
5224
+ : normalizeInboundDisplayName(boundaryString(rawName, field("fromName")));
5225
+ const subject = normalizeInboundSubject(readString("subject"), limits.maxSubjectCharacters);
5226
+ const body = normalizeInboundBody(readString("body"));
5227
+ enforceLimit(body, "body", limits.maxMessageCharacters);
5228
+ const rawFormat = readOptional("format");
5229
+ if (rawFormat !== undefined &&
5230
+ rawFormat !== "plain_text" &&
5231
+ rawFormat !== "markdown") {
5232
+ throw new TypeError(`${field("format")} must be plain_text or markdown`);
5233
+ }
5234
+ const format = rawFormat ?? "plain_text";
5235
+ const externalMessageId = normalizeInboundMessageId(readOptional("externalMessageId"));
5236
+ const inReplyTo = normalizeInboundMessageId(readOptional("inReplyTo"));
5237
+ const rawReferences = readOptional("references");
5238
+ const references = [];
5239
+ if (rawReferences !== undefined) {
5240
+ if (!Array.isArray(rawReferences)) {
5241
+ throw new TypeError(`${field("references")} must be an array`);
5242
+ }
5243
+ const start = Math.max(0, rawReferences.length - maxInboundReferences);
5244
+ for (let position = start; position < rawReferences.length; position += 1) {
5245
+ const normalized = normalizeInboundMessageId(ownDataProperty(rawReferences, position, `${field("references")}[${position}]`));
5246
+ if (normalized !== undefined && !references.includes(normalized)) {
5247
+ references.push(normalized);
5248
+ }
5249
+ }
5250
+ }
5251
+ const rawToken = readOptional("replyRoutingToken");
5252
+ if (rawToken !== undefined && typeof rawToken !== "string") {
5253
+ throw new TypeError(`${field("replyRoutingToken")} must be a string`);
5254
+ }
5255
+ const token = rawToken?.trim();
5256
+ const replyRoutingToken = token !== undefined && replyRoutingTokenPattern.test(token)
5257
+ ? token
5258
+ : undefined;
5259
+ const loopSignals = snapshotLoopSignals(readOptional("loopSignals"));
5260
+ return Object.freeze({
5261
+ channelId,
5262
+ providerEventId,
5263
+ ticketId,
5264
+ messageId,
5265
+ correlationId,
5266
+ from,
5267
+ ...(senderAuthentication === undefined ? {} : { senderAuthentication }),
5268
+ ...(fromName === undefined ? {} : { fromName }),
5269
+ subject,
5270
+ body,
5271
+ format,
5272
+ ...(externalMessageId === undefined ? {} : { externalMessageId }),
5273
+ ...(inReplyTo === undefined ? {} : { inReplyTo }),
5274
+ references: Object.freeze(references),
5275
+ ...(replyRoutingToken === undefined ? {} : { replyRoutingToken }),
5276
+ loopSignals,
5277
+ });
5278
+ }
5279
+ /** Content fingerprint for one provider event; excludes host-minted IDs. */
5280
+ function inboundFingerprintPayload(input, format = "current") {
5281
+ const canonicalSender = format === "current";
5282
+ const includeSenderResult = format !== "pre_sender_result";
5283
+ return {
5284
+ type: "inbound_email",
5285
+ channelId: input.channelId,
5286
+ providerEventId: input.providerEventId,
5287
+ from: canonicalSender ? input.from.toLowerCase() : input.from,
5288
+ ...(!includeSenderResult || input.senderAuthentication === undefined
5289
+ ? {}
5290
+ : {
5291
+ senderAuthentication: canonicalSender
5292
+ ? {
5293
+ ...input.senderAuthentication,
5294
+ email: input.senderAuthentication.email.toLowerCase(),
5295
+ }
5296
+ : input.senderAuthentication,
5297
+ }),
5298
+ fromName: input.fromName ?? null,
5299
+ subject: input.subject,
5300
+ body: input.body,
5301
+ format: input.format,
5302
+ externalMessageId: input.externalMessageId ?? null,
5303
+ inReplyTo: input.inReplyTo ?? null,
5304
+ references: input.references,
5305
+ replyRoutingToken: input.replyRoutingToken ?? null,
5306
+ loopSignals: input.loopSignals,
5307
+ };
5308
+ }
5309
+ function inboundSuppressionReason(input, supportAddresses) {
5310
+ const signals = input.loopSignals;
5311
+ const autoSubmitted = signals.autoSubmitted
5312
+ ?.split(";")[0]
5313
+ ?.trim()
5314
+ .toLowerCase();
5315
+ if (autoSubmitted !== undefined &&
5316
+ autoSubmitted.length > 0 &&
5317
+ autoSubmitted !== "no") {
5318
+ return "auto_submitted";
5319
+ }
5320
+ const precedence = signals.precedence?.trim().toLowerCase();
5321
+ if (precedence !== undefined &&
5322
+ inboundSuppressingPrecedence.has(precedence)) {
5323
+ return "precedence";
5324
+ }
5325
+ if (signals.autoReplyHeaderPresent === true) {
5326
+ return "auto_reply_header";
5327
+ }
5328
+ if (signals.envelopeSender !== undefined) {
5329
+ const envelope = signals.envelopeSender.trim();
5330
+ if (envelope.length === 0 || envelope === "<>") {
5331
+ return "bounce";
5332
+ }
5333
+ }
5334
+ const localPart = input.from.slice(0, input.from.indexOf("@")).toLowerCase();
5335
+ if (localPart === "mailer-daemon") {
5336
+ return "bounce";
5337
+ }
5338
+ const contentType = signals.contentType?.trim().toLowerCase();
5339
+ if (contentType !== undefined &&
5340
+ (contentType.startsWith("multipart/report") ||
5341
+ contentType.startsWith("message/delivery-status") ||
5342
+ contentType.startsWith("message/disposition-notification"))) {
5343
+ return "delivery_report";
5344
+ }
5345
+ if (supportAddresses.has(input.from.toLowerCase())) {
5346
+ return "support_address_loop";
5347
+ }
5348
+ return undefined;
5349
+ }
5350
+ function inboundSenderAcknowledgementAllowed(input) {
5351
+ const suppress = (input.loopSignals.autoResponseSuppress ?? "")
5352
+ .split(",")
5353
+ .map((value) => value.trim().toLowerCase());
5354
+ if (suppress.some((value) => value === "all" || value === "oof" || value === "autoreply")) {
5355
+ return false;
5356
+ }
5357
+ return (input.loopSignals.listId ?? "").trim().length === 0;
5358
+ }
5359
+ function subjectMarkerNumbers(subject, prefix) {
5360
+ const pattern = new RegExp(`\\[${prefix}-([1-9][0-9]{0,15})\\]`, "g");
5361
+ const found = [];
5362
+ for (const match of subject.matchAll(pattern)) {
5363
+ const value = Number(match[1]);
5364
+ if (Number.isSafeInteger(value) && !found.includes(value)) {
5365
+ found.push(value);
5366
+ }
5367
+ }
5368
+ return found;
5369
+ }
5370
+ function threadingHeaderCandidates(input) {
5371
+ const ordered = [
5372
+ ...(input.inReplyTo === undefined ? [] : [input.inReplyTo]),
5373
+ ...[...input.references].reverse(),
5374
+ ];
5375
+ const unique = [];
5376
+ for (const candidate of ordered) {
5377
+ if (candidate !== input.externalMessageId && !unique.includes(candidate)) {
5378
+ unique.push(candidate);
5379
+ }
5380
+ if (unique.length >= maxThreadingLookups) {
5381
+ break;
5382
+ }
5383
+ }
5384
+ return unique;
5385
+ }
5386
+ function sameAddress(left, right) {
5387
+ return left.toLowerCase() === right.toLowerCase();
5388
+ }
5389
+ /**
5390
+ * A thread match with a recorded requester address requires both exact
5391
+ * case-insensitive address equality and a passing host-evaluated sender result
5392
+ * bound to that normalized address. Without a recorded address, only an
5393
+ * opaque routing token can join; a `Message-ID` or subject marker alone is
5394
+ * never enough.
5395
+ */
5396
+ function threadJoinIsSafe(ticket, method, input) {
5397
+ const email = ticket.requester.email;
5398
+ if (email === undefined) {
5399
+ return method === "routing_token";
5400
+ }
5401
+ const authentication = input.senderAuthentication;
5402
+ if (authentication === undefined ||
5403
+ authentication.result !== "pass" ||
5404
+ !sameAddress(email, input.from) ||
5405
+ !sameAddress(authentication.email, input.from)) {
5406
+ return false;
5407
+ }
5408
+ return (authentication.method === "host_identity" ||
5409
+ authentication.dkimAligned ||
5410
+ authentication.spfAligned);
5411
+ }
5412
+ function snapshotSenderMatch(value) {
5413
+ const source = boundaryObject(value, "account matcher result");
5414
+ const matches = ownDataProperty(source, "matches", "account matcher result.matches");
5415
+ if (matches === "none" || matches === "many") {
5416
+ return Object.freeze({ matches });
5417
+ }
5418
+ if (matches === "one") {
5419
+ const principalId = boundaryString(ownDataProperty(source, "principalId", "account matcher result.principalId"), "account matcher result.principalId");
5420
+ requireIdentifier(principalId, "account matcher result.principalId");
5421
+ return Object.freeze({ matches, principalId });
5422
+ }
5423
+ throw new TypeError("account matcher result.matches must be none, one, or many");
5424
+ }
5425
+ function snapshotRoutingDecision(value, allowedCategories) {
5426
+ const source = boundaryObject(value, "inbound routing decision");
5427
+ const priority = ownDataProperty(source, "priority", "inbound routing decision.priority");
5428
+ if (priority !== "low" && priority !== "normal" && priority !== "high") {
5429
+ throw new TypeError("inbound routing decision.priority must be low, normal, or high; inbound mail is never automatically urgent");
5430
+ }
5431
+ const category = snapshotCategory(ownDataProperty(source, "category", "inbound routing decision.category", true), "inbound routing decision.category");
5432
+ if (category !== undefined &&
5433
+ (allowedCategories === undefined || !allowedCategories.has(category))) {
5434
+ throw new TypeError("inbound routing decision.category is not configured for this Support Desk instance");
5435
+ }
5436
+ return Object.freeze({
5437
+ priority,
5438
+ ...(category === undefined ? {} : { category }),
5439
+ });
5440
+ }
5441
+ function inboundAuditAction(input) {
5442
+ return supportAudit.action({
5443
+ id: input.commandId,
5444
+ occurredAt: input.occurredAt,
5445
+ actor: { kind: "system", systemId: inboundEmailSystemActorId },
5446
+ action: input.action,
5447
+ subject: input.ticketId,
5448
+ sequence: input.revision,
5449
+ details: {
5450
+ commandId: input.commandId,
5451
+ correlationId: input.correlationId,
5452
+ channel: "email",
5453
+ matchMethod: input.matchMethod,
5454
+ ...(input.association === undefined
5455
+ ? {}
5456
+ : { association: input.association }),
5457
+ },
5458
+ });
5459
+ }
5460
+ function inboundMessage(input, ticketId, messageId, now) {
5461
+ // No authorPrincipalId: a sender address never authenticates authorship.
5462
+ return {
5463
+ id: messageId,
5464
+ ticketId,
5465
+ authorKind: "customer",
5466
+ channel: "email",
5467
+ visibility: "customer",
5468
+ format: input.format,
5469
+ body: input.body,
5470
+ createdAt: now,
5471
+ ...(input.externalMessageId === undefined
5472
+ ? {}
5473
+ : { externalMessageId: input.externalMessageId }),
5474
+ ...(input.inReplyTo === undefined
5475
+ ? {}
5476
+ : { inReplyToExternalMessageId: input.inReplyTo }),
5477
+ };
5478
+ }
5479
+ function inboundResult(receipt, duplicateDelivery) {
5480
+ const route = receipt.route;
5481
+ return Object.freeze({
5482
+ duplicateDelivery,
5483
+ outcome: receipt.outcome ?? "rejected",
5484
+ ...(receipt.ticketId === undefined ? {} : { ticketId: receipt.ticketId }),
5485
+ ...(receipt.messageId === undefined
5486
+ ? {}
5487
+ : { messageId: receipt.messageId }),
5488
+ ...(route?.kind === "reply" || route?.kind === "new_conversation"
5489
+ ? { matchMethod: route.matchMethod }
5490
+ : {}),
5491
+ ...(route?.kind === "new_conversation"
5492
+ ? {
5493
+ association: route.association,
5494
+ ...(route.relatedTicketId === undefined
5495
+ ? {}
5496
+ : { relatedTicketId: route.relatedTicketId }),
5497
+ }
5498
+ : {}),
5499
+ ...(receipt.diagnostic === undefined ? {} : { reason: receipt.diagnostic }),
5500
+ });
5501
+ }
5502
+ function parseSupportAddresses(input) {
5503
+ const field = "inbound mail processor options.supportAddresses";
5504
+ if (input === undefined) {
5505
+ return new Set();
5506
+ }
5507
+ if (!Array.isArray(input)) {
5508
+ throw new TypeError(`${field} must be an array`);
5509
+ }
5510
+ if (input.length > maxSupportAddresses) {
5511
+ throw new TypeError(`${field} may contain at most ${maxSupportAddresses} values`);
5512
+ }
5513
+ const addresses = new Set();
5514
+ for (let position = 0; position < input.length; position += 1) {
5515
+ const entry = `${field}[${position}]`;
5516
+ const value = normalizeRequesterEmail(boundaryString(ownDataProperty(input, position, entry), entry), entry);
5517
+ addresses.add(value.toLowerCase());
5518
+ }
5519
+ return addresses;
5520
+ }
5521
+ /**
5522
+ * Create the inbound mail processor a host's mail worker calls after it has
5523
+ * verified provider authenticity and normalized bounded MIME content.
5524
+ *
5525
+ * Processing order: reserve the provider-event receipt with `insertIfAbsent`
5526
+ * before any ticket work; suppress bounce and auto-reply loops; persist one
5527
+ * routing decision on the receipt (routing token, then `In-Reply-To` /
5528
+ * `References`, then subject marker, each confirmed against authoritative
5529
+ * records); then create or reply in one ticket-partition transaction with
5530
+ * its Audit event, command receipt, and optional notification; finally mark
5531
+ * the receipt terminal and refresh the queue and threading projections.
5532
+ */
5533
+ export function createInboundMailProcessor(options) {
5534
+ const field = "inbound mail processor options";
5535
+ const source = boundaryObject(options, field);
5536
+ const read = (key, optional = false) => ownDataProperty(source, key, `${field}.${key}`, optional);
5537
+ const store = read("store");
5538
+ const clock = read("clock");
5539
+ const rawLogger = read("logger", true);
5540
+ const logger = rawLogger === undefined ? noopLogger : rawLogger;
5541
+ const accountMatcher = read("accountMatcher");
5542
+ const routingPolicy = read("routingPolicy");
5543
+ const notificationPolicy = read("notificationPolicy", true);
5544
+ if (accountMatcher === undefined ||
5545
+ accountMatcher === null ||
5546
+ typeof accountMatcher.matchSenderEmail !== "function") {
5547
+ throw new TypeError(`${field}.accountMatcher must provide matchSenderEmail()`);
5548
+ }
5549
+ if (routingPolicy === undefined ||
5550
+ routingPolicy === null ||
5551
+ typeof routingPolicy.routeNewConversation !== "function") {
5552
+ throw new TypeError(`${field}.routingPolicy must provide routeNewConversation()`);
5553
+ }
5554
+ if (notificationPolicy !== undefined &&
5555
+ (notificationPolicy === null ||
5556
+ typeof notificationPolicy.notificationFor !== "function")) {
5557
+ throw new TypeError(`${field}.notificationPolicy must provide notificationFor()`);
5558
+ }
5559
+ const limits = resolveSupportDeskLimits(read("limits", true), `${field}.limits`);
5560
+ const rawAttempts = read("maxConflictAttempts", true);
5561
+ if (rawAttempts !== undefined && typeof rawAttempts !== "number") {
5562
+ throw new TypeError("maxConflictAttempts must be between 1 and 20");
5563
+ }
5564
+ const maxConflictAttempts = rawAttempts ?? 4;
5565
+ if (!Number.isSafeInteger(maxConflictAttempts) ||
5566
+ maxConflictAttempts <= 0 ||
5567
+ maxConflictAttempts > 20) {
5568
+ throw new TypeError("maxConflictAttempts must be between 1 and 20");
5569
+ }
5570
+ const rawTerminalRetention = read("queueTerminalRetentionMilliseconds", true);
5571
+ if (rawTerminalRetention !== undefined &&
5572
+ typeof rawTerminalRetention !== "number") {
5573
+ throw new TypeError("queueTerminalRetentionMilliseconds must be a non-negative safe integer");
5574
+ }
5575
+ const terminalRetentionMilliseconds = rawTerminalRetention ??
5576
+ defaultQueueTerminalRetentionMilliseconds;
5577
+ if (!Number.isSafeInteger(terminalRetentionMilliseconds) ||
5578
+ terminalRetentionMilliseconds < 0) {
5579
+ throw new TypeError("queueTerminalRetentionMilliseconds must be a non-negative safe integer");
5580
+ }
5581
+ const rawThreadRetention = read("threadIndexRetentionMilliseconds", true);
5582
+ const threadIndexRetentionMilliseconds = rawThreadRetention === undefined
5583
+ ? defaultThreadIndexRetentionMilliseconds
5584
+ : parseRetentionMilliseconds(rawThreadRetention, `${field}.threadIndexRetentionMilliseconds`);
5585
+ const responseTargetPolicy = parseResponseTargetPolicy(read("responseTargetPolicy", true));
5586
+ const allowedCategories = parseAllowedCategories(read("allowedCategories", true), `${field}.allowedCategories`);
5587
+ const rawPrefix = read("subjectMarkerPrefix", true);
5588
+ if (rawPrefix !== undefined &&
5589
+ (typeof rawPrefix !== "string" ||
5590
+ !subjectMarkerPrefixPattern.test(rawPrefix))) {
5591
+ throw new TypeError(`${field}.subjectMarkerPrefix must match ${subjectMarkerPrefixPattern.source}`);
5592
+ }
5593
+ const subjectMarkerPrefix = rawPrefix;
5594
+ const supportAddresses = parseSupportAddresses(read("supportAddresses", true));
5595
+ const records = store.collection(supportRecords);
5596
+ const receipts = store.collection(inboundReceipts);
5597
+ const numbers = store.collection(ticketNumbers);
5598
+ const index = store.collection(threadIndex);
5599
+ async function bindInboundReplayFingerprint(key, stored, input, replayFingerprint) {
5600
+ if (stored.replayFingerprint !== undefined) {
5601
+ if (stored.replayFingerprint !== replayFingerprint) {
5602
+ throw new SupportDeskConflictError("An inbound provider event ID was reused for different content");
5603
+ }
5604
+ return stored;
5605
+ }
5606
+ const compatibleFingerprints = new Set([replayFingerprint]);
5607
+ if (!compatibleFingerprints.has(stored.payloadFingerprint)) {
5608
+ const priorFingerprints = await Promise.all([
5609
+ requestFingerprint(inboundFingerprintPayload(input, "case_preserving_sender")),
5610
+ requestFingerprint(inboundFingerprintPayload(input, "pre_sender_result")),
5611
+ ]);
5612
+ for (const prior of priorFingerprints) {
5613
+ compatibleFingerprints.add(prior);
5614
+ }
5615
+ }
5616
+ if (!compatibleFingerprints.has(stored.payloadFingerprint)) {
5617
+ throw new SupportDeskConflictError("An inbound provider event ID was reused for different content");
5618
+ }
5619
+ const bound = await receipts.update(key, (current) => {
5620
+ if (current === null) {
5621
+ throw new SupportDeskConflictError("Inbound receipt disappeared before replay binding");
5622
+ }
5623
+ if (current.replayFingerprint !== undefined) {
5624
+ return { action: "keep" };
5625
+ }
5626
+ if (!compatibleFingerprints.has(current.payloadFingerprint)) {
5627
+ return { action: "keep" };
5628
+ }
5629
+ return {
5630
+ action: "write",
5631
+ value: { ...current, replayFingerprint },
5632
+ };
5633
+ }, { maxAttempts: maxConflictAttempts });
5634
+ if (bound.value === null) {
5635
+ throw new SupportDeskConflictError("Inbound receipt disappeared before replay binding");
5636
+ }
5637
+ if (bound.value.replayFingerprint !== replayFingerprint ||
5638
+ !compatibleFingerprints.has(bound.value.payloadFingerprint)) {
5639
+ throw new SupportDeskConflictError("An inbound provider event ID was reused for different content");
5640
+ }
5641
+ return bound.value;
5642
+ }
5643
+ async function finishReceipt(work, terminal) {
5644
+ const processedAt = clock.now();
5645
+ canonicalTimestamp(processedAt, "clock.now()");
5646
+ const result = await receipts.update(work.key, (current) => {
5647
+ if (current === null) {
5648
+ throw new SupportDeskConflictError("Inbound receipt disappeared before it was finalized");
5649
+ }
5650
+ if (current.status !== "processing" && current.status !== "rerouting") {
5651
+ return { action: "keep" };
5652
+ }
5653
+ if (!sameInboundReceiptRoute(current.route, work.route)) {
5654
+ throw new SupportDeskConflictError("Inbound route changed before the receipt was finalized");
5655
+ }
5656
+ return {
5657
+ action: "write",
5658
+ value: {
5659
+ ...current,
5660
+ status: terminal.status,
5661
+ outcome: terminal.outcome,
5662
+ processedAt,
5663
+ ...(terminal.ticketId === undefined
5664
+ ? {}
5665
+ : { ticketId: terminal.ticketId }),
5666
+ ...(terminal.messageId === undefined
5667
+ ? {}
5668
+ : { messageId: terminal.messageId }),
5669
+ ...(terminal.diagnostic === undefined
5670
+ ? {}
5671
+ : { diagnostic: terminal.diagnostic }),
5672
+ },
5673
+ };
5674
+ }, { maxAttempts: maxConflictAttempts });
5675
+ if (result.value === null) {
5676
+ throw new SupportDeskConflictError("Inbound receipt disappeared before it was finalized");
5677
+ }
5678
+ return inboundResult(result.value, work.duplicateDelivery);
5679
+ }
5680
+ async function afterInboundCommit(ticketId, messageId, ticketNumber) {
5681
+ try {
5682
+ await projectTicketToQueue(ticketId, {
5683
+ store,
5684
+ clock,
5685
+ terminalRetentionMilliseconds,
5686
+ maxConflictAttempts,
5687
+ });
5688
+ }
5689
+ catch (error) {
5690
+ safeLog(logger, "error", "support-desk.queue.projection_failed", {
5691
+ ticketId,
5692
+ message: error instanceof Error ? error.message : "queue projection failed",
5693
+ });
5694
+ }
5695
+ await indexThreadingAfterCommit(store, ticketId, { messageId, ticketNumber }, logger);
5696
+ }
5697
+ async function inboundNotification(context) {
5698
+ if (notificationPolicy === undefined) {
5699
+ return undefined;
5700
+ }
5701
+ const proposed = await notificationPolicy.notificationFor(Object.freeze({ ...context }));
5702
+ if (proposed === undefined) {
5703
+ return undefined;
5704
+ }
5705
+ return bindNotificationTicketNumber(snapshotNotification(proposed), context.ticketNumber);
5706
+ }
5707
+ async function proposeNewConversation(input, reservedTicketId, matchMethod, relatedTicketId, forceUnverified = false) {
5708
+ const match = relatedTicketId === undefined && !forceUnverified
5709
+ ? snapshotSenderMatch(await accountMatcher.matchSenderEmail(Object.freeze({ channelId: input.channelId, email: input.from })))
5710
+ : { matches: "none" };
5711
+ const association = match.matches === "one" ? "matched_email" : "unverified";
5712
+ const decision = snapshotRoutingDecision(await routingPolicy.routeNewConversation(Object.freeze({
5713
+ channelId: input.channelId,
5714
+ association,
5715
+ ...(match.matches === "one"
5716
+ ? { principalId: match.principalId }
5717
+ : {}),
5718
+ ...(relatedTicketId === undefined ? {} : { relatedTicketId }),
5719
+ })), allowedCategories);
5720
+ return {
5721
+ kind: "new_conversation",
5722
+ ticketId: reservedTicketId,
5723
+ matchMethod,
5724
+ association,
5725
+ ...(match.matches === "one" ? { principalId: match.principalId } : {}),
5726
+ priority: decision.priority,
5727
+ ...(decision.category === undefined
5728
+ ? {}
5729
+ : { category: decision.category }),
5730
+ ...(relatedTicketId === undefined ? {} : { relatedTicketId }),
5731
+ };
5732
+ }
5733
+ async function claimRouteForRerouting(key, expected) {
5734
+ const claimed = await receipts.update(key, (current) => {
5735
+ if (current === null) {
5736
+ throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
5737
+ }
5738
+ if (current.status !== "processing" ||
5739
+ !sameInboundReceiptRoute(current.route, expected)) {
5740
+ return { action: "keep" };
5741
+ }
5742
+ return {
5743
+ action: "write",
5744
+ value: { ...current, status: "rerouting" },
5745
+ };
5746
+ }, { maxAttempts: maxConflictAttempts });
5747
+ if (claimed.value === null) {
5748
+ throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
5749
+ }
5750
+ return claimed.value;
5751
+ }
5752
+ async function replaceReroutingRoute(key, expected, proposed) {
5753
+ const replaced = await receipts.update(key, (current) => {
5754
+ if (current === null) {
5755
+ throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
5756
+ }
5757
+ if (current.status !== "rerouting" ||
5758
+ !sameInboundReceiptRoute(current.route, expected)) {
5759
+ return { action: "keep" };
5760
+ }
5761
+ return { action: "write", value: { ...current, route: proposed } };
5762
+ }, { maxAttempts: maxConflictAttempts });
5763
+ if (replaced.value === null) {
5764
+ throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
5765
+ }
5766
+ return replaced.value;
5767
+ }
5768
+ async function reserveReplyRerouteFence(input) {
5769
+ const location = commandKey(input.ticketId, input.commandId);
5770
+ for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
5771
+ const existing = await records.get(location);
5772
+ if (inboundReplyRerouteFenceMatches(existing, input.commandId, input.messageId, input.fingerprint)) {
5773
+ return true;
5774
+ }
5775
+ if (existing !== null) {
5776
+ return false;
5777
+ }
5778
+ const ticket = await records.getVersioned(ticketKey(input.ticketId));
5779
+ if (ticket?.value.kind !== "ticket") {
5780
+ return false;
5781
+ }
5782
+ const quota = await records.getVersioned(ticketQuotaKey(input.ticketId));
5783
+ if (quota?.value.kind !== "ticket_quota") {
5784
+ throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
5785
+ }
5786
+ const partitionRows = await records.list(input.ticketId);
5787
+ enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 1);
5788
+ enforceManagedAdmission(ticket.value.ticket, partitionRows, limits, 1);
5789
+ const outcome = await records.transact(input.ticketId, [
5790
+ {
5791
+ action: "putIfUnchanged",
5792
+ version: ticket.version,
5793
+ value: ticket.value,
5794
+ },
5795
+ {
5796
+ action: "putIfUnchanged",
5797
+ version: quota.version,
5798
+ value: quota.value,
5799
+ },
5800
+ {
5801
+ action: "insert",
5802
+ value: {
5803
+ kind: "inbound_reply_reroute_fence",
5804
+ partition: input.ticketId,
5805
+ id: location.id,
5806
+ commandId: input.commandId,
5807
+ messageId: input.messageId,
5808
+ requestFingerprint: input.fingerprint,
5809
+ recordedAt: input.recordedAt,
5810
+ },
5811
+ },
5812
+ ]);
5813
+ if (outcome.committed) {
5814
+ return true;
5815
+ }
5816
+ }
5817
+ const existing = await records.get(location);
5818
+ if (inboundReplyRerouteFenceMatches(existing, input.commandId, input.messageId, input.fingerprint)) {
5819
+ return true;
5820
+ }
5821
+ if (existing !== null) {
5822
+ return false;
5823
+ }
5824
+ 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.");
5825
+ }
5826
+ async function reserveCreateRerouteFence(input) {
5827
+ const location = commandKey(input.ticketId, input.commandId);
5828
+ const existing = await records.get(location);
5829
+ if (inboundCreateRerouteFenceMatches(existing, input.commandId, input.messageId, input.fingerprint)) {
5830
+ return true;
5831
+ }
5832
+ if (existing !== null ||
5833
+ (await records.get(ticketKey(input.ticketId))) !== null) {
5834
+ return false;
5835
+ }
5836
+ enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, 1);
5837
+ const inserted = await records.insertIfAbsent({
5838
+ kind: "inbound_create_reroute_fence",
5839
+ partition: input.ticketId,
5840
+ id: location.id,
5841
+ commandId: input.commandId,
5842
+ messageId: input.messageId,
5843
+ requestFingerprint: input.fingerprint,
5844
+ recordedAt: input.recordedAt,
5845
+ });
5846
+ return (inserted.inserted ||
5847
+ inboundCreateRerouteFenceMatches(inserted.value, input.commandId, input.messageId, input.fingerprint));
5848
+ }
5849
+ async function proposeRoute(input, reservedTicketId) {
5850
+ const reason = inboundSuppressionReason(input, supportAddresses);
5851
+ if (reason !== undefined) {
5852
+ return { kind: "suppressed", reason };
5853
+ }
5854
+ const nowEpoch = canonicalTimestamp(clock.now(), "clock.now()");
5855
+ const lookup = (kind, key) => lookupThreadEntry(records, index, kind, key, nowEpoch, threadIndexRetentionMilliseconds);
5856
+ // The same Message-ID delivered under another provider event is the same
5857
+ // message only when the sender satisfies the existing-ticket join rule.
5858
+ let repeatedMessageTicket;
5859
+ if (input.externalMessageId !== undefined) {
5860
+ const own = await lookup("message_id", await messageIdIndexKey(input.externalMessageId));
5861
+ if (own !== null &&
5862
+ own.entry.source === "inbound" &&
5863
+ own.entry.messageId !== undefined) {
5864
+ if (threadJoinIsSafe(own.ticket, "message_id", input)) {
5865
+ return {
5866
+ kind: "duplicate_message",
5867
+ ticketId: own.ticket.id,
5868
+ messageId: own.entry.messageId,
5869
+ };
5870
+ }
5871
+ repeatedMessageTicket = own.ticket;
5872
+ }
5873
+ }
5874
+ let candidate;
5875
+ let ambiguous = false;
5876
+ if (input.replyRoutingToken !== undefined) {
5877
+ const hit = await lookup("reply_token", await replyTokenIndexKey(input.replyRoutingToken));
5878
+ if (hit !== null) {
5879
+ candidate = { ticket: hit.ticket, method: "routing_token" };
5880
+ }
5881
+ }
5882
+ if (candidate === undefined) {
5883
+ const found = [];
5884
+ for (const header of threadingHeaderCandidates(input)) {
5885
+ const hit = await lookup("message_id", await messageIdIndexKey(header));
5886
+ if (hit !== null &&
5887
+ !found.some((ticket) => ticket.id === hit.ticket.id)) {
5888
+ found.push(hit.ticket);
5889
+ }
5890
+ }
5891
+ const first = found[0];
5892
+ if (first !== undefined) {
5893
+ candidate = { ticket: first, method: "message_id" };
5894
+ ambiguous = found.length > 1;
5895
+ }
5896
+ }
5897
+ // Keep a repeated inbound Message-ID ahead of the display-only subject
5898
+ // marker when a stronger token or threading-header locator did not match.
5899
+ if (candidate === undefined && repeatedMessageTicket !== undefined) {
5900
+ candidate = {
5901
+ ticket: repeatedMessageTicket,
5902
+ method: "message_id",
5903
+ };
5904
+ }
5905
+ if (candidate === undefined && subjectMarkerPrefix !== undefined) {
5906
+ const markers = subjectMarkerNumbers(input.subject, subjectMarkerPrefix);
5907
+ ambiguous = markers.length > 1;
5908
+ for (const marker of markers) {
5909
+ const hit = await lookup("ticket_number", ticketNumberIndexKey(marker));
5910
+ if (hit !== null && candidate === undefined) {
5911
+ candidate = { ticket: hit.ticket, method: "subject_marker" };
5912
+ }
5913
+ }
5914
+ }
5915
+ if (candidate !== undefined &&
5916
+ !ambiguous &&
5917
+ candidate.ticket.id !== reservedTicketId &&
5918
+ threadJoinIsSafe(candidate.ticket, candidate.method, input)) {
5919
+ return {
5920
+ kind: "reply",
5921
+ ticketId: candidate.ticket.id,
5922
+ matchMethod: candidate.method,
5923
+ };
5924
+ }
5925
+ const relatedTicketId = candidate !== undefined && candidate.ticket.id !== reservedTicketId
5926
+ ? candidate.ticket.id
5927
+ : undefined;
5928
+ return proposeNewConversation(input, reservedTicketId, candidate?.method ?? "none", relatedTicketId, ambiguous);
5929
+ }
5930
+ async function decideRoute(key, stored, input, reservedTicketId, commandId, messageId, fingerprint) {
5931
+ if (stored.status !== "processing" && stored.status !== "rerouting") {
5932
+ return stored;
5933
+ }
5934
+ if (stored.route !== undefined) {
5935
+ const route = stored.route;
5936
+ if (route.kind === "suppressed") {
5937
+ return stored;
5938
+ }
5939
+ if (route.kind === "duplicate_message") {
5940
+ const target = await records.get(ticketKey(route.ticketId));
5941
+ if (target?.kind === "ticket" &&
5942
+ threadJoinIsSafe(target.ticket, "message_id", input)) {
5943
+ return stored;
5944
+ }
5945
+ if (stored.status === "processing") {
5946
+ return decideRoute(key, await claimRouteForRerouting(key, route), input, reservedTicketId, commandId, messageId, fingerprint);
5947
+ }
5948
+ return replaceReroutingRoute(key, route, await proposeNewConversation(input, reservedTicketId, "message_id", route.ticketId, true));
5949
+ }
5950
+ if (route.kind === "new_conversation") {
5951
+ if (route.relatedTicketId === undefined ||
5952
+ (route.association === "unverified" &&
5953
+ route.principalId === undefined)) {
5954
+ return stored;
5955
+ }
5956
+ const existing = await records.get(commandKey(route.ticketId, commandId));
5957
+ const alreadyFenced = inboundCreateRerouteFenceMatches(existing, commandId, messageId, fingerprint);
5958
+ // A committed create remains authoritative for receipt replay.
5959
+ if (existing !== null && !alreadyFenced) {
5960
+ return stored;
5961
+ }
5962
+ if (stored.status === "processing") {
5963
+ return decideRoute(key, await claimRouteForRerouting(key, route), input, reservedTicketId, commandId, messageId, fingerprint);
5964
+ }
5965
+ if (!alreadyFenced &&
5966
+ !(await reserveCreateRerouteFence({
5967
+ ticketId: route.ticketId,
5968
+ commandId,
5969
+ messageId,
5970
+ fingerprint,
5971
+ recordedAt: stored.receivedAt,
5972
+ }))) {
5973
+ return stored;
5974
+ }
5975
+ return replaceReroutingRoute(key, route, await proposeNewConversation(input, reservedTicketId, route.matchMethod, route.relatedTicketId, true));
5976
+ }
5977
+ const commandLocation = commandKey(route.ticketId, commandId);
5978
+ const existing = await records.get(commandLocation);
5979
+ const alreadyFenced = inboundReplyRerouteFenceMatches(existing, commandId, messageId, fingerprint);
5980
+ if (existing !== null && !alreadyFenced) {
5981
+ return stored;
5982
+ }
5983
+ const target = await records.get(ticketKey(route.ticketId));
5984
+ if (!alreadyFenced &&
5985
+ (target?.kind !== "ticket" ||
5986
+ threadJoinIsSafe(target.ticket, route.matchMethod, input))) {
5987
+ return stored;
5988
+ }
5989
+ if (stored.status === "processing") {
5990
+ return decideRoute(key, await claimRouteForRerouting(key, route), input, reservedTicketId, commandId, messageId, fingerprint);
5991
+ }
5992
+ if (!alreadyFenced) {
5993
+ const fenced = await reserveReplyRerouteFence({
5994
+ ticketId: route.ticketId,
5995
+ commandId,
5996
+ messageId,
5997
+ fingerprint,
5998
+ recordedAt: stored.receivedAt,
5999
+ });
6000
+ if (!fenced) {
6001
+ return stored;
6002
+ }
6003
+ }
6004
+ return replaceReroutingRoute(key, route, await proposeNewConversation(input, reservedTicketId, route.matchMethod, route.ticketId, true));
6005
+ }
6006
+ const proposed = await proposeRoute(input, reservedTicketId);
6007
+ const result = await receipts.update(key, (current) => {
6008
+ if (current === null) {
6009
+ throw new SupportDeskConflictError("Inbound receipt disappeared before routing");
6010
+ }
6011
+ if (current.status !== "processing" || current.route !== undefined) {
6012
+ return { action: "keep" };
6013
+ }
6014
+ return { action: "write", value: { ...current, route: proposed } };
6015
+ }, { maxAttempts: maxConflictAttempts });
6016
+ if (result.value === null ||
6017
+ ((result.value.status === "processing" ||
6018
+ result.value.status === "rerouting") &&
6019
+ result.value.route === undefined)) {
6020
+ throw new SupportDeskConflictError("Inbound routing decision was not stored");
6021
+ }
6022
+ return result.value;
6023
+ }
6024
+ async function createConversation(work, route) {
6025
+ const { input, messageId, commandId, fingerprint } = work;
6026
+ const ticketId = route.ticketId;
6027
+ const commandLocation = commandKey(ticketId, commandId);
6028
+ const existingVersioned = await records.getVersioned(commandLocation);
6029
+ const existing = existingVersioned?.value ?? null;
6030
+ const createFence = existingVersioned !== null &&
6031
+ inboundCreateRerouteFenceMatches(existingVersioned.value, commandId, messageId, fingerprint)
6032
+ ? existingVersioned
6033
+ : null;
6034
+ if (existing !== null && createFence === null) {
6035
+ if (!duplicateMatches(existing, "inbound_email_create", messageId, fingerprint)) {
6036
+ throw new SupportDeskConflictError("An inbound ticket command ID was reused for different content");
6037
+ }
6038
+ }
6039
+ else {
6040
+ // ticket, quota, message, audit, command, and an optional delivery job.
6041
+ enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, 6);
6042
+ // Reserved outside the ticket transaction; a failed create leaves a gap.
6043
+ const ticketNumber = await reserveNextTicketNumber(numbers, maxConflictAttempts);
6044
+ const now = clock.now();
6045
+ const displayName = input.fromName === undefined ? {} : { displayName: input.fromName };
6046
+ const ticket = await applyResponseTargets(createTicket({
6047
+ id: ticketId,
6048
+ number: ticketNumber,
6049
+ subject: input.subject,
6050
+ channel: "email",
6051
+ priority: route.priority,
6052
+ ...(route.category === undefined ? {} : { category: route.category }),
6053
+ requester: route.association === "matched_email"
6054
+ ? {
6055
+ association: "matched_email",
6056
+ principalId: route.principalId,
6057
+ email: input.from,
6058
+ ...displayName,
6059
+ }
6060
+ : {
6061
+ association: "unverified",
6062
+ email: input.from,
6063
+ ...displayName,
6064
+ },
6065
+ createdAt: now,
6066
+ ...(route.relatedTicketId === undefined
6067
+ ? {}
6068
+ : { relatedTicketId: route.relatedTicketId }),
6069
+ }), responseTargetPolicy, "created");
6070
+ const notification = await inboundNotification({
6071
+ outcome: "created",
6072
+ channelId: input.channelId,
6073
+ ticketId,
6074
+ ticketNumber,
6075
+ messageId,
6076
+ association: route.association,
6077
+ matchMethod: route.matchMethod,
6078
+ senderAcknowledgementAllowed: inboundSenderAcknowledgementAllowed(input),
6079
+ });
6080
+ const notificationContent = deliveryContent(notification);
6081
+ const commandRecord = {
6082
+ kind: "command",
6083
+ partition: ticketId,
6084
+ id: commandLocation.id,
6085
+ commandId,
6086
+ commandType: "inbound_email_create",
6087
+ messageId,
6088
+ requestFingerprint: fingerprint,
6089
+ completedAt: now,
6090
+ };
6091
+ const actions = [
6092
+ {
6093
+ action: "insert",
6094
+ value: { kind: "ticket", partition: ticketId, id: "ticket", ticket },
6095
+ },
6096
+ {
6097
+ action: "insert",
6098
+ value: {
6099
+ kind: "ticket_quota",
6100
+ partition: ticketId,
6101
+ id: "quota",
6102
+ messageCount: 1,
6103
+ },
6104
+ },
6105
+ {
6106
+ action: "insert",
6107
+ value: {
6108
+ kind: "message",
6109
+ partition: ticketId,
6110
+ id: `message:${messageId}`,
6111
+ ordinal: ticket.revision,
6112
+ message: inboundMessage(input, ticketId, messageId, now),
6113
+ ...(notificationContent === undefined
6114
+ ? {}
6115
+ : { deliveryContent: notificationContent }),
6116
+ },
6117
+ },
6118
+ inboundAuditAction({
6119
+ commandId,
6120
+ correlationId: work.correlationId,
6121
+ ticketId,
6122
+ revision: ticket.revision,
6123
+ occurredAt: now,
6124
+ action: supportTicketAuditActions.created,
6125
+ matchMethod: route.matchMethod,
6126
+ association: route.association,
6127
+ }),
6128
+ ...(createFence === null
6129
+ ? [{ action: "insert", value: commandRecord }]
6130
+ : [
6131
+ {
6132
+ action: "putIfUnchanged",
6133
+ version: createFence.version,
6134
+ value: commandRecord,
6135
+ },
6136
+ ]),
6137
+ ...(notification === undefined
6138
+ ? []
6139
+ : [deliveryJobAction(ticketId, messageId, now, notification)]),
6140
+ ];
6141
+ enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, actions.length);
6142
+ const outcome = await records.transact(ticketId, actions);
6143
+ if (!outcome.committed) {
6144
+ const raced = await records.get(commandKey(ticketId, commandId));
6145
+ if (!duplicateMatches(raced, "inbound_email_create", messageId, fingerprint)) {
6146
+ if (raced !== null &&
6147
+ !inboundCreateRerouteFenceMatches(raced, commandId, messageId, fingerprint)) {
6148
+ throw new SupportDeskConflictError("An inbound ticket command ID was reused for different content");
6149
+ }
6150
+ 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.");
6151
+ }
6152
+ }
6153
+ }
6154
+ const result = await finishReceipt(work, {
6155
+ status: "processed",
6156
+ outcome: "created",
6157
+ ticketId,
6158
+ messageId,
6159
+ });
6160
+ await afterInboundCommit(ticketId, messageId, true);
6161
+ return result;
6162
+ }
6163
+ async function appendReply(work, route) {
6164
+ const { input, messageId, commandId, fingerprint } = work;
6165
+ const ticketId = route.ticketId;
6166
+ const completed = async () => {
6167
+ const result = await finishReceipt(work, {
6168
+ status: "processed",
6169
+ outcome: "replied",
6170
+ ticketId,
6171
+ messageId,
6172
+ });
6173
+ await afterInboundCommit(ticketId, messageId, false);
6174
+ return result;
6175
+ };
6176
+ const rejected = (diagnostic) => finishReceipt(work, {
6177
+ status: "rejected",
6178
+ outcome: "rejected",
6179
+ diagnostic,
6180
+ });
6181
+ for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
6182
+ const duplicate = await records.get(commandKey(ticketId, commandId));
6183
+ if (duplicate !== null) {
6184
+ if (!duplicateMatches(duplicate, "inbound_email_reply", messageId, fingerprint)) {
6185
+ throw new SupportDeskConflictError("An inbound ticket command ID was reused for different content");
6186
+ }
6187
+ return completed();
6188
+ }
6189
+ const versioned = await records.getVersioned(ticketKey(ticketId));
6190
+ if (versioned?.value.kind !== "ticket") {
6191
+ return rejected("ticket_unavailable");
6192
+ }
6193
+ const ticket = versioned.value.ticket;
6194
+ const partitionRows = await records.list(ticketId);
6195
+ if (input.externalMessageId !== undefined) {
6196
+ const prior = partitionRows.find((row) => row.kind === "message" &&
6197
+ row.message.channel === "email" &&
6198
+ row.message.authorKind === "customer" &&
6199
+ row.message.externalMessageId === input.externalMessageId);
6200
+ if (prior !== undefined) {
6201
+ return finishReceipt(work, {
6202
+ status: "processed",
6203
+ outcome: "duplicate_message",
6204
+ ticketId,
6205
+ messageId: prior.message.id,
6206
+ });
6207
+ }
6208
+ }
6209
+ const quota = await records.getVersioned(ticketQuotaKey(ticketId));
6210
+ if (quota?.value.kind !== "ticket_quota") {
6211
+ throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
6212
+ }
6213
+ if (quota.value.messageCount >= limits.maxMessagesPerTicket) {
6214
+ return rejected("ticket_messages_limit");
6215
+ }
6216
+ const notification = await inboundNotification({
6217
+ outcome: "replied",
6218
+ channelId: input.channelId,
6219
+ ticketId,
6220
+ ticketNumber: ticket.number,
6221
+ messageId,
6222
+ association: ticket.requester.association,
6223
+ matchMethod: route.matchMethod,
6224
+ ...(ticket.assignedTo === undefined
6225
+ ? {}
6226
+ : { assignedTo: ticket.assignedTo }),
6227
+ senderAcknowledgementAllowed: inboundSenderAcknowledgementAllowed(input),
6228
+ });
6229
+ const requiresHandoff = effectiveTicketHandling(ticket).state === "assistant";
6230
+ const now = clampTicketTime(clock.now(), ticket.updatedAt);
6231
+ // Customer-reply semantics: any status, including resolved or closed,
6232
+ // moves to waiting_on_support. No actor: email is not authentication.
6233
+ const updated = await applyResponseTargets(applyTicketEvent(ticket, {
6234
+ type: "customer_replied",
6235
+ occurredAt: now,
6236
+ channel: "email",
6237
+ }), responseTargetPolicy, "customer_replied");
6238
+ const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
6239
+ try {
6240
+ enforceManagedAdmission(ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, (notification === undefined ? 3 : 4) +
6241
+ (requiresHandoff ? 1 : 0) +
6242
+ learningMutation.additionalRows, true);
6243
+ }
6244
+ catch (error) {
6245
+ if (!(error instanceof SupportDeskLimitError))
6246
+ throw error;
6247
+ return rejected(error.field === "ticket_messages"
6248
+ ? "ticket_messages_limit"
6249
+ : "ticket_partition_limit");
6250
+ }
6251
+ const notificationContent = deliveryContent(notification);
6252
+ const outcome = await records.transact(ticketId, [
6253
+ ...learningMutation.actions,
6254
+ {
6255
+ action: "putIfUnchanged",
6256
+ version: quota.version,
6257
+ value: {
6258
+ ...quota.value,
6259
+ messageCount: quota.value.messageCount + 1,
6260
+ },
6261
+ },
6262
+ {
6263
+ action: "insert",
6264
+ value: {
6265
+ kind: "message",
6266
+ partition: ticketId,
6267
+ id: `message:${messageId}`,
6268
+ ordinal: updated.revision,
6269
+ message: inboundMessage(input, ticketId, messageId, now),
6270
+ ...(notificationContent === undefined
6271
+ ? {}
6272
+ : { deliveryContent: notificationContent }),
6273
+ },
6274
+ },
6275
+ inboundAuditAction({
6276
+ commandId,
6277
+ correlationId: work.correlationId,
6278
+ ticketId,
6279
+ revision: updated.revision,
6280
+ occurredAt: now,
6281
+ action: supportTicketAuditActions.customerReplied,
6282
+ matchMethod: route.matchMethod,
6283
+ }),
6284
+ {
6285
+ action: "insert",
6286
+ value: {
6287
+ kind: "command",
6288
+ partition: ticketId,
6289
+ id: `command:${commandId}`,
6290
+ commandId,
6291
+ commandType: "inbound_email_reply",
6292
+ messageId,
6293
+ requestFingerprint: fingerprint,
6294
+ completedAt: now,
6295
+ },
6296
+ },
6297
+ ...(notification === undefined
6298
+ ? []
6299
+ : [deliveryJobAction(ticketId, messageId, now, notification)]),
6300
+ ...(requiresHandoff
6301
+ ? [
6302
+ {
6303
+ action: "insert",
6304
+ value: handoffWork({
6305
+ ticket: updated,
6306
+ commandId,
6307
+ occurredAt: now,
6308
+ lastVisibleMessageId: messageId,
6309
+ reason: "email_reply",
6310
+ }),
6311
+ },
6312
+ ]
6313
+ : []),
6314
+ ]);
6315
+ if (outcome.committed) {
6316
+ return completed();
6317
+ }
6318
+ }
6319
+ const last = await records.get(commandKey(ticketId, commandId));
6320
+ if (duplicateMatches(last, "inbound_email_reply", messageId, fingerprint)) {
6321
+ return completed();
6322
+ }
6323
+ if (last !== null) {
6324
+ throw new SupportDeskConflictError("An inbound ticket command ID was reused for different content");
6325
+ }
6326
+ 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.");
6327
+ }
6328
+ return Object.freeze({
6329
+ async processInboundEmail(rawInput) {
6330
+ const input = snapshotInboundEmailInput(rawInput, limits);
6331
+ const replayFingerprint = await requestFingerprint(inboundFingerprintPayload(input));
6332
+ const location = await inboundReceiptLocation(input.channelId, input.providerEventId);
6333
+ const key = {
6334
+ partition: location.bucket,
6335
+ id: receiptSlotId(location.slot, "inbound receipt slot"),
6336
+ };
6337
+ const receivedAt = new Date(canonicalTimestamp(clock.now(), "clock.now()")).toISOString();
6338
+ const inserted = await receipts.insertIfAbsent({
6339
+ bucket: location.bucket,
6340
+ slot: location.slot,
6341
+ channelId: input.channelId,
6342
+ providerEventId: input.providerEventId,
6343
+ ...(input.externalMessageId === undefined
6344
+ ? {}
6345
+ : { externalMessageId: input.externalMessageId }),
6346
+ payloadFingerprint: replayFingerprint,
6347
+ replayFingerprint,
6348
+ status: "processing",
6349
+ receivedAt,
6350
+ reservedTicketId: input.ticketId,
6351
+ reservedMessageId: input.messageId,
6352
+ correlationId: input.correlationId,
6353
+ });
6354
+ let stored = inserted.value;
6355
+ if (stored.channelId !== input.channelId ||
6356
+ stored.providerEventId !== input.providerEventId) {
6357
+ throw new SupportDeskConflictError("An inbound receipt hash slot collided with a different provider event");
6358
+ }
6359
+ stored = await bindInboundReplayFingerprint(key, stored, input, replayFingerprint);
6360
+ const fingerprint = stored.payloadFingerprint;
6361
+ const duplicateDelivery = !inserted.inserted;
6362
+ if (stored.status !== "processing" && stored.status !== "rerouting") {
6363
+ return inboundResult(stored, duplicateDelivery);
6364
+ }
6365
+ const reservedTicketId = stored.reservedTicketId;
6366
+ const reservedMessageId = stored.reservedMessageId;
6367
+ const correlationId = stored.correlationId;
6368
+ if (reservedTicketId === undefined ||
6369
+ reservedMessageId === undefined ||
6370
+ correlationId === undefined) {
6371
+ throw new SupportDeskConflictError("Inbound receipt is missing its reserved identifiers");
6372
+ }
6373
+ // Stable per provider event and independent of retry identifiers.
6374
+ const commandId = `inbound:${location.bucket.slice(-30)}${location.slot}`;
6375
+ const decided = await decideRoute(key, stored, input, reservedTicketId, commandId, reservedMessageId, fingerprint);
6376
+ if (decided.status !== "processing" && decided.status !== "rerouting") {
6377
+ return inboundResult(decided, duplicateDelivery);
6378
+ }
6379
+ const route = decided.route;
6380
+ const work = {
6381
+ key,
6382
+ input,
6383
+ route,
6384
+ messageId: reservedMessageId,
6385
+ correlationId,
6386
+ commandId,
6387
+ fingerprint,
6388
+ duplicateDelivery,
6389
+ };
6390
+ switch (route.kind) {
6391
+ case "suppressed":
6392
+ return finishReceipt(work, {
6393
+ status: "rejected",
6394
+ outcome: "suppressed",
6395
+ diagnostic: route.reason,
6396
+ });
6397
+ case "duplicate_message":
6398
+ return finishReceipt(work, {
6399
+ status: "processed",
6400
+ outcome: "duplicate_message",
6401
+ ticketId: route.ticketId,
6402
+ messageId: route.messageId,
6403
+ });
6404
+ case "new_conversation":
6405
+ return createConversation(work, route);
6406
+ case "reply":
6407
+ return appendReply(work, route);
6408
+ }
6409
+ },
6410
+ });
6411
+ }
3048
6412
  //# sourceMappingURL=index.js.map