@xema/omni-protocol 0.1.98 → 0.1.99

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") {
@@ -78,7 +78,7 @@ 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
  });
@@ -87,7 +87,6 @@ const OFFERABLE_PHASES = membersOf({
87
87
  });
88
88
  const PREVIEW_DEADLINES = membersOf({ calls: true, "host-calls": 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 }),
@@ -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
  }
@@ -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,7 +1197,8 @@ 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
1203
  validateHistory(task.history, `${path}.history`, into, { phase: task.phase, media: task.media });
1216
1204
  validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
@@ -1225,16 +1213,14 @@ function validateTaskInto(task, context, path, into) {
1225
1213
  }
1226
1214
  validateTaskMedia(task, task.media, context.channel, task.phase, `${path}.media`, 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.
@@ -1755,13 +1754,17 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1755
1754
  into.add("snapshot.calendar.shape", `${path}.scheduledActivities`, "scheduledActivities 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");
@@ -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-media-started":
1957
+ case "team-media-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.media.channel", `${at}.type`, "only a voice provider publishes media transitions");
1960
+ into.require(context.capabilities === undefined || context.capabilities.lead === true, "event.teamMedia.capability", `${at}.type`, "team media reaches a login that declares lead, and nobody else");
1961
+ into.require(isUserId(event.memberId), "event.teamMedia.memberId", `${at}.memberId`, "team media names the member whose call the lead is on");
1962
+ into.require(isAssignmentId(event.assignmentId), "event.teamMedia.assignmentId", `${at}.assignmentId`, "team media 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");
@@ -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) {
@@ -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;
@@ -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" },
2553
+ executeTeam: { success: "applied", failure: "failed" },
2546
2554
  openMedia: { 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
@@ -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