@xema/omni-protocol 0.1.101 → 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.
@@ -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,68 +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
1611
  }
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
- const listened = member.listening;
1675
- 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)) {
1676
- 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");
1677
- }
1678
- into.oneOf(member.listening.mode, LISTENING_MODES, "team.member.listening.mode", `${at}.listening.mode`);
1679
- into.timestamp(member.listening.since, "team.member.listening.since", `${at}.listening.since`);
1680
- }
1681
- }
1682
- // The member's own history for the day: about the person, not any one call.
1683
- if (member.shift !== undefined)
1684
- validateShiftInto(member.shift, `${at}.shift`, into);
1612
+ validateTeamMemberInto(member, at, context, into);
1685
1613
  });
1686
1614
  // One member's call at a time.
1687
1615
  const listened = teamMembers.members.filter((member) => isPlainObject(member) && member.listening !== undefined);
1688
1616
  if (listened.length > 1)
1689
1617
  into.add("team.listening.single", `${path}.members`, "a lead is on one member's call at a time");
1690
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
+ }
1691
1695
  export function validateSnapshot(snapshot, manifest, path = "snapshot", context = {}) {
1692
1696
  const into = new Collector();
1693
1697
  if (!isPlainObject(snapshot)) {
@@ -1761,14 +1765,15 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1761
1765
  into.add("snapshot.calendar.shape", `${path}.calendar`, "calendar must be an array when present");
1762
1766
  }
1763
1767
  }
1764
- // The login is the permission: a lead's snapshot carries a team member list, `members: []` included,
1765
- // while the team feature is on. The other direction -- a team member list to a login that does
1766
- // not lead, or to a lead who turned the feature off -- is the team member list's own rule.
1767
- if (context.capabilities?.lead === true && context.leadFeatures !== false && snapshot.team === undefined) {
1768
- 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");
1769
1774
  }
1770
1775
  if (context.leadFeatures === false && snapshot.team !== undefined) {
1771
- 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");
1772
1777
  }
1773
1778
  if (snapshot.team !== undefined)
1774
1779
  validateTeamMembersInto(snapshot.team, `${path}.team`, { ...context, levels, channel: isChannel(channel) ? channel : undefined }, into);
@@ -1786,11 +1791,6 @@ function validateTaskOutcome(value, path, into) {
1786
1791
  case "completed":
1787
1792
  into.oneOf(value.by, COMPLETED_BY, "event.taskEnded.outcome.completed", `${path}.by`);
1788
1793
  break;
1789
- case "transferred":
1790
- if (value.destinationId !== undefined) {
1791
- into.filled(value.destinationId, "event.taskEnded.outcome.transferred", `${path}.destinationId`, "a destinationId must not be empty when present");
1792
- }
1793
- break;
1794
1794
  case "taken-over":
1795
1795
  // A lead is not a directory item: the ending names the lead, and the host resolves the name.
1796
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");
@@ -1904,8 +1904,8 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1904
1904
  if (isPlainObject(event.task) && typeof event.task.phase === "string") {
1905
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}`);
1906
1906
  }
1907
- if (event.assignmentExpiresAt !== undefined)
1908
- 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");
1909
1909
  break;
1910
1910
  case "task-updated":
1911
1911
  validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
@@ -1956,10 +1956,26 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1956
1956
  validateQueueSummary(event.summary, `${at}.summary`, into);
1957
1957
  break;
1958
1958
  case "team-updated":
1959
- 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");
1960
- into.require(context.leadFeatures !== false, "event.team.features", `${at}.team`, "the lead turned the team feature off, so nothing of the team reaches them");
1961
- validateTeamMembersInto(event.team, `${at}.team`, { ...context, levels, channel: isChannel(channel) ? channel : undefined }, into);
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);
1962
1977
  break;
1978
+ }
1963
1979
  case "contacts-updated":
1964
1980
  into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
1965
1981
  if (!Array.isArray(event.contacts)) {
@@ -2282,7 +2298,6 @@ export function validateHostReport(report, path = "host") {
2282
2298
  // ---------------------------------------------------------------------------
2283
2299
  // Commands.
2284
2300
  // ---------------------------------------------------------------------------
2285
- const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
2286
2301
  const LEAD_ASSIST_ACTIONS = ["request", "cancel"];
2287
2302
  const CONFERENCE_ACTIONS = ["add", "remove"];
2288
2303
  /** Validate the exact interaction/assignment target before provider command dispatch. */
@@ -2303,7 +2318,7 @@ export function validateTaskCommandRequest(request, task, path = "request", task
2303
2318
  /**
2304
2319
  * What a command needs to be issuable, checked against the task it names: the capability the
2305
2320
  * guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
2306
- * 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
2307
2322
  * own shape is checked. A host validates before sending, and an adapter before acting: a command
2308
2323
  * for a control the task never offered is the host's error, and this names it.
2309
2324
  */
@@ -2336,10 +2351,8 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2336
2351
  case "complete":
2337
2352
  fields.push("outcome", "notes");
2338
2353
  break;
2339
- case "transfer":
2340
- fields.push("action");
2341
- if (command.action === "cold" || command.action === "warm")
2342
- fields.push("dialId", "destinationId");
2354
+ case "schedule":
2355
+ fields.push("at", "note");
2343
2356
  break;
2344
2357
  case "lead-assist":
2345
2358
  fields.push("action");
@@ -2362,16 +2375,10 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2362
2375
  case "connect-back":
2363
2376
  dial("command.connectBack.dialId");
2364
2377
  break;
2365
- case "transfer":
2366
- if (into.oneOf(command.action, TRANSFER_ACTIONS, "command.transfer.action", `${path}.action`)) {
2367
- if (command.action === "cold" || command.action === "warm") {
2368
- dial("command.transfer.dialId");
2369
- into.filled(command.destinationId, "command.transfer.destinationId", `${path}.destinationId`, "a transfer names the directory item it goes to");
2370
- }
2371
- else {
2372
- 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`);
2373
- }
2374
- }
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");
2375
2382
  break;
2376
2383
  case "lead-assist":
2377
2384
  into.oneOf(command.action, LEAD_ASSIST_ACTIONS, "command.leadAssist.action", `${path}.action`);
@@ -2504,19 +2511,10 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2504
2511
  offered("connectBack");
2505
2512
  inPhase("command.phase.completing", "completing");
2506
2513
  break;
2507
- case "transfer":
2508
- interaction();
2509
- if (command.action === "cold") {
2510
- if (offered("coldTransfer"))
2511
- listed("coldTransfer");
2512
- }
2513
- else if (command.action === "warm") {
2514
- if (offered("warmTransfer"))
2515
- listed("warmTransfer");
2516
- }
2517
- else if (command.action === "complete" || command.action === "cancel") {
2518
- 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`);
2519
- }
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");
2520
2518
  break;
2521
2519
  case "lead-assist":
2522
2520
  interaction();
@@ -2543,7 +2541,9 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2543
2541
  break;
2544
2542
  }
2545
2543
  case "complete": {
2546
- 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");
2547
2547
  // What travels with complete is what the outcomes capability published: a code from its
2548
2548
  // list where it has one, a code at all where it requires one, notes as it said, nothing where it published nothing.
2549
2549
  const outcomes = capabilities.outcomes;
@@ -2999,10 +2999,10 @@ export function validateRecordingCommandShape(value, source, path = "command") {
2999
2999
  check(false, "command.shape", path, "expected recording command");
3000
3000
  return violations;
3001
3001
  }
3002
- keys(value, ["type", "source", "requestId", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"])], path);
3002
+ keys(value, ["type", "source", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"])], path);
3003
3003
  check(value.type === "recording" && value.source === source, "command.source", path, `this executor accepts only ${source} recording commands`);
3004
3004
  check(RECORDING_ACTIONS.includes(value.action), "command.action", `${path}.action`, "unsupported recording action");
3005
- for (const key of ["requestId", "observationId", ...(value.action === "start" ? [] : ["recordingId"])])
3005
+ for (const key of ["observationId", ...(value.action === "start" ? [] : ["recordingId"])])
3006
3006
  check(filled(value[key]), "command.identity", `${path}.${key}`, "expected nonempty scoped identity");
3007
3007
  return violations;
3008
3008
  }