@pegma/support-desk-application 0.2.4 → 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 (43) hide show
  1. package/README.md +10 -7
  2. package/dist/attachments.d.ts +57 -0
  3. package/dist/attachments.d.ts.map +1 -0
  4. package/dist/attachments.js +91 -0
  5. package/dist/attachments.js.map +1 -0
  6. package/dist/index.d.ts +80 -4
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +855 -41
  9. package/dist/index.js.map +1 -1
  10. package/dist/resolution-learning-records.d.ts +2 -0
  11. package/dist/resolution-learning-records.d.ts.map +1 -1
  12. package/dist/resolution-learning-records.js +7 -0
  13. package/dist/resolution-learning-records.js.map +1 -1
  14. package/dist/resolution-learning-review-commit.d.ts +18 -0
  15. package/dist/resolution-learning-review-commit.d.ts.map +1 -0
  16. package/dist/resolution-learning-review-commit.js +199 -0
  17. package/dist/resolution-learning-review-commit.js.map +1 -0
  18. package/dist/resolution-learning-review-identity.d.ts +28 -0
  19. package/dist/resolution-learning-review-identity.d.ts.map +1 -0
  20. package/dist/resolution-learning-review-identity.js +110 -0
  21. package/dist/resolution-learning-review-identity.js.map +1 -0
  22. package/dist/resolution-learning-staged-records.d.ts +45 -0
  23. package/dist/resolution-learning-staged-records.d.ts.map +1 -0
  24. package/dist/resolution-learning-staged-records.js +193 -0
  25. package/dist/resolution-learning-staged-records.js.map +1 -0
  26. package/dist/resolution-learning-staged-types.d.ts +88 -0
  27. package/dist/resolution-learning-staged-types.d.ts.map +1 -0
  28. package/dist/resolution-learning-staged-types.js +2 -0
  29. package/dist/resolution-learning-staged-types.js.map +1 -0
  30. package/dist/resolution-learning-staged.d.ts +12 -0
  31. package/dist/resolution-learning-staged.d.ts.map +1 -0
  32. package/dist/resolution-learning-staged.js +585 -0
  33. package/dist/resolution-learning-staged.js.map +1 -0
  34. package/dist/resolution-learning-types.d.ts +6 -0
  35. package/dist/resolution-learning-types.d.ts.map +1 -1
  36. package/dist/resolution-learning-worker.d.ts.map +1 -1
  37. package/dist/resolution-learning-worker.js +58 -186
  38. package/dist/resolution-learning-worker.js.map +1 -1
  39. package/dist/resolution-learning.d.ts +3 -2
  40. package/dist/resolution-learning.d.ts.map +1 -1
  41. package/dist/resolution-learning.js +29 -2
  42. package/dist/resolution-learning.js.map +1 -1
  43. 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";
@@ -20,6 +22,109 @@ import { engineeringFollowupNow, engineeringFollowupTrusted, engineeringFollowup
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"),
@@ -872,6 +1077,7 @@ function snapshotCustomerTicketAdmission(input) {
872
1077
  "body",
873
1078
  "category",
874
1079
  "requesterEmail",
1080
+ "attachments",
875
1081
  "preference",
876
1082
  "disclosureVersion",
877
1083
  ];
@@ -934,6 +1140,7 @@ function snapshotReplyToCustomerTicketCommand(input) {
934
1140
  ? undefined
935
1141
  : snapshotNotification(raw.notification);
936
1142
  return Object.freeze({
1143
+ attachments: snapshotAttachmentInputs(ownDataProperty(source, "attachments", "attachments", true)),
937
1144
  commandId: boundaryString(raw.commandId, "reply command.commandId"),
938
1145
  correlationId: boundaryString(raw.correlationId, "reply command.correlationId"),
939
1146
  ticketId: boundaryString(raw.ticketId, "reply command.ticketId"),
@@ -997,8 +1204,24 @@ function toCustomerMessage(message) {
997
1204
  format: message.format,
998
1205
  body: message.body,
999
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
+ }),
1000
1219
  });
1001
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
+ }
1002
1225
  function customerMessages(records) {
1003
1226
  const messages = records.filter((record) => record.kind === "message" && record.message.visibility === "customer");
1004
1227
  const ordinals = new Set();
@@ -1082,6 +1305,7 @@ function parseAllowedCategories(input, name = "application options.allowedCatego
1082
1305
  }
1083
1306
  function duplicateMatches(existing, commandType, messageId, requestFingerprint) {
1084
1307
  return (existing?.kind === "command" &&
1308
+ existing.attachmentDeletion?.phase !== "pending" &&
1085
1309
  existing.commandType === commandType &&
1086
1310
  existing.messageId === messageId &&
1087
1311
  existing.requestFingerprint === requestFingerprint);
@@ -1211,7 +1435,11 @@ function derivationReserveRows(rows, consuming = false) {
1211
1435
  ? row.terminalReserveRows
1212
1436
  : 0), 0);
1213
1437
  const followup = rows.reduce((sum, row) => sum + (row.kind === "engineering_followup_work" ? row.completionRows : 0), 0);
1214
- 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;
1215
1443
  }
1216
1444
  /** Reserve is opt-in metadata on the existing quota, never a second counter. */
1217
1445
  function enforceManagedAdmission(ticket, rows, limits, additionalRows, addsMessage = false, firstHumanReply = false, consumesWithdrawalReserve = false) {
@@ -1219,7 +1447,7 @@ function enforceManagedAdmission(ticket, rows, limits, additionalRows, addsMessa
1219
1447
  const reserved = quota?.assistantManaged === true &&
1220
1448
  ticket.firstStaffRespondedAt === undefined &&
1221
1449
  !firstHumanReply;
1222
- enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket -
1450
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket -
1223
1451
  (reserved ? humanReserveRows(rows) : 0) -
1224
1452
  derivationReserveRows(rows, consumesWithdrawalReserve), additionalRows);
1225
1453
  if (addsMessage &&
@@ -1241,7 +1469,7 @@ function enforceHandlingControlAdmission(ticket, rows, limits, additionalRows, c
1241
1469
  const quota = rows.find((row) => row.kind === "ticket_quota");
1242
1470
  const reserved = quota?.assistantManaged === true &&
1243
1471
  ticket.firstStaffRespondedAt === undefined;
1244
- enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket -
1472
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket -
1245
1473
  (reserved ? firstHumanReplyReserveRows : 0) -
1246
1474
  derivationReserveRows(rows, consumesWithdrawalReserve), additionalRows);
1247
1475
  if (reserved && quota.messageCount >= limits.maxMessagesPerTicket)
@@ -1352,6 +1580,7 @@ function snapshotStaffReplyCommand(input) {
1352
1580
  ? undefined
1353
1581
  : snapshotNotification(raw.notification);
1354
1582
  return Object.freeze({
1583
+ attachments: snapshotAttachmentInputs(ownDataProperty(source, "attachments", "attachments", true)),
1355
1584
  ...snapshotRevisionHint(source),
1356
1585
  commandId: boundaryString(raw.commandId, "staff reply command.commandId"),
1357
1586
  correlationId: boundaryString(raw.correlationId, "staff reply command.correlationId"),
@@ -1371,6 +1600,7 @@ function snapshotAddNoteCommand(input) {
1371
1600
  body: ownDataProperty(source, "body", "add note command.body"),
1372
1601
  };
1373
1602
  return Object.freeze({
1603
+ attachments: snapshotAttachmentInputs(ownDataProperty(source, "attachments", "attachments", true)),
1374
1604
  ...snapshotRevisionHint(source),
1375
1605
  commandId: boundaryString(raw.commandId, "add note command.commandId"),
1376
1606
  correlationId: boundaryString(raw.correlationId, "add note command.correlationId"),
@@ -2091,6 +2321,7 @@ export function createSupportDeskApplication(options) {
2091
2321
  typeof rawMaxConflictAttempts !== "number") {
2092
2322
  throw new TypeError("application options.maxConflictAttempts must be a number");
2093
2323
  }
2324
+ const attachmentConfig = attachmentConfiguration(ownDataProperty(source, "attachments", "application options.attachments", true));
2094
2325
  const records = store.collection(supportRecords);
2095
2326
  const index = store.collection(customerTicketIndex);
2096
2327
  const numbers = store.collection(ticketNumbers);
@@ -2381,6 +2612,466 @@ export function createSupportDeskApplication(options) {
2381
2612
  async function reserveTicketNumber() {
2382
2613
  return reserveNextTicketNumber(numbers, maxConflictAttempts);
2383
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
+ }
2384
3075
  const resolutionLearning = createResolutionLearningServices({
2385
3076
  records,
2386
3077
  clock,
@@ -2389,14 +3080,15 @@ export function createSupportDeskApplication(options) {
2389
3080
  maxConflictAttempts,
2390
3081
  admission: (ticket, rows, additional) => enforceManagedAdmission(ticket, rows, limits, additional),
2391
3082
  withdrawalAdmission: (ticket, rows, additional) => enforceHandlingControlAdmission(ticket, rows, limits, additional, "learning"),
2392
- audit: (command, ticket, actorId, now, action) => ticketAuditAction({
3083
+ audit: (command, ticket, actorId, now, action, eventId) => ticketAuditAction({
3084
+ ...(eventId === undefined ? {} : { eventId }),
2393
3085
  commandId: command.commandId,
2394
3086
  correlationId: command.correlationId,
2395
3087
  ticketId: ticket.id,
2396
3088
  revision: ticket.revision,
2397
3089
  actorId,
2398
3090
  occurredAt: now,
2399
- action: action,
3091
+ action,
2400
3092
  }),
2401
3093
  });
2402
3094
  const engineeringDependencies = {
@@ -2415,7 +3107,7 @@ export function createSupportDeskApplication(options) {
2415
3107
  if (current !== undefined)
2416
3108
  enforceHandlingControlAdmission(current.ticket, rows, limits, additional, consumption);
2417
3109
  else
2418
- enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket - derivationReserveRows(rows, consumption), additional);
3110
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket - derivationReserveRows(rows, consumption), additional);
2419
3111
  },
2420
3112
  audit: (command, ticket, actorId, now, action) => ticketAuditAction({
2421
3113
  commandId: command.commandId,
@@ -2785,7 +3477,7 @@ export function createSupportDeskApplication(options) {
2785
3477
  if (operation === "assistant_reply" && !assistantHandlingEnabled)
2786
3478
  throw new SupportDeskConflictError("Assistant handling is disabled");
2787
3479
  const rows = await records.list(command.ticketId);
2788
- enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket);
3480
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket);
2789
3481
  const latest = latestVisibleRecord(rows);
2790
3482
  if (operation === "assistant_reply" || operation === "assistant_escalate")
2791
3483
  requireAssistantTurn(ticket, rows, command);
@@ -2856,7 +3548,7 @@ export function createSupportDeskApplication(options) {
2856
3548
  ? await planMutation()
2857
3549
  : readValue(await mutationRead);
2858
3550
  const learningConfirmation = operation === "confirm_customer_success"
2859
- ? 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))
2860
3552
  : { actions: [], additionalRows: 0 };
2861
3553
  const newRows = (operation === "take_over" ? 2 : 3) +
2862
3554
  learningMutation.additionalRows +
@@ -3018,7 +3710,7 @@ export function createSupportDeskApplication(options) {
3018
3710
  conversation(ticket, rows) {
3019
3711
  return {
3020
3712
  ticket: toCustomerTicketSummary(ticket),
3021
- messages: customerMessages(rows),
3713
+ messages: assistantConversationMessages(rows),
3022
3714
  revision: ticket.revision,
3023
3715
  handling: { ...effectiveTicketHandling(ticket) },
3024
3716
  ...(latestVisibleRecord(rows) === undefined
@@ -3029,6 +3721,82 @@ export function createSupportDeskApplication(options) {
3029
3721
  mutateHandling,
3030
3722
  });
3031
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
+ },
3032
3800
  ...resolutionLearning.application,
3033
3801
  ...engineeringEvidence.application,
3034
3802
  ...engineeringWorkers,
@@ -3082,7 +3850,7 @@ export function createSupportDeskApplication(options) {
3082
3850
  const latest = latestVisibleRecord(rows);
3083
3851
  return {
3084
3852
  ticket: toCustomerTicketSummary(ticket),
3085
- messages: customerMessages(rows),
3853
+ messages: assistantConversationMessages(rows),
3086
3854
  revision: ticket.revision,
3087
3855
  handling: { ...effectiveTicketHandling(ticket) },
3088
3856
  ...(latest?.message.authorKind === "customer"
@@ -3239,12 +4007,15 @@ export function createSupportDeskApplication(options) {
3239
4007
  const inputLimits = admitted === undefined ? limits : supportDeskLimitMaximums;
3240
4008
  enforceLimit(command.subject, "subject", inputLimits.maxSubjectCharacters);
3241
4009
  enforceLimit(command.body, "body", inputLimits.maxMessageCharacters);
4010
+ const attachmentInputs = command.attachments ?? [];
4011
+ const descriptors = await attachmentDescriptors(attachmentInputs);
3242
4012
  const fingerprintPayload = {
3243
4013
  type: "create_customer_ticket",
3244
4014
  ticketId: command.ticketId,
3245
4015
  messageId: command.messageId,
3246
4016
  subject: command.subject,
3247
4017
  body: command.body,
4018
+ ...(descriptors.length === 0 ? {} : { attachments: descriptors }),
3248
4019
  // Omit absent category so pre-category receipts still match retries.
3249
4020
  ...(command.category === undefined
3250
4021
  ? {}
@@ -3283,6 +4054,7 @@ export function createSupportDeskApplication(options) {
3283
4054
  let createIsReplay = duplicateMatches(existingCreate, commandType, command.messageId, fingerprint);
3284
4055
  if (admitted === undefined &&
3285
4056
  !createIsReplay &&
4057
+ descriptors.length === 0 &&
3286
4058
  command.initialHandling === undefined &&
3287
4059
  existingCreate?.kind === "command" &&
3288
4060
  existingCreate.commandType === "create_customer_ticket" &&
@@ -3328,8 +4100,10 @@ export function createSupportDeskApplication(options) {
3328
4100
  authorizeAdmitted();
3329
4101
  return { view, initialMode };
3330
4102
  }
3331
- if (admitted !== undefined && existingCreate !== null)
3332
- throw new SupportDeskConflictError("Admitted create command changed");
4103
+ if (existingCreate !== null)
4104
+ throw new SupportDeskConflictError(admitted === undefined
4105
+ ? "Command ID reused with different input"
4106
+ : "Admitted create command changed");
3333
4107
  if (initialHandling === "assistant" && !assistantHandlingEnabled)
3334
4108
  throw new SupportDeskConflictError("Assistant handling is disabled");
3335
4109
  if (command.category !== undefined) {
@@ -3361,6 +4135,9 @@ export function createSupportDeskApplication(options) {
3361
4135
  }
3362
4136
  }
3363
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);
3364
4141
  // Fail closed before durable number/index reservation so a too-small
3365
4142
  // partition budget cannot exhaust the principal ticket index with
3366
4143
  // reserved-but-never-created tickets.
@@ -3372,6 +4149,7 @@ export function createSupportDeskApplication(options) {
3372
4149
  ? 1
3373
4150
  : 0);
3374
4151
  enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, estimatedCreateRows +
4152
+ attachmentInputs.length * 6 +
3375
4153
  (resolutionLearningOptions.enabled ? 2 : 0) +
3376
4154
  (engineeringOptions.enabled ? 2 : 0) +
3377
4155
  (initialHandling === "assistant"
@@ -3545,16 +4323,26 @@ export function createSupportDeskApplication(options) {
3545
4323
  // Create starts an empty partition; fail closed if the first write would
3546
4324
  // exceed the configured physical-record budget.
3547
4325
  enforcePartitionRecordLimit(0, limits.maxRecordsPerTicket, actions.length);
3548
- if (admitted !== undefined && verifyBeforeCommit !== undefined) {
3549
- authorizeAdmitted();
3550
- const assertCurrent = await waitForAdmission(() => verifyBeforeCommit(initialMode));
3551
- authorizeAdmitted();
3552
- if (assistantAccess !== undefined)
3553
- authorizeAdmitted(assistantAccess, true);
3554
- if (typeof assertCurrent !== "function" || assertCurrent() !== true)
3555
- throw new SupportDeskConflictError("Admitted create authority changed");
4326
+ if (attachmentInputs.length > 0) {
4327
+ const initialRows = actions.flatMap((action) => action.action === "insert" ? [action.value] : []);
4328
+ enforceManagedAdmission(ticket, initialRows, limits, attachmentInputs.length * 6);
3556
4329
  }
3557
- const outcome = await records.transact(command.ticketId, actions);
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);
3558
4346
  if (!outcome.committed) {
3559
4347
  const existing = await records.get(commandKey(command.ticketId, command.commandId));
3560
4348
  if (!duplicateMatches(existing, commandType, command.messageId, fingerprint)) {
@@ -3581,11 +4369,17 @@ export function createSupportDeskApplication(options) {
3581
4369
  requireIdentifier(command.commandId, "commandId");
3582
4370
  requireIdentifier(command.correlationId, "correlationId");
3583
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
+ : {};
3584
4377
  const fingerprint = await requestFingerprint({
3585
4378
  type: "reply_customer_ticket",
3586
4379
  ticketId: command.ticketId,
3587
4380
  messageId: command.messageId,
3588
4381
  body: command.body,
4382
+ ...attachmentFingerprint,
3589
4383
  notification: stableNotification(command.notification),
3590
4384
  });
3591
4385
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
@@ -3593,6 +4387,8 @@ export function createSupportDeskApplication(options) {
3593
4387
  if (duplicateMatches(duplicate, "reply_customer_ticket", command.messageId, fingerprint)) {
3594
4388
  return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
3595
4389
  }
4390
+ if (duplicate !== null)
4391
+ throw new SupportDeskConflictError("Command ID reused with different input");
3596
4392
  const versioned = await records.getVersioned(ticketKey(command.ticketId));
3597
4393
  if (versioned?.value.kind !== "ticket" ||
3598
4394
  versioned.value.ticket.requester.association !== "authenticated" ||
@@ -3634,8 +4430,12 @@ export function createSupportDeskApplication(options) {
3634
4430
  });
3635
4431
  }
3636
4432
  const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
3637
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedReplyRows + learningMutation.additionalRows);
3638
- 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);
3639
4439
  const message = {
3640
4440
  id: command.messageId,
3641
4441
  ticketId: command.ticketId,
@@ -3662,7 +4462,7 @@ export function createSupportDeskApplication(options) {
3662
4462
  const notificationJob = command.notification === undefined
3663
4463
  ? undefined
3664
4464
  : deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
3665
- const outcome = await records.transact(command.ticketId, [
4465
+ const outcome = await transactWithAttachments(command.ticketId, [
3666
4466
  ...learningMutation.actions,
3667
4467
  {
3668
4468
  action: "putIfUnchanged",
@@ -3711,7 +4511,7 @@ export function createSupportDeskApplication(options) {
3711
4511
  ...(assistantIngress === undefined
3712
4512
  ? []
3713
4513
  : [{ action: "insert", value: assistantIngress }]),
3714
- ]);
4514
+ ], attachmentInputs, descriptors);
3715
4515
  if (outcome.committed) {
3716
4516
  return customerViewAfterChange(access.principalId, command.ticketId, notificationThreading(command));
3717
4517
  }
@@ -3897,12 +4697,18 @@ export function createSupportDeskApplication(options) {
3897
4697
  requireIdentifier(command.commandId, "commandId");
3898
4698
  requireIdentifier(command.correlationId, "correlationId");
3899
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
+ : {};
3900
4705
  const fingerprint = await requestFingerprint({
3901
4706
  ...snapshotRevisionHint(command),
3902
4707
  type: "staff_reply",
3903
4708
  ticketId: command.ticketId,
3904
4709
  messageId: command.messageId,
3905
4710
  body: command.body,
4711
+ ...attachmentFingerprint,
3906
4712
  notification: stableNotification(command.notification),
3907
4713
  });
3908
4714
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
@@ -3933,10 +4739,12 @@ export function createSupportDeskApplication(options) {
3933
4739
  occurredAt: now,
3934
4740
  });
3935
4741
  const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, true);
3936
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, (command.notification === undefined ? 3 : 4) +
3937
- learningMutation.additionalRows);
4742
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, (command.notification === undefined ? 3 : 4) +
4743
+ learningMutation.additionalRows +
4744
+ attachmentInputs.length * 6);
3938
4745
  enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, (command.notification === undefined ? 3 : 4) +
3939
- learningMutation.additionalRows, true, true);
4746
+ learningMutation.additionalRows +
4747
+ attachmentInputs.length * 6, true, true);
3940
4748
  const message = {
3941
4749
  id: command.messageId,
3942
4750
  ticketId: command.ticketId,
@@ -3952,7 +4760,7 @@ export function createSupportDeskApplication(options) {
3952
4760
  const notificationJob = command.notification === undefined
3953
4761
  ? undefined
3954
4762
  : deliveryJobAction(command.ticketId, command.messageId, now, command.notification);
3955
- const outcome = await records.transact(command.ticketId, [
4763
+ const outcome = await transactWithAttachments(command.ticketId, [
3956
4764
  ...learningMutation.actions,
3957
4765
  {
3958
4766
  action: "putIfUnchanged",
@@ -3999,7 +4807,7 @@ export function createSupportDeskApplication(options) {
3999
4807
  },
4000
4808
  },
4001
4809
  ...(notificationJob === undefined ? [] : [notificationJob]),
4002
- ]);
4810
+ ], attachmentInputs, descriptors);
4003
4811
  if (outcome.committed) {
4004
4812
  return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
4005
4813
  }
@@ -4019,12 +4827,18 @@ export function createSupportDeskApplication(options) {
4019
4827
  requireIdentifier(command.commandId, "commandId");
4020
4828
  requireIdentifier(command.correlationId, "correlationId");
4021
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
+ : {};
4022
4835
  const fingerprint = await requestFingerprint({
4023
4836
  ...snapshotRevisionHint(command),
4024
4837
  type: "add_note",
4025
4838
  ticketId: command.ticketId,
4026
4839
  messageId: command.messageId,
4027
4840
  body: command.body,
4841
+ ...attachmentFingerprint,
4028
4842
  });
4029
4843
  for (let attempt = 1; attempt <= maxConflictAttempts; attempt += 1) {
4030
4844
  const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
@@ -4040,8 +4854,8 @@ export function createSupportDeskApplication(options) {
4040
4854
  if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
4041
4855
  return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
4042
4856
  const partitionRows = await records.list(command.ticketId);
4043
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 3);
4044
- 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);
4045
4859
  const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
4046
4860
  if (quota?.value.kind !== "ticket_quota") {
4047
4861
  throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
@@ -4073,7 +4887,7 @@ export function createSupportDeskApplication(options) {
4073
4887
  body: command.body,
4074
4888
  createdAt: now,
4075
4889
  };
4076
- const outcome = await records.transact(command.ticketId, [
4890
+ const outcome = await transactWithAttachments(command.ticketId, [
4077
4891
  {
4078
4892
  action: "putIfUnchanged",
4079
4893
  version: versioned.version,
@@ -4120,7 +4934,7 @@ export function createSupportDeskApplication(options) {
4120
4934
  resultTicket: updated,
4121
4935
  },
4122
4936
  },
4123
- ]);
4937
+ ], attachmentInputs, descriptors);
4124
4938
  if (outcome.committed) {
4125
4939
  return staffCommandResult(command.ticketId, command.commandId);
4126
4940
  }
@@ -4175,7 +4989,7 @@ export function createSupportDeskApplication(options) {
4175
4989
  if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
4176
4990
  return staffCommandResult(command.ticketId, command.commandId, notificationThreading(command));
4177
4991
  const partitionRows = await records.list(command.ticketId);
4178
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords);
4992
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, addedRecords);
4179
4993
  enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, addedRecords, command.messageId !== undefined);
4180
4994
  let quotaVersion = null;
4181
4995
  if (command.messageId !== undefined) {
@@ -4324,7 +5138,7 @@ export function createSupportDeskApplication(options) {
4324
5138
  if (await checkStaffRevision(versioned.value.ticket, command, fingerprint))
4325
5139
  return staffCommandResult(command.ticketId, command.commandId);
4326
5140
  const partitionRows = await records.list(command.ticketId);
4327
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 2);
5141
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, 2);
4328
5142
  enforceManagedAdmission(versioned.value.ticket, partitionRows, limits, 2);
4329
5143
  const statusBefore = versioned.value.ticket.status;
4330
5144
  const customerUpdatedBefore = versioned.value.ticket.customerUpdatedAt;
@@ -4415,7 +5229,7 @@ export function createSupportDeskApplication(options) {
4415
5229
  if (command.tags.some((tag) => !allowedTags.has(tag)))
4416
5230
  throw new TypeError("tags must be on the host allowlist");
4417
5231
  const rows = await records.list(command.ticketId);
4418
- enforcePartitionRecordLimit(rows.length, limits.maxRecordsPerTicket, 2);
5232
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(rows), limits.maxRecordsPerTicket, 2);
4419
5233
  enforceManagedAdmission(versioned.value.ticket, rows, limits, 2);
4420
5234
  const now = clampTicketTime(clock.now(), versioned.value.ticket.updatedAt);
4421
5235
  const updated = applyTicketEvent(versioned.value.ticket, {
@@ -4555,7 +5369,7 @@ export function createSupportDeskApplication(options) {
4555
5369
  occurredAt: now,
4556
5370
  });
4557
5371
  const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, partitionRows, command.messageId !== undefined);
4558
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, addedRecords + learningMutation.additionalRows);
5372
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, addedRecords + learningMutation.additionalRows);
4559
5373
  enforceManagedAdmission(versioned.value.ticket, projectFollowupRows(partitionRows, learningMutation.nextWorkRows), limits, addedRecords + learningMutation.additionalRows, command.messageId !== undefined);
4560
5374
  // Optional system message + mail commit with the lifecycle change so a
4561
5375
  // resolution notification cannot be lost after the ticket is resolved.
@@ -6049,7 +6863,7 @@ export function createInboundMailProcessor(options) {
6049
6863
  throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
6050
6864
  }
6051
6865
  const partitionRows = await records.list(input.ticketId);
6052
- enforcePartitionRecordLimit(partitionRows.length, limits.maxRecordsPerTicket, 1);
6866
+ enforcePartitionRecordLimit(partitionSizeWithAttachmentReserve(partitionRows), limits.maxRecordsPerTicket, 1);
6053
6867
  enforceManagedAdmission(ticket.value.ticket, partitionRows, limits, 1);
6054
6868
  const outcome = await records.transact(input.ticketId, [
6055
6869
  {