@xema/omni-protocol 0.1.82 → 0.1.84

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.
@@ -9,7 +9,7 @@
9
9
  // Each list is pinned to its type both ways -- a member the type lacks, or a member the list
10
10
  // lacks, fails to compile -- so what the validators accept cannot drift from what the
11
11
  // declarations say.
12
- import { RECORDING_ACTIONS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, MONITORING_BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HANDLING_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, TASK_COMMAND_NAMES, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
12
+ import { RECORDING_ACTIONS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, MONITORING_BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, INTERACTION_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, TASK_COMMAND_NAMES, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
13
13
  export class ProtocolConformanceError extends Error {
14
14
  violations;
15
15
  constructor(violations, summary = "Adapter violates the Omni protocol") {
@@ -57,7 +57,7 @@ const BREAK_APPROVALS = membersOf({
57
57
  const TEAM_AVAILABILITIES = membersOf({
58
58
  ready: true, "on-task": true, "on-break": true, reserved: true, "signed-out": true,
59
59
  });
60
- const HANDLING_STEPS = membersOf({
60
+ const INTERACTION_STEPS = membersOf({
61
61
  queued: true, offered: true, answered: true, held: true, muted: true, transferred: true, conferenced: true, unanswered: true,
62
62
  });
63
63
  const CUSTOM_UI_CONTROLS = membersOf({ button: true, toggle: true, "menu-item": true });
@@ -85,7 +85,7 @@ const MEMBER_BREAKS = membersOf({
85
85
  const OFFERABLE_PHASES = membersOf({
86
86
  pending: true,
87
87
  });
88
- const PREVIEW_DEADLINES = membersOf({ calls: true, expires: true });
88
+ const PREVIEW_DEADLINES = membersOf({ calls: true, "host-calls": true, waits: true });
89
89
  const PHONES = membersOf({ softphone: true, deskPhone: true });
90
90
  const TEAM_CAPABILITIES = membersOf({ breakControl: true, leadAssistControl: true, policyControl: true, monitorControl: true });
91
91
  const MONITOR_MODES = membersOf({ monitor: true, whisper: true, barge: true });
@@ -395,6 +395,78 @@ export function validatePhone(value, manifest, path = "context.phone") {
395
395
  function manifestDials(manifest) {
396
396
  return isPlainObject(manifest) ? Array.isArray(manifest.dialOutcomes) : undefined;
397
397
  }
398
+ /** Undefined explicitly means unavailable; a returned estimate is not a clock guarantee. */
399
+ export function validateProviderTimeEstimate(value, scope, path = "estimate") {
400
+ const into = new Collector();
401
+ if (!isPlainObject(scope)) {
402
+ into.add("timeEstimate.scope", path, "provider/login scope is required");
403
+ return into.violations;
404
+ }
405
+ for (const key of ["providerId", "loginId"])
406
+ into.filled(scope[key], "timeEstimate.scope", `scope.${key}`, "explicit provider/login scope is required");
407
+ if (value === undefined)
408
+ return into.violations;
409
+ if (!isPlainObject(value)) {
410
+ into.add("timeEstimate.shape", path, "expected a provider time estimate or undefined");
411
+ return into.violations;
412
+ }
413
+ for (const key of ["providerId", "loginId"])
414
+ into.require(value[key] === scope[key], "timeEstimate.scope", `${path}.${key}`, "estimate must match the requested provider/login");
415
+ into.timestamp(value.at, "timeEstimate.at", `${path}.at`);
416
+ into.filled(value.clockId, "timeEstimate.clock", `${path}.clockId`, "estimate must identify its clock domain");
417
+ if (value.uncertaintyMs !== undefined)
418
+ into.require(typeof value.uncertaintyMs === "number" && Number.isFinite(value.uncertaintyMs) && value.uncertaintyMs >= 0, "timeEstimate.uncertainty", `${path}.uncertaintyMs`, "estimated uncertainty must be finite and nonnegative");
419
+ for (const key of Object.keys(value))
420
+ into.require(["providerId", "loginId", "at", "clockId", "uncertaintyMs"].includes(key), "timeEstimate.field", `${path}.${key}`, "unsupported estimate field");
421
+ return into.violations;
422
+ }
423
+ /** Validate host-local periodic clock-check configuration. Omission disables checking. */
424
+ export function validateProviderTimeCheckPolicy(value, path = "timeCheck") {
425
+ const into = new Collector();
426
+ if (value === undefined)
427
+ return into.violations;
428
+ if (!isPlainObject(value)) {
429
+ into.add("timeCheck.policy.shape", path, "expected an explicit clock-check policy");
430
+ return into.violations;
431
+ }
432
+ for (const field of ["intervalMs", "timeoutMs", "maxRoundTripMs", "maxSampleAgeMs"]) {
433
+ into.require(typeof value[field] === "number" && Number.isSafeInteger(value[field]) && value[field] > 0, "timeCheck.policy.duration", `${path}.${field}`, "expected positive safe integer milliseconds");
434
+ }
435
+ for (const key of Object.keys(value))
436
+ into.require(["intervalMs", "timeoutMs", "maxRoundTripMs", "maxSampleAgeMs"].includes(key), "timeCheck.policy.field", `${path}.${key}`, "unsupported policy field");
437
+ if (!into.violations.length)
438
+ into.require(value.maxRoundTripMs <= value.timeoutMs && value.timeoutMs <= value.intervalMs, "timeCheck.policy.order", path, "maxRoundTripMs <= timeoutMs <= intervalMs is required");
439
+ return into.violations;
440
+ }
441
+ export function validateProviderTimeCheckRequest(value, path = "request") {
442
+ const into = new Collector();
443
+ if (!isPlainObject(value)) {
444
+ into.add("timeCheck.request.shape", path, "expected a clock-check request");
445
+ return into.violations;
446
+ }
447
+ into.filled(value.requestId, "timeCheck.request.id", `${path}.requestId`, "a check needs a fresh request ID");
448
+ for (const key of Object.keys(value))
449
+ into.require(key === "requestId", "timeCheck.request.field", `${path}.${key}`, "unsupported request field");
450
+ return into.violations;
451
+ }
452
+ /** Checks response shape/correlation, not source-clock accuracy, round-trip delay or freshness. */
453
+ export function validateProviderTimeCheckResult(value, request, loginId, path = "result") {
454
+ const into = new Collector();
455
+ into.violations.push(...validateProviderTimeCheckRequest(request));
456
+ into.filled(loginId, "timeCheck.login", "loginId", "current authenticated login is required");
457
+ if (!isPlainObject(value)) {
458
+ into.add("timeCheck.result.shape", path, "expected a clock-check response");
459
+ return into.violations;
460
+ }
461
+ into.filled(value.requestId, "timeCheck.result.id", `${path}.requestId`, "response must identify its request");
462
+ into.require(isPlainObject(request) && value.requestId === request.requestId, "timeCheck.result.request", `${path}.requestId`, "response must match the outstanding check");
463
+ into.require(value.loginId === loginId, "timeCheck.result.login", `${path}.loginId`, "response belongs to the current login");
464
+ into.filled(value.clockId, "timeCheck.result.clock", `${path}.clockId`, "provider clock domain/incarnation is required");
465
+ into.timestamp(value.providerTime, "timeCheck.result.time", `${path}.providerTime`);
466
+ for (const key of Object.keys(value))
467
+ into.require(["requestId", "loginId", "clockId", "providerTime"].includes(key), "timeCheck.result.field", `${path}.${key}`, "unsupported response field");
468
+ return into.violations;
469
+ }
398
470
  export function validateManifest(manifest, path = "manifest") {
399
471
  const into = new Collector();
400
472
  if (!isPlainObject(manifest)) {
@@ -430,6 +502,10 @@ export function validateManifest(manifest, path = "manifest") {
430
502
  }
431
503
  if (channelValid)
432
504
  validateIdleCapabilities(manifest.idleCapabilities, manifest.channel, `${path}.idleCapabilities`, into);
505
+ if (manifest.timestampAuthority !== undefined)
506
+ into.require(manifest.timestampAuthority === "provider", "manifest.timestampAuthority", `${path}.timestampAuthority`, "provider timestamp authority must be explicit; omission makes no trust promise");
507
+ if (manifest.timeCheck !== undefined)
508
+ into.require(manifest.timeCheck === true, "manifest.timeCheck", `${path}.timeCheck`, "declare true or omit unsupported clock checks");
433
509
  validateDialOutcomes(manifest, `${path}.dialOutcomes`, into);
434
510
  validatePhones(manifest, `${path}.phones`, into);
435
511
  if (manifest.phaseLabels !== undefined) {
@@ -446,7 +522,8 @@ export function validateManifest(manifest, path = "manifest") {
446
522
  if (manifest.runningStepReports !== undefined) {
447
523
  into.require(manifest.runningStepReports === true, "manifest.runningStepReports", `${path}.runningStepReports`, "runningStepReports is declared by presence, as true; a provider that takes begin and end only omits it");
448
524
  }
449
- into.require(typeof manifest.disposalSettleMs === "number" && Number.isInteger(manifest.disposalSettleMs) && manifest.disposalSettleMs > 0, "manifest.disposalSettleMs", `${path}.disposalSettleMs`, "disposalSettleMs is how long after an applied disposal the task-ended is owed, a positive whole number of milliseconds, stated by every provider");
525
+ into.require(!Object.hasOwn(manifest, "disposalSettleMs"), "manifest.completionSettleMs.renamed", `${path}.disposalSettleMs`, "use completionSettleMs; the former field is not accepted");
526
+ into.require(typeof manifest.completionSettleMs === "number" && Number.isInteger(manifest.completionSettleMs) && manifest.completionSettleMs > 0, "manifest.completionSettleMs", `${path}.completionSettleMs`, "completionSettleMs is how long after an applied completion the task-ended is owed, a positive whole number of milliseconds, stated by every provider");
450
527
  if (manifest.orgLevels !== undefined) {
451
528
  if (!Array.isArray(manifest.orgLevels)) {
452
529
  into.add("manifest.orgLevels.shape", `${path}.orgLevels`, "orgLevels must be an array when present");
@@ -803,81 +880,82 @@ function validateTaskAttributes(value, path, into, levels) {
803
880
  }
804
881
  });
805
882
  }
806
- function validateHandlingHistory(value, path, into, task = {}) {
883
+ function validateInteractionHistory(value, path, into, task = {}) {
807
884
  if (value === undefined)
808
885
  return;
809
886
  if (!isPlainObject(value)) {
810
- into.add("task.handlingHistory.shape", path, "handlingHistory must be an object with its steps when present");
887
+ into.add("task.interactionHistory.shape", path, "interactionHistory must be an object with its steps when present");
811
888
  return;
812
889
  }
813
890
  // What the record adds up to before this agent: each total present when the provider knows it
814
891
  // and absent when it does not, never a plausible nought.
815
- for (const field of ["handleSeconds", "holdSeconds", "queueSeconds"]) {
892
+ into.require(!Object.hasOwn(value, "handleSeconds"), "task.interactionHistory.interactionSeconds.renamed", `${path}.handleSeconds`, "use interactionSeconds; the former field is not accepted");
893
+ for (const field of ["interactionSeconds", "holdSeconds", "queueSeconds"]) {
816
894
  if (value[field] !== undefined) {
817
- into.require(isDurationSeconds(value[field]), `task.handlingHistory.${field}`, `${path}.${field}`, `${field} must be a whole number of seconds, zero or more, or omitted when unknown`);
895
+ into.require(isDurationSeconds(value[field]), `task.interactionHistory.${field}`, `${path}.${field}`, `${field} must be a whole number of seconds, zero or more, or omitted when unknown`);
818
896
  }
819
897
  }
820
898
  if (value.transfers !== undefined) {
821
- into.require(typeof value.transfers === "number" && Number.isInteger(value.transfers) && value.transfers >= 0, "task.handlingHistory.transfers", `${path}.transfers`, "transfers must be a whole number, zero or more, or omitted when unknown");
899
+ into.require(typeof value.transfers === "number" && Number.isInteger(value.transfers) && value.transfers >= 0, "task.interactionHistory.transfers", `${path}.transfers`, "transfers must be a whole number, zero or more, or omitted when unknown");
822
900
  }
823
901
  if (!Array.isArray(value.steps)) {
824
- into.add("task.handlingHistory.steps.shape", `${path}.steps`, "handlingHistory carries its steps as an array, empty when the task has had none");
902
+ into.add("task.interactionHistory.steps.shape", `${path}.steps`, "interactionHistory carries its steps as an array, empty when the task has had none");
825
903
  return;
826
904
  }
827
905
  let previous;
828
906
  value.steps.forEach((entry, index) => {
829
907
  const at = `${path}.steps[${index}]`;
830
908
  if (!isPlainObject(entry)) {
831
- into.add("task.handlingHistory.entry", at, "each handling step must be an object");
909
+ into.add("task.interactionHistory.entry", at, "each interaction step must be an object");
832
910
  return;
833
911
  }
834
- into.oneOf(entry.step, HANDLING_STEPS, "task.handlingHistory.step", `${at}.step`);
835
- if (into.timestamp(entry.at, "task.handlingHistory.at", `${at}.at`)) {
912
+ into.oneOf(entry.step, INTERACTION_STEPS, "task.interactionHistory.step", `${at}.step`);
913
+ if (into.timestamp(entry.at, "task.interactionHistory.at", `${at}.at`)) {
836
914
  // The record is one entry per occurrence, oldest first: a second hold is a second entry
837
915
  // after the first, never a revision of it or an entry filed out of its turn.
838
916
  const instant = Date.parse(entry.at);
839
917
  if (previous !== undefined && instant < previous) {
840
- into.add("task.handlingHistory.order", `${at}.at`, "handling steps are oldest first; this entry is earlier than the one before it");
918
+ into.add("task.interactionHistory.order", `${at}.at`, "interaction steps are oldest first; this entry is earlier than the one before it");
841
919
  }
842
920
  previous = instant;
843
921
  }
844
922
  if (entry.seconds !== undefined) {
845
923
  // Omitted while a leg is still running. Nought is a claim that it took no time.
846
- into.require(isDurationSeconds(entry.seconds) && entry.seconds > 0, "task.handlingHistory.seconds", `${at}.seconds`, "seconds must be a positive whole number; omit it while the step is still running");
924
+ into.require(isDurationSeconds(entry.seconds) && entry.seconds > 0, "task.interactionHistory.seconds", `${at}.seconds`, "seconds must be a positive whole number; omit it while the step is still running");
847
925
  }
848
926
  else {
849
927
  // An open leg is one still running, and the task says whether it can be: a hold runs only while
850
928
  // the task is paused, and a mute only while its media is up. An open entry after that is a leg
851
929
  // nobody closed, which reads exactly like a leg running now.
852
930
  if (entry.step === "held") {
853
- into.require(task.phase === "paused", "task.handlingHistory.held.open", `${at}.seconds`, `a held entry without seconds is a hold still running, and the task is ${describeValue(task.phase)}: the hold has ended, and its duration is stated`);
931
+ into.require(task.phase === "paused", "task.interactionHistory.held.open", `${at}.seconds`, `a held entry without seconds is a hold still running, and the task is ${describeValue(task.phase)}: the hold has ended, and its duration is stated`);
854
932
  }
855
933
  if (entry.step === "muted") {
856
- into.require(task.media !== "ended" && task.phase !== "completing", "task.handlingHistory.muted.open", `${at}.seconds`, "a muted entry without seconds is a mute still running, and the call is over: the provider closes every open leg at the instant the media ends, and the duration is stated");
934
+ into.require(task.media !== "ended" && task.phase !== "completing", "task.interactionHistory.muted.open", `${at}.seconds`, "a muted entry without seconds is still running, but this interaction's media has ended: the provider closes its open leg and states the duration; other channels may continue");
857
935
  }
858
936
  }
859
937
  // A muted entry carries whose the silence was, as the host reported it; no other step has it.
860
938
  // And it names the agent: the host has exactly one, the provider knows who, so an unattributed
861
939
  // muted leg is a record that dropped a fact it held rather than one it could not establish.
862
940
  if (entry.step === "muted") {
863
- into.oneOf(entry.mutedBy, MUTED_BY, "task.handlingHistory.mutedBy", `${at}.mutedBy`);
864
- into.require(entry.by !== undefined, "task.handlingHistory.muted.by", `${at}.by`, "a muted leg is attributed to the login's agent: the host reported it, and the provider knows who the host's agent is");
941
+ into.oneOf(entry.mutedBy, MUTED_BY, "task.interactionHistory.mutedBy", `${at}.mutedBy`);
942
+ into.require(entry.by !== undefined, "task.interactionHistory.muted.by", `${at}.by`, "a muted leg is attributed to the login's agent: the host reported it, and the provider knows who the host's agent is");
865
943
  }
866
- else if (HANDLING_STEPS.includes(entry.step)) {
867
- into.require(entry.mutedBy === undefined, "task.handlingHistory.mutedBy.unexpected", `${at}.mutedBy`, "only a muted step says who silenced the microphone");
944
+ else if (INTERACTION_STEPS.includes(entry.step)) {
945
+ into.require(entry.mutedBy === undefined, "task.interactionHistory.mutedBy.unexpected", `${at}.mutedBy`, "only a muted step says who silenced the microphone");
868
946
  }
869
947
  if (entry.by !== undefined) {
870
- into.require(isUserId(entry.by), "task.handlingHistory.by", `${at}.by`, "by must be a non-empty user id; omit it when the person cannot be identified");
948
+ into.require(isUserId(entry.by), "task.interactionHistory.by", `${at}.by`, "by must be a non-empty user id; omit it when the person cannot be identified");
871
949
  // On `queued` nobody takes part, so there is nothing to name.
872
- into.require(entry.step !== "queued", "task.handlingHistory.by.unexpected", `${at}.by`, "a queued step names nobody");
950
+ into.require(entry.step !== "queued", "task.interactionHistory.by.unexpected", `${at}.by`, "a queued step names nobody");
873
951
  }
874
952
  // A step that dialled says where to and, when a host placed it, which dial; no other step dialled.
875
- const dialled = typeof entry.step === "string" && HANDLING_STEPS_THAT_DIAL.includes(entry.step);
953
+ const dialled = typeof entry.step === "string" && INTERACTION_STEPS_THAT_DIAL.includes(entry.step);
876
954
  for (const field of ["dialId", "destinationId"]) {
877
955
  if (entry[field] === undefined)
878
956
  continue;
879
- if (into.filled(entry[field], `task.handlingHistory.${field}`, `${at}.${field}`, `${field} must not be empty when present`)) {
880
- into.require(dialled, `task.handlingHistory.${field}.unexpected`, `${at}.${field}`, `${describeValue(entry.step)} dialled nothing; ${field} belongs on transferred, conferenced, or unanswered`);
957
+ if (into.filled(entry[field], `task.interactionHistory.${field}`, `${at}.${field}`, `${field} must not be empty when present`)) {
958
+ into.require(dialled, `task.interactionHistory.${field}.unexpected`, `${at}.${field}`, `${describeValue(entry.step)} dialled nothing; ${field} belongs on transferred, conferenced, or unanswered`);
881
959
  }
882
960
  }
883
961
  });
@@ -885,9 +963,9 @@ function validateHandlingHistory(value, path, into, task = {}) {
885
963
  /** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
886
964
  const TASK_MEDIA_STATES = membersOf({ started: true, ended: true });
887
965
  /** Real-time media is a voice affair, and its state is one of two words. */
888
- /** Whether a host dial is ringing the party: the party entry carries `stage: "ringing"` and the host's `dialId`. */
889
- function partyRingingByHost(task) {
890
- return Array.isArray(task.onCall) && task.onCall.some(entry => isPlainObject(entry) && entry.role === "party" && entry.stage === "ringing" && typeof entry.dialId === "string");
966
+ /** A host dial, or explicitly provider-triggered preview dial, is ringing the party. */
967
+ function partyRingingBeforeWork(task) {
968
+ return Array.isArray(task.onCall) && task.onCall.some(entry => isPlainObject(entry) && entry.role === "party" && entry.stage === "ringing" && (isFilled(entry.dialId) || (task.phase === "preview" && task.atDeadline === "calls" && entry.dialId === undefined)));
891
969
  }
892
970
  function validateTaskMedia(task, value, channel, phase, path, into) {
893
971
  if (value === undefined)
@@ -897,9 +975,9 @@ function validateTaskMedia(task, value, channel, phase, path, into) {
897
975
  if (into.oneOf(value, TASK_MEDIA_STATES, "task.media", path)) {
898
976
  // Nothing is acquired while pending, and a preview has placed no call: media names a task whose
899
977
  // work has begun, on a snapshot as on the event, or a host would open the microphone on an offer.
900
- // The one task not yet at work that has audio is one whose party the host is dialling: ring-back
901
- // is audio, and every host-placed dial's media starts on dialling.
902
- into.require(!WORK_NOT_BEGUN.includes(phase) || partyRingingByHost(task), "task.media.beforeWork", path, `a ${describeValue(phase)} task has no media: audio arrives once its work has begun, or once the host is dialling its party`);
978
+ // Ring-back may precede answer for a host dial or an explicitly provider-triggered preview.
979
+ // A deadline alone is never evidence that media started.
980
+ into.require(!WORK_NOT_BEGUN.includes(phase) || partyRingingBeforeWork(task), "task.media.beforeWork", path, `a ${describeValue(phase)} task has no media: audio arrives once its work has begun, or once an evidenced host/provider preview dial is ringing its party`);
903
981
  }
904
982
  }
905
983
  const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
@@ -1043,6 +1121,7 @@ function validateTaskInto(task, context, path, into) {
1043
1121
  into.add("task.shape", path, "a task must be an object");
1044
1122
  return;
1045
1123
  }
1124
+ into.require(!Object.hasOwn(task, "handlingHistory"), "task.interactionHistory.renamed", `${path}.handlingHistory`, "use interactionHistory; the former field is not accepted");
1046
1125
  into.require(isTaskId(task.id), "task.id", `${path}.id`, "a task needs a non-empty id");
1047
1126
  into.require(isAllocationId(task.allocationId), "task.allocationId", `${path}.allocationId`, "a task names this life of itself: an allocation id minted once per offer, never reused for the life of the login");
1048
1127
  into.filled(task.title, "task.title", `${path}.title`, "a task needs a title");
@@ -1063,7 +1142,7 @@ function validateTaskInto(task, context, path, into) {
1063
1142
  into.timestamp(task.previewEndsAt, "task.preview.previewEndsAt", `${path}.previewEndsAt`);
1064
1143
  }
1065
1144
  if (task.atDeadline === undefined) {
1066
- into.add("task.preview.atDeadline.required", `${path}.atDeadline`, "a preview with a deadline says what the system does at it: calls, or expires -- an agent counting down has to know which");
1145
+ into.add("task.preview.atDeadline.required", `${path}.atDeadline`, "a preview with a deadline says what happens at it: provider calls, host calls, or waits for the agent -- an agent counting down has to know which");
1067
1146
  }
1068
1147
  else {
1069
1148
  into.oneOf(task.atDeadline, PREVIEW_DEADLINES, "task.preview.atDeadline", `${path}.atDeadline`);
@@ -1129,19 +1208,16 @@ function validateTaskInto(task, context, path, into) {
1129
1208
  }
1130
1209
  validateBrowsers(task.browsers, `${path}.browsers`, into);
1131
1210
  validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
1132
- validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into, { phase: task.phase, media: task.media });
1211
+ validateInteractionHistory(task.interactionHistory, `${path}.interactionHistory`, into, { phase: task.phase, media: task.media });
1133
1212
  validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
1134
- // The room is who is on the call now, and a task outlives its call by the whole of wrap-up: a
1135
- // task whose call has ended -- completing, or media ended -- carries nobody, or the last thing the
1136
- // wire said about the call stays true for ever. Empty and absent both say nobody.
1137
- // A completing task's call is over: its media has ended, or it never started. Audio still stated
1138
- // as started on a task in wrap-up is a call whose audio never ended -- the customer heard through
1139
- // the agent's notes -- and a task stating it contradicts itself.
1213
+ // Media and onCall describe this agent's interaction, not the continuing caller journey.
1214
+ // Once the interaction's media ends or it enters wrap, its live room is cleared. Other
1215
+ // channels may remain connected under another interaction or in IVR/queue stages.
1140
1216
  if (task.phase === "completing" && task.media === "started") {
1141
- into.add("task.media.completing", `${path}.media`, "a completing task's media has ended or never started: the call is over, and media still started is audio that never ended");
1217
+ into.add("task.media.completing", `${path}.media`, "a completing task's interaction media has ended or never started; it cannot still be started during wrap");
1142
1218
  }
1143
1219
  if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.media === "ended")) {
1144
- into.add("task.onCall.ended", `${path}.onCall`, `the call has ended (${task.phase === "completing" ? "the task is completing" : "its media ended"}) and onCall still names people on it: the room is who is on the call now, and now nobody is`);
1220
+ into.add("task.onCall.ended", `${path}.onCall`, `this interaction is no longer connected (${task.phase === "completing" ? "the task is completing" : "its media ended"}); clear its onCall view without implying that other channels ended`);
1145
1221
  }
1146
1222
  validateTaskMedia(task, task.media, context.channel, task.phase, `${path}.media`, into);
1147
1223
  validateLeadAssist(task.leadAssist, context.channel, `${path}.leadAssist`, into);
@@ -1235,6 +1311,184 @@ function validateImposedBreak(value, path, into) {
1235
1311
  into.add("break.imposed.endsAutomatically", `${path}.endsAutomatically`, "an imposed break must say whether it ends automatically");
1236
1312
  }
1237
1313
  }
1314
+ /** Checks break status against the full currently retained task set, after a transaction. */
1315
+ export function validateBreakStatus(state, tasks, path = "snapshot") {
1316
+ const into = new Collector();
1317
+ validateBreakState(state, `${path}.break`, into);
1318
+ into.require(Array.isArray(tasks), "break.tasks.shape", `${path}.tasks`, "the complete current task list is required");
1319
+ if (Array.isArray(tasks))
1320
+ tasks.forEach((task, index) => {
1321
+ into.require(isPlainObject(task), "break.task.shape", `${path}.tasks[${index}]`, "each retained task must be an object");
1322
+ });
1323
+ // A break in effect begins when the work ends, so it holds no task. A snapshot reporting both
1324
+ // describes a state the agent cannot be in, whichever half is stale. The one exception is a
1325
+ // lead listening to a call during a break that is work of another sort -- coaching,
1326
+ // administrative, training -- and on no other.
1327
+ // The converse: a committed break with nothing outstanding has begun. starting-after-task beside
1328
+ // no task is a break waiting on work that does not exist, and an empty list reading as "still
1329
+ // finishing" is the plausible nought.
1330
+ ruleEvaluated("break.starting-after-task.tasks");
1331
+ if (isPlainObject(state) && state.approval === "starting-after-task"
1332
+ && Array.isArray(tasks) && tasks.length === 0) {
1333
+ into.add("break.starting-after-task.tasks", `${path}.break.approval`, "a break starting after the task waits on a task, and the snapshot carries none: with nothing outstanding the break is in-effect");
1334
+ }
1335
+ if (isPlainObject(state) && state.approval === "in-effect"
1336
+ && Array.isArray(tasks) && tasks.length > 0) {
1337
+ const listening = tasks.every((task) => isPlainObject(task) && task.monitoring !== undefined);
1338
+ if (!listening) {
1339
+ into.add("break.in-effect.tasks", `${path}.tasks`, "a break in effect holds no task: it begins when the work ends, and until then the state is starting-after-task");
1340
+ }
1341
+ else {
1342
+ const reasons = Array.isArray(state.reasons) ? state.reasons : [];
1343
+ const active = reasons.find((reason) => isPlainObject(reason) && reason.id === state.activeReasonId);
1344
+ const kind = isPlainObject(active) ? active.kind : undefined;
1345
+ into.require(typeof kind === "string" && MONITORING_BREAK_KINDS.includes(kind), "snapshot.monitoring.break", `${path}.tasks`, `a lead listens during a ${MONITORING_BREAK_KINDS.join(", ")} break and no other; the active reason is ${kind === undefined ? "unstated" : describeValue(kind)}`);
1346
+ }
1347
+ }
1348
+ return into.violations;
1349
+ }
1350
+ /**
1351
+ * Checks lead break dispatch using current authentication, transport, roster (`team`) and,
1352
+ * for place/release, the target's full `memberBreak`. The provider must authorize the target
1353
+ * and recheck the decision atomically; a roster is not authority to act after it has changed.
1354
+ */
1355
+ export function validateTeamBreakCommand(request, context, path = "teamBreakCommand") {
1356
+ const into = new Collector();
1357
+ if (!isPlainObject(context) || !isPlainObject(request) || !isPlainObject(request.command)) {
1358
+ into.add("team.break.command.shape", path, "a command and current authentication/transport/team context are required");
1359
+ return into.violations;
1360
+ }
1361
+ const auth = isPlainObject(context.authentication) ? context.authentication : {};
1362
+ into.violations.push(...validateAuthenticationState(auth, `${path}.authentication`));
1363
+ const caps = isPlainObject(auth.capabilities) ? auth.capabilities : {};
1364
+ into.require((auth.status === "authenticated" || auth.status === "refreshing") && context.transport === "active", "team.break.command.connection", path, "lead commands require a live login and active transport");
1365
+ into.require(isPlainObject(caps.team) && caps.team.breakControl === true, "team.break.command.capability", path, "the login must declare team.breakControl");
1366
+ const command = request.command;
1367
+ const allowed = {
1368
+ decide: ["type", "memberId", "decision", "reason"], policy: ["type", "policy"],
1369
+ place: ["type", "memberId", "reasonId", "reason"], release: ["type", "memberId"],
1370
+ };
1371
+ const fields = typeof command.type === "string" && Object.hasOwn(allowed, command.type) ? allowed[command.type] : undefined;
1372
+ if (!fields) {
1373
+ into.add("team.break.command.type", path, "unknown lead break command");
1374
+ return into.violations;
1375
+ }
1376
+ for (const key of Object.keys(request))
1377
+ into.require(key === "command", "team.break.request.field", `${path}.${key}`, "only command is supported");
1378
+ for (const key of Object.keys(command))
1379
+ into.require(fields.includes(key), "team.break.command.field", `${path}.${key}`, "field is not supported for this command");
1380
+ if (command.reason !== undefined)
1381
+ into.filled(command.reason, "team.break.command.reason", path, "reason must not be empty");
1382
+ if (command.type === "policy") {
1383
+ into.require(["ask", "auto-approve", "suspended"].includes(command.policy), "team.break.command.policy", path, "unknown break policy");
1384
+ return into.violations;
1385
+ }
1386
+ into.filled(command.memberId, "team.break.command.member", path, "name the target member");
1387
+ const team = isPlainObject(context.team) ? context.team : {};
1388
+ const members = Array.isArray(team.members) ? team.members : [];
1389
+ const member = members.find((m) => isPlainObject(m) && m.id === command.memberId);
1390
+ const self = isPlainObject(auth.identity) ? auth.identity.id : undefined;
1391
+ into.require(isPlainObject(member) && command.memberId !== self, "team.break.command.member", path, "the target must be another member of the current authorized roster");
1392
+ if (command.type === "decide") {
1393
+ into.require(command.decision === "granted" || command.decision === "denied", "team.break.command.decision", path, "decide granted or denied");
1394
+ into.require(isPlainObject(member) && member.break === "awaiting-decision", "team.break.command.awaiting", path, "decide only a currently awaiting-decision request");
1395
+ }
1396
+ else {
1397
+ validateBreakState(context.memberBreak, `${path}.memberBreak`, into);
1398
+ const state = isPlainObject(context.memberBreak) ? context.memberBreak : {};
1399
+ if (command.type === "release")
1400
+ into.require(state.imposed !== undefined && (state.approval === "in-effect" || state.approval === "starting-after-task"), "team.break.command.release", path, "release a currently imposed break");
1401
+ if (command.type === "place") {
1402
+ if (command.reasonId !== undefined)
1403
+ into.filled(command.reasonId, "team.break.command.reasonId", path, "reasonId must not be empty");
1404
+ const reasons = Array.isArray(state.reasons) ? state.reasons : [];
1405
+ into.require(state.reasons === undefined ? command.reasonId === undefined : reasons.some((r) => isPlainObject(r) && r.id === command.reasonId), "team.break.command.reasonId", path, "place uses the target's currently published reason codes");
1406
+ }
1407
+ }
1408
+ return into.violations;
1409
+ }
1410
+ /**
1411
+ * Validate immediately before dispatch against current provider state. Context must contain
1412
+ * the current authentication and transport. This cannot fence a concurrent backend change;
1413
+ * the provider must recheck atomically. Result validation never replaces authoritative state.
1414
+ */
1415
+ export function validateBreakCommand(method, request, state, context, path = "breakCommand") {
1416
+ const into = new Collector();
1417
+ validateBreakState(state, `${path}.state`, into);
1418
+ if (!isPlainObject(context)) {
1419
+ into.add("break.command.context", path, "current authentication and transport are required");
1420
+ return into.violations;
1421
+ }
1422
+ into.violations.push(...validateAuthenticationState(context.authentication, `${path}.authentication`));
1423
+ const auth = isPlainObject(context.authentication) ? context.authentication : {};
1424
+ into.require(auth.status === "authenticated" || auth.status === "refreshing", "break.command.authentication", path, "break commands require a live authenticated login");
1425
+ into.require(isPlainObject(auth.capabilities) && auth.capabilities.breaks === true, "break.command.capability", path, "the login must declare breaks");
1426
+ into.require(context.transport === "active", "break.command.transport", path, "break commands require active transport");
1427
+ if (!["requestBreak", "commitBreak", "cancelBreak", "endBreak"].includes(method)) {
1428
+ into.add("break.command.method", path, "unknown break method");
1429
+ return into.violations;
1430
+ }
1431
+ if (!isPlainObject(state) || into.violations.length)
1432
+ return into.violations;
1433
+ const approval = state.approval;
1434
+ if (method === "requestBreak") {
1435
+ into.require(approval === "not-requested", "break.command.request.pending", path, "a new request starts only from not-requested");
1436
+ if (!isPlainObject(request)) {
1437
+ into.add("break.request.shape", path, "requestBreak takes a request object");
1438
+ return into.violations;
1439
+ }
1440
+ for (const key of Object.keys(request))
1441
+ into.require(key === "reason" || key === "reasonId", "break.request.field", `${path}.${key}`, "a break request carries only reason and reasonId");
1442
+ for (const key of ["reason", "reasonId"])
1443
+ if (request[key] !== undefined)
1444
+ into.filled(request[key], `break.request.${key}`, `${path}.${key}`, "a supplied reason must not be empty");
1445
+ const reasons = Array.isArray(state.reasons) ? state.reasons : [];
1446
+ const selected = reasons.find((r) => isPlainObject(r) && r.id === request.reasonId);
1447
+ if (state.reasons !== undefined)
1448
+ into.require(selected !== undefined, "break.request.reasonId", path, "choose a currently published reasonId; free text is not a substitute");
1449
+ else
1450
+ into.require(request.reasonId === undefined, "break.request.reasonId.unexpected", path, "no reason codes were published");
1451
+ into.require(state.mayAsk === true || (isPlainObject(selected) && selected.alwaysAvailable === true), "break.request.mayAsk", path, "asking is disabled except for the selected alwaysAvailable reason");
1452
+ }
1453
+ else {
1454
+ into.require(request === undefined, "break.command.arguments", path, "this break method takes no arguments");
1455
+ if (method === "commitBreak")
1456
+ into.require(approval === "granted" || approval === "starting-after-task" || approval === "in-effect", "break.command.commit.grant", path, "commit requires a grant; repeated committed state is idempotent");
1457
+ if (method === "cancelBreak")
1458
+ into.require(approval === "awaiting-decision" || approval === "granted", "break.command.cancel.precommit", path, "cancel only a pre-commit request; a raced commit requires commit recovery");
1459
+ if (method === "endBreak") {
1460
+ into.require(approval === "starting-after-task" || approval === "in-effect", "break.command.end.started", path, "end a committed break, including a returning provider still finishing work");
1461
+ into.require(state.imposed === undefined, "break.command.end.imposed", path, "an agent cannot end an imposed break; an authorized lead releases it");
1462
+ }
1463
+ }
1464
+ return into.violations;
1465
+ }
1466
+ /**
1467
+ * Checks two complete break states within one provider/login's ordered event stream.
1468
+ * Call before replacing accepted state; report violations and reconcile on failure.
1469
+ * This is not a freshness check: validate a fresh authoritative snapshot separately and
1470
+ * use it as a new baseline, never as an event transition. Source ordering and connection
1471
+ * fencing remain required; neither timestamps nor approval rank identify a break attempt.
1472
+ */
1473
+ export function validateBreakTransition(before, after, path = "break") {
1474
+ const into = new Collector();
1475
+ validateBreakState(before, `${path}.before`, into);
1476
+ validateBreakState(after, `${path}.after`, into);
1477
+ if (into.violations.length || !isPlainObject(before) || !isPlainObject(after))
1478
+ return into.violations;
1479
+ const from = before.approval;
1480
+ const to = after.approval;
1481
+ const committed = to === "starting-after-task" || to === "in-effect";
1482
+ if (committed && (from === "not-requested" || from === "awaiting-decision") && after.imposed === undefined) {
1483
+ into.add("stream.breakState.commitBeforeGrant", `${path}.after.approval`, `${to} follows a commit, and a commit follows granted; the break stood at ${from}`);
1484
+ }
1485
+ const backwards = (from === "in-effect" && (to === "awaiting-decision" || to === "granted" || to === "starting-after-task")) ||
1486
+ (from === "starting-after-task" && (to === "awaiting-decision" || to === "granted")) ||
1487
+ (from === "granted" && to === "awaiting-decision");
1488
+ if (backwards)
1489
+ into.add("stream.breakState.backwards", `${path}.after.approval`, `a break does not go from ${from} back to ${to}; a new request passes through not-requested`);
1490
+ return into.violations;
1491
+ }
1238
1492
  function validateBreakState(value, path, into) {
1239
1493
  if (!isPlainObject(value)) {
1240
1494
  into.add("break.shape", path, "break state must be an object");
@@ -1417,7 +1671,6 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1417
1671
  && context.loginId !== undefined) {
1418
1672
  into.require(snapshot.loginId === context.loginId, "snapshot.loginId.mismatch", `${path}.loginId`, `a snapshot for session ${describeValue(snapshot.loginId)} on a login whose session is ${context.loginId}`);
1419
1673
  }
1420
- validateBreakState(snapshot.break, `${path}.break`, into);
1421
1674
  // The count is the provider's confirmation of how much work it answered with. Stated, never
1422
1675
  // inferred: an unanswered or blank state lacks it, and cannot pass as a confirmed empty.
1423
1676
  if (into.require(typeof snapshot.taskCount === "number" && Number.isInteger(snapshot.taskCount) && snapshot.taskCount >= 0, "snapshot.taskCount", `${path}.taskCount`, "a snapshot states its task count: a whole number, zero or more")
@@ -1459,32 +1712,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1459
1712
  }
1460
1713
  });
1461
1714
  }
1462
- // A break in effect begins when the work ends, so it holds no task. A snapshot reporting both
1463
- // describes a state the agent cannot be in, whichever half is stale. The one exception is a
1464
- // lead listening to a call during a break that is work of another sort -- coaching,
1465
- // administrative, training -- and on no other.
1466
- // The converse: a committed break with nothing outstanding has begun. starting-after-task beside
1467
- // no task is a break waiting on work that does not exist, and an empty list reading as "still
1468
- // finishing" is the plausible nought.
1469
- ruleEvaluated("break.starting-after-task.tasks");
1470
- if (isPlainObject(snapshot.break) && snapshot.break.approval === "starting-after-task"
1471
- && Array.isArray(snapshot.tasks) && snapshot.tasks.length === 0) {
1472
- into.add("break.starting-after-task.tasks", `${path}.break.approval`, "a break starting after the task waits on a task, and the snapshot carries none: with nothing outstanding the break is in-effect");
1473
- }
1474
- if (isPlainObject(snapshot.break) && snapshot.break.approval === "in-effect"
1475
- && Array.isArray(snapshot.tasks) && snapshot.tasks.length > 0) {
1476
- const listening = snapshot.tasks.every((task) => isPlainObject(task) && task.monitoring !== undefined);
1477
- if (!listening) {
1478
- into.add("break.in-effect.tasks", `${path}.tasks`, "a break in effect holds no task: it begins when the work ends, and until then the state is starting-after-task");
1479
- }
1480
- else {
1481
- const state = snapshot.break;
1482
- const reasons = Array.isArray(state.reasons) ? state.reasons : [];
1483
- const active = reasons.find((reason) => isPlainObject(reason) && reason.id === state.activeReasonId);
1484
- const kind = isPlainObject(active) ? active.kind : undefined;
1485
- into.require(typeof kind === "string" && MONITORING_BREAK_KINDS.includes(kind), "snapshot.monitoring.break", `${path}.tasks`, `a lead listens during a ${MONITORING_BREAK_KINDS.join(", ")} break and no other; the active reason is ${kind === undefined ? "unstated" : describeValue(kind)}`);
1486
- }
1487
- }
1715
+ into.violations.push(...validateBreakStatus(snapshot.break, snapshot.tasks, path));
1488
1716
  // Presence is the permission, and it cuts both ways: data a provider never declared a
1489
1717
  // capability for is data Omni would show against a control the agent does not have.
1490
1718
  const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
@@ -1904,34 +2132,34 @@ export function validateHostGuarantees(guarantees, path = "host.guarantees") {
1904
2132
  * a task, a step, when it began, how long so far if the host says, and an explicit end that
1905
2133
  * carries the final duration.
1906
2134
  */
1907
- export function validateHandlingReport(report, path = "handlingReport", manifest) {
2135
+ export function validateInteractionReport(report, path = "interactionReport", manifest) {
1908
2136
  const into = new Collector();
1909
2137
  if (!isPlainObject(report)) {
1910
- into.add("handlingReport.shape", path, "a handling report must be an object");
2138
+ into.add("interactionReport.shape", path, "an interaction report must be an object");
1911
2139
  return into.violations;
1912
2140
  }
1913
- into.require(isTaskId(report.taskId), "handlingReport.taskId", `${path}.taskId`, "a report names the task");
1914
- into.require(isAllocationId(report.allocationId), "handlingReport.allocationId", `${path}.allocationId`, "a report names the task's allocation, so a late one never lands on the next life of the id");
1915
- into.oneOf(report.step, HANDLING_STEPS, "handlingReport.step", `${path}.step`);
1916
- into.timestamp(report.at, "handlingReport.at", `${path}.at`);
2141
+ into.require(isTaskId(report.taskId), "interactionReport.taskId", `${path}.taskId`, "a report names the task");
2142
+ into.require(isAllocationId(report.allocationId), "interactionReport.allocationId", `${path}.allocationId`, "a report names the task's allocation, so a late one never lands on the next life of the id");
2143
+ into.oneOf(report.step, INTERACTION_STEPS, "interactionReport.step", `${path}.step`);
2144
+ into.timestamp(report.at, "interactionReport.at", `${path}.at`);
1917
2145
  // A muted leg says whose the silence was; no other leg has anyone to name for it.
1918
2146
  if (report.step === "muted")
1919
- into.oneOf(report.mutedBy, MUTED_BY, "handlingReport.mutedBy", `${path}.mutedBy`);
1920
- else if (HANDLING_STEPS.includes(report.step)) {
1921
- into.require(report.mutedBy === undefined, "handlingReport.mutedBy.unexpected", `${path}.mutedBy`, "only a muted leg says who silenced the microphone");
2147
+ into.oneOf(report.mutedBy, MUTED_BY, "interactionReport.mutedBy", `${path}.mutedBy`);
2148
+ else if (INTERACTION_STEPS.includes(report.step)) {
2149
+ into.require(report.mutedBy === undefined, "interactionReport.mutedBy.unexpected", `${path}.mutedBy`, "only a muted leg says who silenced the microphone");
1922
2150
  }
1923
2151
  if (report.seconds !== undefined) {
1924
- into.require(isDurationSeconds(report.seconds) && report.seconds > 0, "handlingReport.seconds", `${path}.seconds`, "seconds must be a positive whole number; omit it rather than report nought");
2152
+ into.require(isDurationSeconds(report.seconds) && report.seconds > 0, "interactionReport.seconds", `${path}.seconds`, "seconds must be a positive whole number; omit it rather than report nought");
1925
2153
  }
1926
2154
  if (report.ended !== undefined) {
1927
- if (into.require(report.ended === true, "handlingReport.ended", `${path}.ended`, "ended is declared by presence, as true; a running leg omits it")) {
1928
- into.require(report.seconds !== undefined, "handlingReport.ended.seconds", `${path}.seconds`, "an ended leg states its final duration");
2155
+ if (into.require(report.ended === true, "interactionReport.ended", `${path}.ended`, "ended is declared by presence, as true; a running leg omits it")) {
2156
+ into.require(report.seconds !== undefined, "interactionReport.ended.seconds", `${path}.seconds`, "an ended leg states its final duration");
1929
2157
  }
1930
2158
  }
1931
2159
  else if (report.seconds !== undefined && manifest !== undefined) {
1932
2160
  // A running report crosses only to a provider that asked for one: begin and end are the whole
1933
2161
  // of what the rest receive.
1934
- into.require(isPlainObject(manifest) && manifest.runningStepReports === true, "handlingReport.running.unexpected", `${path}.seconds`, "this provider takes begin and end only; a running report was never asked for");
2162
+ into.require(isPlainObject(manifest) && manifest.runningStepReports === true, "interactionReport.running.unexpected", `${path}.seconds`, "this provider takes begin and end only; a running report was never asked for");
1935
2163
  }
1936
2164
  return into.violations;
1937
2165
  }
@@ -2020,6 +2248,22 @@ export function validateHostReport(report, path = "host") {
2020
2248
  const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
2021
2249
  const LEAD_ASSIST_ACTIONS = ["request", "cancel", "take-over", "leave"];
2022
2250
  const CONFERENCE_ACTIONS = ["add", "remove"];
2251
+ /** Validate the exact interaction/allocation target before provider command dispatch. */
2252
+ export function validateTaskCommandRequest(request, task, path = "request", taskContext) {
2253
+ const into = new Collector();
2254
+ if (!isPlainObject(request) || !isPlainObject(task)) {
2255
+ into.add("command.request.shape", path, "a request and the current published task are required");
2256
+ return into.violations;
2257
+ }
2258
+ into.filled(request.taskId, "command.request.taskId", `${path}.taskId`, "name the interaction task");
2259
+ into.filled(request.allocationId, "command.request.allocationId", `${path}.allocationId`, "name its allocation");
2260
+ into.require(request.taskId === task.id, "command.request.taskId.mismatch", `${path}.taskId`, "the command must target this exact interaction");
2261
+ into.require(request.allocationId === task.allocationId, "command.request.allocationId.mismatch", `${path}.allocationId`, "the command must target this exact allocation");
2262
+ for (const key of Object.keys(request))
2263
+ into.require(["taskId", "allocationId", "command"].includes(key), "command.request.field", `${path}.${key}`, "unsupported task command request field");
2264
+ into.violations.push(...validateTaskCommand(request.command, task, `${path}.command`, taskContext));
2265
+ return into.violations;
2266
+ }
2023
2267
  /**
2024
2268
  * What a command needs to be issuable, checked against the task it names: the capability the
2025
2269
  * guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
@@ -2044,6 +2288,36 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2044
2288
  if (type !== "custom" && !into.require(typeof type === "string" && names.includes(type), "command.type", `${path}.type`, channel === undefined ? `unsupported command: ${describeValue(type)}` : `a ${channel} task has no ${describeValue(type)} command`)) {
2045
2289
  return into.violations;
2046
2290
  }
2291
+ // Built-in commands carry only their declared fields; custom controls own their payload.
2292
+ // Recording already applies its action-specific field check below.
2293
+ if (type !== "custom" && type !== "recording") {
2294
+ let fields = ["type"];
2295
+ switch (type) {
2296
+ case "call":
2297
+ case "connect-back":
2298
+ fields.push("dialId");
2299
+ break;
2300
+ case "complete":
2301
+ fields.push("disposition", "notes");
2302
+ break;
2303
+ case "transfer":
2304
+ fields.push("action");
2305
+ if (command.action === "cold" || command.action === "warm")
2306
+ fields.push("dialId", "destinationId");
2307
+ break;
2308
+ case "lead-assist":
2309
+ fields.push("action");
2310
+ if (command.action === "request")
2311
+ fields.push("note");
2312
+ break;
2313
+ case "conference":
2314
+ fields.push("action", "destinationId");
2315
+ fields.push(command.action === "add" ? "dialId" : "party");
2316
+ break;
2317
+ }
2318
+ for (const key of Object.keys(command))
2319
+ into.require(fields.includes(key), "command.field", `${path}.${key}`, "field is not supported for this command");
2320
+ }
2047
2321
  const dial = (rule) => into.filled(command.dialId, rule, `${path}.dialId`, "a command that dials carries the host's dialId");
2048
2322
  switch (type) {
2049
2323
  case "call":
@@ -2141,9 +2415,9 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2141
2415
  };
2142
2416
  const inPhase = (rule, ...phases) => into.require(typeof task.phase === "string" && phases.includes(task.phase), rule, path, `${describeValue(type)} belongs to ${phases.join(" or ")}, and the task is ${describeValue(task.phase)}`);
2143
2417
  // A control on the contact acts on a contact being handled. Before in-progress there is nothing
2144
- // to act on yet, and in completing the handling has ended: a call with nobody on it, a
2145
- // conversation closed. What a capability offers, the phase decides whether there is anything to use it on.
2146
- const handling = () => inPhase("command.phase.handling", "in-progress", "paused");
2418
+ // to act on yet, and in completing this agent's interaction has ended. The caller and other
2419
+ // channels may continue elsewhere; this task's phase no longer permits these controls.
2420
+ const interaction = () => inPhase("command.phase.interaction", "in-progress", "paused");
2147
2421
  // A destination is one the directory offered: the id Omni sends is the id the provider published.
2148
2422
  const listed = (name) => {
2149
2423
  const declared = capabilities[name];
@@ -2166,11 +2440,11 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2166
2440
  case "resume":
2167
2441
  case "pause":
2168
2442
  offered("hold");
2169
- handling();
2443
+ interaction();
2170
2444
  break;
2171
2445
  case "end-call":
2172
2446
  offered("endCall");
2173
- handling();
2447
+ interaction();
2174
2448
  break;
2175
2449
  case "recording": {
2176
2450
  if (!offered("recording"))
@@ -2179,11 +2453,11 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2179
2453
  const state = isPlainObject(task.recording) ? task.recording.provider : undefined;
2180
2454
  into.violations.push(...validateRecordingCommandState(command, policy, state, path));
2181
2455
  if (command.action === "start" || command.action === "resume") {
2182
- handling();
2456
+ interaction();
2183
2457
  into.require(task.media === "started", "recording.request.phase", path, "capture needs live media");
2184
2458
  }
2185
2459
  else
2186
- inPhase("command.phase.handling", "in-progress", "paused", "completing");
2460
+ inPhase("command.phase.interaction", "in-progress", "paused", "completing");
2187
2461
  break;
2188
2462
  }
2189
2463
  case "connect-back":
@@ -2191,7 +2465,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2191
2465
  inPhase("command.phase.completing", "completing");
2192
2466
  break;
2193
2467
  case "transfer":
2194
- handling();
2468
+ interaction();
2195
2469
  if (command.action === "cold") {
2196
2470
  if (offered("coldTransfer"))
2197
2471
  listed("coldTransfer");
@@ -2205,7 +2479,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2205
2479
  }
2206
2480
  break;
2207
2481
  case "lead-assist":
2208
- handling();
2482
+ interaction();
2209
2483
  if (command.action === "request" || command.action === "cancel") {
2210
2484
  offered("leadAssist");
2211
2485
  if (command.action === "cancel") {
@@ -2217,7 +2491,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2217
2491
  }
2218
2492
  break;
2219
2493
  case "conference": {
2220
- handling();
2494
+ interaction();
2221
2495
  const conferencing = offered("conference");
2222
2496
  if (conferencing && command.action === "add")
2223
2497
  listed("conference");
@@ -2327,6 +2601,8 @@ export function validateResult(result, method, path = "result", dialId) {
2327
2601
  else {
2328
2602
  into.require(result.dialId === undefined, "result.dialId.unexpected", `${path}.dialId`, `${statuses.success} dialled nothing and names no dial`);
2329
2603
  }
2604
+ if (method === "recordStep")
2605
+ into.timestamp(result.at, "result.recordStep.at", `${path}.at`);
2330
2606
  }
2331
2607
  else if (statuses.failure !== undefined && result.status === statuses.failure) {
2332
2608
  if (result.failure === undefined) {