@xema/omni-protocol 0.1.100 → 0.1.102

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, BROWSER_ISOLATION_SCHEMES, HISTORY_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, SHIFT_EVENT_KINDS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HISTORY_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") {
@@ -71,10 +71,10 @@ const DIAL_DESTINATION_POLICIES = membersOf({ "contacts-only": true, "any-number
71
71
  const DIAL_OUTCOMES = membersOf({ answered: true, busy: true, "no-answer": true, unreachable: true, rejected: true, cancelled: true, unexplained: true });
72
72
  /** The outcomes that name a cause. `unexplained` is declarable only beside one of these. */
73
73
  const EXPLAINED_FAILURES = ["busy", "no-answer", "unreachable", "rejected", "cancelled"];
74
- const ON_CALL_ROLES = membersOf({ party: true, agent: true, consulted: true, conferenced: true });
74
+ const ON_CALL_ROLES = membersOf({ party: true, agent: true, conferenced: true });
75
75
  const ON_CALL_STAGES = membersOf({ ringing: true, joined: true });
76
76
  /** The task capabilities under which a command dials, and so need the manifest to say how a dial ends. */
77
- const DIALLING_CAPABILITIES = ["connectBack", "coldTransfer", "warmTransfer", "conference"];
77
+ const DIALLING_CAPABILITIES = ["connectBack", "conference"];
78
78
  const SNAPSHOT_REASONS = membersOf({
79
79
  reconnected: true, "provider-requested": true,
80
80
  });
@@ -98,7 +98,7 @@ const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
98
98
  const TASK_CAPABILITIES = {
99
99
  voice: membersOf({
100
100
  browsers: true, outcomes: true, custom: true, decline: true, hold: true,
101
- endCall: true, terminateCall: true, connectBack: true, coldTransfer: true, warmTransfer: true, leadAssist: true, conference: true, recording: true,
101
+ endCall: true, terminateCall: true, connectBack: true, schedule: true, leadAssist: true, conference: true, recording: true,
102
102
  }),
103
103
  chat: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, hold: true }),
104
104
  email: membersOf({ browsers: true, outcomes: true, custom: true, decline: true }),
@@ -579,7 +579,7 @@ export function validateManifest(manifest, path = "manifest") {
579
579
  // ---------------------------------------------------------------------------
580
580
  function validateDestinationDirectory(value, path, into) {
581
581
  if (!isPlainObject(value)) {
582
- into.add("task.destinations.shape", path, "a transfer or conference control carries the directory it offers: { destinations: [...] }");
582
+ into.add("task.destinations.shape", path, "a conference control carries the directory it offers: { destinations: [...] }");
583
583
  return;
584
584
  }
585
585
  if (!Array.isArray(value.destinations)) {
@@ -982,7 +982,7 @@ const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
982
982
  /**
983
983
  * Who is on the call, as the provider states it. Voice only. Each entry is a role with what that
984
984
  * role needs and nothing another role would: a party or an agent dialled nowhere, so neither
985
- * carries a destination; somebody consulted or conferenced came from one, so both do.
985
+ * carries a destination; somebody conferenced came from one, so they do.
986
986
  */
987
987
  function validateOnCall(value, channel, path, into) {
988
988
  if (value === undefined)
@@ -993,7 +993,7 @@ function validateOnCall(value, channel, path, into) {
993
993
  into.add("task.onCall.shape", path, "onCall is an array of who is on the call, when the provider knows");
994
994
  return;
995
995
  }
996
- const counted = { party: 0, consulted: 0 };
996
+ let parties = 0;
997
997
  value.forEach((entry, index) => {
998
998
  const at = `${path}[${index}]`;
999
999
  if (!isPlainObject(entry)) {
@@ -1007,15 +1007,15 @@ function validateOnCall(value, channel, path, into) {
1007
1007
  if (entry.held !== undefined) {
1008
1008
  into.require(entry.held === true, "task.onCall.held", `${at}.held`, "held is stated by presence: send true or omit it");
1009
1009
  }
1010
- if (role === "party" || role === "consulted")
1011
- counted[role] += 1;
1010
+ if (role === "party")
1011
+ parties += 1;
1012
1012
  if (role === "agent") {
1013
1013
  into.require(isUserId(entry.userId), "task.onCall.userId", `${at}.userId`, "an agent on the call is named by their user id");
1014
1014
  }
1015
1015
  else {
1016
1016
  into.require(entry.userId === undefined, "task.onCall.userId.unexpected", `${at}.userId`, `a ${role} is not named by a user id`);
1017
1017
  }
1018
- if (role === "consulted" || role === "conferenced") {
1018
+ if (role === "conferenced") {
1019
1019
  into.filled(entry.destinationId, "task.onCall.destinationId", `${at}.destinationId`, `a ${role} entry names the directory item that was dialled`);
1020
1020
  // Stated, so a snapshot says who is present without anyone having seen the dial's outcome.
1021
1021
  if (into.oneOf(entry.stage, ON_CALL_STAGES, "task.onCall.stage", `${at}.stage`)) {
@@ -1037,17 +1037,16 @@ function validateOnCall(value, channel, path, into) {
1037
1037
  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");
1038
1038
  }
1039
1039
  for (const field of ["destinationId", "label"]) {
1040
- into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a party is not a directory item; ${field} belongs on consulted or conferenced`);
1040
+ into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a party is not a directory item; ${field} belongs on conferenced`);
1041
1041
  }
1042
1042
  }
1043
1043
  else {
1044
1044
  for (const field of ["destinationId", "dialId", "label", "stage"]) {
1045
- 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`);
1045
+ 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 conferenced`);
1046
1046
  }
1047
1047
  }
1048
1048
  });
1049
- into.require(counted.party <= 1, "task.onCall.party.single", path, "a call has one party; a second is somebody else's role");
1050
- into.require(counted.consulted <= 1, "task.onCall.consulted.single", path, "one consultation at a time: transfer complete and cancel name no destination because there is exactly one");
1049
+ into.require(parties <= 1, "task.onCall.party.single", path, "a call has one party; a second is somebody else's role");
1051
1050
  }
1052
1051
  const LEAD_STAGES = membersOf({ requested: true, joined: true });
1053
1052
  /** The agent's request for a lead. Voice only; `joined` names the lead, `requested` cannot. */
@@ -1122,13 +1121,13 @@ function validateTaskInto(task, context, path, into) {
1122
1121
  into.require(context.dialOutcomesDeclared !== false, "task.preview.dialOutcomes.required", `${path}.phase`, "a preview ends in the agent pressing Call, which dials, and the manifest declares no dialOutcomes to say how a dial ends");
1123
1122
  }
1124
1123
  // The deadline and what happens at it travel together, and only while the record is being previewed.
1125
- if (task.previewEndsAt !== undefined || task.atDeadline !== undefined) {
1126
- into.require(task.phase === "preview", "task.preview.deadline.unexpected", `${path}.previewEndsAt`, "previewEndsAt and atDeadline belong to a task in preview; past it there is nothing to wait for");
1127
- if (task.previewEndsAt === undefined) {
1128
- into.add("task.preview.previewEndsAt.required", `${path}.previewEndsAt`, "atDeadline says what happens at a deadline, so there has to be one");
1124
+ if (task.previewEndsInSeconds !== undefined || task.atDeadline !== undefined) {
1125
+ into.require(task.phase === "preview", "task.preview.deadline.unexpected", `${path}.previewEndsInSeconds`, "previewEndsInSeconds and atDeadline belong to a task in preview; past it there is nothing to wait for");
1126
+ if (task.previewEndsInSeconds === undefined) {
1127
+ into.add("task.preview.previewEndsInSeconds.required", `${path}.previewEndsInSeconds`, "atDeadline says what happens at a deadline, so there has to be one");
1129
1128
  }
1130
1129
  else {
1131
- into.timestamp(task.previewEndsAt, "task.preview.previewEndsAt", `${path}.previewEndsAt`);
1130
+ into.require(isDurationSeconds(task.previewEndsInSeconds), "task.preview.previewEndsInSeconds", `${path}.previewEndsInSeconds`, "previewEndsInSeconds is how long the preview has left: a whole number of seconds, zero or more, counted from this publication");
1132
1131
  }
1133
1132
  if (task.atDeadline === undefined) {
1134
1133
  into.add("task.preview.atDeadline.required", `${path}.atDeadline`, "a preview with a deadline says what happens at it: provider dials, host dials, or waits for the agent -- an agent counting down has to know which");
@@ -1235,6 +1234,14 @@ function validateTaskInto(task, context, path, into) {
1235
1234
  if (capabilities.browsers === true && Array.isArray(task.browsers) && task.browsers.length === 0) {
1236
1235
  into.add("task.browsers.required", `${path}.browsers`, "a task that declares capabilities.browsers supplies at least one; with none, omit the capability");
1237
1236
  }
1237
+ // An outcome is given in wrap, by the agent: a provider that completes the task itself cannot
1238
+ // wait for one, and a task with no wrap has nowhere to take one.
1239
+ if (isPlainObject(capabilities.outcomes) && capabilities.outcomes.required === true) {
1240
+ into.require(task.completionMode !== "provider-automatic", "task.outcomes.required.mode", `${path}.capabilities.outcomes.required`, "a required outcome is the agent's to give, so the task completes on the agent's command; provider-automatic completion cannot wait for it");
1241
+ }
1242
+ if (capabilities.outcomes !== undefined) {
1243
+ into.require(task.wrapAllowance !== 0, "task.outcomes.wrapAllowance", `${path}.capabilities.outcomes`, "outcomes are collected in wrap, and wrapAllowance 0 gives none: publish a wrap or no outcomes");
1244
+ }
1238
1245
  for (const [name, declared] of Object.entries(capabilities)) {
1239
1246
  if (declared === undefined)
1240
1247
  continue;
@@ -1267,8 +1274,6 @@ function validateTaskInto(task, context, path, into) {
1267
1274
  case "custom":
1268
1275
  validateCustomCapabilities(declared, `${path}.capabilities.custom`, into);
1269
1276
  break;
1270
- case "coldTransfer":
1271
- case "warmTransfer":
1272
1277
  case "conference":
1273
1278
  validateDestinationDirectory(declared, `${path}.capabilities.${name}`, into);
1274
1279
  break;
@@ -1287,7 +1292,7 @@ function validateForcedBreak(value, path, into) {
1287
1292
  into.add("break.forced.shape", path, "a forced break must be an object");
1288
1293
  return;
1289
1294
  }
1290
- into.require(isUserId(value.by), "break.forced.by", `${path}.by`, "a forced break must say who forced it");
1295
+ into.require(isUserId(value.by) || value.by === "provider", "break.forced.by", `${path}.by`, "a forced break says who forced it: the lead by user id, or provider where the platform did");
1291
1296
  for (const field of ["endsAutomatically", "endsAt"]) {
1292
1297
  into.require(!Object.hasOwn(value, field), "break.forced.manualResume", `${path}.${field}`, "automatic ending fields are unsupported; the agent must resume manually");
1293
1298
  }
@@ -1406,9 +1411,8 @@ export function validateTeamCommand(request, context, path = "teamCommand") {
1406
1411
  }
1407
1412
  }
1408
1413
  else if (command.type === "join" || command.type === "decline") {
1409
- const requests = Array.isArray(team.requests) ? team.requests : [];
1410
- into.require(requests.some((r) => isPlainObject(r) && r.memberId === command.memberId
1411
- && (command.assignmentId === undefined || r.assignmentId === command.assignmentId)), "team.command.request", path, `${command.type} answers a request the team member list carries, and this member has none`);
1414
+ const asked = isPlainObject(target) && isPlainObject(target.request) ? target.request : undefined;
1415
+ into.require(asked !== undefined && (command.assignmentId === undefined || asked.assignmentId === command.assignmentId), "team.command.request", path, `${command.type} answers a request the team member list carries on the member, and this member has none`);
1412
1416
  }
1413
1417
  else if (command.type === "coach" || command.type === "join-call" || command.type === "leave") {
1414
1418
  into.require(isPlainObject(target) && target.listening !== undefined, "team.command.listening", path, `${command.type} needs the lead on this member's call: the team member list shows listening on them`);
@@ -1593,32 +1597,6 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
1593
1597
  // Policies are present where the provider offers policy control, absent where it does not.
1594
1598
  if (teamMembers.policies !== undefined)
1595
1599
  validateTeamPoliciesInto(teamMembers.policies, `${path}.policies`, into, context.levels);
1596
- // Requests are what a lead acts on: always carried, `[]` when nobody is asking.
1597
- if (!Array.isArray(teamMembers.requests)) {
1598
- into.add("team.requests.shape", `${path}.requests`, "a team member list carries its requests: [] when nobody is asking");
1599
- }
1600
- else {
1601
- const seenRequests = new Set();
1602
- teamMembers.requests.forEach((request, index) => {
1603
- const at = `${path}.requests[${index}]`;
1604
- if (!isPlainObject(request)) {
1605
- into.add("team.request.shape", at, "each request must be an object");
1606
- return;
1607
- }
1608
- if (into.require(isUserId(request.memberId), "team.request.memberId", `${at}.memberId`, "a request names the member asking")) {
1609
- into.require(request.memberId !== context.self, "team.request.self", `${at}.memberId`, "the team member list carries the reader's own ask: an agent's request for a lead goes to whoever leads them");
1610
- }
1611
- if (into.require(isAssignmentId(request.assignmentId), "team.request.assignmentId", `${at}.assignmentId`, "a request names the member's assignment the lead would join")) {
1612
- const key = `${String(request.memberId)}@${String(request.assignmentId)}`;
1613
- if (seenRequests.has(key))
1614
- into.add("team.request.unique", at, `duplicate request: ${key}`);
1615
- seenRequests.add(key);
1616
- }
1617
- if (request.note !== undefined)
1618
- into.filled(request.note, "team.request.note", `${at}.note`, "a note must not be empty when present");
1619
- into.timestamp(request.since, "team.request.since", `${at}.since`);
1620
- });
1621
- }
1622
1600
  if (!Array.isArray(teamMembers.members)) {
1623
1601
  into.add("team.members.shape", `${path}.members`, "a team member list must carry a members array");
1624
1602
  return;
@@ -1626,61 +1604,94 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
1626
1604
  const seen = new Set();
1627
1605
  teamMembers.members.forEach((member, index) => {
1628
1606
  const at = `${path}.members[${index}]`;
1629
- if (!isPlainObject(member)) {
1630
- into.add("team.member.shape", at, "each team member must be an object");
1631
- return;
1632
- }
1633
- if (into.require(isUserId(member.id), "team.member.id", `${at}.id`, "a team member needs a user id")) {
1607
+ if (isPlainObject(member) && isUserId(member.id)) {
1634
1608
  if (seen.has(member.id))
1635
1609
  into.add("team.member.unique", `${at}.id`, `duplicate team member: ${member.id}`);
1636
1610
  seen.add(member.id);
1637
- into.require(member.id !== context.self, "team.member.self", `${at}.id`, "the team member list carries the agent it is published to: a lead does not report to themself");
1638
- }
1639
- into.oneOf(member.availability, TEAM_AVAILABILITIES, "team.member.availability", `${at}.availability`);
1640
- if (member.since !== undefined)
1641
- into.timestamp(member.since, "team.member.since", `${at}.since`);
1642
- if (member.break !== undefined) {
1643
- // Only an outstanding request appears here: `not-requested` is absence, `on-break` is
1644
- // `availability: "on-break"`, and a denial never survives to be reported.
1645
- if (into.oneOf(member.break, MEMBER_BREAKS, "team.member.break", `${at}.break`)) {
1646
- into.require(member.availability !== "signed-out" && member.availability !== "on-break", "team.member.break.availability", `${at}.break`, "a member on a break or signed out has no request outstanding");
1647
- }
1648
- }
1649
- // The member's open tasks, as the lead sees them: trimmed by the provider, held to the task rules otherwise.
1650
- if (member.tasks !== undefined) {
1651
- if (!Array.isArray(member.tasks)) {
1652
- into.add("team.member.tasks.shape", `${at}.tasks`, "a member's tasks are an array: [] when they hold none, omitted only where the provider cannot see them");
1653
- }
1654
- else {
1655
- const held = new Set();
1656
- member.tasks.forEach((task, index) => {
1657
- const channel = isPlainObject(task) && typeof task.channel === "string" && isChannel(task.channel) ? task.channel : context.channel ?? "voice";
1658
- validateTaskInto(task, { channel, levels: context.levels, member: true }, `${at}.tasks[${index}]`, into);
1659
- if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
1660
- if (held.has(task.assignmentId))
1661
- into.add("team.member.tasks.unique", `${at}.tasks[${index}].assignmentId`, `duplicate assignment on one member: ${task.assignmentId}`);
1662
- held.add(task.assignmentId);
1663
- }
1664
- });
1665
- into.require(member.availability !== "signed-out" || member.tasks.length === 0, "team.member.tasks.availability", `${at}.tasks`, "a member signed out holds no task");
1666
- }
1667
- }
1668
- // The lead on this member's call, in the mode they are heard.
1669
- if (member.listening !== undefined) {
1670
- if (!isPlainObject(member.listening)) {
1671
- into.add("team.member.listening.shape", `${at}.listening`, "listening must be an object when present");
1672
- }
1673
- else {
1674
- into.oneOf(member.listening.mode, LISTENING_MODES, "team.member.listening.mode", `${at}.listening.mode`);
1675
- into.timestamp(member.listening.since, "team.member.listening.since", `${at}.listening.since`);
1676
- }
1677
1611
  }
1612
+ validateTeamMemberInto(member, at, context, into);
1678
1613
  });
1679
1614
  // One member's call at a time.
1680
1615
  const listened = teamMembers.members.filter((member) => isPlainObject(member) && member.listening !== undefined);
1681
1616
  if (listened.length > 1)
1682
1617
  into.add("team.listening.single", `${path}.members`, "a lead is on one member's call at a time");
1683
1618
  }
1619
+ /** One member as the lead sees them, whole: on the list, and on `team-member-updated` alone. */
1620
+ export function validateTeamMember(member, path = "member", context = {}) {
1621
+ const into = new Collector();
1622
+ validateTeamMemberInto(member, path, context, into);
1623
+ return into.violations;
1624
+ }
1625
+ function validateTeamMemberInto(member, at, context, into) {
1626
+ if (!isPlainObject(member)) {
1627
+ into.add("team.member.shape", at, "each team member must be an object");
1628
+ return;
1629
+ }
1630
+ if (into.require(isUserId(member.id), "team.member.id", `${at}.id`, "a team member needs a user id")) {
1631
+ into.require(member.id !== context.self, "team.member.self", `${at}.id`, "the team member list carries the agent it is published to: a lead does not report to themself");
1632
+ }
1633
+ into.oneOf(member.availability, TEAM_AVAILABILITIES, "team.member.availability", `${at}.availability`);
1634
+ if (member.since !== undefined)
1635
+ into.timestamp(member.since, "team.member.since", `${at}.since`);
1636
+ if (member.break !== undefined) {
1637
+ // Only an outstanding request appears here: `not-requested` is absence, `on-break` is
1638
+ // `availability: "on-break"`, and a denial never survives to be reported.
1639
+ if (into.oneOf(member.break, MEMBER_BREAKS, "team.member.break", `${at}.break`)) {
1640
+ into.require(member.availability !== "signed-out" && member.availability !== "on-break", "team.member.break.availability", `${at}.break`, "a member on a break or signed out has no request outstanding");
1641
+ }
1642
+ }
1643
+ // The member's open tasks, as the lead sees them: trimmed by the provider, held to the task rules otherwise.
1644
+ if (member.tasks !== undefined) {
1645
+ if (!Array.isArray(member.tasks)) {
1646
+ into.add("team.member.tasks.shape", `${at}.tasks`, "a member's tasks are an array: [] when they hold none, omitted only where the provider cannot see them");
1647
+ }
1648
+ else {
1649
+ const held = new Set();
1650
+ member.tasks.forEach((task, index) => {
1651
+ const channel = isPlainObject(task) && typeof task.channel === "string" && isChannel(task.channel) ? task.channel : context.channel ?? "voice";
1652
+ validateTaskInto(task, { channel, levels: context.levels, member: true }, `${at}.tasks[${index}]`, into);
1653
+ if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
1654
+ if (held.has(task.assignmentId))
1655
+ into.add("team.member.tasks.unique", `${at}.tasks[${index}].assignmentId`, `duplicate assignment on one member: ${task.assignmentId}`);
1656
+ held.add(task.assignmentId);
1657
+ }
1658
+ });
1659
+ into.require(member.availability !== "signed-out" || member.tasks.length === 0, "team.member.tasks.availability", `${at}.tasks`, "a member signed out holds no task");
1660
+ }
1661
+ }
1662
+ // The lead on this member's call, in the mode they are heard.
1663
+ if (member.listening !== undefined) {
1664
+ if (!isPlainObject(member.listening)) {
1665
+ into.add("team.member.listening.shape", `${at}.listening`, "listening must be an object when present");
1666
+ }
1667
+ else {
1668
+ const listened = member.listening;
1669
+ if (into.require(isAssignmentId(member.listening.assignmentId), "team.member.listening.assignmentId", `${at}.listening.assignmentId`, "listening names which of the member's calls the lead is on: the application opens the lead's audio on it") && Array.isArray(member.tasks)) {
1670
+ into.require(member.tasks.some((task) => isPlainObject(task) && task.assignmentId === listened.assignmentId), "team.member.listening.assignment", `${at}.listening.assignmentId`, "the call the lead is on is one the member's tasks carry");
1671
+ }
1672
+ into.oneOf(member.listening.mode, LISTENING_MODES, "team.member.listening.mode", `${at}.listening.mode`);
1673
+ into.timestamp(member.listening.since, "team.member.listening.since", `${at}.listening.since`);
1674
+ }
1675
+ }
1676
+ // The member's own history for the day: about the person, not any one call.
1677
+ if (member.shift !== undefined)
1678
+ validateShiftInto(member.shift, `${at}.shift`, into);
1679
+ // The member's standing ask for a lead, on one of the calls they hold.
1680
+ if (member.request !== undefined) {
1681
+ if (!isPlainObject(member.request)) {
1682
+ into.add("team.member.request.shape", `${at}.request`, "request must be an object when present");
1683
+ }
1684
+ else {
1685
+ const asked = member.request;
1686
+ if (into.require(isAssignmentId(asked.assignmentId), "team.member.request.assignmentId", `${at}.request.assignmentId`, "a request names the member's assignment the lead would join") && Array.isArray(member.tasks)) {
1687
+ into.require(member.tasks.some((task) => isPlainObject(task) && task.assignmentId === asked.assignmentId), "team.member.request.assignment", `${at}.request.assignmentId`, "the call the member asks a lead onto is one their tasks carry");
1688
+ }
1689
+ if (asked.note !== undefined)
1690
+ into.filled(asked.note, "team.member.request.note", `${at}.request.note`, "a note must not be empty when present");
1691
+ into.timestamp(asked.since, "team.member.request.since", `${at}.request.since`);
1692
+ }
1693
+ }
1694
+ }
1684
1695
  export function validateSnapshot(snapshot, manifest, path = "snapshot", context = {}) {
1685
1696
  const into = new Collector();
1686
1697
  if (!isPlainObject(snapshot)) {
@@ -1754,14 +1765,15 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1754
1765
  into.add("snapshot.calendar.shape", `${path}.calendar`, "calendar must be an array when present");
1755
1766
  }
1756
1767
  }
1757
- // The login is the permission: a lead's snapshot carries a team member list, `members: []` included,
1758
- // while the team feature is on. The other direction -- a team member list to a login that does
1759
- // not lead, or to a lead who turned the feature off -- is the team member list's own rule.
1760
- if (context.capabilities?.lead === true && context.leadFeatures !== false && snapshot.team === undefined) {
1761
- into.add("team.required", `${path}.team`, "the login declares lead and the team feature is on, so every snapshot carries a team member list: members: [] when nobody is in it");
1768
+ // The switch is the permission: once the application has said the team feature is on, a
1769
+ // snapshot carries the team, `members: []` included. Before it has said, the provider assumes
1770
+ // nothing and owes nothing. The other direction -- a team member list to a login that does not
1771
+ // lead, or to a lead who turned the feature off -- is the team member list's own rule.
1772
+ if (context.leadFeatures === true && snapshot.team === undefined) {
1773
+ into.add("team.required", `${path}.team`, "the team feature is on, so every snapshot carries a team member list: members: [] when nobody is in it");
1762
1774
  }
1763
1775
  if (context.leadFeatures === false && snapshot.team !== undefined) {
1764
- into.add("team.unexpected", `${path}.team`, "the lead turned the team feature off, so nothing of the team reaches them");
1776
+ into.add("team.unexpected", `${path}.team`, "the team feature is not on for this lead: nothing of the team reaches them until the application switches it on, and nothing after it switches it off");
1765
1777
  }
1766
1778
  if (snapshot.team !== undefined)
1767
1779
  validateTeamMembersInto(snapshot.team, `${path}.team`, { ...context, levels, channel: isChannel(channel) ? channel : undefined }, into);
@@ -1779,11 +1791,6 @@ function validateTaskOutcome(value, path, into) {
1779
1791
  case "completed":
1780
1792
  into.oneOf(value.by, COMPLETED_BY, "event.taskEnded.outcome.completed", `${path}.by`);
1781
1793
  break;
1782
- case "transferred":
1783
- if (value.destinationId !== undefined) {
1784
- into.filled(value.destinationId, "event.taskEnded.outcome.transferred", `${path}.destinationId`, "a destinationId must not be empty when present");
1785
- }
1786
- break;
1787
1794
  case "taken-over":
1788
1795
  // A lead is not a directory item: the ending names the lead, and the host resolves the name.
1789
1796
  into.require(isUserId(value.leadId), "event.taskEnded.outcome.takenOver", `${path}.leadId`, "a take-over names the lead who took the call, by user id");
@@ -1897,8 +1904,8 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1897
1904
  if (isPlainObject(event.task) && typeof event.task.phase === "string") {
1898
1905
  into.require(OFFERABLE_PHASES.includes(event.task.phase), "event.taskOffered.phase", `${at}.task.phase`, `task-offered introduces a task as ${OFFERABLE_PHASES.join(", ")}, never as ${event.task.phase}`);
1899
1906
  }
1900
- if (event.assignmentExpiresAt !== undefined)
1901
- into.timestamp(event.assignmentExpiresAt, "event.taskOffered.assignmentExpiresAt", `${at}.assignmentExpiresAt`);
1907
+ if (event.expiresInSeconds !== undefined)
1908
+ into.require(isDurationSeconds(event.expiresInSeconds), "event.taskOffered.expiresInSeconds", `${at}.expiresInSeconds`, "expiresInSeconds is how long the offer stands: a whole number of seconds, zero or more, counted from this event");
1902
1909
  break;
1903
1910
  case "task-updated":
1904
1911
  validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
@@ -1949,18 +1956,26 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1949
1956
  validateQueueSummary(event.summary, `${at}.summary`, into);
1950
1957
  break;
1951
1958
  case "team-updated":
1952
- into.require(context.capabilities === undefined || context.capabilities.lead === true, "event.team.capability", `${at}.team`, "team-updated reaches a login that declares lead, and nobody else");
1953
- into.require(context.leadFeatures !== false, "event.team.features", `${at}.team`, "the lead turned the team feature off, so nothing of the team reaches them");
1954
- validateTeamMembersInto(event.team, `${at}.team`, { ...context, levels, channel: isChannel(channel) ? channel : undefined }, into);
1955
- break;
1956
- case "team-audio-started":
1957
- case "team-audio-ended":
1958
- // The lead's channel in a member's call: audio the application opens on the member's assignment.
1959
- into.require(channel === "voice", "event.audio.channel", `${at}.type`, "only a voice provider publishes audio transitions");
1960
- into.require(context.capabilities === undefined || context.capabilities.lead === true, "event.teamAudio.capability", `${at}.type`, "team audio reaches a login that declares lead, and nobody else");
1961
- into.require(isUserId(event.memberId), "event.teamAudio.memberId", `${at}.memberId`, "team audio names the member whose call the lead is on");
1962
- into.require(isAssignmentId(event.assignmentId), "event.teamAudio.assignmentId", `${at}.assignmentId`, "team audio names the member's assignment");
1959
+ case "team-member-updated":
1960
+ case "team-member-removed":
1961
+ case "team-policies-updated": {
1962
+ const field = event.type === "team-updated" ? "team" : event.type === "team-member-updated" ? "member" : event.type === "team-member-removed" ? "memberId" : "policies";
1963
+ into.require(context.capabilities === undefined || context.capabilities.lead === true, "event.team.capability", `${at}.${field}`, `${event.type} reaches a login that declares lead, and nobody else`);
1964
+ into.require(context.leadFeatures !== false, "event.team.features", `${at}.${field}`, "the team feature is not on for this lead: nothing of the team reaches them until the application switches it on, and nothing after it switches it off");
1965
+ const teamContext = { ...context, levels, channel: isChannel(channel) ? channel : undefined };
1966
+ if (event.type === "team-updated")
1967
+ validateTeamMembersInto(event.team, `${at}.team`, teamContext, into);
1968
+ else if (event.type === "team-member-updated")
1969
+ validateTeamMemberInto(event.member, `${at}.member`, teamContext, into);
1970
+ else if (event.type === "team-member-removed") {
1971
+ if (into.require(isUserId(event.memberId), "event.teamMemberRemoved.memberId", `${at}.memberId`, "team-member-removed names the member taken off, by user id")) {
1972
+ into.require(event.memberId !== context.self, "team.member.self", `${at}.memberId`, "a lead does not report to themself, so is never removed from their own team");
1973
+ }
1974
+ }
1975
+ else
1976
+ validateTeamPoliciesInto(event.policies, `${at}.policies`, into, levels);
1963
1977
  break;
1978
+ }
1964
1979
  case "contacts-updated":
1965
1980
  into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
1966
1981
  if (!Array.isArray(event.contacts)) {
@@ -2021,6 +2036,43 @@ function validatePreferencesInto(value, path, into, levels) {
2021
2036
  });
2022
2037
  }
2023
2038
  /** The team's policy per capability as the lead sees it: the setting, who set it, who locked it. */
2039
+ function validateShiftInto(value, path, into) {
2040
+ if (!isPlainObject(value)) {
2041
+ into.add("team.member.shift.shape", path, "shift must be an object when present");
2042
+ return;
2043
+ }
2044
+ const signedIn = into.timestamp(value.signedInAt, "team.member.shift.signedInAt", `${path}.signedInAt`);
2045
+ if (value.signedOutAt !== undefined && into.timestamp(value.signedOutAt, "team.member.shift.signedOutAt", `${path}.signedOutAt`) && signedIn) {
2046
+ into.require(Date.parse(value.signedOutAt) >= Date.parse(value.signedInAt), "team.member.shift.signedOutAt.order", `${path}.signedOutAt`, "a member signs out after signing in");
2047
+ }
2048
+ for (const field of ["talkSeconds", "holdSeconds", "breakSeconds"]) {
2049
+ if (value[field] !== undefined)
2050
+ into.require(isDurationSeconds(value[field]), `team.member.shift.${field}`, `${path}.${field}`, `${field} must be a whole number of seconds, zero or more, or omitted when unknown`);
2051
+ }
2052
+ if (value.tasksHandled !== undefined)
2053
+ into.require(Number.isInteger(value.tasksHandled) && value.tasksHandled >= 0, "team.member.shift.tasksHandled", `${path}.tasksHandled`, "tasksHandled must be a whole number, zero or more, or omitted when unknown");
2054
+ if (value.events !== undefined) {
2055
+ if (!Array.isArray(value.events)) {
2056
+ into.add("team.member.shift.events.shape", `${path}.events`, "events must be an array when present");
2057
+ return;
2058
+ }
2059
+ let previous;
2060
+ value.events.forEach((event, index) => {
2061
+ const at = `${path}.events[${index}]`;
2062
+ if (!isPlainObject(event)) {
2063
+ into.add("team.member.shift.event.shape", at, "each shift event must be an object");
2064
+ return;
2065
+ }
2066
+ into.oneOf(event.kind, SHIFT_EVENT_KINDS, "team.member.shift.event.kind", `${at}.kind`);
2067
+ if (into.timestamp(event.at, "team.member.shift.event.at", `${at}.at`)) {
2068
+ const instant = Date.parse(event.at);
2069
+ if (previous !== undefined && instant < previous)
2070
+ into.add("team.member.shift.events.order", `${at}.at`, "shift events are oldest first; this event is earlier than the one before it");
2071
+ previous = instant;
2072
+ }
2073
+ });
2074
+ }
2075
+ }
2024
2076
  function validateTeamPoliciesInto(value, path, into, levels) {
2025
2077
  if (!isPlainObject(value)) {
2026
2078
  into.add("team.policies.shape", path, "policies must be an object keyed by capability");
@@ -2246,7 +2298,6 @@ export function validateHostReport(report, path = "host") {
2246
2298
  // ---------------------------------------------------------------------------
2247
2299
  // Commands.
2248
2300
  // ---------------------------------------------------------------------------
2249
- const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
2250
2301
  const LEAD_ASSIST_ACTIONS = ["request", "cancel"];
2251
2302
  const CONFERENCE_ACTIONS = ["add", "remove"];
2252
2303
  /** Validate the exact interaction/assignment target before provider command dispatch. */
@@ -2267,7 +2318,7 @@ export function validateTaskCommandRequest(request, task, path = "request", task
2267
2318
  /**
2268
2319
  * What a command needs to be issuable, checked against the task it names: the capability the
2269
2320
  * guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
2270
- * consulted entry, a lead requested, somebody else on the call. Without a task only the command's
2321
+ * lead requested, somebody else on the call. Without a task only the command's
2271
2322
  * own shape is checked. A host validates before sending, and an adapter before acting: a command
2272
2323
  * for a control the task never offered is the host's error, and this names it.
2273
2324
  */
@@ -2300,10 +2351,8 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2300
2351
  case "complete":
2301
2352
  fields.push("outcome", "notes");
2302
2353
  break;
2303
- case "transfer":
2304
- fields.push("action");
2305
- if (command.action === "cold" || command.action === "warm")
2306
- fields.push("dialId", "destinationId");
2354
+ case "schedule":
2355
+ fields.push("at", "note");
2307
2356
  break;
2308
2357
  case "lead-assist":
2309
2358
  fields.push("action");
@@ -2326,16 +2375,10 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2326
2375
  case "connect-back":
2327
2376
  dial("command.connectBack.dialId");
2328
2377
  break;
2329
- case "transfer":
2330
- if (into.oneOf(command.action, TRANSFER_ACTIONS, "command.transfer.action", `${path}.action`)) {
2331
- if (command.action === "cold" || command.action === "warm") {
2332
- dial("command.transfer.dialId");
2333
- into.filled(command.destinationId, "command.transfer.destinationId", `${path}.destinationId`, "a transfer names the directory item it goes to");
2334
- }
2335
- else {
2336
- into.require(command.dialId === undefined && command.destinationId === undefined, "command.transfer.unexpected", path, `${describeValue(command.action)} names no destination: there is exactly one it could mean`);
2337
- }
2338
- }
2378
+ case "schedule":
2379
+ into.timestamp(command.at, "command.schedule.at", `${path}.at`);
2380
+ if (command.note !== undefined)
2381
+ into.filled(command.note, "command.schedule.note", `${path}.note`, "a note must not be empty when present");
2339
2382
  break;
2340
2383
  case "lead-assist":
2341
2384
  into.oneOf(command.action, LEAD_ASSIST_ACTIONS, "command.leadAssist.action", `${path}.action`);
@@ -2468,19 +2511,10 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2468
2511
  offered("connectBack");
2469
2512
  inPhase("command.phase.completing", "completing");
2470
2513
  break;
2471
- case "transfer":
2472
- interaction();
2473
- if (command.action === "cold") {
2474
- if (offered("coldTransfer"))
2475
- listed("coldTransfer");
2476
- }
2477
- else if (command.action === "warm") {
2478
- if (offered("warmTransfer"))
2479
- listed("warmTransfer");
2480
- }
2481
- else if (command.action === "complete" || command.action === "cancel") {
2482
- into.require(onCall.some(entry => entry.role === "consulted"), "command.transfer.consulted", path, `${describeValue(command.action)} needs a consulted entry on onCall: without one there is nothing to complete or cancel`);
2483
- }
2514
+ case "schedule":
2515
+ // A follow-up is promised on the call and written up in wrap alike.
2516
+ offered("schedule");
2517
+ inPhase("command.phase.interaction", "in-progress", "paused", "completing");
2484
2518
  break;
2485
2519
  case "lead-assist":
2486
2520
  interaction();
@@ -2507,7 +2541,9 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2507
2541
  break;
2508
2542
  }
2509
2543
  case "complete": {
2510
- into.require(task.completionMode === "agent-command", "command.complete.mode", path, "complete belongs to agent-command; a provider-automatic task completes itself");
2544
+ // The agent may finish early under either mode; the provider is free to end at once. With
2545
+ // no wrap at all there is no window to cut short, and nothing for the command to do.
2546
+ into.require(task.wrapAllowance !== 0, "command.complete.wrapAllowance", path, "the task has no wrap: with wrapAllowance 0 the provider ends it itself, and there is nothing to complete");
2511
2547
  // What travels with complete is what the outcomes capability published: a code from its
2512
2548
  // list where it has one, a code at all where it requires one, notes as it said, nothing where it published nothing.
2513
2549
  const outcomes = capabilities.outcomes;
@@ -2963,10 +2999,10 @@ export function validateRecordingCommandShape(value, source, path = "command") {
2963
2999
  check(false, "command.shape", path, "expected recording command");
2964
3000
  return violations;
2965
3001
  }
2966
- keys(value, ["type", "source", "requestId", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"])], path);
3002
+ keys(value, ["type", "source", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"])], path);
2967
3003
  check(value.type === "recording" && value.source === source, "command.source", path, `this executor accepts only ${source} recording commands`);
2968
3004
  check(RECORDING_ACTIONS.includes(value.action), "command.action", `${path}.action`, "unsupported recording action");
2969
- for (const key of ["requestId", "observationId", ...(value.action === "start" ? [] : ["recordingId"])])
3005
+ for (const key of ["observationId", ...(value.action === "start" ? [] : ["recordingId"])])
2970
3006
  check(filled(value[key]), "command.identity", `${path}.${key}`, "expected nonempty scoped identity");
2971
3007
  return violations;
2972
3008
  }