@xema/omni-protocol 0.1.25 → 0.1.26

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 { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, negotiateProtocolVersion, DEFAULT_TIERS, effectiveTiers, } from "./index.js";
12
+ import { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, 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") {
@@ -36,7 +36,7 @@ export function assertNoViolations(violations, summary) {
36
36
  */
37
37
  const membersOf = (members) => Object.keys(members);
38
38
  const CHANNELS = membersOf({ voice: true, chat: true, email: true });
39
- const CONNECTION_RECOVERIES = membersOf({ reconnect: true, reauthenticate: true });
39
+ const TRANSPORT_RECOVERIES = membersOf({ reconnect: true, reauthenticate: true });
40
40
  const TASK_PHASES = membersOf({
41
41
  pending: true, confirmed: true, preparing: true, "in-progress": true, paused: true, completing: true,
42
42
  });
@@ -44,7 +44,7 @@ const COMPLETION_MODES = membersOf({ "agent-command": true, "provider-automatic"
44
44
  const ACCEPTANCE_MODES = membersOf({
45
45
  "no-preference": true, "require-agent-acceptance": true, "require-automatic-acceptance": true,
46
46
  });
47
- const CONNECTION_STATUSES = membersOf({ connecting: true, active: true, error: true });
47
+ const TRANSPORT_STATUSES = membersOf({ connecting: true, active: true, error: true });
48
48
  const AUTHENTICATION_METHODS = membersOf({ "browser-sso": true, credentials: true });
49
49
  const AUTHENTICATION_STATUSES = membersOf({
50
50
  "signed-out": true, authenticating: true, authenticated: true, refreshing: true, expired: true,
@@ -59,7 +59,7 @@ const HANDLING_STEPS = membersOf({
59
59
  queued: true, offered: true, answered: true, held: true, muted: true, transferred: true, conferenced: true, unanswered: true,
60
60
  });
61
61
  const DESTINATION_KINDS = membersOf({ queue: true, agent: true, external: true });
62
- const CUSTOM_UI_KINDS = membersOf({ button: true, toggle: true, "menu-item": true });
62
+ const CUSTOM_UI_CONTROLS = membersOf({ button: true, toggle: true, "menu-item": true });
63
63
  const CUSTOM_UI_PLACEMENTS = membersOf({ primary: true, secondary: true, overflow: true });
64
64
  const NOTES_POLICIES = membersOf({ required: true, optional: true, hidden: true });
65
65
  const ACCESS_MODES = membersOf({ "allow-all": true, "block-all": true });
@@ -165,7 +165,7 @@ export function validateContact(contact, path = "contact") {
165
165
  validateContactInto(contact, path, into);
166
166
  return into.violations;
167
167
  }
168
- function validateContactInto(contact, path, into, tiers) {
168
+ function validateContactInto(contact, path, into, levels) {
169
169
  if (!isPlainObject(contact)) {
170
170
  into.add("contact.shape", path, "a contact must be an object");
171
171
  return;
@@ -174,7 +174,7 @@ function validateContactInto(contact, path, into, tiers) {
174
174
  if (contact[field] === undefined)
175
175
  continue;
176
176
  if ((field === "number" || field === "email") && isLocked(contact[field])) {
177
- validateLockedInto(contact[field], `contact.${field}.locked`, `${path}.${field}`, tiers, into);
177
+ validateLockedInto(contact[field], `contact.${field}.locked`, `${path}.${field}`, levels, into);
178
178
  continue;
179
179
  }
180
180
  into.filled(contact[field], `contact.${field}`, `${path}.${field}`, `${field} must not be empty when present`);
@@ -207,7 +207,7 @@ function validateScheduledActivityInto(activity, path, into) {
207
207
  // ---------------------------------------------------------------------------
208
208
  // Manifest.
209
209
  // ---------------------------------------------------------------------------
210
- function validateBrowserAccessPolicy(value, path, into) {
210
+ function validateBrowserAccess(value, path, into) {
211
211
  if (!isPlainObject(value)) {
212
212
  into.add("manifest.personalBrowser.access.shape", path, "an access policy must be an object");
213
213
  return;
@@ -246,7 +246,7 @@ function validateIdleCapabilities(value, channel, path, into) {
246
246
  into.add("manifest.personalBrowser.shape", `${path}.personalBrowser`, "personalBrowser must be an object when present");
247
247
  }
248
248
  else {
249
- validateBrowserAccessPolicy(browser.access, `${path}.personalBrowser.access`, into);
249
+ validateBrowserAccess(browser.access, `${path}.personalBrowser.access`, into);
250
250
  if (browser.accessPolicyScope !== undefined) {
251
251
  into.oneOf(browser.accessPolicyScope, ACCESS_POLICY_SCOPES, "manifest.personalBrowser.accessPolicyScope", `${path}.personalBrowser.accessPolicyScope`);
252
252
  }
@@ -257,7 +257,7 @@ function validateIdleCapabilities(value, channel, path, into) {
257
257
  into.add("manifest.dial.shape", `${path}.dial`, "dial must be an object when present");
258
258
  }
259
259
  else {
260
- into.oneOf(value.dial.destinationPolicy, DIAL_DESTINATION_POLICIES, "manifest.dial.destinationPolicy", `${path}.dial.destinationPolicy`);
260
+ into.oneOf(value.dial.destinations, DIAL_DESTINATION_POLICIES, "manifest.dial.destinations", `${path}.dial.destinations`);
261
261
  }
262
262
  }
263
263
  for (const flag of ["calendar", "contacts"]) {
@@ -312,34 +312,34 @@ export function validateManifest(manifest, path = "manifest") {
312
312
  }
313
313
  }
314
314
  }
315
- if (manifest.orgTiers !== undefined) {
316
- if (!Array.isArray(manifest.orgTiers)) {
317
- into.add("manifest.orgTiers.shape", `${path}.orgTiers`, "orgTiers must be an array when present");
315
+ if (manifest.orgLevels !== undefined) {
316
+ if (!Array.isArray(manifest.orgLevels)) {
317
+ into.add("manifest.orgLevels.shape", `${path}.orgLevels`, "orgLevels must be an array when present");
318
318
  }
319
319
  else {
320
320
  const ids = new Set();
321
321
  let wellFormed = true;
322
- manifest.orgTiers.forEach((tier, index) => {
323
- const at = `${path}.orgTiers[${index}]`;
324
- if (!isPlainObject(tier)) {
325
- into.add("manifest.orgTier.shape", at, "each tier must be an object with an id and a label");
322
+ manifest.orgLevels.forEach((level, index) => {
323
+ const at = `${path}.orgLevels[${index}]`;
324
+ if (!isPlainObject(level)) {
325
+ into.add("manifest.orgLevel.shape", at, "each level must be an object with an id and a label");
326
326
  wellFormed = false;
327
327
  return;
328
328
  }
329
- if (into.filled(tier.id, "manifest.orgTier.id", `${at}.id`, "a tier needs an id")) {
330
- if (ids.has(tier.id)) {
331
- into.add("manifest.orgTier.unique", `${at}.id`, `duplicate tier: ${tier.id}`);
329
+ if (into.filled(level.id, "manifest.orgLevel.id", `${at}.id`, "a level needs an id")) {
330
+ if (ids.has(level.id)) {
331
+ into.add("manifest.orgLevel.unique", `${at}.id`, `duplicate level: ${level.id}`);
332
332
  wellFormed = false;
333
333
  }
334
- ids.add(tier.id);
334
+ ids.add(level.id);
335
335
  }
336
336
  else
337
337
  wellFormed = false;
338
- if (!into.filled(tier.label, "manifest.orgTier.label", `${at}.label`, "a tier needs the label a desk shows for it"))
338
+ if (!into.filled(level.label, "manifest.orgLevel.label", `${at}.label`, "a level needs the label a desk shows for it"))
339
339
  wellFormed = false;
340
340
  });
341
341
  if (wellFormed && !ids.has("person")) {
342
- into.add("manifest.orgTiers.person", `${path}.orgTiers`, "a declared ladder states the whole ladder and must include person, the subject of every resolution");
342
+ into.add("manifest.orgLevels.person", `${path}.orgLevels`, "a declared ladder states the whole ladder and must include person, the subject of every resolution");
343
343
  }
344
344
  }
345
345
  }
@@ -460,7 +460,7 @@ function validateCustomCapabilities(value, path, into) {
460
460
  into.add("task.custom.ui", `${at}.ui`, "a custom capability needs a ui description");
461
461
  return;
462
462
  }
463
- into.oneOf(custom.ui.kind, CUSTOM_UI_KINDS, "task.custom.ui.kind", `${at}.ui.kind`);
463
+ into.oneOf(custom.ui.control, CUSTOM_UI_CONTROLS, "task.custom.ui.control", `${at}.ui.control`);
464
464
  into.filled(custom.ui.label, "task.custom.ui.label", `${at}.ui.label`, "a custom control needs a label");
465
465
  into.oneOf(custom.ui.placement, CUSTOM_UI_PLACEMENTS, "task.custom.ui.placement", `${at}.ui.placement`);
466
466
  if (custom.ui.render !== undefined)
@@ -485,48 +485,48 @@ function validateCustomCapabilities(value, path, into) {
485
485
  }
486
486
  });
487
487
  }
488
- const DEFAULT_TIER_IDS = DEFAULT_TIERS.map(tier => tier.id);
488
+ const DEFAULT_LEVEL_IDS = DEFAULT_LEVELS.map(level => level.id);
489
489
  const POLICY_SETTINGS = membersOf({ on: true, off: true, agent: true });
490
490
  const POLICY_KEYS = new Set([
491
491
  ...TASK_CAPABILITIES.voice, ...TASK_CAPABILITIES.chat, ...TASK_CAPABILITIES.email, "dial",
492
492
  ].filter(name => name !== "browsers" && name !== "dispositions" && name !== "custom"));
493
493
  const AGENT_SETTABLE = /^(hold|mute|skill:.+)$/;
494
494
  const isLocked = (value) => isPlainObject(value) && value.lockedBy !== undefined;
495
- /** The tier ids in force: the manifest's, or the defaults when the caller holds no manifest. */
496
- const tierIds = (tiers) => tiers ?? DEFAULT_TIER_IDS;
497
- /** `lockedBy`: a declared tier other than `person`, who never locks their own value. */
498
- function validateLockedByInto(value, rule, path, tiers, into) {
499
- if (!into.filled(value, rule, path, "lockedBy names the tier that locked it"))
495
+ /** The level ids in force: the manifest's, or the defaults when the caller holds no manifest. */
496
+ const levelIds = (levels) => levels ?? DEFAULT_LEVEL_IDS;
497
+ /** `lockedBy`: a declared level other than `person`, who never locks their own value. */
498
+ function validateLockedByInto(value, rule, path, levels, into) {
499
+ if (!into.filled(value, rule, path, "lockedBy names the level that locked it"))
500
500
  return;
501
501
  into.require(value !== "person", `${rule}.person`, path, "a person never locks their own value");
502
- into.require(tierIds(tiers).includes(value), `${rule}.unknown`, path, `${String(value)} is not a tier this manifest declares: in force are ${tierIds(tiers).join(", ")}`);
502
+ into.require(levelIds(levels).includes(value), `${rule}.unknown`, path, `${String(value)} is not a level this manifest declares: in force are ${levelIds(levels).join(", ")}`);
503
503
  }
504
504
  /** `{ lockedBy, reason? }` standing in for a value: who locked it, and a reason if given. */
505
- function validateLockedInto(value, rule, path, tiers, into) {
506
- validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`, tiers, into);
505
+ function validateLockedInto(value, rule, path, levels, into) {
506
+ validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`, levels, into);
507
507
  if (value.reason !== undefined)
508
508
  into.filled(value.reason, `${rule}.reason`, `${path}.reason`, "a reason must not be empty when present");
509
509
  }
510
510
  /** What every resolved value carries: who set it, and who locked it if anyone. */
511
- function validateResolvedInto(value, rule, path, tiers, into) {
512
- if (into.filled(value.setBy, `${rule}.setBy`, `${path}.setBy`, "setBy names who stated the value: a tier, or provisioning")) {
513
- into.require(value.setBy === "provisioning" || tierIds(tiers).includes(value.setBy), `${rule}.setBy.unknown`, `${path}.setBy`, `${String(value.setBy)} is neither provisioning nor a tier this manifest declares`);
511
+ function validateResolvedInto(value, rule, path, levels, into) {
512
+ if (into.filled(value.setBy, `${rule}.setBy`, `${path}.setBy`, "setBy names who stated the value: a level, or provisioning")) {
513
+ into.require(value.setBy === "provider" || levelIds(levels).includes(value.setBy), `${rule}.setBy.unknown`, `${path}.setBy`, `${String(value.setBy)} is neither provisioning nor a level this manifest declares`);
514
514
  }
515
515
  if (value.lockedBy !== undefined)
516
- validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`, tiers, into);
516
+ validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`, levels, into);
517
517
  if (value.reason !== undefined) {
518
518
  into.filled(value.reason, `${rule}.reason`, `${path}.reason`, "a reason must not be empty when present");
519
519
  into.require(value.lockedBy !== undefined, `${rule}.reason.unexpected`, `${path}.reason`, "a reason goes with lockedBy: it says why it was locked");
520
520
  }
521
521
  }
522
- /** The tier ids a manifest puts in force, for validators that receive one. */
523
- function manifestTiers(manifest) {
522
+ /** The level ids a manifest puts in force, for validators that receive one. */
523
+ function manifestLevels(manifest) {
524
524
  if (!isPlainObject(manifest))
525
525
  return undefined;
526
- const declared = Array.isArray(manifest.orgTiers)
527
- ? manifest.orgTiers.filter((tier) => isPlainObject(tier) && typeof tier.id === "string")
526
+ const declared = Array.isArray(manifest.orgLevels)
527
+ ? manifest.orgLevels.filter((level) => isPlainObject(level) && typeof level.id === "string")
528
528
  : undefined;
529
- return effectiveTiers(declared).map(tier => tier.id);
529
+ return effectiveLevels(declared).map(level => level.id);
530
530
  }
531
531
  const CUSTOM_RENDERS = membersOf({ inline: true, page: true });
532
532
  const CREDENTIAL_FIELD_TYPES = membersOf({ text: true, password: true });
@@ -556,6 +556,9 @@ function validateBrowsers(value, path, into) {
556
556
  names.add(browser.name);
557
557
  }
558
558
  into.filled(browser.purpose, "task.browser.purpose", `${at}.purpose`, "a browser needs a purpose");
559
+ if (browser.urlVisibility !== undefined) {
560
+ into.oneOf(browser.urlVisibility, URL_VISIBILITIES, "task.browser.urlVisibility", `${at}.urlVisibility`);
561
+ }
559
562
  if (into.filled(browser.url, "task.browser.url", `${at}.url`, "a browser needs a url")) {
560
563
  let scheme;
561
564
  try {
@@ -649,7 +652,7 @@ function validateHandlingHistory(value, path, into) {
649
652
  });
650
653
  }
651
654
  /** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
652
- const TASK_MEDIA_STATES = membersOf({ ready: true, ended: true });
655
+ const TASK_MEDIA_STATES = membersOf({ started: true, ended: true });
653
656
  /** Real-time media is a voice affair, and its state is one of two words. */
654
657
  function validateTaskMedia(value, channel, path, into) {
655
658
  if (value === undefined)
@@ -674,7 +677,7 @@ function validateConsultation(value, channel, path, into) {
674
677
  if (value.since !== undefined)
675
678
  into.timestamp(value.since, "task.consultation.since", `${path}.since`);
676
679
  }
677
- const LEAD_STATUSES = membersOf({ requested: true, joined: true });
680
+ const LEAD_STAGES = membersOf({ requested: true, joined: true });
678
681
  /** The agent's request for a lead. Voice only; `joined` names the lead, `requested` cannot. */
679
682
  function validateLead(value, channel, path, into) {
680
683
  if (value === undefined)
@@ -685,8 +688,8 @@ function validateLead(value, channel, path, into) {
685
688
  into.add("task.lead.shape", path, "lead must be an object when present");
686
689
  return;
687
690
  }
688
- if (into.oneOf(value.status, LEAD_STATUSES, "task.lead.status", `${path}.status`)) {
689
- if (value.status === "joined") {
691
+ if (into.oneOf(value.stage, LEAD_STAGES, "task.lead.stage", `${path}.stage`)) {
692
+ if (value.stage === "joined") {
690
693
  into.require(isUserId(value.leadId), "task.lead.leadId", `${path}.leadId`, "a joined lead is named by their user id");
691
694
  }
692
695
  else {
@@ -730,11 +733,11 @@ function validateTaskInto(task, context, path, into) {
730
733
  // The allowance is coupled to the mode: a provider that will complete the task itself is going
731
734
  // to act on the allowance, so it must state one; a provider waiting for `complete` may omit it
732
735
  // to say it imposes no deadline. Present, it is a duration either way.
733
- if (task.completionAllowance === undefined) {
734
- into.require(task.completionMode !== "provider-automatic", "task.completionAllowance.required", `${path}.completionAllowance`, "provider-automatic completion needs an allowance to act on");
736
+ if (task.wrapAllowance === undefined) {
737
+ into.require(task.completionMode !== "provider-automatic", "task.wrapAllowance.required", `${path}.wrapAllowance`, "provider-automatic completion needs an allowance to act on");
735
738
  }
736
739
  else {
737
- into.require(isDurationSeconds(task.completionAllowance), "task.completionAllowance", `${path}.completionAllowance`, "completionAllowance must be a whole number of seconds, zero or more, or omitted under agent-command");
740
+ 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");
738
741
  }
739
742
  // The channel is fixed per provider by its manifest, so a task claiming another one is a
740
743
  // task Omni would render with the wrong controls.
@@ -743,7 +746,7 @@ function validateTaskInto(task, context, path, into) {
743
746
  into.filled(task.reference, "task.reference", `${path}.reference`, "a reference must not be empty when present");
744
747
  }
745
748
  if (task.contact !== undefined)
746
- validateContactInto(task.contact, `${path}.contact`, into, context.tiers);
749
+ validateContactInto(task.contact, `${path}.contact`, into, context.levels);
747
750
  validateBrowsers(task.browsers, `${path}.browsers`, into);
748
751
  validateTaskAttributes(task.attributes, `${path}.attributes`, into);
749
752
  validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into);
@@ -775,7 +778,7 @@ function validateTaskInto(task, context, path, into) {
775
778
  // queue provides -- browsers, dispositions, custom controls -- is content, not a control.
776
779
  if (isLocked(declared)) {
777
780
  if (into.require(name !== "browsers" && name !== "dispositions" && name !== "custom", "task.capability.locked.unexpected", `${path}.capabilities.${name}`, `${name} is what the queue provides, not a control anyone locks`)) {
778
- validateLockedInto(declared, "task.capability.locked", `${path}.capabilities.${name}`, context.tiers, into);
781
+ validateLockedInto(declared, "task.capability.locked", `${path}.capabilities.${name}`, context.levels, into);
779
782
  }
780
783
  continue;
781
784
  }
@@ -826,15 +829,15 @@ function validateBreakState(value, path, into) {
826
829
  return;
827
830
  }
828
831
  into.oneOf(value.approval, BREAK_APPROVALS, "break.approval", `${path}.approval`);
829
- into.require(typeof value.accepting === "boolean", "break.accepting", `${path}.accepting`, "accepting must be a boolean");
832
+ into.require(typeof value.mayAsk === "boolean", "break.mayAsk", `${path}.mayAsk`, "mayAsk says whether the agent may ask for a break: a boolean");
830
833
  for (const field of ["refusedReason", "decisionReason"]) {
831
834
  if (value[field] !== undefined) {
832
835
  into.filled(value[field], `break.${field}`, `${path}.${field}`, `${field} must not be empty when present`);
833
836
  }
834
837
  }
835
- // The refusal is the reason the control is withdrawn; beside `accepting: true` it explains nothing.
838
+ // The refusal is the reason the control is withdrawn; beside `mayAsk: true` it explains nothing.
836
839
  if (value.refusedReason !== undefined) {
837
- into.require(value.accepting !== true, "break.refusedReason.accepting", `${path}.refusedReason`, "refusedReason is shown when accepting is false; omit it while the agent may ask");
840
+ into.require(value.mayAsk !== true, "break.refusedReason.mayAsk", `${path}.refusedReason`, "refusedReason is shown when mayAsk is false; omit it while the agent may ask");
838
841
  }
839
842
  if (value.retryAfterMs !== undefined) {
840
843
  into.require(typeof value.retryAfterMs === "number" && Number.isFinite(value.retryAfterMs) && value.retryAfterMs >= 0, "break.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
@@ -910,7 +913,7 @@ function validateTeamRosterInto(roster, path, context, into) {
910
913
  }
911
914
  }
912
915
  if (roster.policies !== undefined)
913
- validateTeamPoliciesInto(roster.policies, `${path}.policies`, into, context.tiers);
916
+ validateTeamPoliciesInto(roster.policies, `${path}.policies`, into, context.levels);
914
917
  if (roster.requests === undefined) {
915
918
  // `[]` says nobody is asking; omission says the lead may not be asked. A login that may be
916
919
  // asked therefore always carries the list.
@@ -986,11 +989,11 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
986
989
  return into.violations;
987
990
  }
988
991
  const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
989
- const tiers = context.tiers ?? manifestTiers(manifest);
990
- into.oneOf(snapshot.status, CONNECTION_STATUSES, "snapshot.status", `${path}.status`);
991
- if (into.filled(snapshot.sessionId, "snapshot.sessionId", `${path}.sessionId`, "a snapshot needs the session id it belongs to")
992
- && context.sessionId !== undefined) {
993
- into.require(snapshot.sessionId === context.sessionId, "snapshot.sessionId.mismatch", `${path}.sessionId`, `a snapshot for session ${String(snapshot.sessionId)} on a login whose session is ${context.sessionId}`);
992
+ const levels = context.levels ?? manifestLevels(manifest);
993
+ into.oneOf(snapshot.transport, TRANSPORT_STATUSES, "snapshot.transport", `${path}.status`);
994
+ if (into.filled(snapshot.loginId, "snapshot.loginId", `${path}.loginId`, "a snapshot needs the login id it belongs to")
995
+ && context.loginId !== undefined) {
996
+ into.require(snapshot.loginId === context.loginId, "snapshot.loginId.mismatch", `${path}.loginId`, `a snapshot for session ${String(snapshot.loginId)} on a login whose session is ${context.loginId}`);
994
997
  }
995
998
  validateBreakState(snapshot.break, `${path}.break`, into);
996
999
  // The count is the provider's confirmation of how much work it answered with. Stated, never
@@ -1006,7 +1009,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1006
1009
  const seen = new Set();
1007
1010
  let assisting;
1008
1011
  snapshot.tasks.forEach((task, index) => {
1009
- validateTaskInto(task, { channel, tiers }, `${path}.tasks[${index}]`, into);
1012
+ validateTaskInto(task, { channel, levels }, `${path}.tasks[${index}]`, into);
1010
1013
  // A lead assists one call at a time.
1011
1014
  if (isPlainObject(task) && task.assisting !== undefined) {
1012
1015
  if (assisting !== undefined)
@@ -1071,7 +1074,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1071
1074
  into.add("team.required", `${path}.team`, "the login declares capabilities.team, so every snapshot carries a roster: [] when nobody is in it");
1072
1075
  }
1073
1076
  if (snapshot.team !== undefined)
1074
- validateTeamRosterInto(snapshot.team, `${path}.team`, { ...context, tiers }, into);
1077
+ validateTeamRosterInto(snapshot.team, `${path}.team`, { ...context, levels }, into);
1075
1078
  return into.violations;
1076
1079
  }
1077
1080
  // ---------------------------------------------------------------------------
@@ -1155,11 +1158,11 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1155
1158
  return into.violations;
1156
1159
  }
1157
1160
  const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
1158
- const tiers = context.tiers ?? manifestTiers(manifest);
1161
+ const levels = context.levels ?? manifestLevels(manifest);
1159
1162
  into.filled(envelope.id, "event.id", `${path}.id`, "an event needs an id");
1160
- if (into.filled(envelope.sessionId, "event.sessionId", `${path}.sessionId`, "an event needs the session id it belongs to")
1161
- && context.sessionId !== undefined) {
1162
- into.require(envelope.sessionId === context.sessionId, "event.sessionId.mismatch", `${path}.sessionId`, `an event for session ${String(envelope.sessionId)} on a login whose session is ${context.sessionId}`);
1163
+ if (into.filled(envelope.loginId, "event.loginId", `${path}.loginId`, "an event needs the login id it belongs to")
1164
+ && context.loginId !== undefined) {
1165
+ into.require(envelope.loginId === context.loginId, "event.loginId.mismatch", `${path}.loginId`, `an event for session ${String(envelope.loginId)} on a login whose session is ${context.loginId}`);
1163
1166
  }
1164
1167
  const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
1165
1168
  into.timestamp(envelope.occurredAt, "event.occurredAt", `${path}.occurredAt`);
@@ -1172,28 +1175,28 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1172
1175
  switch (event.type) {
1173
1176
  case "snapshot":
1174
1177
  into.oneOf(event.reason, SNAPSHOT_REASONS, "event.snapshot.reason", `${at}.reason`);
1175
- into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`, { ...context, tiers }));
1178
+ into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`, { ...context, levels }));
1176
1179
  break;
1177
- case "provider-status":
1178
- into.oneOf(event.status, CONNECTION_STATUSES, "event.providerStatus.status", `${at}.status`);
1180
+ case "transport-status":
1181
+ into.oneOf(event.status, TRANSPORT_STATUSES, "event.transportStatus.status", `${at}.status`);
1179
1182
  // An error says how to revive it; any other status has nothing to revive.
1180
1183
  if (event.status === "error") {
1181
- if (into.require(event.recovery !== undefined, "event.providerStatus.recovery.required", `${at}.recovery`, "an error names its recovery: reconnect, or reauthenticate")) {
1182
- into.oneOf(event.recovery, CONNECTION_RECOVERIES, "event.providerStatus.recovery", `${at}.recovery`);
1184
+ if (into.require(event.recovery !== undefined, "event.transportStatus.recovery.required", `${at}.recovery`, "an error names its recovery: reconnect, or reauthenticate")) {
1185
+ into.oneOf(event.recovery, TRANSPORT_RECOVERIES, "event.transportStatus.recovery", `${at}.recovery`);
1183
1186
  }
1184
1187
  }
1185
1188
  else {
1186
- into.require(event.recovery === undefined, "event.providerStatus.recovery.unexpected", `${at}.recovery`, "recovery goes with an error; nothing needs reviving here");
1189
+ into.require(event.recovery === undefined, "event.transportStatus.recovery.unexpected", `${at}.recovery`, "recovery goes with an error; nothing needs reviving here");
1187
1190
  }
1188
1191
  if (event.message !== undefined) {
1189
- into.filled(event.message, "event.providerStatus.message", `${at}.message`, "a message must not be empty when present");
1192
+ into.filled(event.message, "event.transportStatus.message", `${at}.message`, "a message must not be empty when present");
1190
1193
  }
1191
1194
  break;
1192
1195
  case "break-state":
1193
1196
  validateBreakState(event.break, `${at}.break`, into);
1194
1197
  break;
1195
1198
  case "task-offered":
1196
- validateTaskInto(event.task, { channel, tiers }, `${at}.task`, into);
1199
+ validateTaskInto(event.task, { channel, levels }, `${at}.task`, into);
1197
1200
  // An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
1198
1201
  if (isPlainObject(event.task) && typeof event.task.phase === "string") {
1199
1202
  into.require(OFFERABLE_PHASES.includes(event.task.phase), "event.taskOffered.phase", `${at}.task.phase`, `task-offered introduces a task as ${OFFERABLE_PHASES.join(", ")}, never as ${event.task.phase}`);
@@ -1214,10 +1217,10 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1214
1217
  }
1215
1218
  break;
1216
1219
  case "task-updated":
1217
- validateTaskInto(event.task, { channel, tiers }, `${at}.task`, into);
1220
+ validateTaskInto(event.task, { channel, levels }, `${at}.task`, into);
1218
1221
  break;
1219
- case "task-media-ready":
1220
- into.require(isTaskId(event.taskId), "event.taskMediaReady.taskId", `${at}.taskId`, "a task id is required");
1222
+ case "task-media-started":
1223
+ into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
1221
1224
  break;
1222
1225
  case "task-media-ended":
1223
1226
  into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
@@ -1239,7 +1242,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1239
1242
  validateProviderSummary(event.summary, `${at}.summary`, into);
1240
1243
  break;
1241
1244
  case "team-updated":
1242
- validateTeamRosterInto(event.team, `${at}.team`, { ...context, tiers }, into);
1245
+ validateTeamRosterInto(event.team, `${at}.team`, { ...context, levels }, into);
1243
1246
  break;
1244
1247
  case "contacts-updated":
1245
1248
  into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
@@ -1278,7 +1281,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1278
1281
  // ---------------------------------------------------------------------------
1279
1282
  const PREFERENCE_ID = /^(hold|mute|skill:.+)$/;
1280
1283
  /** The choices left to the person, each with where it stands. */
1281
- function validatePreferencesInto(value, path, into, tiers) {
1284
+ function validatePreferencesInto(value, path, into, levels) {
1282
1285
  if (!Array.isArray(value)) {
1283
1286
  into.add("preferences.shape", path, "preferences must be an array");
1284
1287
  return;
@@ -1297,11 +1300,11 @@ function validatePreferencesInto(value, path, into, tiers) {
1297
1300
  }
1298
1301
  into.filled(preference.label, "preference.label", `${at}.label`, "a preference needs a label");
1299
1302
  into.require(typeof preference.enabled === "boolean", "preference.enabled", `${at}.enabled`, "a preference says where it stands");
1300
- validateResolvedInto(preference, "preference", at, tiers, into);
1303
+ validateResolvedInto(preference, "preference", at, levels, into);
1301
1304
  });
1302
1305
  }
1303
1306
  /** The team's policy per capability as the lead sees it: the setting, who set it, who locked it. */
1304
- function validateTeamPoliciesInto(value, path, into, tiers) {
1307
+ function validateTeamPoliciesInto(value, path, into, levels) {
1305
1308
  if (!isPlainObject(value)) {
1306
1309
  into.add("team.policies.shape", path, "policies must be an object keyed by capability");
1307
1310
  return;
@@ -1319,7 +1322,7 @@ function validateTeamPoliciesInto(value, path, into, tiers) {
1319
1322
  if (into.oneOf(policy.setting, POLICY_SETTINGS, "team.policy.setting", `${at}.setting`)) {
1320
1323
  into.require(policy.setting !== "agent" || AGENT_SETTABLE.test(key), "team.policy.agent", `${at}.setting`, `${key} is the team's, on or off; only hold, mute and skills may be left to the person`);
1321
1324
  }
1322
- validateResolvedInto(policy, "team.policy", at, tiers, into);
1325
+ validateResolvedInto(policy, "team.policy", at, levels, into);
1323
1326
  into.require(policy.setBy !== "person", "team.policy.setBy", `${at}.setBy`, "a team policy is not set by a person");
1324
1327
  }
1325
1328
  }
@@ -1365,12 +1368,6 @@ export function validateHostReport(report, path = "host") {
1365
1368
  return into.violations;
1366
1369
  }
1367
1370
  into.require(typeof report.online === "boolean", "host.online", `${path}.online`, "a host report says whether it has a network");
1368
- if (!isPlainObject(report.browsers)) {
1369
- into.add("host.browsers.shape", `${path}.browsers`, "a host report says what its chrome shows of a task browser's URL");
1370
- }
1371
- else {
1372
- into.oneOf(report.browsers.urlVisibility, URL_VISIBILITIES, "host.browsers.urlVisibility", `${path}.browsers.urlVisibility`);
1373
- }
1374
1371
  if (report.audio === undefined)
1375
1372
  return into.violations;
1376
1373
  if (!isPlainObject(report.audio)) {
@@ -1474,7 +1471,7 @@ function validateUser(value, rule, path, into) {
1474
1471
  into.require(isUserId(value.id), `${rule}.id`, `${path}.id`, "an identity needs a provider-issued user id");
1475
1472
  into.filled(value.displayName, `${rule}.displayName`, `${path}.displayName`, "an identity needs a display name");
1476
1473
  }
1477
- function validateSessionCapabilitiesInto(value, path, into, tiers) {
1474
+ function validateUserCapabilitiesInto(value, path, into, levels) {
1478
1475
  if (!isPlainObject(value)) {
1479
1476
  into.add("authentication.capabilities.shape", path, "a usable login declares its capabilities: an object, {} when it has none");
1480
1477
  return;
@@ -1488,7 +1485,7 @@ function validateSessionCapabilitiesInto(value, path, into, tiers) {
1488
1485
  if (Array.isArray(declared) && declared.length === 0) {
1489
1486
  into.add("authentication.capability.preferences.empty", `${path}.preferences`, "a login with nothing left to the person omits preferences rather than declaring an empty list");
1490
1487
  }
1491
- validatePreferencesInto(declared, `${path}.preferences`, into, tiers);
1488
+ validatePreferencesInto(declared, `${path}.preferences`, into, levels);
1492
1489
  continue;
1493
1490
  }
1494
1491
  if (name === "team") {
@@ -1521,7 +1518,7 @@ export function validateAuthenticationState(state, path = "authentication", cont
1521
1518
  // may carry an identity. Anything else is a state claiming knowledge it does not have.
1522
1519
  if (state.status === "authenticated" || state.status === "refreshing") {
1523
1520
  validateUser(state.identity, "authentication.identity", `${path}.identity`, into);
1524
- validateSessionCapabilitiesInto(state.capabilities, `${path}.capabilities`, into, context.tiers);
1521
+ validateUserCapabilitiesInto(state.capabilities, `${path}.capabilities`, into, context.levels);
1525
1522
  }
1526
1523
  else if (state.status === "expired") {
1527
1524
  if (state.identity !== undefined)