@xema/omni-protocol 0.1.24 → 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,13 +989,19 @@ 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);
999
+ // The count is the provider's confirmation of how much work it answered with. Stated, never
1000
+ // inferred: an unanswered or blank state lacks it, and cannot pass as a confirmed empty.
1001
+ if (into.require(typeof snapshot.taskCount === "number" && Number.isInteger(snapshot.taskCount) && snapshot.taskCount >= 0, "snapshot.taskCount", `${path}.taskCount`, "a snapshot states its task count: a whole number, zero or more")
1002
+ && Array.isArray(snapshot.tasks)) {
1003
+ into.require(snapshot.taskCount === snapshot.tasks.length, "snapshot.taskCount.mismatch", `${path}.taskCount`, `taskCount says ${snapshot.taskCount} and tasks carries ${snapshot.tasks.length}: a count that does not reconcile is an answer nobody gave`);
1004
+ }
996
1005
  if (!Array.isArray(snapshot.tasks)) {
997
1006
  into.add("snapshot.tasks.shape", `${path}.tasks`, "a snapshot must carry a tasks array");
998
1007
  }
@@ -1000,7 +1009,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1000
1009
  const seen = new Set();
1001
1010
  let assisting;
1002
1011
  snapshot.tasks.forEach((task, index) => {
1003
- validateTaskInto(task, { channel, tiers }, `${path}.tasks[${index}]`, into);
1012
+ validateTaskInto(task, { channel, levels }, `${path}.tasks[${index}]`, into);
1004
1013
  // A lead assists one call at a time.
1005
1014
  if (isPlainObject(task) && task.assisting !== undefined) {
1006
1015
  if (assisting !== undefined)
@@ -1065,7 +1074,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1065
1074
  into.add("team.required", `${path}.team`, "the login declares capabilities.team, so every snapshot carries a roster: [] when nobody is in it");
1066
1075
  }
1067
1076
  if (snapshot.team !== undefined)
1068
- validateTeamRosterInto(snapshot.team, `${path}.team`, { ...context, tiers }, into);
1077
+ validateTeamRosterInto(snapshot.team, `${path}.team`, { ...context, levels }, into);
1069
1078
  return into.violations;
1070
1079
  }
1071
1080
  // ---------------------------------------------------------------------------
@@ -1149,11 +1158,11 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1149
1158
  return into.violations;
1150
1159
  }
1151
1160
  const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
1152
- const tiers = context.tiers ?? manifestTiers(manifest);
1161
+ const levels = context.levels ?? manifestLevels(manifest);
1153
1162
  into.filled(envelope.id, "event.id", `${path}.id`, "an event needs an id");
1154
- if (into.filled(envelope.sessionId, "event.sessionId", `${path}.sessionId`, "an event needs the session id it belongs to")
1155
- && context.sessionId !== undefined) {
1156
- 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}`);
1157
1166
  }
1158
1167
  const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
1159
1168
  into.timestamp(envelope.occurredAt, "event.occurredAt", `${path}.occurredAt`);
@@ -1166,28 +1175,28 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1166
1175
  switch (event.type) {
1167
1176
  case "snapshot":
1168
1177
  into.oneOf(event.reason, SNAPSHOT_REASONS, "event.snapshot.reason", `${at}.reason`);
1169
- into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`, { ...context, tiers }));
1178
+ into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`, { ...context, levels }));
1170
1179
  break;
1171
- case "provider-status":
1172
- 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`);
1173
1182
  // An error says how to revive it; any other status has nothing to revive.
1174
1183
  if (event.status === "error") {
1175
- if (into.require(event.recovery !== undefined, "event.providerStatus.recovery.required", `${at}.recovery`, "an error names its recovery: reconnect, or reauthenticate")) {
1176
- 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`);
1177
1186
  }
1178
1187
  }
1179
1188
  else {
1180
- 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");
1181
1190
  }
1182
1191
  if (event.message !== undefined) {
1183
- 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");
1184
1193
  }
1185
1194
  break;
1186
1195
  case "break-state":
1187
1196
  validateBreakState(event.break, `${at}.break`, into);
1188
1197
  break;
1189
1198
  case "task-offered":
1190
- validateTaskInto(event.task, { channel, tiers }, `${at}.task`, into);
1199
+ validateTaskInto(event.task, { channel, levels }, `${at}.task`, into);
1191
1200
  // An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
1192
1201
  if (isPlainObject(event.task) && typeof event.task.phase === "string") {
1193
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}`);
@@ -1208,10 +1217,10 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1208
1217
  }
1209
1218
  break;
1210
1219
  case "task-updated":
1211
- validateTaskInto(event.task, { channel, tiers }, `${at}.task`, into);
1220
+ validateTaskInto(event.task, { channel, levels }, `${at}.task`, into);
1212
1221
  break;
1213
- case "task-media-ready":
1214
- 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");
1215
1224
  break;
1216
1225
  case "task-media-ended":
1217
1226
  into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
@@ -1233,7 +1242,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1233
1242
  validateProviderSummary(event.summary, `${at}.summary`, into);
1234
1243
  break;
1235
1244
  case "team-updated":
1236
- validateTeamRosterInto(event.team, `${at}.team`, { ...context, tiers }, into);
1245
+ validateTeamRosterInto(event.team, `${at}.team`, { ...context, levels }, into);
1237
1246
  break;
1238
1247
  case "contacts-updated":
1239
1248
  into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
@@ -1272,7 +1281,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1272
1281
  // ---------------------------------------------------------------------------
1273
1282
  const PREFERENCE_ID = /^(hold|mute|skill:.+)$/;
1274
1283
  /** The choices left to the person, each with where it stands. */
1275
- function validatePreferencesInto(value, path, into, tiers) {
1284
+ function validatePreferencesInto(value, path, into, levels) {
1276
1285
  if (!Array.isArray(value)) {
1277
1286
  into.add("preferences.shape", path, "preferences must be an array");
1278
1287
  return;
@@ -1291,11 +1300,11 @@ function validatePreferencesInto(value, path, into, tiers) {
1291
1300
  }
1292
1301
  into.filled(preference.label, "preference.label", `${at}.label`, "a preference needs a label");
1293
1302
  into.require(typeof preference.enabled === "boolean", "preference.enabled", `${at}.enabled`, "a preference says where it stands");
1294
- validateResolvedInto(preference, "preference", at, tiers, into);
1303
+ validateResolvedInto(preference, "preference", at, levels, into);
1295
1304
  });
1296
1305
  }
1297
1306
  /** The team's policy per capability as the lead sees it: the setting, who set it, who locked it. */
1298
- function validateTeamPoliciesInto(value, path, into, tiers) {
1307
+ function validateTeamPoliciesInto(value, path, into, levels) {
1299
1308
  if (!isPlainObject(value)) {
1300
1309
  into.add("team.policies.shape", path, "policies must be an object keyed by capability");
1301
1310
  return;
@@ -1313,7 +1322,7 @@ function validateTeamPoliciesInto(value, path, into, tiers) {
1313
1322
  if (into.oneOf(policy.setting, POLICY_SETTINGS, "team.policy.setting", `${at}.setting`)) {
1314
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`);
1315
1324
  }
1316
- validateResolvedInto(policy, "team.policy", at, tiers, into);
1325
+ validateResolvedInto(policy, "team.policy", at, levels, into);
1317
1326
  into.require(policy.setBy !== "person", "team.policy.setBy", `${at}.setBy`, "a team policy is not set by a person");
1318
1327
  }
1319
1328
  }
@@ -1359,12 +1368,6 @@ export function validateHostReport(report, path = "host") {
1359
1368
  return into.violations;
1360
1369
  }
1361
1370
  into.require(typeof report.online === "boolean", "host.online", `${path}.online`, "a host report says whether it has a network");
1362
- if (!isPlainObject(report.browsers)) {
1363
- into.add("host.browsers.shape", `${path}.browsers`, "a host report says what its chrome shows of a task browser's URL");
1364
- }
1365
- else {
1366
- into.oneOf(report.browsers.urlVisibility, URL_VISIBILITIES, "host.browsers.urlVisibility", `${path}.browsers.urlVisibility`);
1367
- }
1368
1371
  if (report.audio === undefined)
1369
1372
  return into.violations;
1370
1373
  if (!isPlainObject(report.audio)) {
@@ -1468,7 +1471,7 @@ function validateUser(value, rule, path, into) {
1468
1471
  into.require(isUserId(value.id), `${rule}.id`, `${path}.id`, "an identity needs a provider-issued user id");
1469
1472
  into.filled(value.displayName, `${rule}.displayName`, `${path}.displayName`, "an identity needs a display name");
1470
1473
  }
1471
- function validateSessionCapabilitiesInto(value, path, into, tiers) {
1474
+ function validateUserCapabilitiesInto(value, path, into, levels) {
1472
1475
  if (!isPlainObject(value)) {
1473
1476
  into.add("authentication.capabilities.shape", path, "a usable login declares its capabilities: an object, {} when it has none");
1474
1477
  return;
@@ -1482,7 +1485,7 @@ function validateSessionCapabilitiesInto(value, path, into, tiers) {
1482
1485
  if (Array.isArray(declared) && declared.length === 0) {
1483
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");
1484
1487
  }
1485
- validatePreferencesInto(declared, `${path}.preferences`, into, tiers);
1488
+ validatePreferencesInto(declared, `${path}.preferences`, into, levels);
1486
1489
  continue;
1487
1490
  }
1488
1491
  if (name === "team") {
@@ -1515,7 +1518,7 @@ export function validateAuthenticationState(state, path = "authentication", cont
1515
1518
  // may carry an identity. Anything else is a state claiming knowledge it does not have.
1516
1519
  if (state.status === "authenticated" || state.status === "refreshing") {
1517
1520
  validateUser(state.identity, "authentication.identity", `${path}.identity`, into);
1518
- validateSessionCapabilitiesInto(state.capabilities, `${path}.capabilities`, into, context.tiers);
1521
+ validateUserCapabilitiesInto(state.capabilities, `${path}.capabilities`, into, context.levels);
1519
1522
  }
1520
1523
  else if (state.status === "expired") {
1521
1524
  if (state.identity !== undefined)