@pegma/support-desk-application 0.2.3 → 0.2.5

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 (52) hide show
  1. package/README.md +60 -11
  2. package/dist/assistant-turns.d.ts.map +1 -1
  3. package/dist/assistant-turns.js +7 -5
  4. package/dist/assistant-turns.js.map +1 -1
  5. package/dist/attachments.d.ts +57 -0
  6. package/dist/attachments.d.ts.map +1 -0
  7. package/dist/attachments.js +91 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/engineering-followup-source.d.ts +1 -1
  10. package/dist/engineering-followup-source.d.ts.map +1 -1
  11. package/dist/engineering-followup-source.js +15 -1
  12. package/dist/engineering-followup-source.js.map +1 -1
  13. package/dist/engineering-followup-types.d.ts +22 -1
  14. package/dist/engineering-followup-types.d.ts.map +1 -1
  15. package/dist/index.d.ts +104 -5
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1065 -70
  18. package/dist/index.js.map +1 -1
  19. package/dist/resolution-learning-records.d.ts +2 -0
  20. package/dist/resolution-learning-records.d.ts.map +1 -1
  21. package/dist/resolution-learning-records.js +7 -0
  22. package/dist/resolution-learning-records.js.map +1 -1
  23. package/dist/resolution-learning-review-commit.d.ts +18 -0
  24. package/dist/resolution-learning-review-commit.d.ts.map +1 -0
  25. package/dist/resolution-learning-review-commit.js +199 -0
  26. package/dist/resolution-learning-review-commit.js.map +1 -0
  27. package/dist/resolution-learning-review-identity.d.ts +28 -0
  28. package/dist/resolution-learning-review-identity.d.ts.map +1 -0
  29. package/dist/resolution-learning-review-identity.js +110 -0
  30. package/dist/resolution-learning-review-identity.js.map +1 -0
  31. package/dist/resolution-learning-staged-records.d.ts +45 -0
  32. package/dist/resolution-learning-staged-records.d.ts.map +1 -0
  33. package/dist/resolution-learning-staged-records.js +193 -0
  34. package/dist/resolution-learning-staged-records.js.map +1 -0
  35. package/dist/resolution-learning-staged-types.d.ts +88 -0
  36. package/dist/resolution-learning-staged-types.d.ts.map +1 -0
  37. package/dist/resolution-learning-staged-types.js +2 -0
  38. package/dist/resolution-learning-staged-types.js.map +1 -0
  39. package/dist/resolution-learning-staged.d.ts +12 -0
  40. package/dist/resolution-learning-staged.d.ts.map +1 -0
  41. package/dist/resolution-learning-staged.js +585 -0
  42. package/dist/resolution-learning-staged.js.map +1 -0
  43. package/dist/resolution-learning-types.d.ts +6 -0
  44. package/dist/resolution-learning-types.d.ts.map +1 -1
  45. package/dist/resolution-learning-worker.d.ts.map +1 -1
  46. package/dist/resolution-learning-worker.js +58 -186
  47. package/dist/resolution-learning-worker.js.map +1 -1
  48. package/dist/resolution-learning.d.ts +3 -2
  49. package/dist/resolution-learning.d.ts.map +1 -1
  50. package/dist/resolution-learning.js +83 -7
  51. package/dist/resolution-learning.js.map +1 -1
  52. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1,9 +1,11 @@
1
+ import { attachmentConfiguration, attachmentDescriptors, snapshotAttachmentInputs, newAttachment, attachmentDigest, } from "./attachments.js";
2
+ import { assertResolutionLearningReviewAttempt, assertResolutionLearningReviewReservation, } from "./resolution-learning-staged-records.js";
1
3
  import { auditRecordId, defineAudit } from "@pegma/audit";
2
4
  import { hasPermission } from "@pegma/authorization-core";
3
5
  import { defineMail, maxMailAttempts } from "@pegma/mail";
4
6
  import { noopLogger } from "@pegma/spine";
5
7
  import { ConcurrencyError, defineCollection, MAX_SCAN_PAGE_SIZE, } from "@pegma/storage-core";
6
- import { applyTicketEvent, createTicket, effectiveTicketHandling, } from "@pegma/support-desk-core";
8
+ import { applyTicketEvent, createTicket, effectiveTicketHandling, normalizeAttachmentFilename, } from "@pegma/support-desk-core";
7
9
  import { assertAssistantTurnRecord, createAssistantTurnRecord, resolveAssistantTurnOptions, } from "./assistant-turn-records.js";
8
10
  import { createAssistantTurnServices, } from "./assistant-turns.js";
9
11
  import { createEngineeringEvidenceServices, planEngineeringTicketMutation, } from "./engineering-evidence.js";
@@ -16,10 +18,113 @@ import { createEngineeringFollowupSourceServices, planEngineeringFollowupSourceC
16
18
  import { createEngineeringFollowupWorkServices } from "./engineering-followup-work.js";
17
19
  import { fields as followupFields, integer as followupInteger, text as followupText, } from "./assistant-turn-records.js";
18
20
  import { engineeringFollowupCommandKey, engineeringFollowupHeadKey, safeEngineeringFollowupSnapshot as followupSnapshot, checkEngineeringFollowupRecordCapacity, } from "./engineering-followup-records.js";
19
- import { engineeringFollowupNow, engineeringFollowupVersionsCurrent, } from "./engineering-followup-source.js";
21
+ import { engineeringFollowupNow, engineeringFollowupTrusted, engineeringFollowupVersionsCurrent, } from "./engineering-followup-source.js";
20
22
  import { assertResolutionLearningRecord, resolveResolutionLearningOptions, } from "./resolution-learning-records.js";
21
23
  import { createResolutionLearningServices, createResolutionLearningSource, planResolutionLearningTicketMutation, } from "./resolution-learning.js";
22
24
  export { resolutionLearningDeliveryBinding, resolutionLearningManifestHash, } from "./resolution-learning-worker.js";
25
+ /** Reserve Audit/receipt slots so attachment cleanup can still finish at capacity. */
26
+ function partitionSizeWithAttachmentReserve(rows) {
27
+ return rows.reduce((size, row) => size +
28
+ (row.kind === "message"
29
+ ? (row.message.attachments ?? []).reduce((reserved, item) => reserved +
30
+ (item.scanStatus === "pending_scan"
31
+ ? 6
32
+ : item.scanStatus === "clean" ||
33
+ item.scanStatus === "quarantined"
34
+ ? 4
35
+ : item.scanStatus === "deleting"
36
+ ? 2
37
+ : 0), 0)
38
+ : 0), rows.length);
39
+ }
40
+ /** One bounded physical page per call; host persists the returned cursor. */
41
+ export async function sweepExpiredAttachments(options) {
42
+ requireStaffMutationAccess(options.access, supportPermissions.manage);
43
+ const limit = options.limit ?? 20;
44
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
45
+ throw new TypeError("Invalid attachment sweep limit");
46
+ const now = canonicalTimestamp(options.clock.now(), "attachment sweep now");
47
+ const page = await options.store.collection(supportRecords).scan({
48
+ limit,
49
+ ...(options.cursor === undefined ? {} : { cursor: options.cursor }),
50
+ });
51
+ let deleted = 0;
52
+ const failures = [];
53
+ for (const row of page.records) {
54
+ if (row.value.kind !== "message")
55
+ continue;
56
+ for (const attachment of row.value.message.attachments ?? []) {
57
+ if (attachment.scanStatus === "deleted" ||
58
+ (attachment.scanStatus !== "deleting" &&
59
+ canonicalTimestamp(attachment.expiresAt, "attachment expiresAt") >
60
+ now))
61
+ continue;
62
+ const target = {
63
+ ticketId: row.key.partition,
64
+ messageId: row.value.message.id,
65
+ attachmentId: attachment.id,
66
+ };
67
+ try {
68
+ await options.application.deleteAttachment(options.access, {
69
+ ...target,
70
+ commandId: `expire-${attachment.id}`,
71
+ correlationId: `expire-${attachment.id}`,
72
+ });
73
+ deleted++;
74
+ }
75
+ catch {
76
+ failures.push(target);
77
+ }
78
+ }
79
+ }
80
+ return {
81
+ nextCursor: page.nextCursor,
82
+ physicalRows: page.records.length,
83
+ deleted,
84
+ failures,
85
+ };
86
+ }
87
+ /**
88
+ * Host inventory supplies at most 100 candidates with the put-time context.
89
+ * The host MUST stop and drain ALL attachment writers across every process,
90
+ * including uncertain transaction outcomes, throughout withUploadsPaused.
91
+ * This explicit maintenance fence prevents deleting an in-flight commit's blob.
92
+ */
93
+ export async function cleanupUnreferencedAttachmentBlobs(options) {
94
+ if (options.candidates.length > 100)
95
+ throw new TypeError("At most 100 orphan candidates");
96
+ const candidates = options.candidates.map((candidate) => {
97
+ const source = boundaryObject(candidate, "orphan candidate");
98
+ const read = (field) => boundaryString(ownDataProperty(source, field, field), field);
99
+ const value = {
100
+ key: read("key"),
101
+ ticketId: read("ticketId"),
102
+ messageId: read("messageId"),
103
+ };
104
+ requireIdentifier(value.ticketId, "ticketId");
105
+ requireIdentifier(value.messageId, "messageId");
106
+ if (!/^support-attachments\/[a-f0-9-]{36}$/.test(value.key))
107
+ throw new TypeError("Invalid attachment key");
108
+ return Object.freeze(value);
109
+ });
110
+ return options.withUploadsPaused(async () => {
111
+ let deleted = 0;
112
+ for (const candidate of candidates) {
113
+ if (!/^support-attachments\/[a-f0-9-]{36}$/.test(candidate.key))
114
+ throw new TypeError("Invalid attachment key");
115
+ const row = await options.store.collection(supportRecords).get({
116
+ partition: candidate.ticketId,
117
+ id: `message:${candidate.messageId}`,
118
+ });
119
+ if (row?.kind === "message" &&
120
+ row.message.attachments?.some((item) => item.blobKey === candidate.key && item.scanStatus !== "deleted"))
121
+ continue;
122
+ await options.blobs.delete(candidate.key);
123
+ deleted++;
124
+ }
125
+ return { deleted };
126
+ });
127
+ }
23
128
  export { engineeringFollowupModelManifestHash, engineeringFollowupContextHash, engineeringFollowupPublicFactsHash, engineeringFollowupChecksBinding, engineeringFollowupRetestRequestId, engineeringFollowupRetestProductBinding, engineeringFollowupRetestRequestHash, engineeringFollowupRetestSummary, engineeringFollowupModelAttemptCount, } from "./engineering-followup-records.js";
24
129
  export { engineeringPolicyRecords, engineeringPolicyAudit, engineeringPolicyAuditActions, createEngineeringPolicyServices, } from "./engineering-policy.js";
25
130
  export { engineeringModelManifestHash, engineeringRoutingBinding, engineeringExportChecksBinding, engineeringRoutingBindingHash, engineeringStaffSelectionHash, } from "./engineering-work.js";
@@ -139,6 +244,9 @@ export const supportTicketAuditActions = Object.freeze({
139
244
  resolved: "support.ticket.resolved",
140
245
  closed: "support.ticket.closed",
141
246
  reopened: "support.ticket.reopened",
247
+ attachmentScan: "support.ticket.attachment_scan",
248
+ attachmentDeleting: "support.ticket.attachment_deleting",
249
+ attachmentDeleted: "support.ticket.attachment_deleted",
142
250
  assistantReplied: "support.ticket.assistant_replied",
143
251
  humanRequested: "support.ticket.human_requested",
144
252
  humanEscalated: "support.ticket.human_escalated",
@@ -148,6 +256,7 @@ export const supportTicketAuditActions = Object.freeze({
148
256
  learningEvidenceRecorded: "support.learning.evidence_recorded",
149
257
  learningEvidenceInvalidated: "support.learning.evidence_invalidated",
150
258
  learningWithdrawn: "support.learning.withdrawn",
259
+ learningReviewRequested: "support.learning.review_requested",
151
260
  learningReviewed: "support.learning.reviewed",
152
261
  learningReviewCancelled: "support.learning.review_cancelled",
153
262
  engineeringEnrolled: "support.engineering.enrolled",
@@ -205,10 +314,46 @@ function receiptSlotId(slot, field) {
205
314
  }
206
315
  return `slot:${slot}`;
207
316
  }
317
+ function assertAttachmentDeletionReceipt(value, location) {
318
+ if (value === null || typeof value !== "object")
319
+ return;
320
+ const record = value;
321
+ if (record.attachmentDeletion === undefined)
322
+ return;
323
+ const marker = boundaryObject(record.attachmentDeletion, "attachment deletion receipt");
324
+ const expected = [
325
+ "phase",
326
+ "actorId",
327
+ "messageId",
328
+ "attachmentId",
329
+ "correlationId",
330
+ ];
331
+ if (Object.keys(marker).length !== expected.length ||
332
+ Object.keys(marker).some((key) => !expected.includes(key)))
333
+ throw new TypeError("Invalid attachment deletion receipt fields");
334
+ for (const name of expected.slice(1))
335
+ requireIdentifier(boundaryString(ownDataProperty(marker, name, name), name), name);
336
+ const phase = ownDataProperty(marker, "phase", "attachment deletion phase");
337
+ if (phase !== "pending" && phase !== "complete")
338
+ throw new TypeError("Invalid attachment deletion phase");
339
+ if (record.kind !== "command" ||
340
+ record.commandType !== "attachment_state" ||
341
+ record.partition !== location.partition ||
342
+ record.id !== location.id ||
343
+ location.id !== `command:${record.commandId}` ||
344
+ record.messageId !== undefined ||
345
+ typeof record.requestFingerprint !== "string" ||
346
+ !/^[a-f0-9]{64}$/.test(record.requestFingerprint))
347
+ throw new TypeError("Invalid attachment deletion receipt identity");
348
+ requireIdentifier(record.commandId, "attachment deletion commandId");
349
+ requireIdentifier(record.partition, "attachment deletion ticketId");
350
+ canonicalTimestamp(record.completedAt, "attachment deletion timestamp");
351
+ }
208
352
  function jsonCodec(key) {
209
353
  return {
210
354
  encode(value) {
211
355
  const location = key(value);
356
+ assertAttachmentDeletionReceipt(value, location);
212
357
  if ([
213
358
  "engineering_followup_head",
214
359
  "engineering_followup_work",
@@ -227,6 +372,15 @@ function jsonCodec(key) {
227
372
  location.id.startsWith("engineering-work:") ||
228
373
  location.id.startsWith("engineering-command:"))
229
374
  assertEngineeringRecord(value, location);
375
+ if (value.kind ===
376
+ "resolution_learning_review_attempt" ||
377
+ location.id.startsWith("learning-review-attempt:"))
378
+ assertResolutionLearningReviewAttempt(value, location);
379
+ if (value.kind ===
380
+ "resolution_learning_review_reservation" ||
381
+ (location.id.startsWith("learning-command:") &&
382
+ value.kind !== "resolution_learning_receipt"))
383
+ assertResolutionLearningReviewReservation(value, location);
230
384
  if ([
231
385
  "resolution_learning_source",
232
386
  "resolution_learning_work",
@@ -250,6 +404,16 @@ function jsonCodec(key) {
250
404
  throw new TypeError("stored Support Desk record has no JSON payload");
251
405
  }
252
406
  const decoded = JSON.parse(record.payload);
407
+ if (decoded?.attachmentDeletion !==
408
+ undefined) {
409
+ if (typeof record.partition !== "string" ||
410
+ typeof record.id !== "string")
411
+ throw new TypeError("Attachment deletion receipt has invalid physical envelope");
412
+ assertAttachmentDeletionReceipt(decoded, {
413
+ partition: record.partition,
414
+ id: record.id,
415
+ });
416
+ }
253
417
  if ([
254
418
  "engineering_followup_head",
255
419
  "engineering_followup_work",
@@ -284,6 +448,32 @@ function jsonCodec(key) {
284
448
  id: record.id,
285
449
  });
286
450
  }
451
+ if (decoded?.kind ===
452
+ "resolution_learning_review_attempt" ||
453
+ (typeof record.id === "string" &&
454
+ record.id.startsWith("learning-review-attempt:"))) {
455
+ if (typeof record.partition !== "string" ||
456
+ typeof record.id !== "string")
457
+ throw new TypeError("Review attempt has invalid physical envelope");
458
+ assertResolutionLearningReviewAttempt(decoded, {
459
+ partition: record.partition,
460
+ id: record.id,
461
+ });
462
+ }
463
+ if (decoded?.kind ===
464
+ "resolution_learning_review_reservation" ||
465
+ (typeof record.id === "string" &&
466
+ record.id.startsWith("learning-command:") &&
467
+ decoded?.kind !==
468
+ "resolution_learning_receipt")) {
469
+ if (typeof record.partition !== "string" ||
470
+ typeof record.id !== "string")
471
+ throw new TypeError("Review reservation has invalid physical envelope");
472
+ assertResolutionLearningReviewReservation(decoded, {
473
+ partition: record.partition,
474
+ id: record.id,
475
+ });
476
+ }
287
477
  if ([
288
478
  "resolution_learning_source",
289
479
  "resolution_learning_work",
@@ -415,7 +605,7 @@ export const supportAudit = defineAudit({
415
605
  });
416
606
  function ticketAuditAction(input) {
417
607
  return supportAudit.action({
418
- id: input.commandId,
608
+ id: input.eventId ?? input.commandId,
419
609
  occurredAt: input.occurredAt,
420
610
  actor: { kind: "principal", principalId: input.actorId },
421
611
  action: input.action,
@@ -493,6 +683,20 @@ const maxNotificationVariableBytes = 8_192;
493
683
  const maxNotificationVariableTotalBytes = 8_192;
494
684
  const maxAllowedCategories = 32;
495
685
  const categoryPattern = /^[a-z][a-z0-9_]{0,31}$/;
686
+ function snapshotAttachmentStateCommand(input) {
687
+ const source = boundaryObject(input, "attachment command");
688
+ const read = (field) => boundaryString(ownDataProperty(source, field, field), field);
689
+ const command = {
690
+ ticketId: read("ticketId"),
691
+ messageId: read("messageId"),
692
+ attachmentId: read("attachmentId"),
693
+ commandId: read("commandId"),
694
+ correlationId: read("correlationId"),
695
+ };
696
+ for (const [field, value] of Object.entries(command))
697
+ requireIdentifier(value, field);
698
+ return Object.freeze(command);
699
+ }
496
700
  function requirePermission(access, permission) {
497
701
  if (!hasPermission(access, permission)) {
498
702
  throw new SupportDeskAuthorizationError(permission);
@@ -849,6 +1053,7 @@ function snapshotCreateCustomerTicketCommand(input) {
849
1053
  ? undefined
850
1054
  : snapshotNotification(raw.notification);
851
1055
  return Object.freeze({
1056
+ attachments: snapshotAttachmentInputs(ownDataProperty(source, "attachments", "attachments", true)),
852
1057
  commandId: boundaryString(raw.commandId, "create command.commandId"),
853
1058
  correlationId: boundaryString(raw.correlationId, "create command.correlationId"),
854
1059
  ticketId: boundaryString(raw.ticketId, "create command.ticketId"),
@@ -861,6 +1066,66 @@ function snapshotCreateCustomerTicketCommand(input) {
861
1066
  ...(initialHandling === undefined ? {} : { initialHandling }),
862
1067
  });
863
1068
  }
1069
+ function snapshotCustomerTicketAdmission(input) {
1070
+ const source = boundaryObject(input, "admitted create command");
1071
+ const allowed = [
1072
+ "commandId",
1073
+ "correlationId",
1074
+ "ticketId",
1075
+ "messageId",
1076
+ "subject",
1077
+ "body",
1078
+ "category",
1079
+ "requesterEmail",
1080
+ "attachments",
1081
+ "preference",
1082
+ "disclosureVersion",
1083
+ ];
1084
+ const keys = Reflect.ownKeys(source);
1085
+ if (Array.isArray(source) ||
1086
+ keys.some((key) => typeof key !== "string" || !allowed.includes(key)))
1087
+ throw new TypeError("Invalid admitted create command fields");
1088
+ const command = Object.freeze(Object.fromEntries(keys.map((key) => [
1089
+ key,
1090
+ ownDataProperty(source, key, "admitted create command field"),
1091
+ ])));
1092
+ const preference = ownDataProperty(command, "preference", "preference");
1093
+ if (preference !== "human" && preference !== "assistant_if_available")
1094
+ throw new TypeError("Invalid admitted create preference");
1095
+ const raw = ownDataProperty(command, "disclosureVersion", "disclosureVersion", true);
1096
+ const disclosureVersion = raw === undefined ? undefined : boundaryString(raw, "disclosureVersion");
1097
+ if (disclosureVersion !== undefined)
1098
+ requireIdentifier(disclosureVersion, "disclosureVersion");
1099
+ return {
1100
+ command,
1101
+ intent: Object.freeze({
1102
+ preference,
1103
+ ...(disclosureVersion === undefined ? {} : { disclosureVersion }),
1104
+ }),
1105
+ };
1106
+ }
1107
+ function customerCreateMethods(create) {
1108
+ return {
1109
+ async createCustomerTicket(access, input) {
1110
+ return (await create(access, input)).view;
1111
+ },
1112
+ async createAdmittedCustomerTicket(access, input, options) {
1113
+ requirePermission(access, supportPermissions.create);
1114
+ requirePermission(access, supportPermissions.readOwn);
1115
+ const { command, intent } = snapshotCustomerTicketAdmission(input);
1116
+ return create(access, command, {
1117
+ intent,
1118
+ ...(options === undefined ? {} : { options }),
1119
+ });
1120
+ },
1121
+ };
1122
+ }
1123
+ function admittedCreateMode(record) {
1124
+ if (record?.kind !== "command" ||
1125
+ (record.initialMode !== "human" && record.initialMode !== "assistant"))
1126
+ throw new SupportDeskConflictError("Admitted create receipt mode is invalid");
1127
+ return record.initialMode;
1128
+ }
864
1129
  function snapshotReplyToCustomerTicketCommand(input) {
865
1130
  const source = boundaryObject(input, "reply command");
866
1131
  const raw = {
@@ -875,6 +1140,7 @@ function snapshotReplyToCustomerTicketCommand(input) {
875
1140
  ? undefined
876
1141
  : snapshotNotification(raw.notification);
877
1142
  return Object.freeze({
1143
+ attachments: snapshotAttachmentInputs(ownDataProperty(source, "attachments", "attachments", true)),
878
1144
  commandId: boundaryString(raw.commandId, "reply command.commandId"),
879
1145
  correlationId: boundaryString(raw.correlationId, "reply command.correlationId"),
880
1146
  ticketId: boundaryString(raw.ticketId, "reply command.ticketId"),
@@ -938,8 +1204,24 @@ function toCustomerMessage(message) {
938
1204
  format: message.format,
939
1205
  body: message.body,
940
1206
  createdAt: message.createdAt,
1207
+ ...(message.attachments === undefined
1208
+ ? {}
1209
+ : {
1210
+ attachments: message.attachments.map((item) => ({
1211
+ id: item.id,
1212
+ filename: item.filename,
1213
+ contentType: item.contentType,
1214
+ size: item.size,
1215
+ expiresAt: item.expiresAt,
1216
+ scanStatus: item.scanStatus,
1217
+ })),
1218
+ }),
941
1219
  });
942
1220
  }
1221
+ /** Assistant ports remain body-only even when browser views include safe attachments. */
1222
+ function assistantConversationMessages(records) {
1223
+ return customerMessages(records).map(({ attachments: _attachments, ...message }) => Object.freeze(message));
1224
+ }
943
1225
  function customerMessages(records) {
944
1226
  const messages = records.filter((record) => record.kind === "message" && record.message.visibility === "customer");
945
1227
  const ordinals = new Set();
@@ -1023,6 +1305,7 @@ function parseAllowedCategories(input, name = "application options.allowedCatego
1023
1305
  }
1024
1306
  function duplicateMatches(existing, commandType, messageId, requestFingerprint) {
1025
1307
  return (existing?.kind === "command" &&
1308
+ existing.attachmentDeletion?.phase !== "pending" &&
1026
1309
  existing.commandType === commandType &&
1027
1310
  existing.messageId === messageId &&
1028
1311
  existing.requestFingerprint === requestFingerprint);
@@ -1152,7 +1435,11 @@ function derivationReserveRows(rows, consuming = false) {
1152
1435
  ? row.terminalReserveRows
1153
1436
  : 0), 0);
1154
1437
  const followup = rows.reduce((sum, row) => sum + (row.kind === "engineering_followup_work" ? row.completionRows : 0), 0);
1155
- return learning + engineering + terminal + followup;
1438
+ const stagedReviews = rows.reduce((sum, row) => sum +
1439
+ (row.kind === "resolution_learning_review_attempt"
1440
+ ? row.completionRows
1441
+ : 0), 0);
1442
+ return learning + engineering + terminal + followup + stagedReviews;
1156
1443
  }
1157
1444
  /** Reserve is opt-in metadata on the existing quota, never a second counter. */
1158
1445
  function enforceManagedAdmission(ticket, rows, limits, additionalRows, addsMessage = false, firstHumanReply = false, consumesWithdrawalReserve = false) {
@@ -1160,7 +1447,7 @@ function enforceManagedAdmission(ticket, rows, limits, additionalRows, addsMessa
1160
1447
  const reserved = quota?.assistantManaged === true &&
1161
1448
  ticket.firstStaffRespondedAt === undefined &&
1162
1449
  !firstHumanReply;
1163
- enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket -
1450
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket -
1164
1451
  (reserved ? humanReserveRows(rows) : 0) -
1165
1452
  derivationReserveRows(rows, consumesWithdrawalReserve), additionalRows);
1166
1453
  if (addsMessage &&
@@ -1182,7 +1469,7 @@ function enforceHandlingControlAdmission(ticket, rows, limits, additionalRows, c
1182
1469
  const quota = rows.find((row) => row.kind === "ticket_quota");
1183
1470
  const reserved = quota?.assistantManaged === true &&
1184
1471
  ticket.firstStaffRespondedAt === undefined;
1185
- enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket -
1472
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket -
1186
1473
  (reserved ? firstHumanReplyReserveRows : 0) -
1187
1474
  derivationReserveRows(rows, consumesWithdrawalReserve), additionalRows);
1188
1475
  if (reserved && quota.messageCount >= limits.maxMessagesPerTicket)
@@ -1293,6 +1580,7 @@ function snapshotStaffReplyCommand(input) {
1293
1580
  ? undefined
1294
1581
  : snapshotNotification(raw.notification);
1295
1582
  return Object.freeze({
1583
+ attachments: snapshotAttachmentInputs(ownDataProperty(source, "attachments", "attachments", true)),
1296
1584
  ...snapshotRevisionHint(source),
1297
1585
  commandId: boundaryString(raw.commandId, "staff reply command.commandId"),
1298
1586
  correlationId: boundaryString(raw.correlationId, "staff reply command.correlationId"),
@@ -1312,6 +1600,7 @@ function snapshotAddNoteCommand(input) {
1312
1600
  body: ownDataProperty(source, "body", "add note command.body"),
1313
1601
  };
1314
1602
  return Object.freeze({
1603
+ attachments: snapshotAttachmentInputs(ownDataProperty(source, "attachments", "attachments", true)),
1315
1604
  ...snapshotRevisionHint(source),
1316
1605
  commandId: boundaryString(raw.commandId, "add note command.commandId"),
1317
1606
  correlationId: boundaryString(raw.correlationId, "add note command.correlationId"),
@@ -2032,6 +2321,7 @@ export function createSupportDeskApplication(options) {
2032
2321
  typeof rawMaxConflictAttempts !== "number") {
2033
2322
  throw new TypeError("application options.maxConflictAttempts must be a number");
2034
2323
  }
2324
+ const attachmentConfig = attachmentConfiguration(ownDataProperty(source, "attachments", "application options.attachments", true));
2035
2325
  const records = store.collection(supportRecords);
2036
2326
  const index = store.collection(customerTicketIndex);
2037
2327
  const numbers = store.collection(ticketNumbers);
@@ -2322,6 +2612,466 @@ export function createSupportDeskApplication(options) {
2322
2612
  async function reserveTicketNumber() {
2323
2613
  return reserveNextTicketNumber(numbers, maxConflictAttempts);
2324
2614
  }
2615
+ function validateNewAttachmentPolicy(inputs, descriptors) {
2616
+ if (!inputs.length)
2617
+ return;
2618
+ if (!attachmentConfig.blobs)
2619
+ throw new TypeError("Attachments require a host blob port");
2620
+ if (inputs.length > attachmentConfig.perMessage ||
2621
+ inputs.reduce((n, item) => n + item.bytes.byteLength, 0) >
2622
+ attachmentConfig.messageBytes)
2623
+ throw new TypeError("Attachment message limit exceeded");
2624
+ if (descriptors.some((item) => item.size > attachmentConfig.bytes ||
2625
+ !attachmentConfig.allowed.has(item.contentType)))
2626
+ throw new TypeError("Attachment size or MIME type refused");
2627
+ if (inputs.length > attachmentConfig.perTicket)
2628
+ throw new TypeError("Ticket attachment limit exceeded");
2629
+ }
2630
+ async function transactWithAttachments(ticketId, actions, inputs, descriptors, beforeCommit) {
2631
+ if (!inputs.length) {
2632
+ const assertCurrent = await beforeCommit?.();
2633
+ if (assertCurrent !== undefined)
2634
+ assertCurrent();
2635
+ return records.transact(ticketId, actions);
2636
+ }
2637
+ validateNewAttachmentPolicy(inputs, descriptors);
2638
+ const rows = await records.list(ticketId);
2639
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket, inputs.length * 6 +
2640
+ actions.filter((action) => action.action === "insert").length);
2641
+ const count = rows.reduce((n, row) => n +
2642
+ (row.kind === "message" ? (row.message.attachments?.length ?? 0) : 0), 0);
2643
+ if (count + inputs.length > attachmentConfig.perTicket)
2644
+ throw new TypeError("Ticket attachment limit exceeded");
2645
+ const attachments = descriptors.map((item) => newAttachment({
2646
+ ...item,
2647
+ filename: normalizeAttachmentFilename(item.filename, attachmentConfig.filename),
2648
+ }, clock.now(), attachmentConfig.retention));
2649
+ const blobs = attachmentConfig.blobs;
2650
+ const attemptedKeys = [];
2651
+ const cleanup = async () => {
2652
+ await Promise.allSettled(attemptedKeys.map((key) => blobs.delete(key)));
2653
+ };
2654
+ try {
2655
+ const message = actions.find((action) => action.action === "insert" && action.value.kind === "message");
2656
+ if (!message ||
2657
+ message.action !== "insert" ||
2658
+ message.value.kind !== "message")
2659
+ throw new TypeError("Attachment transaction requires a message");
2660
+ for (let index = 0; index < inputs.length; index++) {
2661
+ const key = attachments[index].blobKey;
2662
+ attemptedKeys.push(key);
2663
+ const result = await blobs.put(key, inputs[index].bytes, {
2664
+ ticketId,
2665
+ messageId: message.value.message.id,
2666
+ });
2667
+ if (result === "exists") {
2668
+ attemptedKeys.pop();
2669
+ throw new SupportDeskConflictError("Attachment blob key collision; retry command");
2670
+ }
2671
+ }
2672
+ const assertCurrent = await beforeCommit?.();
2673
+ if (assertCurrent !== undefined)
2674
+ assertCurrent();
2675
+ }
2676
+ catch (error) {
2677
+ await cleanup();
2678
+ throw error;
2679
+ }
2680
+ const prepared = actions.map((action) => {
2681
+ if (action.action === "insert" && action.value.kind === "message")
2682
+ return {
2683
+ ...action,
2684
+ value: {
2685
+ ...action.value,
2686
+ message: { ...action.value.message, attachments },
2687
+ },
2688
+ };
2689
+ return action;
2690
+ });
2691
+ // A thrown transaction has an unknown outcome. Preserve its blobs for
2692
+ // reconciliation; deleting them could damage a successfully committed row.
2693
+ const result = await records.transact(ticketId, prepared);
2694
+ if (!result.committed)
2695
+ await cleanup();
2696
+ return result;
2697
+ }
2698
+ async function changeAttachmentState(access, input, status, digest) {
2699
+ requireStaffMutationAccess(access, supportPermissions.manage);
2700
+ for (const [field, value] of Object.entries(input))
2701
+ requireIdentifier(value, field);
2702
+ const fingerprint = await requestFingerprint({
2703
+ ...input,
2704
+ status,
2705
+ digest: digest ?? null,
2706
+ });
2707
+ for (let attempt = 0; attempt < maxConflictAttempts; attempt++) {
2708
+ const duplicate = await records.get(commandKey(input.ticketId, input.commandId));
2709
+ if (duplicateMatches(duplicate, "attachment_state", undefined, fingerprint))
2710
+ return;
2711
+ if (duplicate)
2712
+ throw new SupportDeskConflictError("Command ID reused with different input");
2713
+ const ticket = await records.getVersioned(ticketKey(input.ticketId));
2714
+ const message = await records.getVersioned({
2715
+ partition: input.ticketId,
2716
+ id: `message:${input.messageId}`,
2717
+ });
2718
+ if (ticket?.value.kind !== "ticket" || message?.value.kind !== "message")
2719
+ throw new SupportDeskNotFoundError();
2720
+ const attachment = message.value.message.attachments?.find((item) => item.id === input.attachmentId);
2721
+ if (!attachment)
2722
+ throw new SupportDeskNotFoundError();
2723
+ if (digest !== undefined && digest !== attachment.sha256)
2724
+ throw new SupportDeskConflictError("Scan digest mismatch");
2725
+ if ((status === "clean" || status === "quarantined") &&
2726
+ attachment.scanStatus !== "pending_scan")
2727
+ throw new SupportDeskConflictError("Scan result is already final");
2728
+ if (status === "deleting" && attachment.scanStatus === "deleted")
2729
+ return;
2730
+ if (status === "deleting" && attachment.scanStatus === "deleting")
2731
+ return;
2732
+ if (status === "deleted" && attachment.scanStatus === "deleted")
2733
+ return;
2734
+ if (status === "deleted" && attachment.scanStatus !== "deleting")
2735
+ throw new SupportDeskConflictError("Deletion must be fenced first");
2736
+ const rows = await records.list(input.ticketId);
2737
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket, 2);
2738
+ const now = clampTicketTime(clock.now(), ticket.value.ticket.updatedAt);
2739
+ const updated = {
2740
+ ...ticket.value.ticket,
2741
+ revision: ticket.value.ticket.revision + 1,
2742
+ updatedAt: now,
2743
+ };
2744
+ const result = await records.transact(input.ticketId, [
2745
+ {
2746
+ action: "putIfUnchanged",
2747
+ version: ticket.version,
2748
+ value: { ...ticket.value, ticket: updated },
2749
+ },
2750
+ {
2751
+ action: "putIfUnchanged",
2752
+ version: message.version,
2753
+ value: {
2754
+ ...message.value,
2755
+ message: {
2756
+ ...message.value.message,
2757
+ attachments: message.value.message.attachments.map((item) => item.id === attachment.id
2758
+ ? { ...item, scanStatus: status }
2759
+ : item),
2760
+ },
2761
+ },
2762
+ },
2763
+ ticketAuditAction({
2764
+ ...input,
2765
+ actorId: access.principalId,
2766
+ revision: updated.revision,
2767
+ occurredAt: now,
2768
+ action: status === "deleting"
2769
+ ? supportTicketAuditActions.attachmentDeleting
2770
+ : status === "deleted"
2771
+ ? supportTicketAuditActions.attachmentDeleted
2772
+ : supportTicketAuditActions.attachmentScan,
2773
+ details: { attachmentId: attachment.id, status },
2774
+ }),
2775
+ {
2776
+ action: "insert",
2777
+ value: {
2778
+ kind: "command",
2779
+ partition: input.ticketId,
2780
+ id: `command:${input.commandId}`,
2781
+ commandId: input.commandId,
2782
+ commandType: "attachment_state",
2783
+ requestFingerprint: fingerprint,
2784
+ completedAt: now,
2785
+ },
2786
+ },
2787
+ ]);
2788
+ if (result.committed)
2789
+ return;
2790
+ }
2791
+ throw new SupportDeskConflictError();
2792
+ }
2793
+ async function deleteAttachmentWithReceipt(access, input) {
2794
+ requireStaffMutationAccess(access, supportPermissions.manage);
2795
+ const actorId = access.principalId;
2796
+ const authorize = () => {
2797
+ requireStaffMutationAccess(access, supportPermissions.manage);
2798
+ if (access.principalId !== actorId)
2799
+ throw new SupportDeskAuthorizationError(supportPermissions.manage);
2800
+ };
2801
+ const key = commandKey(input.ticketId, input.commandId);
2802
+ const fingerprint = await requestFingerprint({
2803
+ ...input,
2804
+ status: "deleted",
2805
+ digest: null,
2806
+ });
2807
+ const fenceId = `attachment-fence-${await requestFingerprint({ commandId: input.commandId })}`;
2808
+ const fenceEventId = "\u0000support.attachment.fence:" + input.commandId;
2809
+ const fenceFingerprint = await requestFingerprint({
2810
+ ...input,
2811
+ commandId: fenceId,
2812
+ status: "deleting",
2813
+ digest: null,
2814
+ });
2815
+ const matches = (row) => row.kind === "command" &&
2816
+ row.commandType === "attachment_state" &&
2817
+ row.messageId === undefined &&
2818
+ row.requestFingerprint === fingerprint;
2819
+ const requireIdentity = (row) => {
2820
+ const owner = row.attachmentDeletion;
2821
+ if (owner !== undefined &&
2822
+ (owner.actorId !== actorId ||
2823
+ owner.messageId !== input.messageId ||
2824
+ owner.attachmentId !== input.attachmentId ||
2825
+ owner.correlationId !== input.correlationId))
2826
+ throw new SupportDeskConflictError("Attachment deletion belongs to another identity");
2827
+ };
2828
+ let reserved = false;
2829
+ for (let attempt = 0; attempt < maxConflictAttempts; attempt++) {
2830
+ const receipt = await records.getVersioned(key);
2831
+ authorize();
2832
+ if (receipt !== null) {
2833
+ if (!matches(receipt.value))
2834
+ throw new SupportDeskConflictError("Command ID reused with different input");
2835
+ requireIdentity(receipt.value);
2836
+ if (receipt.value.attachmentDeletion?.phase !== "pending")
2837
+ return;
2838
+ reserved = true;
2839
+ break;
2840
+ }
2841
+ const [ticket, message] = await Promise.all([
2842
+ records.getVersioned(ticketKey(input.ticketId)),
2843
+ records.getVersioned({
2844
+ partition: input.ticketId,
2845
+ id: `message:${input.messageId}`,
2846
+ }),
2847
+ ]);
2848
+ authorize();
2849
+ if (ticket?.value.kind !== "ticket" || message?.value.kind !== "message")
2850
+ throw new SupportDeskNotFoundError();
2851
+ const attachment = message.value.message.attachments?.find((item) => item.id === input.attachmentId);
2852
+ if (attachment === undefined)
2853
+ throw new SupportDeskNotFoundError();
2854
+ if (attachment.scanStatus === "deleted")
2855
+ return;
2856
+ const rows = await records.list(input.ticketId);
2857
+ authorize();
2858
+ const pendingOwner = rows.find((row) => row.kind === "command" &&
2859
+ row.attachmentDeletion?.phase === "pending" &&
2860
+ row.attachmentDeletion.messageId === input.messageId &&
2861
+ row.attachmentDeletion.attachmentId === input.attachmentId);
2862
+ if (pendingOwner !== undefined) {
2863
+ if (pendingOwner.id === key.id) {
2864
+ if (!matches(pendingOwner))
2865
+ throw new SupportDeskConflictError("Command ID reused with different input");
2866
+ requireIdentity(pendingOwner);
2867
+ continue;
2868
+ }
2869
+ throw new SupportDeskConflictError("Attachment deletion belongs to another identity");
2870
+ }
2871
+ const changing = attachment.scanStatus !== "deleting";
2872
+ if (!changing) {
2873
+ // Legacy deleting metadata has no pending marker. Recover only its
2874
+ // original tuple, backed by the old atomic fence receipt and Audit.
2875
+ const oldReceipt = rows.find((row) => row.id === `command:${fenceId}`);
2876
+ const oldAudit = rows.find((row) => row.id === auditRecordId(fenceId));
2877
+ if (oldReceipt === undefined ||
2878
+ !duplicateMatches(oldReceipt, "attachment_state", undefined, fenceFingerprint) ||
2879
+ oldAudit?.kind !== "audit" ||
2880
+ oldAudit.event.actor.kind !== "principal" ||
2881
+ oldAudit.event.actor.principalId !== actorId ||
2882
+ oldAudit.event.action !== supportTicketAuditActions.attachmentDeleting)
2883
+ throw new SupportDeskConflictError("Original attachment deletion identity is required");
2884
+ }
2885
+ if (!attachmentConfig.blobs)
2886
+ throw new TypeError("Host blob port required");
2887
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket, changing ? 2 : 1);
2888
+ const now = clampTicketTime(clock.now(), ticket.value.ticket.updatedAt);
2889
+ const pending = {
2890
+ kind: "command",
2891
+ partition: input.ticketId,
2892
+ id: key.id,
2893
+ commandId: input.commandId,
2894
+ commandType: "attachment_state",
2895
+ requestFingerprint: fingerprint,
2896
+ completedAt: now,
2897
+ attachmentDeletion: {
2898
+ phase: "pending",
2899
+ actorId,
2900
+ messageId: input.messageId,
2901
+ attachmentId: input.attachmentId,
2902
+ correlationId: input.correlationId,
2903
+ },
2904
+ };
2905
+ authorize();
2906
+ const result = await records.transact(input.ticketId, [
2907
+ {
2908
+ action: "putIfUnchanged",
2909
+ version: ticket.version,
2910
+ value: {
2911
+ ...ticket.value,
2912
+ ticket: changing
2913
+ ? {
2914
+ ...ticket.value.ticket,
2915
+ revision: ticket.value.ticket.revision + 1,
2916
+ updatedAt: now,
2917
+ }
2918
+ : ticket.value.ticket,
2919
+ },
2920
+ },
2921
+ {
2922
+ action: "putIfUnchanged",
2923
+ version: message.version,
2924
+ value: {
2925
+ ...message.value,
2926
+ message: {
2927
+ ...message.value.message,
2928
+ attachments: message.value.message.attachments.map((item) => item.id === input.attachmentId
2929
+ ? { ...item, scanStatus: "deleting" }
2930
+ : item),
2931
+ },
2932
+ },
2933
+ },
2934
+ { action: "insert", value: pending },
2935
+ ...(changing
2936
+ ? [
2937
+ ticketAuditAction({
2938
+ ...input,
2939
+ eventId: fenceEventId,
2940
+ actorId,
2941
+ revision: ticket.value.ticket.revision + 1,
2942
+ occurredAt: now,
2943
+ action: supportTicketAuditActions.attachmentDeleting,
2944
+ details: {
2945
+ attachmentId: input.attachmentId,
2946
+ status: "deleting",
2947
+ },
2948
+ }),
2949
+ ]
2950
+ : []),
2951
+ ]);
2952
+ if (result.committed) {
2953
+ reserved = true;
2954
+ break;
2955
+ }
2956
+ }
2957
+ if (!reserved)
2958
+ throw new SupportDeskConflictError();
2959
+ // The original identity is now durable. Unknown blob outcomes leave it
2960
+ // pending, so only the same actor and exact command can safely retry.
2961
+ const [currentTicket, message] = await Promise.all([
2962
+ records.get(ticketKey(input.ticketId)),
2963
+ records.get({
2964
+ partition: input.ticketId,
2965
+ id: `message:${input.messageId}`,
2966
+ }),
2967
+ ]);
2968
+ authorize();
2969
+ if (currentTicket?.kind !== "ticket")
2970
+ throw new SupportDeskNotFoundError();
2971
+ const attachment = message?.kind === "message"
2972
+ ? message.message.attachments?.find((item) => item.id === input.attachmentId)
2973
+ : undefined;
2974
+ if (attachment === undefined)
2975
+ throw new SupportDeskNotFoundError();
2976
+ if (attachment.scanStatus !== "deleting" &&
2977
+ attachment.scanStatus !== "deleted")
2978
+ throw new SupportDeskConflictError("Attachment deletion fence changed");
2979
+ if (attachment.scanStatus !== "deleted") {
2980
+ if (!attachmentConfig.blobs)
2981
+ throw new TypeError("Host blob port required");
2982
+ await attachmentConfig.blobs.delete(attachment.blobKey);
2983
+ authorize();
2984
+ }
2985
+ for (let attempt = 0; attempt < maxConflictAttempts; attempt++) {
2986
+ const [receipt, ticket, message] = await Promise.all([
2987
+ records.getVersioned(key),
2988
+ records.getVersioned(ticketKey(input.ticketId)),
2989
+ records.getVersioned({
2990
+ partition: input.ticketId,
2991
+ id: `message:${input.messageId}`,
2992
+ }),
2993
+ ]);
2994
+ authorize();
2995
+ if (receipt === null || !matches(receipt.value))
2996
+ throw new SupportDeskConflictError("Attachment deletion receipt changed");
2997
+ requireIdentity(receipt.value);
2998
+ if (receipt.value.attachmentDeletion?.phase !== "pending")
2999
+ return;
3000
+ if (ticket?.value.kind !== "ticket" || message?.value.kind !== "message")
3001
+ throw new SupportDeskNotFoundError();
3002
+ const attachment = message.value.message.attachments?.find((item) => item.id === input.attachmentId);
3003
+ if (attachment === undefined)
3004
+ throw new SupportDeskNotFoundError();
3005
+ if (attachment.scanStatus !== "deleting" &&
3006
+ attachment.scanStatus !== "deleted")
3007
+ throw new SupportDeskConflictError("Attachment deletion fence changed");
3008
+ const changing = attachment.scanStatus !== "deleted";
3009
+ const rows = await records.list(input.ticketId);
3010
+ authorize();
3011
+ enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket, changing ? 1 : 0);
3012
+ const now = clampTicketTime(clock.now(), ticket.value.ticket.updatedAt);
3013
+ authorize();
3014
+ const result = await records.transact(input.ticketId, [
3015
+ {
3016
+ action: "putIfUnchanged",
3017
+ version: ticket.version,
3018
+ value: {
3019
+ ...ticket.value,
3020
+ ticket: changing
3021
+ ? {
3022
+ ...ticket.value.ticket,
3023
+ revision: ticket.value.ticket.revision + 1,
3024
+ updatedAt: now,
3025
+ }
3026
+ : ticket.value.ticket,
3027
+ },
3028
+ },
3029
+ {
3030
+ action: "putIfUnchanged",
3031
+ version: message.version,
3032
+ value: {
3033
+ ...message.value,
3034
+ message: {
3035
+ ...message.value.message,
3036
+ attachments: message.value.message.attachments.map((item) => item.id === input.attachmentId
3037
+ ? { ...item, scanStatus: "deleted" }
3038
+ : item),
3039
+ },
3040
+ },
3041
+ },
3042
+ {
3043
+ action: "putIfUnchanged",
3044
+ version: receipt.version,
3045
+ value: {
3046
+ ...receipt.value,
3047
+ completedAt: now,
3048
+ attachmentDeletion: {
3049
+ ...receipt.value.attachmentDeletion,
3050
+ phase: "complete",
3051
+ },
3052
+ },
3053
+ },
3054
+ ...(changing
3055
+ ? [
3056
+ ticketAuditAction({
3057
+ ...input,
3058
+ actorId,
3059
+ revision: ticket.value.ticket.revision + 1,
3060
+ occurredAt: now,
3061
+ action: supportTicketAuditActions.attachmentDeleted,
3062
+ details: {
3063
+ attachmentId: input.attachmentId,
3064
+ status: "deleted",
3065
+ },
3066
+ }),
3067
+ ]
3068
+ : []),
3069
+ ]);
3070
+ if (result.committed)
3071
+ return;
3072
+ }
3073
+ throw new SupportDeskConflictError();
3074
+ }
2325
3075
  const resolutionLearning = createResolutionLearningServices({
2326
3076
  records,
2327
3077
  clock,
@@ -2330,14 +3080,15 @@ export function createSupportDeskApplication(options) {
2330
3080
  maxConflictAttempts,
2331
3081
  admission: (ticket, rows, additional) => enforceManagedAdmission(ticket, rows, limits, additional),
2332
3082
  withdrawalAdmission: (ticket, rows, additional) => enforceHandlingControlAdmission(ticket, rows, limits, additional, "learning"),
2333
- audit: (command, ticket, actorId, now, action) => ticketAuditAction({
3083
+ audit: (command, ticket, actorId, now, action, eventId) => ticketAuditAction({
3084
+ ...(eventId === undefined ? {} : { eventId }),
2334
3085
  commandId: command.commandId,
2335
3086
  correlationId: command.correlationId,
2336
3087
  ticketId: ticket.id,
2337
3088
  revision: ticket.revision,
2338
3089
  actorId,
2339
3090
  occurredAt: now,
2340
- action: action,
3091
+ action,
2341
3092
  }),
2342
3093
  });
2343
3094
  const engineeringDependencies = {
@@ -2356,7 +3107,7 @@ export function createSupportDeskApplication(options) {
2356
3107
  if (current !== undefined)
2357
3108
  enforceHandlingControlAdmission(current.ticket, rows, limits, additional, consumption);
2358
3109
  else
2359
- enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket - derivationReserveRows(rows, consumption), additional);
3110
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket - derivationReserveRows(rows, consumption), additional);
2360
3111
  },
2361
3112
  audit: (command, ticket, actorId, now, action) => ticketAuditAction({
2362
3113
  commandId: command.commandId,
@@ -2408,9 +3159,12 @@ export function createSupportDeskApplication(options) {
2408
3159
  };
2409
3160
  const engineeringFollowupSource = createEngineeringFollowupSourceServices(followupDependencies);
2410
3161
  const engineeringFollowupWork = createEngineeringFollowupWorkServices(followupDependencies, engineeringFollowupSource.internal.load);
2411
- const approveAndPostEngineeringFollowupWebUpdate = async (access, input, guard) => {
3162
+ const approveAndPostEngineeringFollowupWebUpdate = async (access, input, guard, options = {}) => {
2412
3163
  const actorId = access.principalId;
3164
+ const { signal, verifyBeforeCommit } = options;
2413
3165
  const authorize = () => {
3166
+ if (signal?.aborted)
3167
+ throw new SupportDeskConflictError("Follow-up posting cancelled");
2414
3168
  requireIdentifier(access.principalId, "principalId");
2415
3169
  if (access.principalId !== actorId)
2416
3170
  throw new SupportDeskAuthorizationError(supportPermissions.engineeringFollowupReview);
@@ -2495,7 +3249,7 @@ export function createSupportDeskApplication(options) {
2495
3249
  requireNonempty(body, "body");
2496
3250
  enforceLimit(body, "body", limits.maxMessageCharacters);
2497
3251
  const bodyHash = await requestFingerprint({ body });
2498
- await guard();
3252
+ const assertProofCurrent = await guard();
2499
3253
  authorize();
2500
3254
  const occurredAt = clampTicketTime(clock.now(), ticket.updatedAt);
2501
3255
  const updated = applyTicketEvent(ticket, {
@@ -2566,8 +3320,22 @@ export function createSupportDeskApplication(options) {
2566
3320
  if (!(await engineeringFollowupVersionsCurrent(followupDependencies, current.loaded, current.work, current.engineeringWork)))
2567
3321
  continue;
2568
3322
  authorize();
2569
- if (now() >=
2570
- Math.min(Date.parse(work.deadline), checked.deadline, Date.parse(current.proof.attestation.validUntil)))
3323
+ const proofDeadline = Math.min(Date.parse(work.deadline), checked.deadline, Date.parse(current.proof.attestation.validUntil));
3324
+ if (now() >= proofDeadline)
3325
+ throw new SupportDeskConflictError("Follow-up posting proof expired");
3326
+ if (verifyBeforeCommit !== undefined) {
3327
+ const authorityDeadline = Math.min(proofDeadline, now() + engineeringFollowupOptions.trustTimeoutMilliseconds);
3328
+ await engineeringFollowupTrusted(followupDependencies, async () => {
3329
+ authorize();
3330
+ await verifyBeforeCommit();
3331
+ }, signal);
3332
+ authorize();
3333
+ if (now() >= authorityDeadline)
3334
+ throw new SupportDeskConflictError("Follow-up posting authority or proof expired");
3335
+ }
3336
+ if (assertProofCurrent !== undefined &&
3337
+ (typeof assertProofCurrent !== "function" ||
3338
+ assertProofCurrent() !== true))
2571
3339
  throw new SupportDeskConflictError("Follow-up posting proof expired");
2572
3340
  const outcome = await records.transact(command.ticketId, [
2573
3341
  ...derivations.actions,
@@ -2663,7 +3431,7 @@ export function createSupportDeskApplication(options) {
2663
3431
  : { assistantTurnFingerprint: assistantTurnCommit.fingerprint }),
2664
3432
  });
2665
3433
  const completion = assistantTurnCommit;
2666
- const parallel = completion?.kind === "complete";
3434
+ const parallel = completion?.kind === "complete" || completion?.kind === "finish";
2667
3435
  for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
2668
3436
  const ticketRead = records.getVersioned(ticketKey(command.ticketId));
2669
3437
  const duplicateRead = parallel
@@ -2692,8 +3460,8 @@ export function createSupportDeskApplication(options) {
2692
3460
  ticket.revision !== command.expectedRevision ||
2693
3461
  effectiveTicketHandling(ticket).generation !==
2694
3462
  command.expectedHandlingGeneration)) {
2695
- // The speculative receipt may predate a concurrent exact completion
2696
- // that this Ticket observation already contains. Recover its receipt
3463
+ // The speculative receipt may predate a concurrent completion or
3464
+ // finish already reflected in this Ticket. Recover its exact receipt
2697
3465
  // before rejecting the now-stale live fence; ordinary paired reads
2698
3466
  // retain their existing cost and original whole-attempt CAS versions.
2699
3467
  const accepted = await records.get(commandKey(command.ticketId, command.commandId));
@@ -2709,7 +3477,7 @@ export function createSupportDeskApplication(options) {
2709
3477
  if (operation === "assistant_reply" && !assistantHandlingEnabled)
2710
3478
  throw new SupportDeskConflictError("Assistant handling is disabled");
2711
3479
  const rows = await records.list(command.ticketId);
2712
- enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
3480
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket);
2713
3481
  const latest = latestVisibleRecord(rows);
2714
3482
  if (operation === "assistant_reply" || operation === "assistant_escalate")
2715
3483
  requireAssistantTurn(ticket, rows, command);
@@ -2780,7 +3548,7 @@ export function createSupportDeskApplication(options) {
2780
3548
  ? await planMutation()
2781
3549
  : readValue(await mutationRead);
2782
3550
  const learningConfirmation = operation === "confirm_customer_success"
2783
- ? await resolutionLearning.customerConfirmation(learningMutation.source, updated, access.principalId, command.lastSeenVisibleMessageId, command.commandId, rows)
3551
+ ? await resolutionLearning.customerConfirmation(learningMutation.source, updated, access.principalId, command.lastSeenVisibleMessageId, command.commandId, rows, partitionSizeWithAttachmentReserve(rows))
2784
3552
  : { actions: [], additionalRows: 0 };
2785
3553
  const newRows = (operation === "take_over" ? 2 : 3) +
2786
3554
  learningMutation.additionalRows +
@@ -2942,7 +3710,7 @@ export function createSupportDeskApplication(options) {
2942
3710
  conversation(ticket, rows) {
2943
3711
  return {
2944
3712
  ticket: toCustomerTicketSummary(ticket),
2945
- messages: customerMessages(rows),
3713
+ messages: assistantConversationMessages(rows),
2946
3714
  revision: ticket.revision,
2947
3715
  handling: { ...effectiveTicketHandling(ticket) },
2948
3716
  ...(latestVisibleRecord(rows) === undefined
@@ -2953,6 +3721,82 @@ export function createSupportDeskApplication(options) {
2953
3721
  mutateHandling,
2954
3722
  });
2955
3723
  return {
3724
+ async downloadAttachment(access, input) {
3725
+ requireIdentifier(access.principalId, "principalId");
3726
+ const actorId = access.principalId;
3727
+ const staff = hasPermission(access, supportPermissions.queueRead);
3728
+ if (!staff)
3729
+ requirePermission(access, supportPermissions.readOwn);
3730
+ const source = boundaryObject(input, "attachment download");
3731
+ input = Object.freeze({
3732
+ ticketId: boundaryString(ownDataProperty(source, "ticketId", "ticketId"), "ticketId"),
3733
+ messageId: boundaryString(ownDataProperty(source, "messageId", "messageId"), "messageId"),
3734
+ attachmentId: boundaryString(ownDataProperty(source, "attachmentId", "attachmentId"), "attachmentId"),
3735
+ });
3736
+ for (const [field, value] of Object.entries(input))
3737
+ requireIdentifier(value, field);
3738
+ const ticket = await records.getVersioned(ticketKey(input.ticketId));
3739
+ if (ticket?.value.kind !== "ticket" ||
3740
+ (!staff &&
3741
+ (ticket.value.ticket.requester.association !== "authenticated" ||
3742
+ ticket.value.ticket.requester.principalId !== actorId)))
3743
+ throw new SupportDeskNotFoundError();
3744
+ const key = {
3745
+ partition: input.ticketId,
3746
+ id: `message:${input.messageId}`,
3747
+ };
3748
+ const row = await records.getVersioned(key);
3749
+ if (row?.value.kind !== "message" ||
3750
+ (!staff && row.value.message.visibility !== "customer"))
3751
+ throw new SupportDeskNotFoundError();
3752
+ const attachment = row.value.message.attachments?.find((item) => item.id === input.attachmentId);
3753
+ if (!attachment ||
3754
+ attachment.scanStatus !== "clean" ||
3755
+ canonicalTimestamp(attachment.expiresAt, "attachment expiresAt") <=
3756
+ canonicalTimestamp(clock.now(), "attachment download now"))
3757
+ throw new SupportDeskNotFoundError();
3758
+ if (!attachmentConfig.blobs)
3759
+ throw new TypeError("Host blob port required");
3760
+ const bytes = await attachmentConfig.blobs.get(attachment.blobKey, attachment.size);
3761
+ if (bytes.byteLength !== attachment.size ||
3762
+ (await attachmentDigest(bytes)) !== attachment.sha256)
3763
+ throw new SupportDeskConflictError("Stored attachment digest mismatch");
3764
+ const [currentTicket, current] = await Promise.all([
3765
+ records.getVersioned(ticketKey(input.ticketId)),
3766
+ records.getVersioned(key),
3767
+ ]);
3768
+ // Blob I/O and hashing may outlive the original resource or access snapshot.
3769
+ // These final observations withhold bytes; they do not lock either record.
3770
+ requirePermission(access, staff ? supportPermissions.queueRead : supportPermissions.readOwn);
3771
+ if (access.principalId !== actorId)
3772
+ throw new SupportDeskNotFoundError();
3773
+ if (currentTicket?.version !== ticket.version ||
3774
+ current?.version !== row.version ||
3775
+ canonicalTimestamp(attachment.expiresAt, "attachment expiresAt") <=
3776
+ canonicalTimestamp(clock.now(), "attachment download now"))
3777
+ throw new SupportDeskConflictError("Attachment changed during download");
3778
+ return {
3779
+ bytes,
3780
+ contentType: attachment.contentType,
3781
+ contentDisposition: `attachment; filename="attachment"; filename*=UTF-8''${encodeURIComponent(attachment.filename).replace(/['()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`)}`,
3782
+ cacheControl: "private, no-store",
3783
+ xContentTypeOptions: "nosniff",
3784
+ };
3785
+ },
3786
+ async recordAttachmentScanResult(access, input) {
3787
+ requireStaffMutationAccess(access, supportPermissions.manage);
3788
+ const source = boundaryObject(input, "attachment scan");
3789
+ const verdict = ownDataProperty(source, "verdict", "verdict");
3790
+ const sha256 = boundaryString(ownDataProperty(source, "sha256", "sha256"), "sha256");
3791
+ if (verdict !== "clean" && verdict !== "quarantined")
3792
+ throw new TypeError("Invalid scan verdict");
3793
+ const command = snapshotAttachmentStateCommand(input);
3794
+ return changeAttachmentState(access, command, verdict, sha256);
3795
+ },
3796
+ async deleteAttachment(access, input) {
3797
+ requireStaffMutationAccess(access, supportPermissions.manage);
3798
+ return deleteAttachmentWithReceipt(access, snapshotAttachmentStateCommand(input));
3799
+ },
2956
3800
  ...resolutionLearning.application,
2957
3801
  ...engineeringEvidence.application,
2958
3802
  ...engineeringWorkers,
@@ -3006,7 +3850,7 @@ export function createSupportDeskApplication(options) {
3006
3850
  const latest = latestVisibleRecord(rows);
3007
3851
  return {
3008
3852
  ticket: toCustomerTicketSummary(ticket),
3009
- messages: customerMessages(rows),
3853
+ messages: assistantConversationMessages(rows),
3010
3854
  revision: ticket.revision,
3011
3855
  handling: { ...effectiveTicketHandling(ticket) },
3012
3856
  ...(latest?.message.authorKind === "customer"
@@ -3106,25 +3950,72 @@ export function createSupportDeskApplication(options) {
3106
3950
  .sort((a, b) => a.revision - b.revision || a.commandId.localeCompare(b.commandId))
3107
3951
  .map(({ kind: _kind, partition: _partition, id: _id, ...proof }) => Object.freeze(proof));
3108
3952
  },
3109
- async createCustomerTicket(access, input) {
3953
+ ...customerCreateMethods(async (access, input, admitted) => {
3110
3954
  requirePermission(access, supportPermissions.create);
3111
3955
  requireIdentifier(access.principalId, "principalId");
3956
+ const actorId = access.principalId;
3957
+ const principal = () => admitted === undefined ? access.principalId : actorId;
3958
+ const signal = admitted?.options?.signal;
3959
+ function authorizeAdmitted(current = access, assistant = false) {
3960
+ if (admitted === undefined)
3961
+ return;
3962
+ if (signal?.aborted)
3963
+ throw new SupportDeskConflictError("Admitted create was cancelled");
3964
+ if (current.principalId !== actorId)
3965
+ throw new SupportDeskAuthorizationError(supportPermissions.create);
3966
+ requirePermission(current, supportPermissions.create);
3967
+ requirePermission(current, supportPermissions.readOwn);
3968
+ if (assistant)
3969
+ requirePermission(current, supportPermissions.assistantStart);
3970
+ }
3971
+ async function waitForAdmission(check) {
3972
+ authorizeAdmitted();
3973
+ if (signal === undefined)
3974
+ return check();
3975
+ let onAbort;
3976
+ try {
3977
+ return await Promise.race([
3978
+ Promise.resolve().then(() => {
3979
+ authorizeAdmitted();
3980
+ return check();
3981
+ }),
3982
+ new Promise((_resolve, reject) => {
3983
+ onAbort = () => reject(new SupportDeskConflictError("Admitted create was cancelled"));
3984
+ signal.addEventListener("abort", onAbort, { once: true });
3985
+ if (signal.aborted)
3986
+ onAbort();
3987
+ }),
3988
+ ]);
3989
+ }
3990
+ finally {
3991
+ if (onAbort !== undefined)
3992
+ signal.removeEventListener("abort", onAbort);
3993
+ }
3994
+ }
3995
+ authorizeAdmitted();
3112
3996
  const command = snapshotCreateCustomerTicketCommand(input);
3113
- if (command.initialHandling === "assistant") {
3997
+ let initialHandling = command.initialHandling;
3998
+ if (admitted === undefined && initialHandling === "assistant") {
3114
3999
  requirePermission(access, supportPermissions.assistantStart);
3115
4000
  }
3116
4001
  requireIdentifier(command.ticketId, "ticketId");
3117
4002
  requireIdentifier(command.messageId, "messageId");
3118
4003
  requireIdentifier(command.commandId, "commandId");
3119
4004
  requireIdentifier(command.correlationId, "correlationId");
3120
- enforceLimit(command.subject, "subject", limits.maxSubjectCharacters);
3121
- enforceLimit(command.body, "body", limits.maxMessageCharacters);
4005
+ // Admitted replay keeps fixed ceilings when later instance limits shrink.
4006
+ // Fresh admission applies the configured limits after receipt recovery.
4007
+ const inputLimits = admitted === undefined ? limits : supportDeskLimitMaximums;
4008
+ enforceLimit(command.subject, "subject", inputLimits.maxSubjectCharacters);
4009
+ enforceLimit(command.body, "body", inputLimits.maxMessageCharacters);
4010
+ const attachmentInputs = command.attachments ?? [];
4011
+ const descriptors = await attachmentDescriptors(attachmentInputs);
3122
4012
  const fingerprintPayload = {
3123
4013
  type: "create_customer_ticket",
3124
4014
  ticketId: command.ticketId,
3125
4015
  messageId: command.messageId,
3126
4016
  subject: command.subject,
3127
4017
  body: command.body,
4018
+ ...(descriptors.length === 0 ? {} : { attachments: descriptors }),
3128
4019
  // Omit absent category so pre-category receipts still match retries.
3129
4020
  ...(command.category === undefined
3130
4021
  ? {}
@@ -3135,7 +4026,20 @@ export function createSupportDeskApplication(options) {
3135
4026
  ? {}
3136
4027
  : { initialHandling: command.initialHandling }),
3137
4028
  };
3138
- const fingerprint = await requestFingerprint(fingerprintPayload);
4029
+ const commandType = admitted === undefined
4030
+ ? "create_customer_ticket"
4031
+ : "create_admitted_customer_ticket";
4032
+ const fingerprint = await requestFingerprint(admitted === undefined
4033
+ ? fingerprintPayload
4034
+ : {
4035
+ ...fingerprintPayload,
4036
+ type: commandType,
4037
+ version: 1,
4038
+ actorId,
4039
+ commandId: command.commandId,
4040
+ preference: admitted.intent.preference,
4041
+ disclosureVersion: admitted.intent.disclosureVersion ?? null,
4042
+ });
3139
4043
  const createThreading = {
3140
4044
  ticketNumber: true,
3141
4045
  ...(command.notification === undefined
@@ -3146,8 +4050,11 @@ export function createSupportDeskApplication(options) {
3146
4050
  // later config change cannot break idempotent retries of the same command.
3147
4051
  // Replay never reserves another instance ticket number.
3148
4052
  const existingCreate = await records.get(commandKey(command.ticketId, command.commandId));
3149
- let createIsReplay = duplicateMatches(existingCreate, "create_customer_ticket", command.messageId, fingerprint);
3150
- if (!createIsReplay &&
4053
+ authorizeAdmitted();
4054
+ let createIsReplay = duplicateMatches(existingCreate, commandType, command.messageId, fingerprint);
4055
+ if (admitted === undefined &&
4056
+ !createIsReplay &&
4057
+ descriptors.length === 0 &&
3151
4058
  command.initialHandling === undefined &&
3152
4059
  existingCreate?.kind === "command" &&
3153
4060
  existingCreate.commandType === "create_customer_ticket" &&
@@ -3174,19 +4081,30 @@ export function createSupportDeskApplication(options) {
3174
4081
  }
3175
4082
  if (createIsReplay) {
3176
4083
  const current = await records.get(ticketKey(command.ticketId));
3177
- if (current?.kind !== "ticket" ||
3178
- !owns(current.ticket, access.principalId))
4084
+ authorizeAdmitted();
4085
+ if (current?.kind !== "ticket" || !owns(current.ticket, principal()))
3179
4086
  throw new SupportDeskNotFoundError();
4087
+ const initialMode = admitted === undefined
4088
+ ? (initialHandling ?? "human")
4089
+ : admittedCreateMode(existingCreate);
3180
4090
  // A crash after ticket commit and before index confirmation must still
3181
4091
  // converge the principal hint on retry.
3182
4092
  const fence = await records.get(ticketReservationKey(command.ticketId));
4093
+ authorizeAdmitted();
3183
4094
  if (fence?.kind === "ticket_reservation" &&
3184
4095
  fence.state === "committed") {
3185
- await confirmCustomerTicket(access.principalId, command.ticketId, fence.token, fence.generation);
4096
+ await confirmCustomerTicket(principal(), command.ticketId, fence.token, fence.generation);
3186
4097
  }
3187
- return customerViewAfterChange(access.principalId, command.ticketId, createThreading);
4098
+ authorizeAdmitted();
4099
+ const view = await customerViewAfterChange(principal(), command.ticketId, createThreading);
4100
+ authorizeAdmitted();
4101
+ return { view, initialMode };
3188
4102
  }
3189
- if (command.initialHandling === "assistant" && !assistantHandlingEnabled)
4103
+ if (existingCreate !== null)
4104
+ throw new SupportDeskConflictError(admitted === undefined
4105
+ ? "Command ID reused with different input"
4106
+ : "Admitted create command changed");
4107
+ if (initialHandling === "assistant" && !assistantHandlingEnabled)
3190
4108
  throw new SupportDeskConflictError("Assistant handling is disabled");
3191
4109
  if (command.category !== undefined) {
3192
4110
  if (allowedCategories === undefined ||
@@ -3194,6 +4112,32 @@ export function createSupportDeskApplication(options) {
3194
4112
  throw new TypeError("create command.category is not configured for this Support Desk instance");
3195
4113
  }
3196
4114
  }
4115
+ const verifyBeforeCommit = admitted?.options?.verifyBeforeCommit;
4116
+ let assistantAccess;
4117
+ if (admitted !== undefined) {
4118
+ enforceLimit(command.subject, "subject", limits.maxSubjectCharacters);
4119
+ enforceLimit(command.body, "body", limits.maxMessageCharacters);
4120
+ if (typeof verifyBeforeCommit !== "function")
4121
+ throw new SupportDeskConflictError("Fresh create admission is required");
4122
+ const resolveAssistantStart = admitted.options?.resolveAssistantStart;
4123
+ if (admitted.intent.preference === "assistant_if_available" &&
4124
+ admitted.intent.disclosureVersion !== undefined &&
4125
+ assistantHandlingEnabled &&
4126
+ assistantTurnOptions !== undefined &&
4127
+ resolveAssistantStart !== undefined) {
4128
+ const current = await waitForAdmission(resolveAssistantStart);
4129
+ authorizeAdmitted();
4130
+ if (current !== null) {
4131
+ authorizeAdmitted(current, true);
4132
+ assistantAccess = current;
4133
+ initialHandling = "assistant";
4134
+ }
4135
+ }
4136
+ }
4137
+ let initialMode = initialHandling ?? "human";
4138
+ // Exact accepted receipts returned above. Apply current attachment policy
4139
+ // only to fresh intake, before it can consume a number or customer slot.
4140
+ validateNewAttachmentPolicy(attachmentInputs, descriptors);
3197
4141
  // Fail closed before durable number/index reservation so a too-small
3198
4142
  // partition budget cannot exhaust the principal ticket index with
3199
4143
  // reserved-but-never-created tickets.
@@ -3201,18 +4145,18 @@ export function createSupportDeskApplication(options) {
3201
4145
  (resolutionLearningOptions.enabled ? 1 : 0) +
3202
4146
  (engineeringOptions.enabled ? 1 : 0) +
3203
4147
  (command.notification === undefined ? 0 : 1) +
3204
- (assistantTurnOptions !== undefined &&
3205
- command.initialHandling === "assistant"
4148
+ (assistantTurnOptions !== undefined && initialHandling === "assistant"
3206
4149
  ? 1
3207
4150
  : 0);
3208
4151
  enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, estimatedCreateRows +
4152
+ attachmentInputs.length * 6 +
3209
4153
  (resolutionLearningOptions.enabled ? 2 : 0) +
3210
4154
  (engineeringOptions.enabled ? 2 : 0) +
3211
- (command.initialHandling === "assistant"
4155
+ (initialHandling === "assistant"
3212
4156
  ? assistantHumanReserveRows +
3213
4157
  (resolutionLearningOptions.enabled ? 1 : 0)
3214
4158
  : 0));
3215
- if (command.initialHandling === "assistant" &&
4159
+ if (initialHandling === "assistant" &&
3216
4160
  limits.maxMessagesPerTicket < 1 + assistantHumanReserveMessages)
3217
4161
  throw new SupportDeskLimitError("ticket_messages", limits.maxMessagesPerTicket);
3218
4162
  // Reserve outside the ticket transaction; gaps are accepted if create fails.
@@ -3228,15 +4172,13 @@ export function createSupportDeskApplication(options) {
3228
4172
  number: ticketNumber,
3229
4173
  subject: command.subject,
3230
4174
  channel: "web",
3231
- ...(command.initialHandling === undefined
3232
- ? {}
3233
- : { initialHandling: command.initialHandling }),
4175
+ ...(initialHandling === undefined ? {} : { initialHandling }),
3234
4176
  ...(command.category === undefined
3235
4177
  ? {}
3236
4178
  : { category: command.category }),
3237
4179
  requester: {
3238
4180
  association: "authenticated",
3239
- principalId: access.principalId,
4181
+ principalId: principal(),
3240
4182
  ...(command.requesterEmail === undefined
3241
4183
  ? {}
3242
4184
  : { email: command.requesterEmail }),
@@ -3247,22 +4189,21 @@ export function createSupportDeskApplication(options) {
3247
4189
  id: command.messageId,
3248
4190
  ticketId: command.ticketId,
3249
4191
  authorKind: "customer",
3250
- authorPrincipalId: access.principalId,
4192
+ authorPrincipalId: principal(),
3251
4193
  channel: "web",
3252
4194
  visibility: "customer",
3253
4195
  format: "plain_text",
3254
4196
  body: command.body,
3255
4197
  createdAt: now,
3256
4198
  };
3257
- const assistantTurn = assistantTurnOptions !== undefined &&
3258
- command.initialHandling === "assistant"
4199
+ const assistantTurn = assistantTurnOptions !== undefined && initialHandling === "assistant"
3259
4200
  ? await createAssistantTurnRecord(ticket, message, command.correlationId, assistantTurnOptions)
3260
4201
  : undefined;
3261
4202
  const notificationJob = notification === undefined
3262
4203
  ? undefined
3263
4204
  : deliveryJobAction(command.ticketId, command.messageId, now, notification);
3264
4205
  const notificationContent = deliveryContent(notification);
3265
- const reservation = await reserveCustomerTicket(access.principalId, command.ticketId, now);
4206
+ const reservation = await reserveCustomerTicket(principal(), command.ticketId, now);
3266
4207
  const existingReservation = await records.getVersioned(ticketReservationKey(command.ticketId));
3267
4208
  if (existingReservation?.value.kind === "ticket_reservation" &&
3268
4209
  existingReservation.value.state === "committed" &&
@@ -3332,7 +4273,7 @@ export function createSupportDeskApplication(options) {
3332
4273
  partition: command.ticketId,
3333
4274
  id: "quota",
3334
4275
  messageCount: 1,
3335
- ...(command.initialHandling === "assistant"
4276
+ ...(initialHandling === "assistant"
3336
4277
  ? { assistantManaged: true }
3337
4278
  : {}),
3338
4279
  },
@@ -3356,7 +4297,7 @@ export function createSupportDeskApplication(options) {
3356
4297
  correlationId: command.correlationId,
3357
4298
  ticketId: command.ticketId,
3358
4299
  revision: ticket.revision,
3359
- actorId: access.principalId,
4300
+ actorId: principal(),
3360
4301
  occurredAt: now,
3361
4302
  action: supportTicketAuditActions.created,
3362
4303
  }),
@@ -3367,10 +4308,11 @@ export function createSupportDeskApplication(options) {
3367
4308
  partition: command.ticketId,
3368
4309
  id: `command:${command.commandId}`,
3369
4310
  commandId: command.commandId,
3370
- commandType: "create_customer_ticket",
4311
+ commandType,
3371
4312
  messageId: command.messageId,
3372
4313
  requestFingerprint: fingerprint,
3373
4314
  completedAt: now,
4315
+ ...(admitted === undefined ? {} : { initialMode }),
3374
4316
  },
3375
4317
  },
3376
4318
  ...(notificationJob === undefined ? [] : [notificationJob]),
@@ -3381,16 +4323,43 @@ export function createSupportDeskApplication(options) {
3381
4323
  // Create starts an empty partition; fail closed if the first write would
3382
4324
  // exceed the configured physical-record budget.
3383
4325
  enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, actions.length);
3384
- const outcome = await records.transact(command.ticketId, actions);
4326
+ if (attachmentInputs.length > 0) {
4327
+ const initialRows = actions.flatMap((action) => action.action === "insert" ? [action.value] : []);
4328
+ enforceManagedAdmission(ticket, initialRows, limits, attachmentInputs.length * 6);
4329
+ }
4330
+ const beforeAttachmentCommit = async () => {
4331
+ authorizeAdmitted();
4332
+ const assertCurrent = admitted !== undefined && verifyBeforeCommit !== undefined
4333
+ ? await waitForAdmission(() => verifyBeforeCommit(initialMode))
4334
+ : undefined;
4335
+ return () => {
4336
+ authorizeAdmitted();
4337
+ if (assistantAccess !== undefined)
4338
+ authorizeAdmitted(assistantAccess, true);
4339
+ if (admitted !== undefined &&
4340
+ (typeof assertCurrent !== "function" || assertCurrent() !== true))
4341
+ throw new SupportDeskConflictError("Admitted create authority changed");
4342
+ return true;
4343
+ };
4344
+ };
4345
+ const outcome = await transactWithAttachments(command.ticketId, actions, attachmentInputs, descriptors, beforeAttachmentCommit);
3385
4346
  if (!outcome.committed) {
3386
4347
  const existing = await records.get(commandKey(command.ticketId, command.commandId));
3387
- if (!duplicateMatches(existing, "create_customer_ticket", command.messageId, fingerprint)) {
4348
+ if (!duplicateMatches(existing, commandType, command.messageId, fingerprint)) {
3388
4349
  throw new SupportDeskConflictError();
3389
4350
  }
4351
+ // An exact competing commit recovers the winner's historical mode,
4352
+ // using the same create/read-own authority as the early receipt path.
4353
+ if (admitted !== undefined)
4354
+ initialMode = admittedCreateMode(existing);
3390
4355
  }
3391
- await confirmCustomerTicket(access.principalId, command.ticketId, reservation.reservationToken, reservation.reservationGeneration);
3392
- return customerViewAfterChange(access.principalId, command.ticketId, createThreading);
3393
- },
4356
+ authorizeAdmitted();
4357
+ await confirmCustomerTicket(principal(), command.ticketId, reservation.reservationToken, reservation.reservationGeneration);
4358
+ authorizeAdmitted();
4359
+ const view = await customerViewAfterChange(principal(), command.ticketId, createThreading);
4360
+ authorizeAdmitted();
4361
+ return { view, initialMode };
4362
+ }),
3394
4363
  async replyToCustomerTicket(access, input) {
3395
4364
  requirePermission(access, supportPermissions.replyOwn);
3396
4365
  requireIdentifier(access.principalId, "principalId");
@@ -3400,11 +4369,17 @@ export function createSupportDeskApplication(options) {
3400
4369
  requireIdentifier(command.commandId, "commandId");
3401
4370
  requireIdentifier(command.correlationId, "correlationId");
3402
4371
  enforceLimit(command.body, "body", limits.maxMessageCharacters);
4372
+ const attachmentInputs = command.attachments ?? [];
4373
+ const descriptors = await attachmentDescriptors(attachmentInputs);
4374
+ const attachmentFingerprint = descriptors.length
4375
+ ? { attachments: descriptors }
4376
+ : {};
3403
4377
  const fingerprint = await requestFingerprint({
3404
4378
  type: "reply_customer_ticket",
3405
4379
  ticketId: command.ticketId,
3406
4380
  messageId: command.messageId,
3407
4381
  body: command.body,
4382
+ ...attachmentFingerprint,
3408
4383
  notification: stableNotification(command.notification),
3409
4384
  });
3410
4385
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
@@ -3412,6 +4387,8 @@ export function createSupportDeskApplication(options) {
3412
4387
  if (duplicateMatches(duplicate, "reply_customer_ticket", command.messageId, fingerprint)) {
3413
4388
  return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
3414
4389
  }
4390
+ if (duplicate !== null)
4391
+ throw new SupportDeskConflictError("Command ID reused with different input");
3415
4392
  const versioned = await records.getVersioned(ticketKey(command.ticketId));
3416
4393
  if (versioned?.value.kind !== "ticket" ||
3417
4394
  versioned.value.ticket.requester.association !== "authenticated" ||
@@ -3453,8 +4430,12 @@ export function createSupportDeskApplication(options) {
3453
4430
  });
3454
4431
  }
3455
4432
  const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
3456
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedReplyRows + learningMutation.additionalRows);
3457
- enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, addedReplyRows + learningMutation.additionalRows, true);
4433
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, addedReplyRows +
4434
+ learningMutation.additionalRows +
4435
+ attachmentInputs.length * 6);
4436
+ enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, addedReplyRows +
4437
+ learningMutation.additionalRows +
4438
+ attachmentInputs.length * 6, true);
3458
4439
  const message = {
3459
4440
  id: command.messageId,
3460
4441
  ticketId: command.ticketId,
@@ -3481,7 +4462,7 @@ export function createSupportDeskApplication(options) {
3481
4462
  const notificationJob = command.notification === undefined
3482
4463
  ? undefined
3483
4464
  : deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
3484
- const outcome = await records.transact(command.ticketId, [
4465
+ const outcome = await transactWithAttachments(command.ticketId, [
3485
4466
  ...learningMutation.actions,
3486
4467
  {
3487
4468
  action: "putIfUnchanged",
@@ -3530,7 +4511,7 @@ export function createSupportDeskApplication(options) {
3530
4511
  ...(assistantIngress === undefined
3531
4512
  ? []
3532
4513
  : [{ action: "insert", value: assistantIngress }]),
3533
- ]);
4514
+ ], attachmentInputs, descriptors);
3534
4515
  if (outcome.committed) {
3535
4516
  return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
3536
4517
  }
@@ -3716,12 +4697,18 @@ export function createSupportDeskApplication(options) {
3716
4697
  requireIdentifier(command.commandId, "commandId");
3717
4698
  requireIdentifier(command.correlationId, "correlationId");
3718
4699
  enforceLimit(command.body, "body", limits.maxMessageCharacters);
4700
+ const attachmentInputs = command.attachments ?? [];
4701
+ const descriptors = await attachmentDescriptors(attachmentInputs);
4702
+ const attachmentFingerprint = descriptors.length
4703
+ ? { attachments: descriptors }
4704
+ : {};
3719
4705
  const fingerprint = await requestFingerprint({
3720
4706
  ...snapshotRevisionHint(command),
3721
4707
  type: "staff_reply",
3722
4708
  ticketId: command.ticketId,
3723
4709
  messageId: command.messageId,
3724
4710
  body: command.body,
4711
+ ...attachmentFingerprint,
3725
4712
  notification: stableNotification(command.notification),
3726
4713
  });
3727
4714
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
@@ -3752,10 +4739,12 @@ export function createSupportDeskApplication(options) {
3752
4739
  occurredAt: now,
3753
4740
  });
3754
4741
  const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
3755
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, (command.notification === undefined ? 3 : 4) +
3756
- learningMutation.additionalRows);
4742
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, (command.notification === undefined ? 3 : 4) +
4743
+ learningMutation.additionalRows +
4744
+ attachmentInputs.length * 6);
3757
4745
  enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, (command.notification === undefined ? 3 : 4) +
3758
- learningMutation.additionalRows, true, true);
4746
+ learningMutation.additionalRows +
4747
+ attachmentInputs.length * 6, true, true);
3759
4748
  const message = {
3760
4749
  id: command.messageId,
3761
4750
  ticketId: command.ticketId,
@@ -3771,7 +4760,7 @@ export function createSupportDeskApplication(options) {
3771
4760
  const notificationJob = command.notification === undefined
3772
4761
  ? undefined
3773
4762
  : deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
3774
- const outcome = await records.transact(command.ticketId, [
4763
+ const outcome = await transactWithAttachments(command.ticketId, [
3775
4764
  ...learningMutation.actions,
3776
4765
  {
3777
4766
  action: "putIfUnchanged",
@@ -3818,7 +4807,7 @@ export function createSupportDeskApplication(options) {
3818
4807
  },
3819
4808
  },
3820
4809
  ...(notificationJob === undefined ? [] : [notificationJob]),
3821
- ]);
4810
+ ], attachmentInputs, descriptors);
3822
4811
  if (outcome.committed) {
3823
4812
  return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
3824
4813
  }
@@ -3838,12 +4827,18 @@ export function createSupportDeskApplication(options) {
3838
4827
  requireIdentifier(command.commandId, "commandId");
3839
4828
  requireIdentifier(command.correlationId, "correlationId");
3840
4829
  enforceLimit(command.body, "body", limits.maxMessageCharacters);
4830
+ const attachmentInputs = command.attachments ?? [];
4831
+ const descriptors = await attachmentDescriptors(attachmentInputs);
4832
+ const attachmentFingerprint = descriptors.length
4833
+ ? { attachments: descriptors }
4834
+ : {};
3841
4835
  const fingerprint = await requestFingerprint({
3842
4836
  ...snapshotRevisionHint(command),
3843
4837
  type: "add_note",
3844
4838
  ticketId: command.ticketId,
3845
4839
  messageId: command.messageId,
3846
4840
  body: command.body,
4841
+ ...attachmentFingerprint,
3847
4842
  });
3848
4843
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
3849
4844
  const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
@@ -3859,8 +4854,8 @@ export function createSupportDeskApplication(options) {
3859
4854
  if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
3860
4855
  return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
3861
4856
  const partitionRows = await records.list(command.ticketId);
3862
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 3);
3863
- enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, 3, true);
4857
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, 3);
4858
+ enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, 3 + attachmentInputs.length * 6, true);
3864
4859
  const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
3865
4860
  if (quota?.value.kind !== "ticket_quota") {
3866
4861
  throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
@@ -3892,7 +4887,7 @@ export function createSupportDeskApplication(options) {
3892
4887
  body: command.body,
3893
4888
  createdAt: now,
3894
4889
  };
3895
- const outcome = await records.transact(command.ticketId, [
4890
+ const outcome = await transactWithAttachments(command.ticketId, [
3896
4891
  {
3897
4892
  action: "putIfUnchanged",
3898
4893
  version: versioned.version,
@@ -3939,7 +4934,7 @@ export function createSupportDeskApplication(options) {
3939
4934
  resultTicket: updated,
3940
4935
  },
3941
4936
  },
3942
- ]);
4937
+ ], attachmentInputs, descriptors);
3943
4938
  if (outcome.committed) {
3944
4939
  return staffCommandResult(command.ticketId, command.commandId);
3945
4940
  }
@@ -3994,7 +4989,7 @@ export function createSupportDeskApplication(options) {
3994
4989
  if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
3995
4990
  return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
3996
4991
  const partitionRows = await records.list(command.ticketId);
3997
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords);
4992
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, addedRecords);
3998
4993
  enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, addedRecords, command.messageId !== undefined);
3999
4994
  let quotaVersion = null;
4000
4995
  if (command.messageId !== undefined) {
@@ -4143,7 +5138,7 @@ export function createSupportDeskApplication(options) {
4143
5138
  if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
4144
5139
  return staffCommandResult(command.ticketId, command.commandId);
4145
5140
  const partitionRows = await records.list(command.ticketId);
4146
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 2);
5141
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, 2);
4147
5142
  enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, 2);
4148
5143
  const statusBefore = versioned.value.ticket.status;
4149
5144
  const customerUpdatedBefore = versioned.value.ticket.customerUpdatedAt;
@@ -4234,7 +5229,7 @@ export function createSupportDeskApplication(options) {
4234
5229
  if (command.tags.some((tag) => !allowedTags.has(tag)))
4235
5230
  throw new TypeError("tags must be on the host allowlist");
4236
5231
  const rows = await records.list(command.ticketId);
4237
- enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket, 2);
5232
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket, 2);
4238
5233
  enforceManagedAdmission(versioned.value.ticket, rows, limits, 2);
4239
5234
  const now = clampTicketTime(clock.now(), versioned.value.ticket.updatedAt);
4240
5235
  const updated = applyTicketEvent(versioned.value.ticket, {
@@ -4374,7 +5369,7 @@ export function createSupportDeskApplication(options) {
4374
5369
  occurredAt: now,
4375
5370
  });
4376
5371
  const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, command.messageId !== undefined);
4377
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords + learningMutation.additionalRows);
5372
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, addedRecords + learningMutation.additionalRows);
4378
5373
  enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, addedRecords + learningMutation.additionalRows, command.messageId !== undefined);
4379
5374
  // Optional system message + mail commit with the lifecycle change so a
4380
5375
  // resolution notification cannot be lost after the ticket is resolved.
@@ -5868,7 +6863,7 @@ export function createInboundMailProcessor(options) {
5868
6863
  throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
5869
6864
  }
5870
6865
  const partitionRows = await records.list(input.ticketId);
5871
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 1);
6866
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, 1);
5872
6867
  enforceManagedAdmission(ticket.value.ticket, partitionRows, limits, 1);
5873
6868
  const outcome = await records.transact(input.ticketId, [
5874
6869
  {