@xema/omni-protocol 0.1.101 → 0.1.103

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
  });
@@ -91,17 +91,17 @@ const LISTENING_MODES = membersOf({ listen: true, coach: true, "join-call": true
91
91
  const COMPLETED_BY = membersOf({ agent: true, provider: true });
92
92
  const CANCELLED_BY = membersOf({ agent: true, provider: true, party: true });
93
93
  const EXPIRABLE_PHASES = membersOf({
94
- pending: true, confirmed: true, preview: true,
94
+ pending: true, confirmed: true,
95
95
  });
96
96
  const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
97
97
  /** The capabilities each channel arm of `TaskCapabilities` declares, keyed off the type itself. */
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
- chat: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, hold: true }),
104
- email: membersOf({ browsers: true, outcomes: true, custom: true, decline: true }),
103
+ chat: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, hold: true, schedule: true }),
104
+ email: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, schedule: true }),
105
105
  };
106
106
  // The published list and the type's keys are the same set, or one of them is wrong.
107
107
  true;
@@ -393,6 +393,10 @@ export function validatePhone(value, manifest, path = "context.phone") {
393
393
  function manifestDials(manifest) {
394
394
  return isPlainObject(manifest) ? Array.isArray(manifest.dialOutcomes) : undefined;
395
395
  }
396
+ /** Whether a manifest declares a calendar, and so may publish tasks that schedule onto it. `undefined` where there is no manifest to ask. */
397
+ function manifestCalendar(manifest) {
398
+ return isPlainObject(manifest) ? isPlainObject(manifest.idleCapabilities) && manifest.idleCapabilities.calendar === true : undefined;
399
+ }
396
400
  /** Undefined explicitly means unavailable; a returned estimate is not a clock guarantee. */
397
401
  export function validateProviderTimeEstimate(value, scope, path = "estimate") {
398
402
  const into = new Collector();
@@ -520,8 +524,10 @@ export function validateManifest(manifest, path = "manifest") {
520
524
  if (manifest.runningStepReports !== undefined) {
521
525
  into.require(manifest.runningStepReports === true, "manifest.runningStepReports", `${path}.runningStepReports`, "runningStepReports is declared by presence, as true; a provider that takes begin and end only omits it");
522
526
  }
523
- into.require(!Object.hasOwn(manifest, "disposalSettleMs"), "manifest.completionSettleMs.renamed", `${path}.disposalSettleMs`, "use completionSettleMs; the former field is not accepted");
524
- into.require(typeof manifest.completionSettleMs === "number" && Number.isInteger(manifest.completionSettleMs) && manifest.completionSettleMs > 0, "manifest.completionSettleMs", `${path}.completionSettleMs`, "completionSettleMs is how long after an applied completion the task-ended is owed, a positive whole number of milliseconds, stated by every provider");
527
+ for (const former of ["disposalSettleMs", "completionSettleMs"]) {
528
+ into.require(!Object.hasOwn(manifest, former), "manifest.settleMs.renamed", `${path}.${former}`, "use settleMs; the former field is not accepted");
529
+ }
530
+ into.require(typeof manifest.settleMs === "number" && Number.isInteger(manifest.settleMs) && manifest.settleMs > 0, "manifest.settleMs", `${path}.settleMs`, "settleMs is how long after applied the wire shows what the provider did -- the task-ended after a complete, the calendar-updated after a schedule -- a positive whole number of milliseconds, stated by every provider");
525
531
  if (manifest.orgLevels !== undefined) {
526
532
  if (!Array.isArray(manifest.orgLevels)) {
527
533
  into.add("manifest.orgLevels.shape", `${path}.orgLevels`, "orgLevels must be an array when present");
@@ -579,7 +585,7 @@ export function validateManifest(manifest, path = "manifest") {
579
585
  // ---------------------------------------------------------------------------
580
586
  function validateDestinationDirectory(value, path, into) {
581
587
  if (!isPlainObject(value)) {
582
- into.add("task.destinations.shape", path, "a transfer or conference control carries the directory it offers: { destinations: [...] }");
588
+ into.add("task.destinations.shape", path, "a conference control carries the directory it offers: { destinations: [...] }");
583
589
  return;
584
590
  }
585
591
  if (!Array.isArray(value.destinations)) {
@@ -982,7 +988,7 @@ const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
982
988
  /**
983
989
  * Who is on the call, as the provider states it. Voice only. Each entry is a role with what that
984
990
  * 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.
991
+ * carries a destination; somebody conferenced came from one, so they do.
986
992
  */
987
993
  function validateOnCall(value, channel, path, into) {
988
994
  if (value === undefined)
@@ -993,7 +999,7 @@ function validateOnCall(value, channel, path, into) {
993
999
  into.add("task.onCall.shape", path, "onCall is an array of who is on the call, when the provider knows");
994
1000
  return;
995
1001
  }
996
- const counted = { party: 0, consulted: 0 };
1002
+ let parties = 0;
997
1003
  value.forEach((entry, index) => {
998
1004
  const at = `${path}[${index}]`;
999
1005
  if (!isPlainObject(entry)) {
@@ -1007,15 +1013,15 @@ function validateOnCall(value, channel, path, into) {
1007
1013
  if (entry.held !== undefined) {
1008
1014
  into.require(entry.held === true, "task.onCall.held", `${at}.held`, "held is stated by presence: send true or omit it");
1009
1015
  }
1010
- if (role === "party" || role === "consulted")
1011
- counted[role] += 1;
1016
+ if (role === "party")
1017
+ parties += 1;
1012
1018
  if (role === "agent") {
1013
1019
  into.require(isUserId(entry.userId), "task.onCall.userId", `${at}.userId`, "an agent on the call is named by their user id");
1014
1020
  }
1015
1021
  else {
1016
1022
  into.require(entry.userId === undefined, "task.onCall.userId.unexpected", `${at}.userId`, `a ${role} is not named by a user id`);
1017
1023
  }
1018
- if (role === "consulted" || role === "conferenced") {
1024
+ if (role === "conferenced") {
1019
1025
  into.filled(entry.destinationId, "task.onCall.destinationId", `${at}.destinationId`, `a ${role} entry names the directory item that was dialled`);
1020
1026
  // Stated, so a snapshot says who is present without anyone having seen the dial's outcome.
1021
1027
  if (into.oneOf(entry.stage, ON_CALL_STAGES, "task.onCall.stage", `${at}.stage`)) {
@@ -1037,17 +1043,16 @@ function validateOnCall(value, channel, path, into) {
1037
1043
  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
1044
  }
1039
1045
  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`);
1046
+ into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a party is not a directory item; ${field} belongs on conferenced`);
1041
1047
  }
1042
1048
  }
1043
1049
  else {
1044
1050
  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`);
1051
+ 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
1052
  }
1047
1053
  }
1048
1054
  });
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");
1055
+ into.require(parties <= 1, "task.onCall.party.single", path, "a call has one party; a second is somebody else's role");
1051
1056
  }
1052
1057
  const LEAD_STAGES = membersOf({ requested: true, joined: true });
1053
1058
  /** The agent's request for a lead. Voice only; `joined` names the lead, `requested` cannot. */
@@ -1121,14 +1126,19 @@ function validateTaskInto(task, context, path, into) {
1121
1126
  into.require(context.channel === "voice", "task.phase.channel", `${path}.phase`, `a ${context.channel} task has no call to preview`);
1122
1127
  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
1128
  }
1129
+ // How long the offer has left travels on the pending task, so a reconnect snapshot restates it.
1130
+ if (task.expiresInSeconds !== undefined) {
1131
+ into.require(task.phase === "pending", "task.pending.expiresInSeconds.unexpected", `${path}.expiresInSeconds`, "expiresInSeconds is how long an offer has left, and belongs to a pending task; accepted, there is no offer to run out");
1132
+ into.require(isDurationSeconds(task.expiresInSeconds), "task.pending.expiresInSeconds", `${path}.expiresInSeconds`, "expiresInSeconds is how long the offer has left: a whole number of seconds, zero or more, counted from this publication");
1133
+ }
1124
1134
  // 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");
1135
+ if (task.previewEndsInSeconds !== undefined || task.atDeadline !== undefined) {
1136
+ 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");
1137
+ if (task.previewEndsInSeconds === undefined) {
1138
+ into.add("task.preview.previewEndsInSeconds.required", `${path}.previewEndsInSeconds`, "atDeadline says what happens at a deadline, so there has to be one");
1129
1139
  }
1130
1140
  else {
1131
- into.timestamp(task.previewEndsAt, "task.preview.previewEndsAt", `${path}.previewEndsAt`);
1141
+ 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
1142
  }
1133
1143
  if (task.atDeadline === undefined) {
1134
1144
  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 +1245,16 @@ function validateTaskInto(task, context, path, into) {
1235
1245
  if (capabilities.browsers === true && Array.isArray(task.browsers) && task.browsers.length === 0) {
1236
1246
  into.add("task.browsers.required", `${path}.browsers`, "a task that declares capabilities.browsers supplies at least one; with none, omit the capability");
1237
1247
  }
1248
+ // An outcome is given in wrap, by the agent: a provider that completes the task itself cannot
1249
+ // wait for one, and a task with no wrap has nowhere to take one.
1250
+ if (isPlainObject(capabilities.outcomes) && capabilities.outcomes.required === true) {
1251
+ 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");
1252
+ }
1253
+ // Under agent-command the allowance is the expected wrap, shown and never acted on: the agent
1254
+ // completes whenever they are done, so there is always a wrap to collect the code in.
1255
+ if (capabilities.outcomes !== undefined) {
1256
+ into.require(!(task.completionMode === "provider-automatic" && task.wrapAllowance === 0), "task.outcomes.wrapAllowance", `${path}.capabilities.outcomes`, "outcomes are collected in wrap, and provider-automatic with wrapAllowance 0 gives none: publish a wrap or no outcomes");
1257
+ }
1238
1258
  for (const [name, declared] of Object.entries(capabilities)) {
1239
1259
  if (declared === undefined)
1240
1260
  continue;
@@ -1249,6 +1269,10 @@ function validateTaskInto(task, context, path, into) {
1249
1269
  if (DIALLING_CAPABILITIES.includes(name) && context.dialOutcomesDeclared === false) {
1250
1270
  into.add("task.capability.dialOutcomes.required", `${path}.capabilities.${name}`, `${name} dials, and the manifest declares no dialOutcomes to say how a dial ends`);
1251
1271
  }
1272
+ // A follow-up lands on the calendar, so the manifest has to have one for it to land on.
1273
+ if (name === "schedule" && context.calendarDeclared === false) {
1274
+ into.add("task.capability.calendar.required", `${path}.capabilities.schedule`, "schedule puts a follow-up on the calendar, and the manifest declares no calendar for it to land on");
1275
+ }
1252
1276
  // A control the queue could allow may stand locked in its place, saying whose. What the
1253
1277
  // queue provides -- browsers, outcomes, custom controls -- is content, not a control.
1254
1278
  if (isLocked(declared)) {
@@ -1267,8 +1291,6 @@ function validateTaskInto(task, context, path, into) {
1267
1291
  case "custom":
1268
1292
  validateCustomCapabilities(declared, `${path}.capabilities.custom`, into);
1269
1293
  break;
1270
- case "coldTransfer":
1271
- case "warmTransfer":
1272
1294
  case "conference":
1273
1295
  validateDestinationDirectory(declared, `${path}.capabilities.${name}`, into);
1274
1296
  break;
@@ -1287,7 +1309,7 @@ function validateForcedBreak(value, path, into) {
1287
1309
  into.add("break.forced.shape", path, "a forced break must be an object");
1288
1310
  return;
1289
1311
  }
1290
- into.require(isUserId(value.by), "break.forced.by", `${path}.by`, "a forced break must say who forced it");
1312
+ 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
1313
  for (const field of ["endsAutomatically", "endsAt"]) {
1292
1314
  into.require(!Object.hasOwn(value, field), "break.forced.manualResume", `${path}.${field}`, "automatic ending fields are unsupported; the agent must resume manually");
1293
1315
  }
@@ -1394,8 +1416,11 @@ export function validateTeamCommand(request, context, path = "teamCommand") {
1394
1416
  else if (command.type === "force-break" || command.type === "end-forced-break") {
1395
1417
  validateBreakState(context.memberBreak, `${path}.memberBreak`, into);
1396
1418
  const state = isPlainObject(context.memberBreak) ? context.memberBreak : {};
1397
- if (command.type === "end-forced-break")
1419
+ if (command.type === "end-forced-break") {
1398
1420
  into.require(state.forced !== undefined && (state.status === "on-break" || state.status === "starting-after-task"), "team.command.endForcedBreak", path, "lift a current forced-break restriction without resuming the agent");
1421
+ // The platform lifts a break it imposed itself; a lead lifts one a lead asked for.
1422
+ into.require(!(isPlainObject(state.forced) && state.forced.by === "provider"), "team.command.endForcedBreak.provider", path, "the platform put this member on the break, and it lifts it: a lead ends a break a lead forced");
1423
+ }
1399
1424
  if (command.type === "force-break") {
1400
1425
  if (command.expectedDurationMs !== undefined)
1401
1426
  into.require(typeof command.expectedDurationMs === "number" && Number.isFinite(command.expectedDurationMs) && command.expectedDurationMs > 0, "team.command.expectedDurationMs", `${path}.expectedDurationMs`, "expectedDurationMs must be positive finite milliseconds; it never resumes the agent");
@@ -1406,9 +1431,8 @@ export function validateTeamCommand(request, context, path = "teamCommand") {
1406
1431
  }
1407
1432
  }
1408
1433
  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`);
1434
+ const asked = isPlainObject(target) && isPlainObject(target.request) ? target.request : undefined;
1435
+ 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
1436
  }
1413
1437
  else if (command.type === "coach" || command.type === "join-call" || command.type === "leave") {
1414
1438
  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 +1617,6 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
1593
1617
  // Policies are present where the provider offers policy control, absent where it does not.
1594
1618
  if (teamMembers.policies !== undefined)
1595
1619
  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
1620
  if (!Array.isArray(teamMembers.members)) {
1623
1621
  into.add("team.members.shape", `${path}.members`, "a team member list must carry a members array");
1624
1622
  return;
@@ -1626,68 +1624,94 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
1626
1624
  const seen = new Set();
1627
1625
  teamMembers.members.forEach((member, index) => {
1628
1626
  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")) {
1627
+ if (isPlainObject(member) && isUserId(member.id)) {
1634
1628
  if (seen.has(member.id))
1635
1629
  into.add("team.member.unique", `${at}.id`, `duplicate team member: ${member.id}`);
1636
1630
  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
1631
  }
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);
1632
+ validateTeamMemberInto(member, at, context, into);
1685
1633
  });
1686
1634
  // One member's call at a time.
1687
1635
  const listened = teamMembers.members.filter((member) => isPlainObject(member) && member.listening !== undefined);
1688
1636
  if (listened.length > 1)
1689
1637
  into.add("team.listening.single", `${path}.members`, "a lead is on one member's call at a time");
1690
1638
  }
1639
+ /** One member as the lead sees them, whole: on the list, and on `team-member-updated` alone. */
1640
+ export function validateTeamMember(member, path = "member", context = {}) {
1641
+ const into = new Collector();
1642
+ validateTeamMemberInto(member, path, context, into);
1643
+ return into.violations;
1644
+ }
1645
+ function validateTeamMemberInto(member, at, context, into) {
1646
+ if (!isPlainObject(member)) {
1647
+ into.add("team.member.shape", at, "each team member must be an object");
1648
+ return;
1649
+ }
1650
+ if (into.require(isUserId(member.id), "team.member.id", `${at}.id`, "a team member needs a user id")) {
1651
+ 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");
1652
+ }
1653
+ into.oneOf(member.availability, TEAM_AVAILABILITIES, "team.member.availability", `${at}.availability`);
1654
+ if (member.since !== undefined)
1655
+ into.timestamp(member.since, "team.member.since", `${at}.since`);
1656
+ if (member.break !== undefined) {
1657
+ // Only an outstanding request appears here: `not-requested` is absence, `on-break` is
1658
+ // `availability: "on-break"`, and a denial never survives to be reported.
1659
+ if (into.oneOf(member.break, MEMBER_BREAKS, "team.member.break", `${at}.break`)) {
1660
+ 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");
1661
+ }
1662
+ }
1663
+ // The member's open tasks, as the lead sees them: trimmed by the provider, held to the task rules otherwise.
1664
+ if (member.tasks !== undefined) {
1665
+ if (!Array.isArray(member.tasks)) {
1666
+ 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");
1667
+ }
1668
+ else {
1669
+ const held = new Set();
1670
+ member.tasks.forEach((task, index) => {
1671
+ const channel = isPlainObject(task) && typeof task.channel === "string" && isChannel(task.channel) ? task.channel : context.channel ?? "voice";
1672
+ validateTaskInto(task, { channel, levels: context.levels, member: true }, `${at}.tasks[${index}]`, into);
1673
+ if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
1674
+ if (held.has(task.assignmentId))
1675
+ into.add("team.member.tasks.unique", `${at}.tasks[${index}].assignmentId`, `duplicate assignment on one member: ${task.assignmentId}`);
1676
+ held.add(task.assignmentId);
1677
+ }
1678
+ });
1679
+ into.require(member.availability !== "signed-out" || member.tasks.length === 0, "team.member.tasks.availability", `${at}.tasks`, "a member signed out holds no task");
1680
+ }
1681
+ }
1682
+ // The lead on this member's call, in the mode they are heard.
1683
+ if (member.listening !== undefined) {
1684
+ if (!isPlainObject(member.listening)) {
1685
+ into.add("team.member.listening.shape", `${at}.listening`, "listening must be an object when present");
1686
+ }
1687
+ else {
1688
+ const listened = member.listening;
1689
+ 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)) {
1690
+ 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");
1691
+ }
1692
+ into.oneOf(member.listening.mode, LISTENING_MODES, "team.member.listening.mode", `${at}.listening.mode`);
1693
+ into.timestamp(member.listening.since, "team.member.listening.since", `${at}.listening.since`);
1694
+ }
1695
+ }
1696
+ // The member's own history for the day: about the person, not any one call.
1697
+ if (member.shift !== undefined)
1698
+ validateShiftInto(member.shift, `${at}.shift`, into);
1699
+ // The member's standing ask for a lead, on one of the calls they hold.
1700
+ if (member.request !== undefined) {
1701
+ if (!isPlainObject(member.request)) {
1702
+ into.add("team.member.request.shape", `${at}.request`, "request must be an object when present");
1703
+ }
1704
+ else {
1705
+ const asked = member.request;
1706
+ 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)) {
1707
+ 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");
1708
+ }
1709
+ if (asked.note !== undefined)
1710
+ into.filled(asked.note, "team.member.request.note", `${at}.request.note`, "a note must not be empty when present");
1711
+ into.timestamp(asked.since, "team.member.request.since", `${at}.request.since`);
1712
+ }
1713
+ }
1714
+ }
1691
1715
  export function validateSnapshot(snapshot, manifest, path = "snapshot", context = {}) {
1692
1716
  const into = new Collector();
1693
1717
  if (!isPlainObject(snapshot)) {
@@ -1713,7 +1737,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1713
1737
  else {
1714
1738
  const seen = new Set();
1715
1739
  snapshot.tasks.forEach((task, index) => {
1716
- validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
1740
+ validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
1717
1741
  if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
1718
1742
  if (seen.has(task.assignmentId))
1719
1743
  into.add("task.assignmentId.unique", `${path}.tasks[${index}].assignmentId`, `duplicate assignment id: ${task.assignmentId}`);
@@ -1761,14 +1785,15 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1761
1785
  into.add("snapshot.calendar.shape", `${path}.calendar`, "calendar must be an array when present");
1762
1786
  }
1763
1787
  }
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");
1788
+ // The switch is the permission: once the application has said the team feature is on, a
1789
+ // snapshot carries the team, `members: []` included. Before it has said, the provider assumes
1790
+ // nothing and owes nothing. The other direction -- a team member list to a login that does not
1791
+ // lead, or to a lead who turned the feature off -- is the team member list's own rule.
1792
+ if (context.leadFeatures === true && snapshot.team === undefined) {
1793
+ 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
1794
  }
1770
1795
  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");
1796
+ 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
1797
  }
1773
1798
  if (snapshot.team !== undefined)
1774
1799
  validateTeamMembersInto(snapshot.team, `${path}.team`, { ...context, levels, channel: isChannel(channel) ? channel : undefined }, into);
@@ -1786,11 +1811,6 @@ function validateTaskOutcome(value, path, into) {
1786
1811
  case "completed":
1787
1812
  into.oneOf(value.by, COMPLETED_BY, "event.taskEnded.outcome.completed", `${path}.by`);
1788
1813
  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
1814
  case "taken-over":
1795
1815
  // A lead is not a directory item: the ending names the lead, and the host resolves the name.
1796
1816
  into.require(isUserId(value.leadId), "event.taskEnded.outcome.takenOver", `${path}.leadId`, "a take-over names the lead who took the call, by user id");
@@ -1899,16 +1919,15 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1899
1919
  validateBreakState(event.break, `${at}.break`, into);
1900
1920
  break;
1901
1921
  case "task-offered":
1902
- validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
1922
+ validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${at}.task`, into);
1903
1923
  // An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
1904
1924
  if (isPlainObject(event.task) && typeof event.task.phase === "string") {
1905
1925
  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
1926
  }
1907
- if (event.assignmentExpiresAt !== undefined)
1908
- into.timestamp(event.assignmentExpiresAt, "event.taskOffered.assignmentExpiresAt", `${at}.assignmentExpiresAt`);
1927
+ into.require(event.expiresInSeconds === undefined, "event.taskOffered.expiresInSeconds.unexpected", `${at}.expiresInSeconds`, "expiresInSeconds travels on the pending task, where a reconnect snapshot restates it, not on the event");
1909
1928
  break;
1910
1929
  case "task-updated":
1911
- validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
1930
+ validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${at}.task`, into);
1912
1931
  break;
1913
1932
  case "task-audio-started":
1914
1933
  into.require(channel === "voice", "event.audio.channel", `${at}.type`, "only a voice provider publishes audio transitions");
@@ -1956,10 +1975,26 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1956
1975
  validateQueueSummary(event.summary, `${at}.summary`, into);
1957
1976
  break;
1958
1977
  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);
1978
+ case "team-member-updated":
1979
+ case "team-member-removed":
1980
+ case "team-policies-updated": {
1981
+ const field = event.type === "team-updated" ? "team" : event.type === "team-member-updated" ? "member" : event.type === "team-member-removed" ? "memberId" : "policies";
1982
+ 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`);
1983
+ 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");
1984
+ const teamContext = { ...context, levels, channel: isChannel(channel) ? channel : undefined };
1985
+ if (event.type === "team-updated")
1986
+ validateTeamMembersInto(event.team, `${at}.team`, teamContext, into);
1987
+ else if (event.type === "team-member-updated")
1988
+ validateTeamMemberInto(event.member, `${at}.member`, teamContext, into);
1989
+ else if (event.type === "team-member-removed") {
1990
+ if (into.require(isUserId(event.memberId), "event.teamMemberRemoved.memberId", `${at}.memberId`, "team-member-removed names the member taken off, by user id")) {
1991
+ 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");
1992
+ }
1993
+ }
1994
+ else
1995
+ validateTeamPoliciesInto(event.policies, `${at}.policies`, into, levels);
1962
1996
  break;
1997
+ }
1963
1998
  case "contacts-updated":
1964
1999
  into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
1965
2000
  if (!Array.isArray(event.contacts)) {
@@ -2282,7 +2317,6 @@ export function validateHostReport(report, path = "host") {
2282
2317
  // ---------------------------------------------------------------------------
2283
2318
  // Commands.
2284
2319
  // ---------------------------------------------------------------------------
2285
- const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
2286
2320
  const LEAD_ASSIST_ACTIONS = ["request", "cancel"];
2287
2321
  const CONFERENCE_ACTIONS = ["add", "remove"];
2288
2322
  /** Validate the exact interaction/assignment target before provider command dispatch. */
@@ -2303,7 +2337,7 @@ export function validateTaskCommandRequest(request, task, path = "request", task
2303
2337
  /**
2304
2338
  * What a command needs to be issuable, checked against the task it names: the capability the
2305
2339
  * 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
2340
+ * lead requested, somebody else on the call. Without a task only the command's
2307
2341
  * own shape is checked. A host validates before sending, and an adapter before acting: a command
2308
2342
  * for a control the task never offered is the host's error, and this names it.
2309
2343
  */
@@ -2336,10 +2370,8 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2336
2370
  case "complete":
2337
2371
  fields.push("outcome", "notes");
2338
2372
  break;
2339
- case "transfer":
2340
- fields.push("action");
2341
- if (command.action === "cold" || command.action === "warm")
2342
- fields.push("dialId", "destinationId");
2373
+ case "schedule":
2374
+ fields.push("at", "note");
2343
2375
  break;
2344
2376
  case "lead-assist":
2345
2377
  fields.push("action");
@@ -2362,16 +2394,10 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2362
2394
  case "connect-back":
2363
2395
  dial("command.connectBack.dialId");
2364
2396
  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
- }
2397
+ case "schedule":
2398
+ into.timestamp(command.at, "command.schedule.at", `${path}.at`);
2399
+ if (command.note !== undefined)
2400
+ into.filled(command.note, "command.schedule.note", `${path}.note`, "a note must not be empty when present");
2375
2401
  break;
2376
2402
  case "lead-assist":
2377
2403
  into.oneOf(command.action, LEAD_ASSIST_ACTIONS, "command.leadAssist.action", `${path}.action`);
@@ -2504,19 +2530,10 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2504
2530
  offered("connectBack");
2505
2531
  inPhase("command.phase.completing", "completing");
2506
2532
  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
- }
2533
+ case "schedule":
2534
+ // A follow-up is promised on the call and written up in wrap alike.
2535
+ offered("schedule");
2536
+ inPhase("command.phase.interaction", "in-progress", "paused", "completing");
2520
2537
  break;
2521
2538
  case "lead-assist":
2522
2539
  interaction();
@@ -2543,7 +2560,9 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2543
2560
  break;
2544
2561
  }
2545
2562
  case "complete": {
2546
- into.require(task.completionMode === "agent-command", "command.complete.mode", path, "complete belongs to agent-command; a provider-automatic task completes itself");
2563
+ // The agent may finish early under either mode; the provider is free to end at once. With
2564
+ // no wrap at all there is no window to cut short, and nothing for the command to do.
2565
+ into.require(!(task.completionMode === "provider-automatic" && task.wrapAllowance === 0), "command.complete.wrapAllowance", path, "the task has no wrap: under provider-automatic with wrapAllowance 0 the provider ends it itself, and there is nothing to complete");
2547
2566
  // What travels with complete is what the outcomes capability published: a code from its
2548
2567
  // list where it has one, a code at all where it requires one, notes as it said, nothing where it published nothing.
2549
2568
  const outcomes = capabilities.outcomes;
@@ -2999,10 +3018,10 @@ export function validateRecordingCommandShape(value, source, path = "command") {
2999
3018
  check(false, "command.shape", path, "expected recording command");
3000
3019
  return violations;
3001
3020
  }
3002
- keys(value, ["type", "source", "requestId", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"])], path);
3021
+ keys(value, ["type", "source", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"])], path);
3003
3022
  check(value.type === "recording" && value.source === source, "command.source", path, `this executor accepts only ${source} recording commands`);
3004
3023
  check(RECORDING_ACTIONS.includes(value.action), "command.action", `${path}.action`, "unsupported recording action");
3005
- for (const key of ["requestId", "observationId", ...(value.action === "start" ? [] : ["recordingId"])])
3024
+ for (const key of ["observationId", ...(value.action === "start" ? [] : ["recordingId"])])
3006
3025
  check(filled(value[key]), "command.identity", `${path}.${key}`, "expected nonempty scoped identity");
3007
3026
  return violations;
3008
3027
  }