@pegma/support-desk-application 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +237 -7
  2. package/dist/assistant-turn-capacity.d.ts +13 -0
  3. package/dist/assistant-turn-capacity.d.ts.map +1 -0
  4. package/dist/assistant-turn-capacity.js +111 -0
  5. package/dist/assistant-turn-capacity.js.map +1 -0
  6. package/dist/assistant-turn-records.d.ts +68 -0
  7. package/dist/assistant-turn-records.d.ts.map +1 -0
  8. package/dist/assistant-turn-records.js +418 -0
  9. package/dist/assistant-turn-records.js.map +1 -0
  10. package/dist/assistant-turn-types.d.ts +133 -0
  11. package/dist/assistant-turn-types.d.ts.map +1 -0
  12. package/dist/assistant-turn-types.js +2 -0
  13. package/dist/assistant-turn-types.js.map +1 -0
  14. package/dist/assistant-turns.d.ts +42 -0
  15. package/dist/assistant-turns.d.ts.map +1 -0
  16. package/dist/assistant-turns.js +660 -0
  17. package/dist/assistant-turns.js.map +1 -0
  18. package/dist/engineering-evidence.d.ts +20 -0
  19. package/dist/engineering-evidence.d.ts.map +1 -0
  20. package/dist/engineering-evidence.js +899 -0
  21. package/dist/engineering-evidence.js.map +1 -0
  22. package/dist/engineering-followup-internal.d.ts +45 -0
  23. package/dist/engineering-followup-internal.d.ts.map +1 -0
  24. package/dist/engineering-followup-internal.js +2 -0
  25. package/dist/engineering-followup-internal.js.map +1 -0
  26. package/dist/engineering-followup-records.d.ts +209 -0
  27. package/dist/engineering-followup-records.d.ts.map +1 -0
  28. package/dist/engineering-followup-records.js +1434 -0
  29. package/dist/engineering-followup-records.js.map +1 -0
  30. package/dist/engineering-followup-source.d.ts +168 -0
  31. package/dist/engineering-followup-source.d.ts.map +1 -0
  32. package/dist/engineering-followup-source.js +1144 -0
  33. package/dist/engineering-followup-source.js.map +1 -0
  34. package/dist/engineering-followup-types.d.ts +364 -0
  35. package/dist/engineering-followup-types.d.ts.map +1 -0
  36. package/dist/engineering-followup-types.js +2 -0
  37. package/dist/engineering-followup-types.js.map +1 -0
  38. package/dist/engineering-followup-work.d.ts +70 -0
  39. package/dist/engineering-followup-work.d.ts.map +1 -0
  40. package/dist/engineering-followup-work.js +1153 -0
  41. package/dist/engineering-followup-work.js.map +1 -0
  42. package/dist/engineering-internal.d.ts +38 -0
  43. package/dist/engineering-internal.d.ts.map +1 -0
  44. package/dist/engineering-internal.js +11 -0
  45. package/dist/engineering-internal.js.map +1 -0
  46. package/dist/engineering-policy.d.ts +47 -0
  47. package/dist/engineering-policy.d.ts.map +1 -0
  48. package/dist/engineering-policy.js +579 -0
  49. package/dist/engineering-policy.js.map +1 -0
  50. package/dist/engineering-reconciliation.d.ts +6 -0
  51. package/dist/engineering-reconciliation.d.ts.map +1 -0
  52. package/dist/engineering-reconciliation.js +464 -0
  53. package/dist/engineering-reconciliation.js.map +1 -0
  54. package/dist/engineering-records.d.ts +227 -0
  55. package/dist/engineering-records.d.ts.map +1 -0
  56. package/dist/engineering-records.js +1272 -0
  57. package/dist/engineering-records.js.map +1 -0
  58. package/dist/engineering-types.d.ts +505 -0
  59. package/dist/engineering-types.d.ts.map +1 -0
  60. package/dist/engineering-types.js +2 -0
  61. package/dist/engineering-types.js.map +1 -0
  62. package/dist/engineering-work.d.ts +32 -0
  63. package/dist/engineering-work.d.ts.map +1 -0
  64. package/dist/engineering-work.js +1757 -0
  65. package/dist/engineering-work.js.map +1 -0
  66. package/dist/index.d.ts +609 -14
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +3680 -233
  69. package/dist/index.js.map +1 -1
  70. package/dist/resolution-learning-records.d.ts +154 -0
  71. package/dist/resolution-learning-records.d.ts.map +1 -0
  72. package/dist/resolution-learning-records.js +657 -0
  73. package/dist/resolution-learning-records.js.map +1 -0
  74. package/dist/resolution-learning-types.d.ts +356 -0
  75. package/dist/resolution-learning-types.d.ts.map +1 -0
  76. package/dist/resolution-learning-types.js +2 -0
  77. package/dist/resolution-learning-types.js.map +1 -0
  78. package/dist/resolution-learning-worker.d.ts +21 -0
  79. package/dist/resolution-learning-worker.d.ts.map +1 -0
  80. package/dist/resolution-learning-worker.js +1027 -0
  81. package/dist/resolution-learning-worker.js.map +1 -0
  82. package/dist/resolution-learning.d.ts +38 -0
  83. package/dist/resolution-learning.d.ts.map +1 -0
  84. package/dist/resolution-learning.js +860 -0
  85. package/dist/resolution-learning.js.map +1 -0
  86. package/package.json +6 -6
@@ -0,0 +1,860 @@
1
+ import { hasPermission } from "@pegma/authorization-core";
2
+ import { MAX_SCAN_PAGE_SIZE, } from "@pegma/storage-core";
3
+ import { SupportDeskAuthorizationError, SupportDeskConflictError, SupportDeskLimitError, SupportDeskNotFoundError, supportPermissions, } from "./index.js";
4
+ import { canonical, dataObject, encodedBytes, fields, fingerprint, integer, text, timestamp, } from "./assistant-turn-records.js";
5
+ import { safeLearningSnapshot as snapshot, assertResolutionLearningRecord, maximumResolutionLearningSourceBytes, resolutionLearningReviewKey, resolutionLearningSourceKey, resolutionLearningWorkKey, validateResolutionCandidate, validateResolutionEvidence, validateResolutionProduct, validateResolutionRequest, validateResolutionTarget, } from "./resolution-learning-records.js";
6
+ const terminal = (ticket) => ticket.status === "resolved" || ticket.status === "closed";
7
+ const result = (source) => ({
8
+ ticketId: source.ticketId,
9
+ sourceGeneration: source.sourceGeneration,
10
+ resolutionCycle: source.resolutionCycle,
11
+ state: source.state,
12
+ ...(source.heldReason === undefined ? {} : { heldReason: source.heldReason }),
13
+ });
14
+ function permission(access, name, staff = false) {
15
+ text(access.principalId, "principalId");
16
+ if (!hasPermission(access, name))
17
+ throw new SupportDeskAuthorizationError(name);
18
+ if (staff && !hasPermission(access, supportPermissions.queueRead))
19
+ throw new SupportDeskAuthorizationError(supportPermissions.queueRead);
20
+ }
21
+ function boundedRecord(row) {
22
+ if (encodedBytes(row) > 60_000)
23
+ throw new SupportDeskLimitError("serialized_record", 60_000);
24
+ if (row.kind === "resolution_learning_source" &&
25
+ maximumResolutionLearningSourceBytes(row) > 60_000)
26
+ throw new SupportDeskLimitError("serialized_record", 60_000);
27
+ assertResolutionLearningRecord(row, { partition: row.partition, id: row.id });
28
+ }
29
+ function sourceAction(source, next) {
30
+ boundedRecord(next);
31
+ return { action: "putIfUnchanged", version: source.version, value: next };
32
+ }
33
+ export async function createResolutionLearningSource(ticket) {
34
+ const row = {
35
+ kind: "resolution_learning_source",
36
+ partition: ticket.id,
37
+ id: "learning-source",
38
+ ticketId: ticket.id,
39
+ sourceId: await fingerprint({
40
+ system: "pegma.support-desk",
41
+ ticketId: ticket.id,
42
+ }),
43
+ resolutionCycle: 1,
44
+ sourceGeneration: 0,
45
+ workGeneration: 1,
46
+ generationCount: 0,
47
+ reviewBindingCount: 0,
48
+ state: "unverified",
49
+ createdAt: ticket.createdAt,
50
+ updatedAt: ticket.updatedAt,
51
+ destinations: [],
52
+ reconciliationPending: false,
53
+ };
54
+ boundedRecord(row);
55
+ return row;
56
+ }
57
+ export async function readResolutionSource(records, ticketId) {
58
+ const stored = await records.getVersioned(resolutionLearningSourceKey(ticketId));
59
+ if (stored === null)
60
+ return null;
61
+ if (stored.value.kind !== "resolution_learning_source")
62
+ throw new SupportDeskConflictError("Learning source key contains another record");
63
+ return { value: stored.value, version: stored.version };
64
+ }
65
+ /** Always runs for existing enrollment, independent of current configuration. */
66
+ export async function planResolutionLearningTicketMutation(records, before, after, invalidateVisible = false) {
67
+ const source = await readResolutionSource(records, after.id);
68
+ const ticketAction = {
69
+ action: "putIfUnchanged",
70
+ version: before.version,
71
+ value: { ...before.value, ticket: after },
72
+ };
73
+ const reopened = terminal(before.value.ticket) && !terminal(after);
74
+ if (source === null || (!reopened && !invalidateVisible))
75
+ return { actions: [ticketAction], additionalRows: 0, source };
76
+ const next = {
77
+ ...source.value,
78
+ resolutionCycle: source.value.resolutionCycle + (reopened ? 1 : 0),
79
+ sourceGeneration: source.value.sourceGeneration + 1,
80
+ workGeneration: source.value.workGeneration + 1,
81
+ state: source.value.state === "withdrawn" ? "withdrawn" : "held",
82
+ heldReason: source.value.state === "withdrawn"
83
+ ? (source.value.heldReason ?? "withdrawn")
84
+ : reopened
85
+ ? "reopened"
86
+ : "visible_source_changed",
87
+ updatedAt: after.updatedAt,
88
+ reconciliationPending: source.value.destinations.length > 0,
89
+ destinations: source.value.destinations.map((entry) => ({
90
+ ...entry,
91
+ reconciliation: "pending",
92
+ })),
93
+ };
94
+ return {
95
+ actions: [ticketAction, sourceAction(source, next)],
96
+ additionalRows: 0,
97
+ source: { value: next, version: source.version },
98
+ };
99
+ }
100
+ async function trustedWait(milliseconds, invoke) {
101
+ const controller = new AbortController();
102
+ let timer;
103
+ try {
104
+ return await Promise.race([
105
+ Promise.resolve().then(() => invoke(controller.signal)),
106
+ new Promise((resolve) => {
107
+ timer = setTimeout(() => {
108
+ controller.abort();
109
+ resolve(null);
110
+ }, milliseconds);
111
+ }),
112
+ ]);
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ finally {
118
+ if (timer !== undefined)
119
+ clearTimeout(timer);
120
+ }
121
+ }
122
+ function heldSource(source, now, reason, pending) {
123
+ return {
124
+ ...source,
125
+ state: source.state === "eligible"
126
+ ? "eligible"
127
+ : source.state === "withdrawn"
128
+ ? "withdrawn"
129
+ : "held",
130
+ heldReason: source.state === "withdrawn" ? (source.heldReason ?? reason) : reason,
131
+ updatedAt: now,
132
+ workGeneration: source.workGeneration + 1,
133
+ ...(pending === undefined ? {} : { pendingProofReference: pending }),
134
+ };
135
+ }
136
+ async function evidencePlan(deps, stored, ticket, evidence, now, product, pending, capacity, rows) {
137
+ const source = stored.value;
138
+ const hold = (reason) => {
139
+ const next = heldSource(source, now, reason, pending);
140
+ return {
141
+ actions: [sourceAction(stored, next)],
142
+ source: next,
143
+ additionalRows: 0,
144
+ };
145
+ };
146
+ if (source.state === "withdrawn")
147
+ return hold("withdrawn");
148
+ const workingSolutionMessageIds = evidence.kind === "customer_confirmation"
149
+ ? rows
150
+ .filter((row) => row.kind === "message" &&
151
+ row.message.visibility === "customer" &&
152
+ (row.message.authorKind === "staff" ||
153
+ row.message.authorKind === "assistant") &&
154
+ row.ordinal <= ticket.revision)
155
+ .sort((a, b) => b.ordinal - a.ordinal)
156
+ .slice(0, 1)
157
+ .map((row) => row.message.id)
158
+ : [evidence.solutionMessageId];
159
+ if (workingSolutionMessageIds.length === 0 &&
160
+ evidence.kind === "customer_confirmation")
161
+ workingSolutionMessageIds.push(evidence.lastVisibleMessageId);
162
+ if (product === null)
163
+ return hold("product_context_unavailable");
164
+ validateResolutionProduct(product);
165
+ validateResolutionEvidence(evidence);
166
+ if (timestamp(product.verifiedAt, "product.verifiedAt") >
167
+ timestamp(now, "acceptedAt") ||
168
+ timestamp(product.validUntil, "product.validUntil") <
169
+ timestamp(now, "acceptedAt"))
170
+ return hold("product_context_unverified");
171
+ const stableEvidence = evidence.kind === "customer_confirmation"
172
+ ? {
173
+ kind: evidence.kind,
174
+ actorId: evidence.actorId,
175
+ lastVisibleMessageId: evidence.lastVisibleMessageId,
176
+ }
177
+ : evidence.kind === "staff_verified_fix"
178
+ ? {
179
+ kind: evidence.kind,
180
+ actorId: evidence.actorId,
181
+ solutionMessageId: evidence.solutionMessageId,
182
+ verificationReference: evidence.verificationReference,
183
+ }
184
+ : {
185
+ kind: evidence.kind,
186
+ actorId: evidence.actorId,
187
+ solutionMessageId: evidence.solutionMessageId,
188
+ executionId: evidence.test.executionId,
189
+ runnerId: evidence.test.runnerId,
190
+ testId: evidence.test.testId,
191
+ productBinding: evidence.test.productBinding,
192
+ };
193
+ const stableProduct = {
194
+ productId: product.productId,
195
+ version: product.version,
196
+ buildId: product.buildId,
197
+ binding: product.binding,
198
+ };
199
+ const proofIdentity = await fingerprint({
200
+ resolutionCycle: source.resolutionCycle,
201
+ evidence: stableEvidence,
202
+ product: stableProduct,
203
+ });
204
+ if (source.state === "eligible" && source.proofIdentity === proofIdentity)
205
+ return { actions: [], source, additionalRows: 0 };
206
+ if (!capacity || source.generationCount >= deps.options.maxGenerations)
207
+ return hold("learning_capacity");
208
+ const sourceGeneration = source.sourceGeneration + 1;
209
+ const evidenceFingerprint = await fingerprint(stableEvidence);
210
+ const productContextFingerprint = await fingerprint(stableProduct);
211
+ const sourceBindingHash = await fingerprint({
212
+ ticketId: ticket.id,
213
+ resolutionCycle: source.resolutionCycle,
214
+ sourceGeneration,
215
+ evidenceFingerprint,
216
+ productContextFingerprint,
217
+ });
218
+ const { pendingProofReference: _pending, heldReason: _held, ...base } = source;
219
+ const next = {
220
+ ...base,
221
+ sourceGeneration,
222
+ workGeneration: source.workGeneration + 1,
223
+ generationCount: source.generationCount + 1,
224
+ state: "eligible",
225
+ updatedAt: now,
226
+ resolvedAt: ticket.resolvedAt,
227
+ acceptedTicketRevision: ticket.revision,
228
+ proofIdentity,
229
+ sourceBindingHash,
230
+ evidenceFingerprint,
231
+ productContextFingerprint,
232
+ evidence,
233
+ product,
234
+ reconciliationPending: source.destinations.length > 0,
235
+ destinations: source.destinations.map((entry) => ({
236
+ ...entry,
237
+ reconciliation: "pending",
238
+ })),
239
+ };
240
+ const work = {
241
+ kind: "resolution_learning_work",
242
+ partition: ticket.id,
243
+ id: resolutionLearningWorkKey(ticket.id, sourceGeneration).id,
244
+ ticketId: ticket.id,
245
+ sourceGeneration,
246
+ resolutionCycle: source.resolutionCycle,
247
+ sourceBindingHash,
248
+ workGeneration: 1,
249
+ acceptedTicketRevision: ticket.revision,
250
+ workingSolutionMessageIds,
251
+ createdAt: now,
252
+ deadline: new Date(timestamp(now, "now") + deps.options.maxWorkMilliseconds).toISOString(),
253
+ evidence,
254
+ product,
255
+ status: "pending",
256
+ stage: "model",
257
+ modelAttempts: [],
258
+ claimGeneration: 0,
259
+ };
260
+ try {
261
+ boundedRecord(next);
262
+ boundedRecord(work);
263
+ }
264
+ catch (error) {
265
+ if (error instanceof SupportDeskLimitError)
266
+ return hold("learning_capacity");
267
+ throw error;
268
+ }
269
+ return {
270
+ actions: [sourceAction(stored, next), { action: "insert", value: work }],
271
+ source: next,
272
+ additionalRows: 1,
273
+ };
274
+ }
275
+ export function createResolutionLearningServices(deps) {
276
+ const { records, options, clock } = deps;
277
+ async function load(ticketId) {
278
+ text(ticketId, "ticketId");
279
+ const ticket = await records.getVersioned({
280
+ partition: ticketId,
281
+ id: "ticket",
282
+ });
283
+ if (ticket?.value.kind !== "ticket")
284
+ throw new SupportDeskNotFoundError();
285
+ const source = await readResolutionSource(records, ticketId);
286
+ if (source === null)
287
+ throw new SupportDeskNotFoundError();
288
+ const rows = await records.list(ticketId);
289
+ if (rows.length > deps.limits.maxRecordsPerTicket)
290
+ throw new SupportDeskLimitError("ticket_partition", deps.limits.maxRecordsPerTicket);
291
+ return {
292
+ ticket: { value: ticket.value, version: ticket.version },
293
+ source,
294
+ rows,
295
+ };
296
+ }
297
+ async function context(ticket, source, actorId, now) {
298
+ const trust = {
299
+ ticketId: ticket.id,
300
+ ticketRevision: ticket.revision,
301
+ resolutionCycle: source.resolutionCycle,
302
+ resolvedAt: ticket.resolvedAt,
303
+ actorId,
304
+ acceptedAt: now,
305
+ };
306
+ const product = options.productContext === undefined
307
+ ? null
308
+ : await trustedWait(options.trustTimeoutMilliseconds, (signal) => options.productContext.resolve(trust, signal));
309
+ if (product !== null) {
310
+ try {
311
+ validateResolutionProduct(product);
312
+ return { product: snapshot(product), trust };
313
+ }
314
+ catch {
315
+ return { product: null, trust };
316
+ }
317
+ }
318
+ return { product: null, trust };
319
+ }
320
+ function room(ticket, rows, extra) {
321
+ try {
322
+ deps.admission(ticket, rows, extra);
323
+ return true;
324
+ }
325
+ catch (error) {
326
+ if (error instanceof SupportDeskLimitError)
327
+ return false;
328
+ throw error;
329
+ }
330
+ }
331
+ async function commandFingerprint(command, actorId, operation) {
332
+ return fingerprint({ operation, actorId, command });
333
+ }
334
+ function receipt(command, hash, outcome) {
335
+ return {
336
+ action: "insert",
337
+ value: {
338
+ kind: "resolution_learning_receipt",
339
+ partition: command.ticketId,
340
+ id: `learning-command:${command.commandId}`,
341
+ fingerprint: hash,
342
+ result: outcome,
343
+ },
344
+ };
345
+ }
346
+ async function replay(command, hash) {
347
+ const found = await records.get({
348
+ partition: command.ticketId,
349
+ id: `learning-command:${command.commandId}`,
350
+ });
351
+ if (found === null)
352
+ return null;
353
+ if (found.kind !== "resolution_learning_receipt" ||
354
+ found.fingerprint !== hash)
355
+ throw new SupportDeskConflictError("Learning command ID reused");
356
+ return snapshot(found.result);
357
+ }
358
+ function validateCommand(command) {
359
+ text(command.ticketId, "ticketId");
360
+ text(command.commandId, "commandId");
361
+ text(command.correlationId, "correlationId");
362
+ }
363
+ async function reconciliationView(ticketId) {
364
+ for (let attempt = 0; attempt < deps.maxConflictAttempts; attempt += 1) {
365
+ const source = await readResolutionSource(records, ticketId);
366
+ if (source === null)
367
+ throw new SupportDeskNotFoundError();
368
+ const destinations = await Promise.all(source.value.destinations.map(async (destination) => {
369
+ const stored = await records.get(resolutionLearningWorkKey(ticketId, destination.sourceGeneration));
370
+ const work = stored?.kind === "resolution_learning_work" ? stored : null;
371
+ const request = work?.exactRequest ?? work?.withdrawnRequest;
372
+ if (work === null ||
373
+ request === undefined ||
374
+ work.ticketId !== ticketId ||
375
+ work.sourceGeneration !== destination.sourceGeneration ||
376
+ work.stepId !== destination.stepId ||
377
+ work.requestHash !== destination.requestHash ||
378
+ request.articleId !== destination.articleId)
379
+ return {
380
+ ...snapshot(destination),
381
+ requestIdentity: null,
382
+ requestIdentityStatus: "unavailable",
383
+ };
384
+ return {
385
+ ...snapshot(destination),
386
+ requestIdentityStatus: "available",
387
+ requestIdentity: {
388
+ operation: request.operation,
389
+ commandId: request.commandId,
390
+ articleId: request.articleId,
391
+ originalExpectedGeneration: request.originalExpectedGeneration,
392
+ },
393
+ };
394
+ }));
395
+ const latest = await readResolutionSource(records, ticketId);
396
+ if (latest?.version !== source.version)
397
+ continue;
398
+ return {
399
+ ticketId,
400
+ sourceGeneration: source.value.sourceGeneration,
401
+ sourceWorkGeneration: source.value.workGeneration,
402
+ state: source.value.state,
403
+ reconciliationPending: source.value.reconciliationPending,
404
+ destinations,
405
+ };
406
+ }
407
+ throw new SupportDeskConflictError("Source changed during reconciliation read");
408
+ }
409
+ async function invalidate(access, input, privacy) {
410
+ permission(access, supportPermissions.learningWithdraw, true);
411
+ const command = snapshot(fields(input, "source invalidation", [
412
+ "ticketId",
413
+ "commandId",
414
+ "correlationId",
415
+ "reason",
416
+ ]));
417
+ validateCommand(command);
418
+ const reasons = privacy
419
+ ? ["privacy_withdrawal", "source_access_revoked", "source_deleted"]
420
+ : ["evidence_revoked", "product_binding_revoked", "check_policy_revoked"];
421
+ if (!reasons.includes(command.reason))
422
+ throw new TypeError("invalid source invalidation reason");
423
+ const hash = await commandFingerprint(command, access.principalId, privacy ? "withdraw" : "invalidate");
424
+ for (let attempt = 0; attempt < deps.maxConflictAttempts; attempt += 1) {
425
+ const prior = await replay(command, hash);
426
+ if (prior !== null)
427
+ return prior;
428
+ const { ticket, source, rows } = await load(command.ticketId);
429
+ if (source.value.state === "withdrawn")
430
+ return result(source.value);
431
+ if (privacy)
432
+ deps.withdrawalAdmission(ticket.value.ticket, rows, 2);
433
+ else
434
+ deps.admission(ticket.value.ticket, rows, 2);
435
+ const now = clock.now();
436
+ const { evidence: _e, product: _p, pendingProofReference: _pending, proofIdentity: _proof, evidenceFingerprint: _ef, productContextFingerprint: _pf, acceptedTicketRevision: _revision, resolvedAt: _resolved, sourceBindingHash: _binding, ...base } = source.value;
437
+ const next = {
438
+ ...(privacy ? base : source.value),
439
+ state: privacy ? "withdrawn" : "held",
440
+ heldReason: command.reason,
441
+ sourceGeneration: source.value.sourceGeneration + 1,
442
+ workGeneration: source.value.workGeneration + 1,
443
+ updatedAt: now,
444
+ reconciliationPending: source.value.destinations.length > 0,
445
+ destinations: source.value.destinations.map((entry) => ({
446
+ ...entry,
447
+ reconciliation: "pending",
448
+ })),
449
+ };
450
+ const actions = [
451
+ {
452
+ action: "putIfUnchanged",
453
+ version: ticket.version,
454
+ value: ticket.value,
455
+ },
456
+ sourceAction(source, next),
457
+ ];
458
+ if (privacy) {
459
+ for (const candidate of rows) {
460
+ if (candidate.kind === "resolution_learning_review") {
461
+ const review = await records.getVersioned({
462
+ partition: command.ticketId,
463
+ id: candidate.id,
464
+ });
465
+ if (review?.value.kind !== "resolution_learning_review")
466
+ throw new SupportDeskConflictError();
467
+ const { reason: _reason, ...minimalReview } = review.value;
468
+ actions.push({
469
+ action: "putIfUnchanged",
470
+ version: review.version,
471
+ value: {
472
+ ...minimalReview,
473
+ reviewerId: "withdrawn",
474
+ decision: "reject",
475
+ },
476
+ });
477
+ }
478
+ if (candidate.kind !== "resolution_learning_work")
479
+ continue;
480
+ const current = await records.getVersioned({
481
+ partition: command.ticketId,
482
+ id: candidate.id,
483
+ });
484
+ if (current?.value.kind !== "resolution_learning_work")
485
+ throw new SupportDeskConflictError();
486
+ const { candidate: _c, evidence: _evidence, product: _product, exactRequest: _request, verifiedCheckReceipt: _checks, ...minimal } = current.value;
487
+ const request = _request === undefined
488
+ ? undefined
489
+ : (({ draftPayload: _body, ...identity }) => identity)(_request);
490
+ actions.push({
491
+ action: "putIfUnchanged",
492
+ version: current.version,
493
+ value: {
494
+ ...minimal,
495
+ workingSolutionMessageIds: [],
496
+ modelAttempts: [],
497
+ usageTombstones: current.value.usageTombstones ??
498
+ current.value.modelAttempts.map((entry) => ({
499
+ stepId: entry.stepId,
500
+ requestHash: entry.requestHash,
501
+ usageReservationId: entry.binding.usageReservationId,
502
+ accounting: entry.accounting,
503
+ })),
504
+ ...(request === undefined ? {} : { withdrawnRequest: request }),
505
+ status: "held",
506
+ heldReason: command.reason,
507
+ workGeneration: minimal.workGeneration + 1,
508
+ },
509
+ });
510
+ }
511
+ }
512
+ actions.push(deps.audit(command, ticket.value.ticket, access.principalId, now, privacy
513
+ ? "support.learning.withdrawn"
514
+ : "support.learning.evidence_invalidated"), receipt(command, hash, result(next)));
515
+ if ((await records.transact(command.ticketId, actions)).committed)
516
+ return result(next);
517
+ }
518
+ const prior = await replay(command, hash);
519
+ if (prior !== null)
520
+ return prior;
521
+ throw new SupportDeskConflictError();
522
+ }
523
+ const application = {
524
+ async enrollResolutionLearning(access, input) {
525
+ permission(access, supportPermissions.learningEvidenceSubmit, true);
526
+ const command = snapshot(fields(input, "enroll command", [
527
+ "ticketId",
528
+ "commandId",
529
+ "correlationId",
530
+ "expectedRevision",
531
+ ]));
532
+ validateCommand(command);
533
+ integer(command.expectedRevision, "expectedRevision", 1);
534
+ const hash = await commandFingerprint(command, access.principalId, "enroll");
535
+ for (let attempt = 0; attempt < deps.maxConflictAttempts; attempt += 1) {
536
+ const previous = await replay(command, hash);
537
+ if (previous !== null)
538
+ return previous;
539
+ const ticket = await records.getVersioned({
540
+ partition: command.ticketId,
541
+ id: "ticket",
542
+ });
543
+ if (ticket?.value.kind !== "ticket")
544
+ throw new SupportDeskNotFoundError();
545
+ const source = await readResolutionSource(records, command.ticketId);
546
+ if (source !== null)
547
+ return result(source.value);
548
+ if (!options.enabled)
549
+ throw new SupportDeskConflictError("Learning enrollment disabled");
550
+ if (ticket.value.ticket.revision !== command.expectedRevision)
551
+ throw new SupportDeskConflictError("Ticket revision changed");
552
+ const rows = await records.list(command.ticketId);
553
+ const created = await createResolutionLearningSource(ticket.value.ticket);
554
+ deps.admission(ticket.value.ticket, [...rows, created], 2);
555
+ if ((await records.transact(command.ticketId, [
556
+ {
557
+ action: "putIfUnchanged",
558
+ version: ticket.version,
559
+ value: ticket.value,
560
+ },
561
+ { action: "insert", value: created },
562
+ deps.audit(command, ticket.value.ticket, access.principalId, clock.now(), "support.learning.enrolled"),
563
+ receipt(command, hash, result(created)),
564
+ ])).committed)
565
+ return result(created);
566
+ }
567
+ throw new SupportDeskConflictError();
568
+ },
569
+ async submitResolutionEvidence(access, input) {
570
+ permission(access, supportPermissions.learningEvidenceSubmit, true);
571
+ const command = snapshot(fields(input, "evidence command", [
572
+ "ticketId",
573
+ "commandId",
574
+ "correlationId",
575
+ "expectedRevision",
576
+ "resolutionCycle",
577
+ "evidence",
578
+ ]));
579
+ validateCommand(command);
580
+ integer(command.expectedRevision, "expectedRevision", 1);
581
+ integer(command.resolutionCycle, "resolutionCycle", 1);
582
+ const raw = dataObject(command.evidence, "evidence input");
583
+ if (raw.kind === "staff_verified_fix")
584
+ fields(raw, "staff proof", [
585
+ "kind",
586
+ "solutionMessageId",
587
+ "verificationReference",
588
+ ]);
589
+ else if (raw.kind === "successful_test")
590
+ fields(raw, "test proof", ["kind", "solutionMessageId", "receipt"]);
591
+ else
592
+ throw new TypeError("invalid evidence kind");
593
+ text(raw.solutionMessageId, "solutionMessageId");
594
+ text(raw.kind === "staff_verified_fix"
595
+ ? raw.verificationReference
596
+ : raw.receipt, "verification reference");
597
+ const hash = await commandFingerprint(command, access.principalId, "evidence");
598
+ for (let attempt = 0; attempt < deps.maxConflictAttempts; attempt += 1) {
599
+ const previous = await replay(command, hash);
600
+ if (previous !== null)
601
+ return previous;
602
+ const { ticket: versioned, source, rows, } = await load(command.ticketId);
603
+ const ticket = versioned.value.ticket;
604
+ if (ticket.revision !== command.expectedRevision ||
605
+ source.value.resolutionCycle !== command.resolutionCycle ||
606
+ !terminal(ticket) ||
607
+ ticket.resolvedAt === undefined)
608
+ throw new SupportDeskConflictError("Resolution fence changed");
609
+ const solution = rows.find((row) => row.kind === "message" &&
610
+ row.message.id === raw.solutionMessageId &&
611
+ row.message.visibility === "customer" &&
612
+ row.ordinal <= ticket.revision);
613
+ if (solution === undefined)
614
+ throw new SupportDeskConflictError("Visible solution message missing");
615
+ const now = clock.now();
616
+ const trusted = await context(ticket, source.value, access.principalId, now);
617
+ let evidence = null;
618
+ if (command.evidence.kind === "staff_verified_fix")
619
+ evidence = { ...command.evidence, actorId: access.principalId };
620
+ else if (trusted.product !== null &&
621
+ options.testReceipts !== undefined) {
622
+ const test = await trustedWait(options.trustTimeoutMilliseconds, (signal) => options.testReceipts.verify(command.evidence.kind === "successful_test"
623
+ ? command.evidence.receipt
624
+ : "", { ...trusted.trust, product: trusted.product }, signal));
625
+ if (test !== null) {
626
+ const proposed = {
627
+ kind: "successful_test",
628
+ actorId: access.principalId,
629
+ solutionMessageId: command.evidence.solutionMessageId,
630
+ test,
631
+ };
632
+ try {
633
+ validateResolutionEvidence(proposed);
634
+ if (test.productBinding === trusted.product.binding &&
635
+ timestamp(test.verifiedAt, "test.verifiedAt") <=
636
+ timestamp(now, "acceptedAt") &&
637
+ timestamp(test.validUntil, "test.validUntil") >=
638
+ timestamp(now, "acceptedAt") &&
639
+ timestamp(test.executedAt, "executedAt") <=
640
+ timestamp(now, "acceptedAt"))
641
+ evidence = snapshot(proposed);
642
+ }
643
+ catch {
644
+ /* Untrusted receipt cannot qualify. */
645
+ }
646
+ }
647
+ }
648
+ deps.admission(ticket, rows, 2);
649
+ const plan = evidence === null
650
+ ? {
651
+ source: heldSource(source.value, now, "test_receipt_unverified", command.commandId),
652
+ actions: [],
653
+ additionalRows: 0,
654
+ }
655
+ : await evidencePlan(deps, source, ticket, evidence, now, trusted.product, command.commandId, room(ticket, rows, 3), rows);
656
+ const actions = [
657
+ {
658
+ action: "putIfUnchanged",
659
+ version: versioned.version,
660
+ value: versioned.value,
661
+ },
662
+ ...(evidence === null
663
+ ? [sourceAction(source, plan.source)]
664
+ : plan.actions),
665
+ deps.audit(command, ticket, access.principalId, now, "support.learning.evidence_recorded"),
666
+ receipt(command, hash, result(plan.source)),
667
+ ];
668
+ if ((await records.transact(command.ticketId, actions)).committed)
669
+ return result(plan.source);
670
+ }
671
+ const previous = await replay(command, hash);
672
+ if (previous !== null)
673
+ return previous;
674
+ throw new SupportDeskConflictError();
675
+ },
676
+ async readResolutionLearningSource(access, ticketId) {
677
+ permission(access, supportPermissions.learningRead, true);
678
+ return snapshot((await load(ticketId)).source.value);
679
+ },
680
+ async readResolutionLearningWork(access, key) {
681
+ permission(access, supportPermissions.learningRead, true);
682
+ await load(key.ticketId);
683
+ integer(key.sourceGeneration, "sourceGeneration", 1);
684
+ const work = await records.get(resolutionLearningWorkKey(key.ticketId, key.sourceGeneration));
685
+ if (work?.kind !== "resolution_learning_work")
686
+ throw new SupportDeskNotFoundError();
687
+ return snapshot(work);
688
+ },
689
+ invalidateResolutionEvidence: (access, command) => invalidate(access, command, false),
690
+ withdrawResolutionLearningSource: (access, command) => invalidate(access, command, true),
691
+ ...createResolutionLearningWorkerServices(deps, load),
692
+ async readResolutionLearningReconciliation(access, ticketId) {
693
+ permission(access, supportPermissions.learningProcess);
694
+ text(ticketId, "ticketId");
695
+ return reconciliationView(ticketId);
696
+ },
697
+ async recordResolutionLearningReconciliation(access, input, guard) {
698
+ permission(access, supportPermissions.learningProcess);
699
+ if (typeof guard !== "function")
700
+ throw new TypeError("Reconciliation requires current full-provenance confirmation");
701
+ const command = snapshot(fields(input, "reconciliation command", [
702
+ "ticketId",
703
+ "expectedSourceGeneration",
704
+ "expectedSourceWorkGeneration",
705
+ "articleId",
706
+ "stepId",
707
+ "requestHash",
708
+ "disposition",
709
+ ], ["proof"]));
710
+ for (const name of [
711
+ "ticketId",
712
+ "articleId",
713
+ "stepId",
714
+ "requestHash",
715
+ ])
716
+ text(command[name], name);
717
+ integer(command.expectedSourceGeneration, "expectedSourceGeneration", 0);
718
+ integer(command.expectedSourceWorkGeneration, "expectedSourceWorkGeneration", 1);
719
+ if (![
720
+ "blocked",
721
+ "retired",
722
+ "unaffected_publication",
723
+ "write_rejected",
724
+ ].includes(command.disposition))
725
+ throw new TypeError("Invalid reconciliation disposition");
726
+ if (command.disposition !== "blocked" && command.proof === undefined)
727
+ throw new TypeError("Terminal reconciliation needs authoritative receipt, cancellation, or revision fence");
728
+ if (command.proof !== undefined) {
729
+ const proof = fields(command.proof, "reconciliation proof", [
730
+ "kind",
731
+ "binding",
732
+ ]);
733
+ if (![
734
+ "terminal_receipt",
735
+ "revision_fence",
736
+ "trusted_cancellation",
737
+ ].includes(proof.kind))
738
+ throw new TypeError("Invalid reconciliation proof");
739
+ text(proof.binding, "proof.binding");
740
+ }
741
+ const hash = await fingerprint(command);
742
+ for (let attempt = 0; attempt < deps.maxConflictAttempts; attempt += 1) {
743
+ const source = await readResolutionSource(records, command.ticketId);
744
+ if (source === null)
745
+ throw new SupportDeskNotFoundError();
746
+ if (source.value.sourceGeneration !== command.expectedSourceGeneration)
747
+ throw new SupportDeskConflictError("Source generation changed during reconciliation");
748
+ const destination = source.value.destinations.find((entry) => entry.articleId === command.articleId &&
749
+ entry.stepId === command.stepId &&
750
+ entry.requestHash === command.requestHash);
751
+ if (destination === undefined)
752
+ throw new SupportDeskConflictError("Reconciliation destination changed");
753
+ if (command.disposition !== "blocked") {
754
+ const projected = await reconciliationView(command.ticketId);
755
+ if (projected.sourceGeneration !== source.value.sourceGeneration ||
756
+ projected.sourceWorkGeneration !== source.value.workGeneration)
757
+ continue;
758
+ if (projected.destinations.find((entry) => entry.articleId === command.articleId)?.requestIdentityStatus !== "available")
759
+ throw new SupportDeskConflictError("Reconciliation request identity unavailable");
760
+ }
761
+ if (destination.lastReconciliation?.fingerprint === hash) {
762
+ await guard();
763
+ return reconciliationView(command.ticketId);
764
+ }
765
+ if (source.value.workGeneration !== command.expectedSourceWorkGeneration)
766
+ throw new SupportDeskConflictError("Source reconciliation version changed");
767
+ const destinations = source.value.destinations.map((entry) => entry !== destination
768
+ ? entry
769
+ : {
770
+ ...entry,
771
+ reconciliation: command.disposition === "blocked"
772
+ ? "blocked"
773
+ : "confirmed",
774
+ lastReconciliation: {
775
+ fingerprint: hash,
776
+ disposition: command.disposition,
777
+ observedAt: clock.now(),
778
+ ...(command.proof === undefined
779
+ ? {}
780
+ : { proof: command.proof }),
781
+ },
782
+ });
783
+ const next = {
784
+ ...source.value,
785
+ workGeneration: source.value.workGeneration + 1,
786
+ destinations,
787
+ reconciliationPending: destinations.some((entry) => entry.reconciliation !== "confirmed"),
788
+ };
789
+ boundedRecord(next);
790
+ await guard();
791
+ if ((await records.transact(command.ticketId, [
792
+ sourceAction(source, next),
793
+ ])).committed)
794
+ return reconciliationView(command.ticketId);
795
+ }
796
+ throw new SupportDeskConflictError();
797
+ },
798
+ async confirmResolutionLearningSource(access, fence) {
799
+ permission(access, supportPermissions.learningProcess);
800
+ try {
801
+ const { ticket, source } = await load(fence.ticketId);
802
+ const latest = await readResolutionSource(records, fence.ticketId);
803
+ return (latest?.version === source.version &&
804
+ terminal(ticket.value.ticket) &&
805
+ source.value.state === "eligible" &&
806
+ source.value.sourceGeneration === fence.sourceGeneration &&
807
+ source.value.resolutionCycle === fence.resolutionCycle &&
808
+ source.value.sourceBindingHash === fence.sourceBindingHash &&
809
+ source.value.resolvedAt === ticket.value.ticket.resolvedAt);
810
+ }
811
+ catch (error) {
812
+ if (error instanceof SupportDeskNotFoundError)
813
+ return false;
814
+ throw error;
815
+ }
816
+ },
817
+ async confirmResolutionLearningTargetReview(access, command) {
818
+ permission(access, supportPermissions.learningProcess);
819
+ validateResolutionTarget(command.target);
820
+ text(command.policyVersion, "policyVersion");
821
+ try {
822
+ const { ticket, source } = await load(command.ticketId);
823
+ if (!terminal(ticket.value.ticket) ||
824
+ source.value.state !== "eligible" ||
825
+ source.value.resolvedAt !== ticket.value.ticket.resolvedAt)
826
+ return false;
827
+ const review = await records.get(await resolutionLearningReviewKey(source.value, command.target, command.policyVersion));
828
+ const latest = await readResolutionSource(records, command.ticketId);
829
+ return (latest?.version === source.version &&
830
+ review?.kind === "resolution_learning_review" &&
831
+ review.sourceGeneration === source.value.sourceGeneration &&
832
+ review.sourceBindingHash === source.value.sourceBindingHash &&
833
+ review.decision === "approve" &&
834
+ review.policyVersion === command.policyVersion &&
835
+ canonical(review.target) === canonical(command.target));
836
+ }
837
+ catch (error) {
838
+ if (error instanceof SupportDeskNotFoundError)
839
+ return false;
840
+ throw error;
841
+ }
842
+ },
843
+ };
844
+ return {
845
+ application,
846
+ async customerConfirmation(source, ticket, actorId, lastVisibleMessageId, commandId, rows) {
847
+ if (source === null)
848
+ return { actions: [], additionalRows: 0 };
849
+ const trusted = await context(ticket, source.value, actorId, ticket.updatedAt);
850
+ const humanTail = (ticket.firstStaffRespondedAt === undefined &&
851
+ rows.some((row) => row.kind === "ticket_quota" && row.assistantManaged === true)
852
+ ? 4
853
+ : 0) + (source.value.state === "withdrawn" ? 0 : 2);
854
+ const plan = await evidencePlan(deps, source, ticket, { kind: "customer_confirmation", actorId, lastVisibleMessageId }, ticket.updatedAt, trusted.product, `success:${commandId}`, rows.length + 4 <= deps.limits.maxRecordsPerTicket - humanTail, rows);
855
+ return { actions: plan.actions, additionalRows: plan.additionalRows };
856
+ },
857
+ };
858
+ }
859
+ import { createResolutionLearningWorkerServices } from "./resolution-learning-worker.js";
860
+ //# sourceMappingURL=resolution-learning.js.map