@xema/omni-protocol 0.1.15 → 0.1.16

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.
package/dist/testing.js CHANGED
@@ -59,8 +59,11 @@ function observeTask(value, seen) {
59
59
  const capabilities = isRecord(value.capabilities) ? value.capabilities : {};
60
60
  if (isRecord(capabilities.dispositions))
61
61
  seen.add("task.dispositions");
62
- if (isRecord(capabilities.blindTransfer) && some(capabilities.blindTransfer.destinations))
63
- seen.add("task.destinations");
62
+ for (const directory of ["blindTransfer", "consultTransfer", "conference"]) {
63
+ const declared = capabilities[directory];
64
+ if (isRecord(declared) && some(declared.destinations))
65
+ seen.add("task.destinations");
66
+ }
64
67
  if (some(capabilities.custom))
65
68
  seen.add("task.custom");
66
69
  }
@@ -173,7 +176,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
173
176
  throw new Error("unreachable: the exercise has an authenticated login");
174
177
  return login;
175
178
  };
176
- const reader = () => ({ self: current().identity.id, capabilities: current().capabilities });
179
+ const reader = () => ({
180
+ self: current().identity.id,
181
+ capabilities: current().capabilities,
182
+ sessionId: context.sessionId,
183
+ autoAcceptTasks: context.autoAcceptTasks ?? true,
184
+ });
177
185
  // The optional methods are optional only until something declares a need for them. Each
178
186
  // check pairs a method with the declaration that requires it, as the guide's Live-connection
179
187
  // table does; a missing one is a control the agent would be shown and could never use.
@@ -229,6 +237,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
229
237
  unsubscribe = connection.subscribe(envelope => {
230
238
  observeEvent(envelope, seen);
231
239
  violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader()));
240
+ if (eventNamesUsers(envelope))
241
+ requireMethod(live, "describeUsers", "an event publishes a UserId");
232
242
  if (typeof envelope?.id === "string") {
233
243
  if (eventIds.has(envelope.id))
234
244
  return;
@@ -310,11 +320,29 @@ export async function exerciseAdapter(adapter, context, options = {}) {
310
320
  function publishesUserIds(snapshot) {
311
321
  if (snapshot?.break?.imposed?.by !== undefined)
312
322
  return true;
313
- if (Array.isArray(snapshot?.team?.members) && snapshot.team.members.length > 0)
323
+ if (teamNamesUsers(snapshot?.team))
314
324
  return true;
315
325
  if (!Array.isArray(snapshot?.tasks))
316
326
  return false;
317
- return snapshot.tasks.some(task => Array.isArray(task?.handlingHistory) && task.handlingHistory.some(step => step?.by !== undefined));
327
+ return snapshot.tasks.some(taskNamesUsers);
328
+ }
329
+ const teamNamesUsers = (team) => isRecord(team) && (some(team.members) || some(team.requests));
330
+ const taskNamesUsers = (task) => isRecord(task) && ((Array.isArray(task.handlingHistory) && task.handlingHistory.some(step => isRecord(step) && step.by !== undefined)) ||
331
+ (isRecord(task.lead) && task.lead.leadId !== undefined) ||
332
+ isRecord(task.assisting));
333
+ /** Whether an event publishes a `UserId`, on a roster, a task, or the snapshot a reconnect carries. */
334
+ function eventNamesUsers(envelope) {
335
+ const event = isRecord(envelope) ? envelope.event : undefined;
336
+ if (!isRecord(event))
337
+ return false;
338
+ switch (event.type) {
339
+ case "snapshot": return publishesUserIds(event.snapshot);
340
+ case "break-state": return isRecord(event.break) && isRecord(event.break.imposed) && event.break.imposed.by !== undefined;
341
+ case "task-offered":
342
+ case "task-updated": return taskNamesUsers(event.task);
343
+ case "team-updated": return teamNamesUsers(event.team);
344
+ default: return false;
345
+ }
318
346
  }
319
347
  /**
320
348
  * `refreshing` carries the identity and capabilities of the login it refreshes. A change to
@@ -30,6 +30,10 @@ export interface ReaderContext {
30
30
  * snapshot carries a roster, nobody else's does, and `requests` need `team.consultControl`.
31
31
  */
32
32
  capabilities?: SessionCapabilities;
33
+ /** The login's `sessionId`. A snapshot or event naming another belongs to a login that is gone. */
34
+ sessionId?: string;
35
+ /** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether `task-offered` carries an `acceptanceMode`. */
36
+ autoAcceptTasks?: boolean;
33
37
  }
34
38
  export declare function validateTeamRoster(roster: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
35
39
  export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
@@ -70,6 +70,12 @@ const SNAPSHOT_REASONS = membersOf({
70
70
  reconnected: true, "provider-requested": true,
71
71
  });
72
72
  const SESSION_CAPABILITIES = membersOf({ breaks: true, team: true });
73
+ const MEMBER_BREAKS = membersOf({
74
+ "awaiting-decision": true, granted: true, "starting-after-task": true,
75
+ });
76
+ const OFFERABLE_PHASES = membersOf({
77
+ pending: true, confirmed: true, preparing: true,
78
+ });
73
79
  const TEAM_CAPABILITIES = membersOf({ breakControl: true, consultControl: true });
74
80
  const COMPLETED_BY = membersOf({ agent: true, provider: true });
75
81
  const EXPIRABLE_PHASES = membersOf({
@@ -283,7 +289,9 @@ export function validateManifest(manifest, path = "manifest") {
283
289
  }
284
290
  else {
285
291
  methods.forEach((method, index) => {
286
- into.oneOf(method, AUTHENTICATION_METHODS, "manifest.authenticationMethod", `${path}.authenticationMethods[${index}]`);
292
+ if (into.oneOf(method, AUTHENTICATION_METHODS, "manifest.authenticationMethod", `${path}.authenticationMethods[${index}]`) && methods.indexOf(method) !== index) {
293
+ into.add("manifest.authenticationMethod.unique", `${path}.authenticationMethods[${index}]`, `duplicate authentication method: ${String(method)}`);
294
+ }
287
295
  });
288
296
  }
289
297
  if (channelValid)
@@ -331,19 +339,28 @@ function validateDestinationDirectory(value, path, into) {
331
339
  return;
332
340
  }
333
341
  into.require(typeof value.allowManualEntry === "boolean", "task.destinations.allowManualEntry", `${path}.allowManualEntry`, "a directory must say whether manual entry is allowed");
342
+ // A directory with nothing in it and no typing is a control with nothing to offer.
343
+ if (value.allowManualEntry === false && !(Array.isArray(value.destinations) && value.destinations.length > 0)) {
344
+ into.add("task.destinations.offer", path, "a directory with no destinations must allow manual entry, or the control has nothing to offer");
345
+ }
334
346
  if (value.destinations === undefined)
335
347
  return;
336
348
  if (!Array.isArray(value.destinations)) {
337
349
  into.add("task.destinations.list", `${path}.destinations`, "destinations must be an array when present");
338
350
  return;
339
351
  }
352
+ const seen = new Set();
340
353
  value.destinations.forEach((destination, index) => {
341
354
  const at = `${path}.destinations[${index}]`;
342
355
  if (!isPlainObject(destination)) {
343
356
  into.add("task.destination.shape", at, "each destination must be an object");
344
357
  return;
345
358
  }
346
- into.filled(destination.id, "task.destination.id", `${at}.id`, "a destination needs an id");
359
+ if (into.filled(destination.id, "task.destination.id", `${at}.id`, "a destination needs an id")) {
360
+ if (seen.has(destination.id))
361
+ into.add("task.destination.unique", `${at}.id`, `duplicate destination id: ${destination.id}`);
362
+ seen.add(destination.id);
363
+ }
347
364
  into.filled(destination.label, "task.destination.label", `${at}.label`, "a destination needs a label");
348
365
  into.filled(destination.address, "task.destination.address", `${at}.address`, "a destination needs an address");
349
366
  into.oneOf(destination.kind, DESTINATION_KINDS, "task.destination.kind", `${at}.kind`);
@@ -359,6 +376,10 @@ function validateDispositions(value, path, into) {
359
376
  if (value.required !== undefined) {
360
377
  into.require(typeof value.required === "boolean", "task.dispositions.required", `${path}.required`, "required must be a boolean when present");
361
378
  }
379
+ // A code must be collected before completion, so there must be one to collect.
380
+ if (value.required === true && !(Array.isArray(value.codes) && value.codes.length > 0)) {
381
+ into.add("task.dispositions.required.codes", `${path}.codes`, "a required disposition policy must publish at least one code");
382
+ }
362
383
  if (value.notes !== undefined)
363
384
  into.oneOf(value.notes, NOTES_POLICIES, "task.dispositions.notes", `${path}.notes`);
364
385
  if (value.codes === undefined)
@@ -414,6 +435,7 @@ function validateBrowsers(value, path, into) {
414
435
  return;
415
436
  }
416
437
  const seen = new Set();
438
+ const names = new Set();
417
439
  value.forEach((browser, index) => {
418
440
  const at = `${path}[${index}]`;
419
441
  if (!isPlainObject(browser)) {
@@ -425,7 +447,13 @@ function validateBrowsers(value, path, into) {
425
447
  into.add("task.browser.unique", `${at}.id`, `duplicate browser id: ${browser.id}`);
426
448
  seen.add(browser.id);
427
449
  }
428
- into.filled(browser.name, "task.browser.name", `${at}.name`, "a browser needs a name");
450
+ // The name is an input to a `TAB_NAME` isolation scheme: two tabs with one name would
451
+ // silently share a session.
452
+ if (into.filled(browser.name, "task.browser.name", `${at}.name`, "a browser needs a name")) {
453
+ if (names.has(browser.name))
454
+ into.add("task.browser.name.unique", `${at}.name`, `duplicate browser name: ${browser.name}`);
455
+ names.add(browser.name);
456
+ }
429
457
  into.filled(browser.purpose, "task.browser.purpose", `${at}.purpose`, "a browser needs a purpose");
430
458
  if (into.filled(browser.url, "task.browser.url", `${at}.url`, "a browser needs a url")) {
431
459
  let scheme;
@@ -463,13 +491,18 @@ function validateTaskAttributes(value, path, into) {
463
491
  into.add("task.attributes.shape", path, "attributes must be an array when present");
464
492
  return;
465
493
  }
494
+ const keys = new Set();
466
495
  value.forEach((attribute, index) => {
467
496
  const at = `${path}[${index}]`;
468
497
  if (!isPlainObject(attribute)) {
469
498
  into.add("task.attribute.shape", at, "each task attribute must be an object");
470
499
  return;
471
500
  }
472
- into.filled(attribute.key, "task.attribute.key", `${at}.key`, "a task attribute needs a key");
501
+ if (into.filled(attribute.key, "task.attribute.key", `${at}.key`, "a task attribute needs a key")) {
502
+ if (keys.has(attribute.key))
503
+ into.add("task.attribute.unique", `${at}.key`, `duplicate attribute key: ${attribute.key}`);
504
+ keys.add(attribute.key);
505
+ }
473
506
  if (attribute.label !== undefined) {
474
507
  into.filled(attribute.label, "task.attribute.label", `${at}.label`, "a label must not be empty when present");
475
508
  }
@@ -509,6 +542,8 @@ function validateHandlingHistory(value, path, into) {
509
542
  }
510
543
  if (entry.by !== undefined) {
511
544
  into.require(isUserId(entry.by), "task.handlingHistory.by", `${at}.by`, "by must be a non-empty user id; omit it when the person cannot be identified");
545
+ // On `queued` nobody takes part, so there is nothing to name.
546
+ into.require(entry.step !== "queued", "task.handlingHistory.by.unexpected", `${at}.by`, "a queued step names nobody");
512
547
  }
513
548
  });
514
549
  }
@@ -611,6 +646,15 @@ function validateTaskInto(task, context, path, into) {
611
646
  return;
612
647
  }
613
648
  const allowed = isChannel(context.channel) ? TASK_CAPABILITIES[context.channel] : TASK_CAPABILITIES.voice;
649
+ // A task that supplies browser definitions declares the capability that shows them, and one
650
+ // that declares it supplies at least one: the capability puts a panel in the workspace, and a
651
+ // panel with nothing in it is a control with nothing to offer.
652
+ if (Array.isArray(task.browsers) && task.browsers.length > 0 && capabilities.browsers !== true) {
653
+ into.add("task.browsers.capability", `${path}.browsers`, "a task that supplies browsers declares capabilities.browsers");
654
+ }
655
+ if (capabilities.browsers === true && Array.isArray(task.browsers) && task.browsers.length === 0) {
656
+ into.add("task.browsers.required", `${path}.browsers`, "a task that declares capabilities.browsers supplies at least one; with none, omit the capability");
657
+ }
614
658
  for (const [name, declared] of Object.entries(capabilities)) {
615
659
  if (declared === undefined)
616
660
  continue;
@@ -669,6 +713,10 @@ function validateBreakState(value, path, into) {
669
713
  into.filled(value[field], `break.${field}`, `${path}.${field}`, `${field} must not be empty when present`);
670
714
  }
671
715
  }
716
+ // The refusal is the reason the control is withdrawn; beside `accepting: true` it explains nothing.
717
+ if (value.refusedReason !== undefined) {
718
+ into.require(value.accepting !== true, "break.refusedReason.accepting", `${path}.refusedReason`, "refusedReason is shown when accepting is false; omit it while the agent may ask");
719
+ }
672
720
  if (value.retryAfterMs !== undefined) {
673
721
  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");
674
722
  }
@@ -678,14 +726,21 @@ function validateBreakState(value, path, into) {
678
726
  // break that is not happening.
679
727
  into.require(value.approval !== "not-requested", "break.activeReasonId.approval", `${path}.activeReasonId`, "activeReasonId must be omitted when no break is requested or in effect");
680
728
  }
681
- if (value.imposed !== undefined)
729
+ if (value.imposed !== undefined) {
682
730
  validateImposedBreak(value.imposed, `${path}.imposed`, into);
731
+ // An imposed break is a break somebody placed; beside `not-requested` there is no break.
732
+ into.require(value.approval !== "not-requested", "break.imposed.approval", `${path}.imposed`, "an imposed break is a break in progress; not-requested says there is none");
733
+ }
683
734
  if (value.reasons === undefined)
684
735
  return;
685
736
  if (!Array.isArray(value.reasons)) {
686
737
  into.add("break.reasons.shape", `${path}.reasons`, "break reasons must be an array when present");
687
738
  return;
688
739
  }
740
+ // A provider that defines no codes omits the field: an empty list is a second spelling of that.
741
+ if (value.reasons.length === 0) {
742
+ into.add("break.reasons.empty", `${path}.reasons`, "a provider that defines no reasons omits the field rather than publishing an empty list");
743
+ }
689
744
  const seen = new Set();
690
745
  value.reasons.forEach((reason, index) => {
691
746
  const at = `${path}.reasons[${index}]`;
@@ -705,6 +760,10 @@ function validateBreakState(value, path, into) {
705
760
  into.require(reason.alwaysAvailable === true, "break.reason.alwaysAvailable", `${at}.alwaysAvailable`, "alwaysAvailable is declared by presence: send true or omit it");
706
761
  }
707
762
  });
763
+ // The active reason is one of the published ones, or it is a reason Omni cannot name.
764
+ if (typeof value.activeReasonId === "string" && value.activeReasonId.length > 0) {
765
+ into.require(seen.has(value.activeReasonId), "break.activeReasonId.known", `${path}.activeReasonId`, `activeReasonId names a reason the provider did not publish: ${value.activeReasonId}`);
766
+ }
708
767
  }
709
768
  export function validateTeamRoster(roster, path = "team", context = {}) {
710
769
  const into = new Collector();
@@ -780,8 +839,13 @@ function validateTeamRosterInto(roster, path, context, into) {
780
839
  into.oneOf(member.availability, TEAM_AVAILABILITIES, "team.member.availability", `${at}.availability`);
781
840
  if (member.since !== undefined)
782
841
  into.timestamp(member.since, "team.member.since", `${at}.since`);
783
- if (member.break !== undefined)
784
- into.oneOf(member.break, BREAK_APPROVALS, "team.member.break", `${at}.break`);
842
+ if (member.break !== undefined) {
843
+ // Only an outstanding request appears here: `not-requested` is absence, `in-effect` is
844
+ // `availability: "on-break"`, and a denial never survives to be reported.
845
+ if (into.oneOf(member.break, MEMBER_BREAKS, "team.member.break", `${at}.break`)) {
846
+ 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");
847
+ }
848
+ }
785
849
  });
786
850
  }
787
851
  export function validateSnapshot(snapshot, manifest, path = "snapshot", context = {}) {
@@ -792,15 +856,25 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
792
856
  }
793
857
  const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
794
858
  into.oneOf(snapshot.status, CONNECTION_STATUSES, "snapshot.status", `${path}.status`);
795
- into.filled(snapshot.sessionId, "snapshot.sessionId", `${path}.sessionId`, "a snapshot needs the session id it belongs to");
859
+ if (into.filled(snapshot.sessionId, "snapshot.sessionId", `${path}.sessionId`, "a snapshot needs the session id it belongs to")
860
+ && context.sessionId !== undefined) {
861
+ 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}`);
862
+ }
796
863
  validateBreakState(snapshot.break, `${path}.break`, into);
797
864
  if (!Array.isArray(snapshot.tasks)) {
798
865
  into.add("snapshot.tasks.shape", `${path}.tasks`, "a snapshot must carry a tasks array");
799
866
  }
800
867
  else {
801
868
  const seen = new Set();
869
+ let assisting;
802
870
  snapshot.tasks.forEach((task, index) => {
803
871
  validateTaskInto(task, { channel }, `${path}.tasks[${index}]`, into);
872
+ // A lead assists one call at a time.
873
+ if (isPlainObject(task) && task.assisting !== undefined) {
874
+ if (assisting !== undefined)
875
+ into.add("snapshot.assisting.single", `${path}.tasks[${index}].assisting`, "a lead assists one call at a time");
876
+ assisting = index;
877
+ }
804
878
  if (isPlainObject(task) && isTaskId(task.id)) {
805
879
  if (seen.has(task.id))
806
880
  into.add("task.id.unique", `${path}.tasks[${index}].id`, `duplicate task id: ${task.id}`);
@@ -919,7 +993,13 @@ function validateProviderSummary(value, path, into) {
919
993
  into.add("event.summary.metrics.shape", `${path}.metrics`, "metrics must be an array when present");
920
994
  return;
921
995
  }
996
+ const ids = new Set();
922
997
  value.metrics.forEach((metric, index) => {
998
+ if (isPlainObject(metric) && isFilled(metric.id)) {
999
+ if (ids.has(metric.id))
1000
+ into.add("event.summary.metric.unique", `${path}.metrics[${index}].id`, `duplicate metric id: ${metric.id}`);
1001
+ ids.add(metric.id);
1002
+ }
923
1003
  const at = `${path}.metrics[${index}]`;
924
1004
  if (!isPlainObject(metric)) {
925
1005
  into.add("event.summary.metric.shape", at, "each metric must be an object");
@@ -938,7 +1018,11 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
938
1018
  }
939
1019
  const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
940
1020
  into.filled(envelope.id, "event.id", `${path}.id`, "an event needs an id");
941
- into.filled(envelope.sessionId, "event.sessionId", `${path}.sessionId`, "an event needs the session id it belongs to");
1021
+ if (into.filled(envelope.sessionId, "event.sessionId", `${path}.sessionId`, "an event needs the session id it belongs to")
1022
+ && context.sessionId !== undefined) {
1023
+ 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}`);
1024
+ }
1025
+ const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
942
1026
  into.timestamp(envelope.occurredAt, "event.occurredAt", `${path}.occurredAt`);
943
1027
  const event = envelope.event;
944
1028
  if (!isPlainObject(event)) {
@@ -962,9 +1046,20 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
962
1046
  break;
963
1047
  case "task-offered":
964
1048
  validateTaskInto(event.task, { channel }, `${at}.task`, into);
1049
+ // An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
1050
+ if (isPlainObject(event.task) && typeof event.task.phase === "string") {
1051
+ 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}`);
1052
+ }
965
1053
  if (event.acceptanceMode !== undefined) {
966
1054
  into.oneOf(event.acceptanceMode, ACCEPTANCE_MODES, "event.taskOffered.acceptanceMode", `${at}.acceptanceMode`);
967
1055
  }
1056
+ // The mode travels exactly when Omni said tasks may be auto-accepted.
1057
+ if (context.autoAcceptTasks === true) {
1058
+ into.require(event.acceptanceMode !== undefined, "event.taskOffered.acceptanceMode.required", `${at}.acceptanceMode`, "autoAcceptTasks is on, so task-offered carries an acceptanceMode");
1059
+ }
1060
+ else if (context.autoAcceptTasks === false) {
1061
+ into.require(event.acceptanceMode === undefined, "event.taskOffered.acceptanceMode.unexpected", `${at}.acceptanceMode`, "autoAcceptTasks is off, so every task requires agent acceptance and task-offered carries no acceptanceMode");
1062
+ }
968
1063
  for (const field of ["allocationExpiresAt", "preparationEndsAt"]) {
969
1064
  if (event[field] !== undefined)
970
1065
  into.timestamp(event[field], `event.taskOffered.${field}`, `${at}.${field}`);
@@ -996,6 +1091,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
996
1091
  validateTeamRosterInto(event.team, `${at}.team`, context, into);
997
1092
  break;
998
1093
  case "contacts-updated":
1094
+ into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
999
1095
  if (!Array.isArray(event.contacts)) {
1000
1096
  into.add("event.contacts.shape", `${at}.contacts`, "contacts must be an array");
1001
1097
  }
@@ -1004,11 +1100,20 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1004
1100
  }
1005
1101
  break;
1006
1102
  case "calendar-updated":
1103
+ into.require(idle.calendar === true, "event.calendar.capability", `${at}.scheduledActivities`, "calendar-updated requires the calendar idle capability");
1007
1104
  if (!Array.isArray(event.scheduledActivities)) {
1008
1105
  into.add("event.calendar.shape", `${at}.scheduledActivities`, "scheduledActivities must be an array");
1009
1106
  }
1010
1107
  else {
1011
- event.scheduledActivities.forEach((activity, index) => validateScheduledActivityInto(activity, `${at}.scheduledActivities[${index}]`, into));
1108
+ const ids = new Set();
1109
+ event.scheduledActivities.forEach((activity, index) => {
1110
+ validateScheduledActivityInto(activity, `${at}.scheduledActivities[${index}]`, into);
1111
+ if (isPlainObject(activity) && isFilled(activity.id)) {
1112
+ if (ids.has(activity.id))
1113
+ into.add("activity.id.unique", `${at}.scheduledActivities[${index}].id`, `duplicate activity id: ${activity.id}`);
1114
+ ids.add(activity.id);
1115
+ }
1116
+ });
1012
1117
  }
1013
1118
  break;
1014
1119
  default:
@@ -1155,6 +1260,9 @@ export function validateAuthenticationState(state, path = "authentication") {
1155
1260
  else {
1156
1261
  into.require(state.identity === undefined, "authentication.identity.unexpected", `${path}.identity`, `${state.status} must not carry an identity`);
1157
1262
  }
1263
+ if (state.failure !== undefined) {
1264
+ into.require(state.status === "expired", "authentication.failure.unexpected", `${path}.failure`, "only an expired state may carry a failure");
1265
+ }
1158
1266
  if (state.expiresAt !== undefined) {
1159
1267
  into.require(state.status === "authenticated", "authentication.expiresAt.unexpected", `${path}.expiresAt`, "only an authenticated state may carry an expiry");
1160
1268
  into.timestamp(state.expiresAt, "authentication.expiresAt", `${path}.expiresAt`);
package/guide.md CHANGED
@@ -1731,7 +1731,7 @@ time. Runtime conformance checks also require the task channel to match its prov
1731
1731
  | `channel` | Channel handling this task. It must equal the source provider's manifest channel. |
1732
1732
  | `taskType` | Required provider-defined source or category of work, such as a voice `Queue Name`, `Mailbox Folder`, `Chat Source`, `Support`, `Billing`, or `Returns`. |
1733
1733
  | `capabilities` | Controls and workspace features available for this specific task. |
1734
- | `browsers` | Named browser definitions for the task workspace; empty when the task does not declare the `browsers` capability. |
1734
+ | `browsers` | Named browser definitions for the task workspace: at least one when the task declares the `browsers` capability, empty when it does not. |
1735
1735
  | `contact` | Optional `Contact` for the person or entity on this task. Often a name and one address; a withheld caller ID may leave nothing to send at all. |
1736
1736
  | `phase` | Current canonical task phase: `pending`, `confirmed`, `preparing`, `in-progress`, `paused`, or `completing`. |
1737
1737
  | `reference` | Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol `id`. |
@@ -2042,14 +2042,13 @@ browser that used it.
2042
2042
 
2043
2043
  ### Task capabilities
2044
2044
 
2045
- Task capabilities belong to each `Task`. If `hold` is false or omitted on one task, Omni
2045
+ Task capabilities belong to each `Task`. If `hold` is omitted on one task, Omni
2046
2046
  must not show or issue hold for that task even if another task from the same provider supports it.
2047
2047
 
2048
2048
  ```ts
2049
2049
  const taskCapabilities = {
2050
2050
  channel: "voice",
2051
2051
  capabilities: {
2052
- browsers: true,
2053
2052
  hold: true,
2054
2053
  dispositions: true,
2055
2054
  },
@@ -2247,7 +2246,7 @@ nothing while none are being accepted — so they are not published separately.
2247
2246
  | `refusedReason` | Display-ready reason shown when `accepting` is false — a standing gate that applies to everyone. |
2248
2247
  | `decisionReason` | The words whoever decided attached, from `decide.reason`. About one request and one decision, not a standing gate. |
2249
2248
  | `retryAfterMs` | How long until the agent may retry, when the provider can say. |
2250
- | `reasons` | Not-ready codes this provider offers. Omitted when it defines none. |
2249
+ | `reasons` | Not-ready codes this provider offers. Omitted when it defines none; an empty list is refused, being a second spelling of the same fact. |
2251
2250
  | `activeReasonId` | The `BreakReason.id` the current break is on. Omitted when there is no break, or when the provider cannot say. |
2252
2251
  | `imposed` | Set when the break was placed on the agent rather than requested. |
2253
2252
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",