@xema/omni-protocol 0.1.66 → 0.1.68

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.
@@ -131,14 +131,33 @@ const isDurationSeconds = (value) => typeof value === "number" && Number.isInteg
131
131
  /** Opaque, non-empty, provider-issued. Never parsed and never compared across providers. */
132
132
  const isUserId = isFilled;
133
133
  const isTaskId = isFilled;
134
+ const isAllocationId = isFilled;
135
+ const ruleListeners = new Set();
136
+ /**
137
+ * Hears every rule a validator evaluates, pass or fail, until the returned function is called. A
138
+ * green run can then say which rules it looked at rather than only which subjects it reached: a
139
+ * rule never evaluated is a visible gap, not a pass. The harness registers one for each exercise.
140
+ */
141
+ export function observeRules(listener) {
142
+ ruleListeners.add(listener);
143
+ return () => { ruleListeners.delete(listener); };
144
+ }
145
+ /** Says a rule was evaluated, for code that decides outside a collector: the stream, the drive. */
146
+ export function ruleEvaluated(...rules) {
147
+ for (const rule of rules)
148
+ for (const listener of ruleListeners)
149
+ listener(rule);
150
+ }
134
151
  class Collector {
135
152
  violations = [];
136
153
  add(rule, path, message) {
154
+ ruleEvaluated(rule);
137
155
  this.violations.push({ rule, path, message });
138
156
  }
139
157
  require(condition, rule, path, message) {
158
+ ruleEvaluated(rule);
140
159
  if (!condition)
141
- this.add(rule, path, message);
160
+ this.violations.push({ rule, path, message });
142
161
  return Boolean(condition);
143
162
  }
144
163
  filled(value, rule, path, message) {
@@ -198,7 +217,7 @@ export function validateScheduledActivity(activity, path = "scheduledActivity")
198
217
  validateScheduledActivityInto(activity, path, into);
199
218
  return into.violations;
200
219
  }
201
- function validateScheduledActivityInto(activity, path, into) {
220
+ function validateScheduledActivityInto(activity, path, into, levels) {
202
221
  if (!isPlainObject(activity)) {
203
222
  into.add("activity.shape", path, "a scheduled activity must be an object");
204
223
  return;
@@ -213,7 +232,7 @@ function validateScheduledActivityInto(activity, path, into) {
213
232
  }
214
233
  }
215
234
  if (activity.party !== undefined)
216
- validateContactInto(activity.party, `${path}.party`, into);
235
+ validateContactInto(activity.party, `${path}.party`, into, levels);
217
236
  validateAttributes(activity.attributes, `${path}.attributes`, into);
218
237
  }
219
238
  // ---------------------------------------------------------------------------
@@ -672,7 +691,7 @@ function validateBrowsers(value, path, into) {
672
691
  }
673
692
  });
674
693
  }
675
- function validateTaskAttributes(value, path, into) {
694
+ function validateTaskAttributes(value, path, into, levels) {
676
695
  if (value === undefined)
677
696
  return;
678
697
  if (!Array.isArray(value)) {
@@ -699,7 +718,7 @@ function validateTaskAttributes(value, path, into) {
699
718
  into.require(typeof attribute.value === "string", "task.attribute.text", `${at}.value`, "a text attribute needs a string value");
700
719
  break;
701
720
  case "contact":
702
- validateContactInto(attribute.contact, `${at}.contact`, into);
721
+ validateContactInto(attribute.party, `${at}.party`, into, levels);
703
722
  break;
704
723
  case "timestamp":
705
724
  into.timestamp(attribute.at, "task.attribute.timestamp", `${at}.at`);
@@ -791,13 +810,18 @@ function validateHandlingHistory(value, path, into, task = {}) {
791
810
  /** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
792
811
  const TASK_MEDIA_STATES = membersOf({ started: true, ended: true });
793
812
  /** Real-time media is a voice affair, and its state is one of two words. */
794
- function validateTaskMedia(value, channel, path, into) {
813
+ function validateTaskMedia(value, channel, phase, path, into) {
795
814
  if (value === undefined)
796
815
  return;
797
816
  if (!into.require(channel === "voice", "task.media.channel", path, `a ${channel} task carries no real-time media state`))
798
817
  return;
799
- into.oneOf(value, TASK_MEDIA_STATES, "task.media", path);
818
+ if (into.oneOf(value, TASK_MEDIA_STATES, "task.media", path)) {
819
+ // Nothing is acquired while pending, and a preview has placed no call: media names a task whose
820
+ // work has begun, on a snapshot as on the event, or a host would open the microphone on an offer.
821
+ into.require(!WORK_NOT_BEGUN.includes(phase), "task.media.beforeWork", path, `a ${String(phase)} task has no media: audio arrives once its work has begun`);
822
+ }
800
823
  }
824
+ const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
801
825
  /**
802
826
  * Who is on the call, as the provider states it. Voice only. Each entry is a role with what that
803
827
  * role needs and nothing another role would: a party or an agent dialled nowhere, so neither
@@ -845,9 +869,23 @@ function validateOnCall(value, channel, path, into) {
845
869
  if (entry.label !== undefined)
846
870
  into.filled(entry.label, "task.onCall.label", `${at}.label`, "a label must not be empty when present");
847
871
  }
872
+ else if (role === "party" && (entry.dialId !== undefined || entry.stage !== undefined)) {
873
+ // The party is dialled again on the same task -- a connect-back the host placed, a callback the
874
+ // platform placed -- and then carries the stage the dial has reached; a dial with no stage is
875
+ // half a claim, and the host's dialId is present where a host placed it, as on any dialled entry.
876
+ into.require(entry.stage !== undefined, "task.onCall.party.dial", at, "a party being dialled again carries the stage the dial has reached; the host's dialId beside it where a host placed the dial");
877
+ if (entry.dialId !== undefined)
878
+ into.filled(entry.dialId, "task.onCall.dialId", `${at}.dialId`, "dialId must not be empty when present");
879
+ if (entry.stage !== undefined && into.oneOf(entry.stage, ON_CALL_STAGES, "task.onCall.stage", `${at}.stage`)) {
880
+ into.require(!(entry.stage === "ringing" && entry.held === true), "task.onCall.held.ringing", `${at}.held`, "nobody ringing is held; held belongs to somebody who has joined");
881
+ }
882
+ for (const field of ["destinationId", "label"]) {
883
+ into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a party is not a directory item; ${field} belongs on consulted or conferenced`);
884
+ }
885
+ }
848
886
  else {
849
887
  for (const field of ["destinationId", "dialId", "label", "stage"]) {
850
- into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a ${role} was dialled from nowhere; ${field} belongs on consulted or conferenced`);
888
+ into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, role === "party" ? `a party carries ${field} on a connect-back alone, with its dial and stage together` : `a ${role} was dialled from nowhere; ${field} belongs on consulted or conferenced`);
851
889
  }
852
890
  }
853
891
  });
@@ -904,6 +942,7 @@ function validateMonitoring(value, channel, path, into) {
904
942
  }
905
943
  into.require(isUserId(value.memberId), "task.monitoring.memberId", `${path}.memberId`, "a monitored call names the member on it");
906
944
  into.require(isTaskId(value.taskId), "task.monitoring.taskId", `${path}.taskId`, "a monitored call names the member's task");
945
+ into.require(isAllocationId(value.allocationId), "task.monitoring.allocationId", `${path}.allocationId`, "a monitored call names the member's task by its allocation too");
907
946
  into.oneOf(value.mode, MONITOR_MODES, "task.monitoring.mode", `${path}.mode`);
908
947
  into.timestamp(value.since, "task.monitoring.since", `${path}.since`);
909
948
  }
@@ -918,6 +957,7 @@ function validateTaskInto(task, context, path, into) {
918
957
  return;
919
958
  }
920
959
  into.require(isTaskId(task.id), "task.id", `${path}.id`, "a task needs a non-empty id");
960
+ 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");
921
961
  into.filled(task.title, "task.title", `${path}.title`, "a task needs a title");
922
962
  into.filled(task.taskType, "task.taskType", `${path}.taskType`, "a task needs a task type");
923
963
  if (into.oneOf(task.phase, TASK_PHASES, "task.phase", `${path}.phase`) && task.phase === "preview") {
@@ -975,7 +1015,7 @@ function validateTaskInto(task, context, path, into) {
975
1015
  if (task.party !== undefined)
976
1016
  validateContactInto(task.party, `${path}.party`, into, context.levels);
977
1017
  validateBrowsers(task.browsers, `${path}.browsers`, into);
978
- validateTaskAttributes(task.attributes, `${path}.attributes`, into);
1018
+ validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
979
1019
  validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into, { phase: task.phase, media: task.media });
980
1020
  validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
981
1021
  // The room is who is on the call now, and a task outlives its call by the whole of wrap-up: a
@@ -984,7 +1024,7 @@ function validateTaskInto(task, context, path, into) {
984
1024
  if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.media === "ended")) {
985
1025
  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`);
986
1026
  }
987
- validateTaskMedia(task.media, context.channel, `${path}.media`, into);
1027
+ validateTaskMedia(task.media, context.channel, task.phase, `${path}.media`, into);
988
1028
  validateLeadAssist(task.leadAssist, context.channel, `${path}.leadAssist`, into);
989
1029
  validateAssisting(task.assisting, context.channel, `${path}.assisting`, into);
990
1030
  validateMonitoring(task.monitoring, context.channel, `${path}.monitoring`, into);
@@ -1197,6 +1237,7 @@ function validateTeamRosterInto(roster, path, context, into) {
1197
1237
  into.require(request.memberId !== context.self, "team.request.self", `${at}.memberId`, "the roster carries the reader's own ask: an agent's request for a lead goes to whoever leads them");
1198
1238
  }
1199
1239
  into.require(isTaskId(request.taskId), "team.request.taskId", `${at}.taskId`, "a request names the task the lead would join");
1240
+ into.require(isAllocationId(request.allocationId), "team.request.allocationId", `${at}.allocationId`, "a request names the task by its allocation too");
1200
1241
  if (request.note !== undefined)
1201
1242
  into.filled(request.note, "team.request.note", `${at}.note`, "a note must not be empty when present");
1202
1243
  into.timestamp(request.since, "team.request.since", `${at}.since`);
@@ -1320,7 +1361,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1320
1361
  if (snapshot.contacts !== undefined) {
1321
1362
  into.require(idle.contacts === true, "snapshot.contacts.capability", `${path}.contacts`, "contacts require the contacts idle capability");
1322
1363
  if (Array.isArray(snapshot.contacts)) {
1323
- snapshot.contacts.forEach((contact, index) => validateContactInto(contact, `${path}.contacts[${index}]`, into));
1364
+ snapshot.contacts.forEach((contact, index) => validateContactInto(contact, `${path}.contacts[${index}]`, into, levels));
1324
1365
  }
1325
1366
  else {
1326
1367
  into.add("snapshot.contacts.shape", `${path}.contacts`, "contacts must be an array when present");
@@ -1331,7 +1372,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1331
1372
  if (Array.isArray(snapshot.scheduledActivities)) {
1332
1373
  const seen = new Set();
1333
1374
  snapshot.scheduledActivities.forEach((activity, index) => {
1334
- validateScheduledActivityInto(activity, `${path}.scheduledActivities[${index}]`, into);
1375
+ validateScheduledActivityInto(activity, `${path}.scheduledActivities[${index}]`, into, levels);
1335
1376
  if (isPlainObject(activity) && isFilled(activity.id)) {
1336
1377
  if (seen.has(activity.id)) {
1337
1378
  into.add("activity.id.unique", `${path}.scheduledActivities[${index}].id`, `duplicate activity id: ${activity.id}`);
@@ -1485,12 +1526,15 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1485
1526
  break;
1486
1527
  case "task-media-started":
1487
1528
  into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
1529
+ into.require(isAllocationId(event.allocationId), "event.taskMediaStarted.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
1488
1530
  break;
1489
1531
  case "task-media-ended":
1490
1532
  into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
1533
+ into.require(isAllocationId(event.allocationId), "event.taskMediaEnded.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
1491
1534
  break;
1492
1535
  case "task-ended":
1493
1536
  into.require(isTaskId(event.taskId), "event.taskEnded.taskId", `${at}.taskId`, "a task id is required");
1537
+ into.require(isAllocationId(event.allocationId), "event.taskEnded.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
1494
1538
  validateTaskOutcome(event.outcome, `${at}.outcome`, into);
1495
1539
  break;
1496
1540
  case "dial-outcome": {
@@ -1502,6 +1546,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1502
1546
  }
1503
1547
  if (event.taskId !== undefined)
1504
1548
  into.require(isTaskId(event.taskId), "event.dialOutcome.taskId", `${at}.taskId`, "taskId must be a task id when present");
1549
+ allocationWithTask(event, "event.dialOutcome", at, into);
1505
1550
  if (event.destinationId !== undefined)
1506
1551
  into.filled(event.destinationId, "event.dialOutcome.destinationId", `${at}.destinationId`, "a destinationId must not be empty when present");
1507
1552
  if (event.reason !== undefined)
@@ -1522,6 +1567,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1522
1567
  into.filled(event.observed, "event.diagnostic.observed", `${at}.observed`, "a diagnostic states what was observed instead");
1523
1568
  if (event.taskId !== undefined)
1524
1569
  into.require(isTaskId(event.taskId), "event.diagnostic.taskId", `${at}.taskId`, "taskId must be a task id when present");
1570
+ allocationWithTask(event, "event.diagnostic", at, into);
1525
1571
  break;
1526
1572
  case "queue-summary":
1527
1573
  validateQueueSummary(event.summary, `${at}.summary`, into);
@@ -1535,7 +1581,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1535
1581
  into.add("event.contacts.shape", `${at}.contacts`, "contacts must be an array");
1536
1582
  }
1537
1583
  else {
1538
- event.contacts.forEach((contact, index) => validateContactInto(contact, `${at}.contacts[${index}]`, into));
1584
+ event.contacts.forEach((contact, index) => validateContactInto(contact, `${at}.contacts[${index}]`, into, levels));
1539
1585
  }
1540
1586
  break;
1541
1587
  case "calendar-updated":
@@ -1546,7 +1592,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1546
1592
  else {
1547
1593
  const ids = new Set();
1548
1594
  event.scheduledActivities.forEach((activity, index) => {
1549
- validateScheduledActivityInto(activity, `${at}.scheduledActivities[${index}]`, into);
1595
+ validateScheduledActivityInto(activity, `${at}.scheduledActivities[${index}]`, into, levels);
1550
1596
  if (isPlainObject(activity) && isFilled(activity.id)) {
1551
1597
  if (ids.has(activity.id))
1552
1598
  into.add("activity.id.unique", `${at}.scheduledActivities[${index}].id`, `duplicate activity id: ${activity.id}`);
@@ -1707,6 +1753,7 @@ export function validateHandlingReport(report, path = "handlingReport", manifest
1707
1753
  return into.violations;
1708
1754
  }
1709
1755
  into.require(isTaskId(report.taskId), "handlingReport.taskId", `${path}.taskId`, "a report names the task");
1756
+ 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");
1710
1757
  into.oneOf(report.step, HANDLING_STEPS, "handlingReport.step", `${path}.step`);
1711
1758
  into.timestamp(report.at, "handlingReport.at", `${path}.at`);
1712
1759
  // A muted leg says whose the silence was; no other leg has anyone to name for it.
@@ -1739,6 +1786,15 @@ function validateMutedBy(device, rule, path, into) {
1739
1786
  into.require(device.mutedBy === undefined, `${rule}.mutedBy.unexpected`, `${path}.mutedBy`, "mutedBy says who silenced a device that is not flowing; a flowing one names nobody");
1740
1787
  }
1741
1788
  }
1789
+ /** An event that may name a task names its allocation with it, and never an allocation alone. */
1790
+ function allocationWithTask(event, rule, at, into) {
1791
+ if (event.taskId !== undefined) {
1792
+ into.require(isAllocationId(event.allocationId), `${rule}.allocationId`, `${at}.allocationId`, "an event that names a task names its allocation too");
1793
+ }
1794
+ else {
1795
+ into.require(event.allocationId === undefined, `${rule}.allocationId.unexpected`, `${at}.allocationId`, "an allocation is named with its task, never alone");
1796
+ }
1797
+ }
1742
1798
  export function validateHostReport(report, path = "host") {
1743
1799
  const into = new Collector();
1744
1800
  if (!isPlainObject(report)) {
@@ -1826,6 +1882,22 @@ export function validateTaskCommand(command, task, path = "command") {
1826
1882
  : [...new Set([...TASK_COMMAND_NAMES.voice, ...TASK_COMMAND_NAMES.chat, ...TASK_COMMAND_NAMES.email])];
1827
1883
  if (type === "custom") {
1828
1884
  into.filled(command.name, "command.custom.name", `${path}.name`, "a custom command names the control it presses");
1885
+ if (task === undefined)
1886
+ return into.violations;
1887
+ // Held to the task: the control it presses is one the task published, and what the control asked for travels with it.
1888
+ const custom = isPlainObject(task) && isPlainObject(task.capabilities) ? task.capabilities.custom : undefined;
1889
+ const control = Array.isArray(custom) ? custom.filter(isPlainObject).find(entry => entry.id === command.name) : undefined;
1890
+ if (control === undefined) {
1891
+ into.add("command.capability.custom", path, `custom ${String(command.name)} presses a control the task never published`);
1892
+ return into.violations;
1893
+ }
1894
+ if (isPlainObject(control.prompt) && Array.isArray(control.prompt.fields)) {
1895
+ for (const field of control.prompt.fields.filter(isPlainObject)) {
1896
+ if (typeof field.name === "string") {
1897
+ into.require(typeof command[field.name] === "string" && command[field.name].length > 0, "command.custom.prompt", `${path}.${field.name}`, `the control asked for ${field.name} and the command does not carry it`);
1898
+ }
1899
+ }
1900
+ }
1829
1901
  return into.violations;
1830
1902
  }
1831
1903
  if (!into.require(typeof type === "string" && names.includes(type), "command.type", `${path}.type`, channel === undefined ? `unsupported command: ${String(type)}` : `a ${channel} task has no ${String(type)} command`)) {
@@ -1911,12 +1983,17 @@ export function validateTaskCommand(command, task, path = "command") {
1911
1983
  // to act on yet, and in completing the handling has ended: a call with nobody on it, a
1912
1984
  // conversation closed. What a capability offers, the phase decides whether there is anything to use it on.
1913
1985
  const handling = () => inPhase("command.phase.handling", "in-progress", "paused");
1986
+ // A destination is one the directory offered: the id Omni sends is the id the provider published.
1987
+ const listed = (name) => {
1988
+ const declared = capabilities[name];
1989
+ const directory = isPlainObject(declared) && Array.isArray(declared.destinations) ? declared.destinations.filter(isPlainObject) : [];
1990
+ into.require(directory.some(item => item.id === command.destinationId), "command.destination.unknown", `${path}.destinationId`, `${String(command.destinationId)} is not a destination the task's ${name} directory offered`);
1991
+ };
1914
1992
  const onCall = Array.isArray(task.onCall) ? task.onCall.filter(isPlainObject) : [];
1915
1993
  switch (type) {
1916
1994
  case "answer":
1917
1995
  case "accept":
1918
1996
  case "decline":
1919
- case "reject":
1920
1997
  inPhase("command.phase.pending", "pending");
1921
1998
  if (type === "decline")
1922
1999
  offered("decline");
@@ -1944,10 +2021,14 @@ export function validateTaskCommand(command, task, path = "command") {
1944
2021
  break;
1945
2022
  case "transfer":
1946
2023
  handling();
1947
- if (command.action === "cold")
1948
- offered("coldTransfer");
1949
- else if (command.action === "warm")
1950
- offered("warmTransfer");
2024
+ if (command.action === "cold") {
2025
+ if (offered("coldTransfer"))
2026
+ listed("coldTransfer");
2027
+ }
2028
+ else if (command.action === "warm") {
2029
+ if (offered("warmTransfer"))
2030
+ listed("warmTransfer");
2031
+ }
1951
2032
  else if (command.action === "complete" || command.action === "cancel") {
1952
2033
  into.require(onCall.some(entry => entry.role === "consulted"), "command.transfer.consulted", path, `${String(command.action)} needs a consulted entry on onCall: without one there is nothing to complete or cancel`);
1953
2034
  }
@@ -1964,9 +2045,12 @@ export function validateTaskCommand(command, task, path = "command") {
1964
2045
  into.require(task.assisting !== undefined, "command.leadAssist.assisting", path, `${String(command.action)} is the lead's own act on a call they joined: the task carries assisting`);
1965
2046
  }
1966
2047
  break;
1967
- case "conference":
2048
+ case "conference": {
1968
2049
  handling();
1969
- if (offered("conference") && command.action === "remove") {
2050
+ const conferencing = offered("conference");
2051
+ if (conferencing && command.action === "add")
2052
+ listed("conference");
2053
+ if (conferencing && command.action === "remove") {
1970
2054
  const others = onCall.filter(entry => entry.role !== "agent");
1971
2055
  if (command.party === true) {
1972
2056
  into.require(others.some(entry => entry.role !== "party"), "command.conference.remove.alone", path, "removing the party would leave the agent alone: that is end-call");
@@ -1977,9 +2061,32 @@ export function validateTaskCommand(command, task, path = "command") {
1977
2061
  }
1978
2062
  }
1979
2063
  break;
1980
- case "complete":
2064
+ }
2065
+ case "complete": {
1981
2066
  into.require(task.completionMode === "agent-command", "command.complete.mode", path, "complete belongs to agent-command; a provider-automatic task completes itself");
2067
+ // What travels with complete is what the dispositions capability published: a code from its
2068
+ // list where it has one, a code at all where it requires one, notes as it said, nothing where it published nothing.
2069
+ const dispositions = capabilities.dispositions;
2070
+ if (dispositions === undefined) {
2071
+ into.require(command.disposition === undefined, "command.complete.disposition.unexpected", `${path}.disposition`, "the task declares no dispositions; complete travels with no code");
2072
+ into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task declares no dispositions; complete travels with no notes");
2073
+ }
2074
+ else if (isPlainObject(dispositions)) {
2075
+ if (Array.isArray(dispositions.codes) && command.disposition !== undefined) {
2076
+ into.require(dispositions.codes.some(code => isPlainObject(code) && code.id === command.disposition), "command.complete.disposition.unknown", `${path}.disposition`, `${String(command.disposition)} is not a code the task published`);
2077
+ }
2078
+ if (dispositions.required === true) {
2079
+ into.require(command.disposition !== undefined, "command.complete.disposition.required", `${path}.disposition`, "the task requires a disposition and complete carries none");
2080
+ }
2081
+ if (dispositions.notes === "none") {
2082
+ into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task takes no notes");
2083
+ }
2084
+ else if (dispositions.notes === "required") {
2085
+ into.require(typeof command.notes === "string" && command.notes.length > 0, "command.complete.notes.required", `${path}.notes`, "the task requires notes and complete carries none");
2086
+ }
2087
+ }
1982
2088
  break;
2089
+ }
1983
2090
  default:
1984
2091
  break;
1985
2092
  }
@@ -2076,6 +2183,26 @@ export function validateTimeZone(value, path = "context.timeZone") {
2076
2183
  into.require(isTimeZone(value), "context.timeZone", path, "a host states the agent's time zone at connect as an IANA name, such as Asia/Kolkata; an offset cannot survive a daylight-saving boundary");
2077
2184
  return into.violations;
2078
2185
  }
2186
+ /**
2187
+ * What `describeUsers(ids)` answered: an array of users, each one asked for, each a valid identity.
2188
+ * The provider may answer fewer than asked, which is what "unknown to it" looks like; it never
2189
+ * answers somebody nobody asked about.
2190
+ */
2191
+ export function validateDescribedUsers(users, asked, path = "describeUsers") {
2192
+ const into = new Collector();
2193
+ if (!Array.isArray(users)) {
2194
+ into.add("describeUsers.shape", path, "describeUsers answers an array of users, empty when it knows none of them");
2195
+ return into.violations;
2196
+ }
2197
+ users.forEach((user, index) => {
2198
+ const at = `${path}[${index}]`;
2199
+ validateUser(user, "describeUsers.user", at, into);
2200
+ if (isPlainObject(user) && typeof user.id === "string") {
2201
+ into.require(asked.includes(user.id), "describeUsers.unasked", `${at}.id`, `${user.id} was not among the ids asked for`);
2202
+ }
2203
+ });
2204
+ return into.violations;
2205
+ }
2079
2206
  function validateUser(value, rule, path, into) {
2080
2207
  if (!isPlainObject(value)) {
2081
2208
  into.add(rule, path, "an identity must be an object");