@xema/omni-protocol 0.1.98 → 0.1.100

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, LISTENING_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, 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") {
@@ -41,7 +41,7 @@ const TASK_PHASES = membersOf({
41
41
  pending: true, confirmed: true, preview: true, "in-progress": true, paused: true, completing: true,
42
42
  });
43
43
  const COMPLETION_MODES = membersOf({ "agent-command": true, "provider-automatic": true });
44
- const CAPABILITY_SOURCES = membersOf({ queue: true, ungoverned: true, undetermined: true });
44
+ const CAPABILITY_SOURCES = membersOf({ queue: true, nobody: true, "not-yet-read": true });
45
45
  const ACCEPTANCE_MODES = membersOf({
46
46
  "no-preference": true, "consent": true, "automatic": true,
47
47
  });
@@ -55,7 +55,7 @@ const BREAK_APPROVALS = membersOf({
55
55
  "not-requested": true, "awaiting-approval": true, granted: true, "starting-after-task": true, "on-break": true,
56
56
  });
57
57
  const TEAM_AVAILABILITIES = membersOf({
58
- ready: true, "on-task": true, "on-break": true, reserved: true, "signed-out": true,
58
+ ready: true, "on-task": true, "on-break": true, elsewhere: true, "signed-out": true,
59
59
  });
60
60
  const HISTORY_STEPS = membersOf({
61
61
  queued: true, offered: true, answered: true, held: true, muted: true, transferred: true, conferenced: true, unanswered: true,
@@ -78,16 +78,15 @@ const DIALLING_CAPABILITIES = ["connectBack", "coldTransfer", "warmTransfer", "c
78
78
  const SNAPSHOT_REASONS = membersOf({
79
79
  reconnected: true, "provider-requested": true,
80
80
  });
81
- const SESSION_CAPABILITIES = membersOf({ breaks: true, team: true, preferences: true });
81
+ const SESSION_CAPABILITIES = membersOf({ breaks: true, lead: true, preferences: true });
82
82
  const MEMBER_BREAKS = membersOf({
83
83
  "awaiting-approval": true, granted: true, "starting-after-task": true,
84
84
  });
85
85
  const OFFERABLE_PHASES = membersOf({
86
86
  pending: true,
87
87
  });
88
- const PREVIEW_DEADLINES = membersOf({ calls: true, "host-calls": true, waits: true });
88
+ const PREVIEW_DEADLINES = membersOf({ "provider-dials": true, "host-dials": true, waits: true });
89
89
  const PHONES = membersOf({ softphone: true, deskPhone: true });
90
- const TEAM_CAPABILITIES = membersOf({ breakControl: true, leadAssistControl: true, policyControl: true, listeningControl: true });
91
90
  const LISTENING_MODES = membersOf({ listen: true, coach: true, "join-call": true });
92
91
  const COMPLETED_BY = membersOf({ agent: true, provider: true });
93
92
  const CANCELLED_BY = membersOf({ agent: true, provider: true, party: true });
@@ -99,7 +98,7 @@ const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
99
98
  const TASK_CAPABILITIES = {
100
99
  voice: membersOf({
101
100
  browsers: true, outcomes: true, custom: true, decline: true, hold: true,
102
- endCall: true, connectBack: true, coldTransfer: true, warmTransfer: true, leadAssist: true, conference: true, recording: true,
101
+ endCall: true, terminateCall: true, connectBack: true, coldTransfer: true, warmTransfer: true, leadAssist: true, conference: true, recording: true,
103
102
  }),
104
103
  chat: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, hold: true }),
105
104
  email: membersOf({ browsers: true, outcomes: true, custom: true, decline: true }),
@@ -924,13 +923,13 @@ function validateHistory(value, path, into, task = {}) {
924
923
  }
925
924
  else {
926
925
  // An open leg is one still running, and the task says whether it can be: a hold runs only while
927
- // the task is paused, and a mute only while its media is up. An open entry after that is a leg
926
+ // the task is paused, and a mute only while its audio is up. An open entry after that is a leg
928
927
  // nobody closed, which reads exactly like a leg running now.
929
928
  if (entry.step === "held") {
930
929
  into.require(task.phase === "paused", "task.history.held.open", `${at}.seconds`, `a held entry without seconds is a hold still running, and the task is ${describeValue(task.phase)}: the hold has ended, and its duration is stated`);
931
930
  }
932
931
  if (entry.step === "muted") {
933
- into.require(task.media !== "ended" && task.phase !== "completing", "task.history.muted.open", `${at}.seconds`, "a muted entry without seconds is still running, but this interaction's media has ended: the provider closes its open leg and states the duration; other channels may continue");
932
+ into.require(task.audio !== "ended" && task.phase !== "completing", "task.history.muted.open", `${at}.seconds`, "a muted entry without seconds is still running, but this interaction's audio has ended: the provider closes its open leg and states the duration; other channels may continue");
934
933
  }
935
934
  }
936
935
  // A muted entry carries whose the silence was, as the host reported it; no other step has it.
@@ -961,22 +960,22 @@ function validateHistory(value, path, into, task = {}) {
961
960
  }
962
961
  /** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
963
962
  const TASK_MEDIA_STATES = membersOf({ started: true, ended: true });
964
- /** Real-time media is a voice affair, and its state is one of two words. */
963
+ /** Real-time audio is a voice affair, and its state is one of two words. */
965
964
  /** A host dial, or explicitly provider-triggered preview dial, is ringing the party. */
966
965
  function partyRingingBeforeWork(task) {
967
- return Array.isArray(task.onCall) && task.onCall.some(entry => isPlainObject(entry) && entry.role === "party" && entry.stage === "ringing" && (isFilled(entry.dialId) || (task.phase === "preview" && task.atDeadline === "calls" && entry.dialId === undefined)));
966
+ return Array.isArray(task.onCall) && task.onCall.some(entry => isPlainObject(entry) && entry.role === "party" && entry.stage === "ringing" && (isFilled(entry.dialId) || (task.phase === "preview" && task.atDeadline === "provider-dials" && entry.dialId === undefined)));
968
967
  }
969
- function validateTaskMedia(task, value, channel, phase, path, into) {
968
+ function validateTaskAudio(task, value, channel, phase, path, into) {
970
969
  if (value === undefined)
971
970
  return;
972
- if (!into.require(channel === "voice", "task.media.channel", path, `a ${channel} task carries no real-time media state`))
971
+ if (!into.require(channel === "voice", "task.audio.channel", path, `a ${channel} task carries no real-time audio state`))
973
972
  return;
974
- if (into.oneOf(value, TASK_MEDIA_STATES, "task.media", path)) {
975
- // Nothing is acquired while pending, and a preview has placed no call: media names a task whose
973
+ if (into.oneOf(value, TASK_MEDIA_STATES, "task.audio", path)) {
974
+ // Nothing is acquired while pending, and a preview has placed no call: audio names a task whose
976
975
  // work has begun, on a snapshot as on the event, or a host would open the microphone on an offer.
977
976
  // Ring-back may precede answer for a host dial or an explicitly provider-triggered preview.
978
- // A deadline alone is never evidence that media started.
979
- into.require(!WORK_NOT_BEGUN.includes(phase) || partyRingingBeforeWork(task), "task.media.beforeWork", path, `a ${describeValue(phase)} task has no media: audio arrives once its work has begun, or once an evidenced host/provider preview dial is ringing its party`);
977
+ // A deadline alone is never evidence that audio started.
978
+ into.require(!WORK_NOT_BEGUN.includes(phase) || partyRingingBeforeWork(task), "task.audio.beforeWork", path, `a ${describeValue(phase)} task has no audio: audio arrives once its work has begun, or once an evidenced host/provider preview dial is ringing its party`);
980
979
  }
981
980
  }
982
981
  const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
@@ -1073,42 +1072,24 @@ function validateLeadAssist(value, channel, path, into) {
1073
1072
  into.filled(value.note, "task.leadAssist.note", `${path}.note`, "a note must not be empty when present");
1074
1073
  into.timestamp(value.since, "task.leadAssist.since", `${path}.since`);
1075
1074
  }
1076
- /** The lead's own task for a call they joined. Voice only. */
1077
- function validateAssisting(value, channel, path, into) {
1075
+ /** A call a lead took over: which member it came from, and when. Voice only. */
1076
+ function validateTakenOver(value, channel, path, into) {
1078
1077
  if (value === undefined)
1079
1078
  return;
1080
- if (!into.require(channel === "voice", "task.assisting.channel", path, `a ${channel} task cannot be a joined call`))
1079
+ if (!into.require(channel === "voice", "task.takenOver.channel", path, `a ${channel} task has no call to take over`))
1081
1080
  return;
1082
1081
  if (!isPlainObject(value)) {
1083
- into.add("task.assisting.shape", path, "assisting must be an object when present");
1082
+ into.add("task.takenOver.shape", path, "takenOver must be an object when present");
1084
1083
  return;
1085
1084
  }
1086
- into.require(isUserId(value.memberId), "task.assisting.memberId", `${path}.memberId`, "a joined call names the member who asked");
1087
- into.require(isAssignmentId(value.assignmentId), "task.assisting.assignmentId", `${path}.assignmentId`, "a joined call names the member's assignment");
1088
- if (value.note !== undefined)
1089
- into.filled(value.note, "task.assisting.note", `${path}.note`, "a note must not be empty when present");
1090
- into.timestamp(value.since, "task.assisting.since", `${path}.since`);
1085
+ into.require(isUserId(value.memberId), "task.takenOver.memberId", `${path}.memberId`, "a taken-over call names the member it was taken from");
1086
+ into.timestamp(value.since, "task.takenOver.since", `${path}.since`);
1091
1087
  }
1092
- /** The lead's own task while they listen: whose call, which call, and how they are heard. Voice only. */
1093
- function validateListening(value, channel, path, into) {
1094
- if (value === undefined)
1095
- return;
1096
- if (!into.require(channel === "voice", "task.listening.channel", path, `a ${channel} task has no call to listen to`))
1097
- return;
1098
- if (!isPlainObject(value)) {
1099
- into.add("task.listening.shape", path, "listening must be an object when present");
1100
- return;
1101
- }
1102
- into.require(isUserId(value.memberId), "task.listening.memberId", `${path}.memberId`, "a call being listened to names the member on it");
1103
- into.require(isAssignmentId(value.assignmentId), "task.listening.assignmentId", `${path}.assignmentId`, "a call being listened to names the member's assignment");
1104
- into.oneOf(value.mode, LISTENING_MODES, "task.listening.mode", `${path}.mode`);
1105
- into.timestamp(value.since, "task.listening.since", `${path}.since`);
1106
- }
1107
- /** Whether a task is the agent's own doing: a dial the host placed on its onCall, a lead's join, a lead's listen. */
1088
+ /** Whether a task is the agent's own doing: a dial the host placed on its onCall, or a call a lead took over. */
1108
1089
  function originatedByTheAgent(task) {
1109
1090
  if (Array.isArray(task.onCall) && task.onCall.some(entry => isPlainObject(entry) && typeof entry.dialId === "string"))
1110
1091
  return true;
1111
- return task.assisting !== undefined || task.listening !== undefined;
1092
+ return task.takenOver !== undefined;
1112
1093
  }
1113
1094
  export function validateTask(task, context, path = "task") {
1114
1095
  const into = new Collector();
@@ -1120,7 +1101,11 @@ function validateTaskInto(task, context, path, into) {
1120
1101
  into.add("task.shape", path, "a task must be an object");
1121
1102
  return;
1122
1103
  }
1123
- into.require(!Object.hasOwn(task, "monitoring"), "task.listening.renamed", `${path}.monitoring`, "use listening; the former field is not accepted");
1104
+ // A task carries no lead state: the lead's view is the team member list, and a lead's own call
1105
+ // is an ordinary assignment marked takenOver.
1106
+ for (const retired of ["assisting", "listening", "monitoring"]) {
1107
+ into.require(!Object.hasOwn(task, retired), "task.leadState.retired", `${path}.${retired}`, "a task carries no lead state; a call a lead took over says takenOver, and the lead's view is the team member list");
1108
+ }
1124
1109
  for (const former of ["handlingHistory", "interactionHistory"]) {
1125
1110
  into.require(!Object.hasOwn(task, former), "task.history.renamed", `${path}.${former}`, "use history; the former field is not accepted");
1126
1111
  }
@@ -1146,7 +1131,7 @@ function validateTaskInto(task, context, path, into) {
1146
1131
  into.timestamp(task.previewEndsAt, "task.preview.previewEndsAt", `${path}.previewEndsAt`);
1147
1132
  }
1148
1133
  if (task.atDeadline === undefined) {
1149
- into.add("task.preview.atDeadline.required", `${path}.atDeadline`, "a preview with a deadline says what happens at it: provider calls, host calls, or waits for the agent -- an agent counting down has to know which");
1134
+ 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");
1150
1135
  }
1151
1136
  else {
1152
1137
  into.oneOf(task.atDeadline, PREVIEW_DEADLINES, "task.preview.atDeadline", `${path}.atDeadline`);
@@ -1166,24 +1151,26 @@ function validateTaskInto(task, context, path, into) {
1166
1151
  into.add("task.acceptance.unexpected", `${path}.acceptance`, "acceptance is an offer's word; a task past pending has been accepted");
1167
1152
  }
1168
1153
  else if (originated) {
1169
- into.require(task.acceptance === "automatic", "task.acceptance.originated", `${path}.acceptance`, "work the agent originated -- a dial, a connect-back, a join, a listen -- was accepted by the command that created it, and says automatic whatever the provisioning");
1154
+ into.require(task.acceptance === "automatic", "task.acceptance.originated", `${path}.acceptance`, "work the agent originated -- a dial, a connect-back, a call taken over -- was accepted by the command that created it, and says automatic whatever the provisioning");
1170
1155
  }
1171
1156
  else if (context.autoAcceptTasks === false) {
1172
1157
  into.add("task.acceptance.unexpected", `${path}.acceptance`, "autoAcceptTasks is off, so every task the queue routes requires agent acceptance and a pending task carries no acceptance");
1173
1158
  }
1174
1159
  }
1175
1160
  else if (originated) {
1176
- into.add("task.acceptance.originated", `${path}.acceptance`, "work the agent originated -- a dial, a connect-back, a join, a listen -- says acceptance: automatic whatever the provisioning: the command that created it accepted it");
1161
+ into.add("task.acceptance.originated", `${path}.acceptance`, "work the agent originated -- a dial, a connect-back, a call taken over -- says acceptance: automatic whatever the provisioning: the command that created it accepted it");
1177
1162
  }
1178
1163
  else if (task.phase === "pending" && context.autoAcceptTasks === true) {
1179
1164
  into.add("task.acceptance.required", `${path}.acceptance`, "autoAcceptTasks is on, so a pending task states how it is accepted");
1180
1165
  }
1181
- into.oneOf(task.completionMode, COMPLETION_MODES, "task.completionMode", `${path}.completionMode`);
1166
+ if (context.member !== true || task.completionMode !== undefined)
1167
+ into.oneOf(task.completionMode, COMPLETION_MODES, "task.completionMode", `${path}.completionMode`);
1182
1168
  // The allowance is coupled to the mode: a provider that will complete the task itself is going
1183
1169
  // to act on the allowance, so it must state one; a provider waiting for `complete` may omit it
1184
1170
  // to say it imposes no deadline. Present, it is a duration either way.
1185
1171
  if (task.wrapAllowance === undefined) {
1186
- into.require(task.completionMode !== "provider-automatic", "task.wrapAllowance.required", `${path}.wrapAllowance`, "provider-automatic completion needs an allowance to act on");
1172
+ if (context.member !== true)
1173
+ into.require(task.completionMode !== "provider-automatic", "task.wrapAllowance.required", `${path}.wrapAllowance`, "provider-automatic completion needs an allowance to act on");
1187
1174
  }
1188
1175
  else {
1189
1176
  into.require(isDurationSeconds(task.wrapAllowance), "task.wrapAllowance", `${path}.wrapAllowance`, "wrapAllowance must be a whole number of seconds, zero or more, or omitted under agent-command");
@@ -1210,31 +1197,30 @@ function validateTaskInto(task, context, path, into) {
1210
1197
  into.violations.push(...validateRecordingState(task.recording.provider, `${path}.recording.provider`));
1211
1198
  }
1212
1199
  }
1213
- validateBrowsers(task.browsers, `${path}.browsers`, into);
1200
+ if (context.member !== true || task.browsers !== undefined)
1201
+ validateBrowsers(task.browsers, `${path}.browsers`, into);
1214
1202
  validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
1215
- validateHistory(task.history, `${path}.history`, into, { phase: task.phase, media: task.media });
1203
+ validateHistory(task.history, `${path}.history`, into, { phase: task.phase, audio: task.audio });
1216
1204
  validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
1217
- // Media and onCall describe this agent's interaction, not the continuing caller journey.
1218
- // Once the interaction's media ends or it enters wrap, its live room is cleared. Other
1205
+ // Audio and onCall describe this agent's interaction, not the continuing caller journey.
1206
+ // Once the interaction's audio ends or it enters wrap, its live room is cleared. Other
1219
1207
  // channels may remain connected under another interaction or in IVR/queue stages.
1220
- if (task.phase === "completing" && task.media === "started") {
1221
- into.add("task.media.completing", `${path}.media`, "a completing task's interaction media has ended or never started; it cannot still be started during wrap");
1208
+ if (task.phase === "completing" && task.audio === "started") {
1209
+ into.add("task.audio.completing", `${path}.audio`, "a completing task's interaction audio has ended or never started; it cannot still be started during wrap");
1222
1210
  }
1223
- if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.media === "ended")) {
1224
- into.add("task.onCall.ended", `${path}.onCall`, `this interaction is no longer connected (${task.phase === "completing" ? "the task is completing" : "its media ended"}); clear its onCall view without implying that other channels ended`);
1211
+ if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.audio === "ended")) {
1212
+ into.add("task.onCall.ended", `${path}.onCall`, `this interaction is no longer connected (${task.phase === "completing" ? "the task is completing" : "its audio ended"}); clear its onCall view without implying that other channels ended`);
1225
1213
  }
1226
- validateTaskMedia(task, task.media, context.channel, task.phase, `${path}.media`, into);
1214
+ validateTaskAudio(task, task.audio, context.channel, task.phase, `${path}.audio`, into);
1227
1215
  validateLeadAssist(task.leadAssist, context.channel, `${path}.leadAssist`, into);
1228
- validateAssisting(task.assisting, context.channel, `${path}.assisting`, into);
1229
- validateListening(task.listening, context.channel, `${path}.listening`, into);
1230
- // A lead's task is one thing: a call they joined on request, or a call they listen to unasked.
1231
- if (task.assisting !== undefined && task.listening !== undefined) {
1232
- into.add("task.listening.assisting", `${path}.listening`, "a task is a joined call or a listened one, never both");
1233
- }
1216
+ validateTakenOver(task.takenOver, context.channel, `${path}.takenOver`, into);
1234
1217
  // Where the terms came from is stated with them: a host cannot tell "the platform permits
1235
1218
  // nothing" from "the terms could not be read" from the set alone, and the provider knows which.
1236
- into.oneOf(task.capabilitySource, CAPABILITY_SOURCES, "task.capabilitySource", `${path}.capabilitySource`);
1219
+ if (context.member !== true || task.capabilitySource !== undefined)
1220
+ into.oneOf(task.capabilitySource, CAPABILITY_SOURCES, "task.capabilitySource", `${path}.capabilitySource`);
1237
1221
  const capabilities = task.capabilities;
1222
+ if (context.member === true && capabilities === undefined && task.browsers === undefined)
1223
+ return;
1238
1224
  if (!isPlainObject(capabilities)) {
1239
1225
  into.add("task.capabilities.shape", `${path}.capabilities`, "a task needs a capabilities object");
1240
1226
  return;
@@ -1319,8 +1305,7 @@ export function validateBreakStatus(state, tasks, path = "snapshot") {
1319
1305
  });
1320
1306
  // A break in effect begins when the work ends, so it holds no task. A snapshot reporting both
1321
1307
  // describes a state the agent cannot be in, whichever half is stale. The one exception is a
1322
- // lead listening to a call during a break that is work of another sort -- coaching,
1323
- // administrative, training -- and on no other.
1308
+ // call a lead took over: the provider assigns it whatever break the lead is on.
1324
1309
  // The converse: a committed break with nothing outstanding has begun. starting-after-task beside
1325
1310
  // no task is a break waiting on work that does not exist, and an empty list reading as "still
1326
1311
  // finishing" is the plausible nought.
@@ -1331,79 +1316,103 @@ export function validateBreakStatus(state, tasks, path = "snapshot") {
1331
1316
  }
1332
1317
  if (isPlainObject(state) && state.status === "on-break"
1333
1318
  && Array.isArray(tasks) && tasks.length > 0) {
1334
- const listening = tasks.every((task) => isPlainObject(task) && task.listening !== undefined);
1335
- if (!listening) {
1336
- into.add("break.on-break.tasks", `${path}.tasks`, "a break in effect holds no task: it begins when the work ends, and until then the state is starting-after-task");
1337
- }
1338
- else {
1339
- const reasons = Array.isArray(state.reasons) ? state.reasons : [];
1340
- const active = reasons.find((reason) => isPlainObject(reason) && reason.id === state.activeReasonId);
1341
- const kind = isPlainObject(active) ? active.kind : undefined;
1342
- into.require(typeof kind === "string" && LISTENING_BREAK_KINDS.includes(kind), "snapshot.listening.break", `${path}.tasks`, `a lead listens during a ${LISTENING_BREAK_KINDS.join(", ")} break and no other; the active reason is ${kind === undefined ? "unstated" : describeValue(kind)}`);
1319
+ const takenOver = tasks.every((task) => isPlainObject(task) && task.takenOver !== undefined);
1320
+ if (!takenOver) {
1321
+ into.add("break.on-break.tasks", `${path}.tasks`, "a break in effect holds no task: it begins when the work ends, and until then the state is starting-after-task; the one exception is a call a lead took over");
1343
1322
  }
1344
1323
  }
1345
1324
  return into.violations;
1346
1325
  }
1347
1326
  /**
1348
- * Checks lead break dispatch using current authentication, transport, team member list (`team`) and,
1349
- * for force-break/end-forced-break, the target's full `memberBreak`. The provider must authorize the target
1350
- * and recheck the decision atomically; a team member list is not authority to act after it has changed.
1327
+ * Checks a lead command before dispatch, against the current authentication, transport and team
1328
+ * member list (`team`) and, for force-break and end-forced-break, the target's full `memberBreak`.
1329
+ * The authority is the login's `lead` flag; the provider rechecks the target and the state
1330
+ * atomically, since a team member list is not authority to act after it has changed.
1351
1331
  */
1352
- export function validateTeamBreakCommand(request, context, path = "teamBreakCommand") {
1332
+ export function validateTeamCommand(request, context, path = "teamCommand") {
1353
1333
  const into = new Collector();
1354
1334
  if (!isPlainObject(context) || !isPlainObject(request) || !isPlainObject(request.command)) {
1355
- into.add("team.break.command.shape", path, "a command and current authentication/transport/team context are required");
1335
+ into.add("team.command.shape", path, "a command and current authentication/transport/team context are required");
1356
1336
  return into.violations;
1357
1337
  }
1358
1338
  const auth = isPlainObject(context.authentication) ? context.authentication : {};
1359
1339
  into.violations.push(...validateAuthenticationState(auth, `${path}.authentication`));
1360
1340
  const caps = isPlainObject(auth.capabilities) ? auth.capabilities : {};
1361
- into.require((auth.status === "authenticated" || auth.status === "refreshing") && context.transport === "active", "team.break.command.connection", path, "lead commands require a live login and active transport");
1362
- into.require(isPlainObject(caps.team) && caps.team.breakControl === true, "team.break.command.capability", path, "the login must declare team.breakControl");
1341
+ into.require((auth.status === "authenticated" || auth.status === "refreshing") && context.transport === "active", "team.command.connection", path, "lead commands require a live login and active transport");
1342
+ into.require(caps.lead === true, "team.command.capability", path, "the login must declare lead");
1363
1343
  const command = request.command;
1344
+ const member = ["type", "memberId", "assignmentId"];
1364
1345
  const allowed = {
1346
+ "lead-features": ["type", "enabled"],
1365
1347
  "decide-break-request": ["type", "memberId", "decision", "reason"], "set-break-policy": ["type", "policy"],
1366
1348
  "force-break": ["type", "memberId", "reasonId", "reason", "expectedDurationMs"], "end-forced-break": ["type", "memberId"],
1349
+ join: member, decline: [...member, "reason"], listen: member, coach: member, "join-call": member, leave: member, "take-over-call": member,
1350
+ "set-policy": ["type", "capability", "setting"],
1367
1351
  };
1368
1352
  const fields = typeof command.type === "string" && Object.hasOwn(allowed, command.type) ? allowed[command.type] : undefined;
1369
1353
  if (!fields) {
1370
- into.add("team.break.command.type", path, "unknown lead break command");
1354
+ into.add("team.command.type", path, `unknown lead command: ${describeValue(command.type)}`);
1371
1355
  return into.violations;
1372
1356
  }
1373
1357
  for (const key of Object.keys(request))
1374
- into.require(key === "command", "team.break.request.field", `${path}.${key}`, "only command is supported");
1358
+ into.require(key === "command", "team.request.field", `${path}.${key}`, "only command is supported");
1375
1359
  for (const key of Object.keys(command))
1376
- into.require(fields.includes(key), "team.break.command.field", `${path}.${key}`, "field is not supported for this command");
1360
+ into.require(fields.includes(key), "team.command.field", `${path}.${key}`, "field is not supported for this command");
1377
1361
  if (command.reason !== undefined)
1378
- into.filled(command.reason, "team.break.command.reason", path, "reason must not be empty");
1362
+ into.filled(command.reason, "team.command.reason", path, "reason must not be empty");
1363
+ if (command.type === "lead-features") {
1364
+ into.require(typeof command.enabled === "boolean", "team.command.enabled", `${path}.enabled`, "lead-features says on or off, as a boolean");
1365
+ return into.violations;
1366
+ }
1379
1367
  if (command.type === "set-break-policy") {
1380
- into.require(["approval-required", "automatically-approved", "requests-suspended"].includes(command.policy), "team.break.command.policy", path, "unknown break policy");
1368
+ into.require(["approval-required", "automatically-approved", "requests-suspended"].includes(command.policy), "team.command.policy", path, "unknown break policy");
1381
1369
  return into.violations;
1382
1370
  }
1383
- into.filled(command.memberId, "team.break.command.member", path, "name the target member");
1371
+ if (command.type === "set-policy") {
1372
+ into.require(typeof command.capability === "string" && (POLICY_KEYS.has(command.capability) || /^skill:.+$/.test(command.capability)), "team.command.capability.key", `${path}.capability`, "a policy names a task control, dial, or a skill");
1373
+ if (into.oneOf(command.setting, POLICY_SETTINGS, "team.command.setting", `${path}.setting`)) {
1374
+ into.require(command.setting !== "person" || (typeof command.capability === "string" && PERSON_SETTABLE.test(command.capability)), "team.command.setting.person", `${path}.setting`, "only hold and skills may be left to the person");
1375
+ }
1376
+ return into.violations;
1377
+ }
1378
+ into.filled(command.memberId, "team.command.member", path, "name the target member");
1384
1379
  const team = isPlainObject(context.team) ? context.team : {};
1385
1380
  const members = Array.isArray(team.members) ? team.members : [];
1386
- const member = members.find((m) => isPlainObject(m) && m.id === command.memberId);
1381
+ const target = members.find((m) => isPlainObject(m) && m.id === command.memberId);
1387
1382
  const self = isPlainObject(auth.identity) ? auth.identity.id : undefined;
1388
- into.require(isPlainObject(member) && command.memberId !== self, "team.break.command.member", path, "the target must be another member of the current authorized team member list");
1383
+ into.require(isPlainObject(target) && command.memberId !== self, "team.command.member", path, "the target must be another member of the current team member list");
1384
+ if (command.assignmentId !== undefined) {
1385
+ into.require(isAssignmentId(command.assignmentId), "team.command.assignmentId", `${path}.assignmentId`, "an assignment id must be non-empty when present");
1386
+ const tasks = isPlainObject(target) && Array.isArray(target.tasks) ? target.tasks : undefined;
1387
+ if (tasks !== undefined)
1388
+ into.require(tasks.some((task) => isPlainObject(task) && task.assignmentId === command.assignmentId), "team.command.assignmentId.unknown", `${path}.assignmentId`, "the assignment named is not one the team member list shows the member holding");
1389
+ }
1389
1390
  if (command.type === "decide-break-request") {
1390
- into.require(command.decision === "granted" || command.decision === "denied", "team.break.command.decision", path, "decide granted or denied");
1391
- into.require(isPlainObject(member) && member.break === "awaiting-approval", "team.break.command.awaiting", path, "decide only a currently awaiting-approval request");
1391
+ into.require(command.decision === "granted" || command.decision === "denied", "team.command.decision", path, "decide granted or denied");
1392
+ into.require(isPlainObject(target) && target.break === "awaiting-approval", "team.command.awaiting", path, "decide only a currently awaiting-approval request");
1392
1393
  }
1393
- else {
1394
+ else if (command.type === "force-break" || command.type === "end-forced-break") {
1394
1395
  validateBreakState(context.memberBreak, `${path}.memberBreak`, into);
1395
1396
  const state = isPlainObject(context.memberBreak) ? context.memberBreak : {};
1396
1397
  if (command.type === "end-forced-break")
1397
- into.require(state.forced !== undefined && (state.status === "on-break" || state.status === "starting-after-task"), "team.break.command.endForcedBreak", path, "lift a current forced-break restriction without resuming the agent");
1398
+ 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");
1398
1399
  if (command.type === "force-break") {
1399
1400
  if (command.expectedDurationMs !== undefined)
1400
- into.require(typeof command.expectedDurationMs === "number" && Number.isFinite(command.expectedDurationMs) && command.expectedDurationMs > 0, "team.break.command.expectedDurationMs", `${path}.expectedDurationMs`, "expectedDurationMs must be positive finite milliseconds; it never resumes the agent");
1401
+ 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");
1401
1402
  if (command.reasonId !== undefined)
1402
- into.filled(command.reasonId, "team.break.command.reasonId", path, "reasonId must not be empty");
1403
+ into.filled(command.reasonId, "team.command.reasonId", path, "reasonId must not be empty");
1403
1404
  const reasons = Array.isArray(state.reasons) ? state.reasons : [];
1404
- into.require(state.reasons === undefined ? command.reasonId === undefined : reasons.some((r) => isPlainObject(r) && r.id === command.reasonId), "team.break.command.reasonId", path, "force-break uses the target's currently published reason codes");
1405
+ into.require(state.reasons === undefined ? command.reasonId === undefined : reasons.some((r) => isPlainObject(r) && r.id === command.reasonId), "team.command.reasonId", path, "force-break uses the target's currently published reason codes");
1405
1406
  }
1406
1407
  }
1408
+ 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`);
1412
+ }
1413
+ else if (command.type === "coach" || command.type === "join-call" || command.type === "leave") {
1414
+ 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`);
1415
+ }
1407
1416
  return into.violations;
1408
1417
  }
1409
1418
  /**
@@ -1576,61 +1585,39 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
1576
1585
  into.add("team.shape", path, "a team member list must be an object");
1577
1586
  return;
1578
1587
  }
1579
- // The login is the permission: a team member list reaches a login that declares `capabilities.team` and
1580
- // nobody else. Only a caller holding the login can check it.
1581
- if (context.capabilities !== undefined && context.capabilities.team === undefined) {
1582
- into.add("team.unentitled", path, "a team member list published to a login that does not declare capabilities.team: the login is the permission");
1583
- }
1584
- // The team's policies travel with the team member list exactly when the login may set them.
1585
- if (context.capabilities !== undefined) {
1586
- const may = context.capabilities.team?.policyControl === true;
1587
- if (may && teamMembers.policies === undefined) {
1588
- into.add("team.policies.required", `${path}.policies`, "the login declares team.policyControl, so the team member list carries the team's policies");
1589
- }
1590
- if (!may && teamMembers.policies !== undefined) {
1591
- into.add("team.policies.capability", `${path}.policies`, "policies require team.policyControl on the login: a lead who may not set them has nothing to see");
1592
- }
1588
+ // The login is the permission: a team member list reaches a login that declares `capabilities.lead`
1589
+ // and nobody else. Only a caller holding the login can check it.
1590
+ if (context.capabilities !== undefined && context.capabilities.lead !== true) {
1591
+ into.add("team.unentitled", path, "a team member list published to a login that does not declare capabilities.lead: the login is the permission");
1593
1592
  }
1593
+ // Policies are present where the provider offers policy control, absent where it does not.
1594
1594
  if (teamMembers.policies !== undefined)
1595
1595
  validateTeamPoliciesInto(teamMembers.policies, `${path}.policies`, into, context.levels);
1596
- if (teamMembers.requests === undefined) {
1597
- // `[]` says nobody is asking; omission says the lead may not be asked. A login that may be
1598
- // asked therefore always carries the list.
1599
- if (context.capabilities?.team?.leadAssistControl === true) {
1600
- into.add("team.requests.required", `${path}.requests`, "the login declares team.leadAssistControl, so the team member list carries requests: [] when nobody is asking");
1601
- }
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");
1602
1599
  }
1603
1600
  else {
1604
- // Requests are what a lead acts on, so a lead who may not act has no business receiving them.
1605
- // Whether they may is on the login, so the check needs the login in hand.
1606
- if (context.capabilities !== undefined) {
1607
- into.require(context.capabilities.team?.leadAssistControl === true, "team.requests.capability", `${path}.requests`, "requests require team.leadAssistControl on the login: a lead who may not join has nothing to decide");
1608
- }
1609
- if (!Array.isArray(teamMembers.requests)) {
1610
- into.add("team.requests.shape", `${path}.requests`, "requests must be an array when present");
1611
- }
1612
- else {
1613
- const seenRequests = new Set();
1614
- teamMembers.requests.forEach((request, index) => {
1615
- const at = `${path}.requests[${index}]`;
1616
- if (!isPlainObject(request)) {
1617
- into.add("team.request.shape", at, "each request must be an object");
1618
- return;
1619
- }
1620
- if (into.filled(request.id, "team.request.id", `${at}.id`, "a request needs an id")) {
1621
- if (seenRequests.has(request.id))
1622
- into.add("team.request.unique", `${at}.id`, `duplicate request id: ${request.id}`);
1623
- seenRequests.add(request.id);
1624
- }
1625
- if (into.require(isUserId(request.memberId), "team.request.memberId", `${at}.memberId`, "a request names the member asking")) {
1626
- 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");
1627
- }
1628
- into.require(isAssignmentId(request.assignmentId), "team.request.assignmentId", `${at}.assignmentId`, "a request names the member's assignment the lead would join");
1629
- if (request.note !== undefined)
1630
- into.filled(request.note, "team.request.note", `${at}.note`, "a note must not be empty when present");
1631
- into.timestamp(request.since, "team.request.since", `${at}.since`);
1632
- });
1633
- }
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
+ });
1634
1621
  }
1635
1622
  if (!Array.isArray(teamMembers.members)) {
1636
1623
  into.add("team.members.shape", `${path}.members`, "a team member list must carry a members array");
@@ -1659,7 +1646,40 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
1659
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");
1660
1647
  }
1661
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
+ }
1662
1678
  });
1679
+ // One member's call at a time.
1680
+ const listened = teamMembers.members.filter((member) => isPlainObject(member) && member.listening !== undefined);
1681
+ if (listened.length > 1)
1682
+ into.add("team.listening.single", `${path}.members`, "a lead is on one member's call at a time");
1663
1683
  }
1664
1684
  export function validateSnapshot(snapshot, manifest, path = "snapshot", context = {}) {
1665
1685
  const into = new Collector();
@@ -1685,21 +1705,8 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1685
1705
  }
1686
1706
  else {
1687
1707
  const seen = new Set();
1688
- let assisting;
1689
- let listening;
1690
1708
  snapshot.tasks.forEach((task, index) => {
1691
1709
  validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
1692
- // A lead assists one call at a time, and listens to one at a time.
1693
- if (isPlainObject(task) && task.assisting !== undefined) {
1694
- if (assisting !== undefined)
1695
- into.add("snapshot.assisting.single", `${path}.tasks[${index}].assisting`, "a lead assists one call at a time");
1696
- assisting = index;
1697
- }
1698
- if (isPlainObject(task) && task.listening !== undefined) {
1699
- if (listening !== undefined)
1700
- into.add("snapshot.listening.single", `${path}.tasks[${index}].listening`, "a lead listens to one call at a time");
1701
- listening = index;
1702
- }
1703
1710
  if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
1704
1711
  if (seen.has(task.assignmentId))
1705
1712
  into.add("task.assignmentId.unique", `${path}.tasks[${index}].assignmentId`, `duplicate assignment id: ${task.assignmentId}`);
@@ -1707,14 +1714,6 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1707
1714
  }
1708
1715
  });
1709
1716
  }
1710
- // A lead listens while holding no work of their own: a listening task is the only task.
1711
- if (Array.isArray(snapshot.tasks) && snapshot.tasks.length > 1) {
1712
- snapshot.tasks.forEach((task, index) => {
1713
- if (isPlainObject(task) && task.listening !== undefined) {
1714
- into.add("snapshot.listening.alone", `${path}.tasks[${index}].listening`, "a lead listens only while holding no task of their own");
1715
- }
1716
- });
1717
- }
1718
1717
  into.violations.push(...validateBreakStatus(snapshot.break, snapshot.tasks, path));
1719
1718
  // Presence is the permission, and it cuts both ways: data a provider never declared a
1720
1719
  // capability for is data Omni would show against a control the agent does not have.
@@ -1725,8 +1724,8 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1725
1724
  if (snapshot.contacts === undefined && idle.contacts === true) {
1726
1725
  into.add("snapshot.contacts.required", `${path}.contacts`, "the manifest declares contacts, so every snapshot carries the contribution: [] when there are none");
1727
1726
  }
1728
- if (snapshot.scheduledActivities === undefined && idle.calendar === true) {
1729
- into.add("snapshot.calendar.required", `${path}.scheduledActivities`, "the manifest declares calendar, so every snapshot carries the contribution: [] when there are none");
1727
+ if (snapshot.calendar === undefined && idle.calendar === true) {
1728
+ into.add("snapshot.calendar.required", `${path}.calendar`, "the manifest declares calendar, so every snapshot carries the contribution: [] when there are none");
1730
1729
  }
1731
1730
  if (snapshot.contacts !== undefined) {
1732
1731
  into.require(idle.contacts === true, "snapshot.contacts.capability", `${path}.contacts`, "contacts require the contacts idle capability");
@@ -1737,31 +1736,35 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1737
1736
  into.add("snapshot.contacts.shape", `${path}.contacts`, "contacts must be an array when present");
1738
1737
  }
1739
1738
  }
1740
- if (snapshot.scheduledActivities !== undefined) {
1741
- into.require(idle.calendar === true, "snapshot.calendar.capability", `${path}.scheduledActivities`, "scheduled activities require the calendar idle capability");
1742
- if (Array.isArray(snapshot.scheduledActivities)) {
1739
+ if (snapshot.calendar !== undefined) {
1740
+ into.require(idle.calendar === true, "snapshot.calendar.capability", `${path}.calendar`, "scheduled activities require the calendar idle capability");
1741
+ if (Array.isArray(snapshot.calendar)) {
1743
1742
  const seen = new Set();
1744
- snapshot.scheduledActivities.forEach((activity, index) => {
1745
- validateScheduledActivityInto(activity, `${path}.scheduledActivities[${index}]`, into, levels);
1743
+ snapshot.calendar.forEach((activity, index) => {
1744
+ validateScheduledActivityInto(activity, `${path}.calendar[${index}]`, into, levels);
1746
1745
  if (isPlainObject(activity) && isFilled(activity.id)) {
1747
1746
  if (seen.has(activity.id)) {
1748
- into.add("activity.id.unique", `${path}.scheduledActivities[${index}].id`, `duplicate activity id: ${activity.id}`);
1747
+ into.add("activity.id.unique", `${path}.calendar[${index}].id`, `duplicate activity id: ${activity.id}`);
1749
1748
  }
1750
1749
  seen.add(activity.id);
1751
1750
  }
1752
1751
  });
1753
1752
  }
1754
1753
  else {
1755
- into.add("snapshot.calendar.shape", `${path}.scheduledActivities`, "scheduledActivities must be an array when present");
1754
+ into.add("snapshot.calendar.shape", `${path}.calendar`, "calendar must be an array when present");
1756
1755
  }
1757
1756
  }
1758
- // The login is the permission: a lead's snapshot carries a team member list, `[]` included. The other
1759
- // direction -- a team member list to a login that does not lead -- is the team member list's own rule.
1760
- if (context.capabilities?.team !== undefined && snapshot.team === undefined) {
1761
- into.add("team.required", `${path}.team`, "the login declares capabilities.team, so every snapshot carries a team member list: [] when nobody is in it");
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");
1762
+ }
1763
+ 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");
1762
1765
  }
1763
1766
  if (snapshot.team !== undefined)
1764
- validateTeamMembersInto(snapshot.team, `${path}.team`, { ...context, levels }, into);
1767
+ validateTeamMembersInto(snapshot.team, `${path}.team`, { ...context, levels, channel: isChannel(channel) ? channel : undefined }, into);
1765
1768
  return into.violations;
1766
1769
  }
1767
1770
  // ---------------------------------------------------------------------------
@@ -1797,8 +1800,6 @@ function validateTaskOutcome(value, path, into) {
1797
1800
  // Only the phases in which a task is still waiting on somebody can expire.
1798
1801
  into.oneOf(value.phase, EXPIRABLE_PHASES, "event.taskEnded.outcome.expired", `${path}.phase`);
1799
1802
  break;
1800
- case "left":
1801
- break;
1802
1803
  case "failed":
1803
1804
  if (!isPlainObject(value.failure)) {
1804
1805
  into.add("event.taskEnded.outcome.failed", `${path}.failure`, "a failed outcome must carry a failure");
@@ -1902,13 +1903,13 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1902
1903
  case "task-updated":
1903
1904
  validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
1904
1905
  break;
1905
- case "task-media-started":
1906
- into.require(channel === "voice", "event.media.channel", `${at}.type`, "only a voice provider publishes media transitions");
1907
- into.require(isAssignmentId(event.assignmentId), "event.taskMediaStarted.assignmentId", `${at}.assignmentId`, "an event about a task names its assignment");
1906
+ case "task-audio-started":
1907
+ into.require(channel === "voice", "event.audio.channel", `${at}.type`, "only a voice provider publishes audio transitions");
1908
+ into.require(isAssignmentId(event.assignmentId), "event.taskAudioStarted.assignmentId", `${at}.assignmentId`, "an event about a task names its assignment");
1908
1909
  break;
1909
- case "task-media-ended":
1910
- into.require(channel === "voice", "event.media.channel", `${at}.type`, "only a voice provider publishes media transitions");
1911
- into.require(isAssignmentId(event.assignmentId), "event.taskMediaEnded.assignmentId", `${at}.assignmentId`, "an event about a task names its assignment");
1910
+ case "task-audio-ended":
1911
+ into.require(channel === "voice", "event.audio.channel", `${at}.type`, "only a voice provider publishes audio transitions");
1912
+ into.require(isAssignmentId(event.assignmentId), "event.taskAudioEnded.assignmentId", `${at}.assignmentId`, "an event about a task names its assignment");
1912
1913
  break;
1913
1914
  case "task-ended":
1914
1915
  into.require(isAssignmentId(event.assignmentId), "event.taskEnded.assignmentId", `${at}.assignmentId`, "an event about a task names its assignment");
@@ -1948,7 +1949,17 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1948
1949
  validateQueueSummary(event.summary, `${at}.summary`, into);
1949
1950
  break;
1950
1951
  case "team-updated":
1951
- validateTeamMembersInto(event.team, `${at}.team`, { ...context, levels }, into);
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");
1952
1963
  break;
1953
1964
  case "contacts-updated":
1954
1965
  into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
@@ -1960,17 +1971,17 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1960
1971
  }
1961
1972
  break;
1962
1973
  case "calendar-updated":
1963
- into.require(idle.calendar === true, "event.calendar.capability", `${at}.scheduledActivities`, "calendar-updated requires the calendar idle capability");
1964
- if (!Array.isArray(event.scheduledActivities)) {
1965
- into.add("event.calendar.shape", `${at}.scheduledActivities`, "scheduledActivities must be an array");
1974
+ into.require(idle.calendar === true, "event.calendar.capability", `${at}.calendar`, "calendar-updated requires the calendar idle capability");
1975
+ if (!Array.isArray(event.calendar)) {
1976
+ into.add("event.calendar.shape", `${at}.calendar`, "calendar must be an array");
1966
1977
  }
1967
1978
  else {
1968
1979
  const ids = new Set();
1969
- event.scheduledActivities.forEach((activity, index) => {
1970
- validateScheduledActivityInto(activity, `${at}.scheduledActivities[${index}]`, into, levels);
1980
+ event.calendar.forEach((activity, index) => {
1981
+ validateScheduledActivityInto(activity, `${at}.calendar[${index}]`, into, levels);
1971
1982
  if (isPlainObject(activity) && isFilled(activity.id)) {
1972
1983
  if (ids.has(activity.id))
1973
- into.add("activity.id.unique", `${at}.scheduledActivities[${index}].id`, `duplicate activity id: ${activity.id}`);
1984
+ into.add("activity.id.unique", `${at}.calendar[${index}].id`, `duplicate activity id: ${activity.id}`);
1974
1985
  ids.add(activity.id);
1975
1986
  }
1976
1987
  });
@@ -2236,7 +2247,7 @@ export function validateHostReport(report, path = "host") {
2236
2247
  // Commands.
2237
2248
  // ---------------------------------------------------------------------------
2238
2249
  const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
2239
- const LEAD_ASSIST_ACTIONS = ["request", "cancel", "take-over-call", "leave"];
2250
+ const LEAD_ASSIST_ACTIONS = ["request", "cancel"];
2240
2251
  const CONFERENCE_ACTIONS = ["add", "remove"];
2241
2252
  /** Validate the exact interaction/assignment target before provider command dispatch. */
2242
2253
  export function validateTaskCommandRequest(request, task, path = "request", taskContext) {
@@ -2282,7 +2293,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2282
2293
  if (type !== "custom" && type !== "recording") {
2283
2294
  let fields = ["type"];
2284
2295
  switch (type) {
2285
- case "call":
2296
+ case "dial":
2286
2297
  case "connect-back":
2287
2298
  fields.push("dialId");
2288
2299
  break;
@@ -2309,8 +2320,8 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2309
2320
  }
2310
2321
  const dial = (rule) => into.filled(command.dialId, rule, `${path}.dialId`, "a command that dials carries the host's dialId");
2311
2322
  switch (type) {
2312
- case "call":
2313
- dial("command.call.dialId");
2323
+ case "dial":
2324
+ dial("command.dial.dialId");
2314
2325
  break;
2315
2326
  case "connect-back":
2316
2327
  dial("command.connectBack.dialId");
@@ -2422,7 +2433,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2422
2433
  if (type === "decline")
2423
2434
  offered("decline");
2424
2435
  break;
2425
- case "call":
2436
+ case "dial":
2426
2437
  inPhase("command.phase.preview", "preview");
2427
2438
  break;
2428
2439
  case "hold":
@@ -2435,6 +2446,10 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2435
2446
  offered("endCall");
2436
2447
  interaction();
2437
2448
  break;
2449
+ case "terminate-call":
2450
+ offered("terminateCall");
2451
+ interaction();
2452
+ break;
2438
2453
  case "recording": {
2439
2454
  if (!offered("recording"))
2440
2455
  break;
@@ -2443,7 +2458,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2443
2458
  into.violations.push(...validateRecordingCommandState(command, policy, state, path));
2444
2459
  if (command.action === "start" || command.action === "resume") {
2445
2460
  interaction();
2446
- into.require(task.media === "started", "recording.request.phase", path, "capture needs live media");
2461
+ into.require(task.audio === "started", "recording.request.phase", path, "capture needs live audio");
2447
2462
  }
2448
2463
  else
2449
2464
  inPhase("command.phase.interaction", "in-progress", "paused", "completing");
@@ -2469,14 +2484,9 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2469
2484
  break;
2470
2485
  case "lead-assist":
2471
2486
  interaction();
2472
- if (command.action === "request" || command.action === "cancel") {
2473
- offered("leadAssist");
2474
- if (command.action === "cancel") {
2475
- into.require(isPlainObject(task.leadAssist) && task.leadAssist.stage === "requested", "command.leadAssist.requested", path, "cancel needs a request standing: leadAssist with stage requested");
2476
- }
2477
- }
2478
- else {
2479
- into.require(task.assisting !== undefined, "command.leadAssist.assisting", path, `${describeValue(command.action)} is the lead's own act on a call they joined: the task carries assisting`);
2487
+ offered("leadAssist");
2488
+ if (command.action === "cancel") {
2489
+ into.require(isPlainObject(task.leadAssist) && task.leadAssist.stage === "requested", "command.leadAssist.requested", path, "cancel needs a request standing: leadAssist with stage requested");
2480
2490
  }
2481
2491
  break;
2482
2492
  case "conference": {
@@ -2540,13 +2550,10 @@ const RESULT_STATUSES = {
2540
2550
  commitBreak: { success: "committed", failure: "failed" },
2541
2551
  cancelBreak: { success: "cancelled", failure: "failed" },
2542
2552
  endBreak: { success: "ended", failure: "failed" },
2543
- executeTeamBreak: { success: "applied", failure: "failed" },
2544
- executeTeamLeadAssist: { success: "applied", failure: "failed" },
2545
- executeTeamListen: { success: "applied", failure: "failed" },
2546
- openMedia: { success: "opened", failure: "unavailable" },
2553
+ executeTeam: { success: "applied", failure: "failed" },
2554
+ openAudio: { success: "opened", failure: "unavailable" },
2547
2555
  setPreference: { success: "applied", failure: "failed" },
2548
2556
  recordStep: { success: "recorded", failure: "failed" },
2549
- executeTeamPolicy: { success: "applied", failure: "failed" },
2550
2557
  };
2551
2558
  /**
2552
2559
  * Validates what a connection method answered. A result is untrusted for the same reason a
@@ -2564,22 +2571,22 @@ export function validateResult(result, method, path = "result", dialId) {
2564
2571
  into.add("result.shape", path, `${method} must answer an object`);
2565
2572
  return into.violations;
2566
2573
  }
2567
- if (method === "openMedia" && result.status !== "opened") {
2568
- into.require(result.session === undefined, "result.session.unexpected", `${path}.session`, "only opened carries a media session");
2574
+ if (method === "openAudio" && result.status !== "opened") {
2575
+ into.require(result.audio === undefined, "result.audio.unexpected", `${path}.audio`, "only opened carries an audio session");
2569
2576
  }
2570
2577
  if (result.status === statuses.success) {
2571
2578
  into.require(result.failure === undefined, "result.failure.unexpected", `${path}.failure`, `${statuses.success} carries no failure`);
2572
- if (method === "openMedia") {
2573
- if (isPlainObject(result.session)) {
2579
+ if (method === "openAudio") {
2580
+ if (isPlainObject(result.audio)) {
2574
2581
  // Streams may come from another realm, and Node conformance runs have no MediaStream
2575
2582
  // constructor. Check the boundary shape without claiming to verify live audio.
2576
- into.require(isPlainObject(result.session.remoteAudio), "result.session.remoteAudio", `${path}.session.remoteAudio`, "an opened session carries its remote audio stream");
2583
+ into.require(isPlainObject(result.audio.remoteAudio), "result.audio.remoteAudio", `${path}.audio.remoteAudio`, "an opened session carries its remote audio stream");
2577
2584
  for (const method of ["setMuted", "close"]) {
2578
- into.require(typeof result.session[method] === "function", `result.session.${method}`, `${path}.session.${method}`, `a media session implements ${method}`);
2585
+ into.require(typeof result.audio[method] === "function", `result.audio.${method}`, `${path}.audio.${method}`, `an audio session implements ${method}`);
2579
2586
  }
2580
2587
  }
2581
2588
  else {
2582
- into.add("result.session", `${path}.session`, "opened carries the media session");
2589
+ into.add("result.audio", `${path}.audio`, "opened carries the audio session");
2583
2590
  }
2584
2591
  }
2585
2592
  if (dials) {
@@ -2683,43 +2690,9 @@ function validateUserCapabilitiesInto(value, path, into, levels) {
2683
2690
  validatePreferencesInto(declared, `${path}.preferences`, into, levels);
2684
2691
  continue;
2685
2692
  }
2686
- if (name === "team") {
2687
- if (!isPlainObject(declared)) {
2688
- into.add("authentication.capability.team.shape", `${path}.team`, "team names what the lead may do: an object, {} for a lead with no controls");
2689
- continue;
2690
- }
2691
- for (const [control, on] of Object.entries(declared)) {
2692
- if (on === undefined)
2693
- continue;
2694
- if (!into.require(TEAM_CAPABILITIES.includes(control), "authentication.capability.team.unknown", `${path}.team.${control}`, `unsupported team capability: ${control}`))
2695
- continue;
2696
- if (control === "listeningControl") {
2697
- validateListeningControl(on, `${path}.team.listeningControl`, into);
2698
- continue;
2699
- }
2700
- into.require(on === true, "authentication.capability.value", `${path}.team.${control}`, `${control} is declared by presence: send true or omit it`);
2701
- }
2702
- continue;
2703
- }
2704
2693
  into.require(declared === true, "authentication.capability.value", `${path}.${name}`, `${name} is declared by presence: send true or omit it`);
2705
2694
  }
2706
2695
  }
2707
- /**
2708
- * The modes a lead may listen in, as a list: coach and join-call begin with silent listening, so a list
2709
- * without `listen` names modes the lead could never reach.
2710
- */
2711
- function validateListeningControl(value, path, into) {
2712
- if (!Array.isArray(value) || value.length === 0) {
2713
- into.add("authentication.capability.team.listeningControl.shape", path, "listeningControl lists the modes this lead may listen in: a non-empty array, or omitted for a lead who may not");
2714
- return;
2715
- }
2716
- value.forEach((mode, index) => {
2717
- if (into.oneOf(mode, LISTENING_MODES, "authentication.capability.team.listeningControl.mode", `${path}[${index}]`) && value.indexOf(mode) !== index) {
2718
- into.add("authentication.capability.team.listeningControl.unique", `${path}[${index}]`, `duplicate listen mode: ${describeValue(mode)}`);
2719
- }
2720
- });
2721
- into.require(value.includes("listen"), "authentication.capability.team.listeningControl.listen", path, "coach and join-call begin with silent listening: a lead who may listen in any mode may listen");
2722
- }
2723
2696
  /**
2724
2697
  * A failure an authentication reports: on a `rejected` start or completion, or on an `expired`
2725
2698
  * state. A provider names its own codes freely; an `omni.` code is the contract's and has to be one
@@ -2920,7 +2893,7 @@ export function validateRecordingPolicy(value, path = "capabilities.recording")
2920
2893
  check(false, "policy.shape", at, "expected permitted actions");
2921
2894
  continue;
2922
2895
  }
2923
- keys(policy, [...RECORDING_ACTIONS, ...(source === "host" ? ["destinationId"] : [])], at);
2896
+ keys(policy, [...RECORDING_ACTIONS, ...(source === "host" ? ["storageId"] : [])], at);
2924
2897
  check(RECORDING_ACTIONS.some(action => policy[action] !== undefined), "policy.empty", at, "omit paths offering no actions");
2925
2898
  for (const action of RECORDING_ACTIONS) {
2926
2899
  if (policy[action] === undefined)
@@ -2928,7 +2901,7 @@ export function validateRecordingPolicy(value, path = "capabilities.recording")
2928
2901
  check(policy[action] === true, "policy.action", `${at}.${action}`, "permission is true or absent");
2929
2902
  }
2930
2903
  if (source === "host")
2931
- check(filled(policy.destinationId), "destination", `${at}.destinationId`, "host storage destination must be explicit per task");
2904
+ check(filled(policy.storageId), "storage", `${at}.storageId`, "host storage must be named per task");
2932
2905
  }
2933
2906
  return violations;
2934
2907
  }
@@ -2936,15 +2909,15 @@ export function validateHostRecording(value, softphone, path = "host.recording")
2936
2909
  if (value === undefined)
2937
2910
  return [];
2938
2911
  const { violations, check, keys } = collector();
2939
- check(softphone, "host.channel", path, "initial host recording requires voice softphone media");
2912
+ check(softphone, "host.channel", path, "initial host recording requires voice softphone audio");
2940
2913
  if (!object(value)) {
2941
2914
  check(false, "host.shape", path, "expected host recording declaration and executor");
2942
2915
  return violations;
2943
2916
  }
2944
- keys(value, ["actions", "destinationIds", "execute", "announcesToCaller"], path);
2917
+ keys(value, ["actions", "storageIds", "execute", "announcesToCaller"], path);
2945
2918
  if (value.announcesToCaller !== undefined)
2946
2919
  check(value.announcesToCaller === true, "host.announcesToCaller", `${path}.announcesToCaller`, "caller announcement guarantee is true or absent");
2947
- for (const [key, allowed] of [["actions", RECORDING_ACTIONS], ["destinationIds", undefined]]) {
2920
+ for (const [key, allowed] of [["actions", RECORDING_ACTIONS], ["storageIds", undefined]]) {
2948
2921
  const list = value[key];
2949
2922
  if (!Array.isArray(list)) {
2950
2923
  check(false, "host.list", `${path}.${key}`, "expected explicit array");
@@ -3060,14 +3033,14 @@ export function validateRecordingRequest(request, task, context, path = "request
3060
3033
  state = Array.isArray(reports) ? reports.find(r => object(r) && r.assignmentId === task.assignmentId)?.state : undefined;
3061
3034
  if (object(context.host)) {
3062
3035
  check(Array.isArray(context.host.actions) && context.host.actions.includes(command.action), "host.action", path, "host does not support action");
3063
- check(object(policy) && Array.isArray(context.host.destinationIds) && context.host.destinationIds.includes(policy.destinationId), "host.destination", path, "task destination is not provisioned by this host");
3036
+ check(object(policy) && Array.isArray(context.host.storageIds) && context.host.storageIds.includes(policy.storageId), "host.storage", path, "task storage is not one this host provisions");
3064
3037
  }
3065
3038
  }
3066
3039
  const fresh = effectiveRecordingState(state, context.now);
3067
3040
  check(fresh.status !== "unknown", "request.freshness", path, "current state or trusted observer time unavailable/expired");
3068
3041
  violations.push(...validateRecordingCommandState(command, policy, fresh, `${path}.command`));
3069
3042
  const opening = command.action === "start" || command.action === "resume";
3070
- check(opening ? ["in-progress", "paused"].includes(task.phase) && task.media === "started" : ["in-progress", "paused", "completing"].includes(task.phase), "request.phase", path, "capture needs live task media; terminal controls may finish recordings during wrap-up");
3043
+ check(opening ? ["in-progress", "paused"].includes(task.phase) && task.audio === "started" : ["in-progress", "paused", "completing"].includes(task.phase), "request.phase", path, "capture needs live task audio; terminal controls may finish recordings during wrap-up");
3071
3044
  return violations;
3072
3045
  }
3073
3046
  /** Check recording-specific result semantics against the owner's confirming observations. */