@xema/omni-protocol 0.1.77 → 0.1.79

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.
@@ -47,6 +47,7 @@ const ACCEPTANCE_MODES = membersOf({
47
47
  });
48
48
  const TRANSPORT_STATUSES = membersOf({ connecting: true, active: true, error: true });
49
49
  const AUTHENTICATION_METHODS = membersOf({ "browser-sso": true, credentials: true });
50
+ const AUTHENTICATION_BROWSERS = membersOf({ system: true, omni: true });
50
51
  const AUTHENTICATION_STATUSES = membersOf({
51
52
  "signed-out": true, authenticating: true, authenticated: true, refreshing: true, expired: true,
52
53
  });
@@ -54,7 +55,7 @@ const BREAK_APPROVALS = membersOf({
54
55
  "not-requested": true, "awaiting-decision": true, granted: true, "starting-after-task": true, "in-effect": true,
55
56
  });
56
57
  const TEAM_AVAILABILITIES = membersOf({
57
- ready: true, "on-task": true, "on-break": true, "signed-out": true,
58
+ ready: true, "on-task": true, "on-break": true, reserved: true, "signed-out": true,
58
59
  });
59
60
  const HANDLING_STEPS = membersOf({
60
61
  queued: true, offered: true, answered: true, held: true, muted: true, transferred: true, conferenced: true, unanswered: true,
@@ -82,7 +83,7 @@ const MEMBER_BREAKS = membersOf({
82
83
  "awaiting-decision": true, granted: true, "starting-after-task": true,
83
84
  });
84
85
  const OFFERABLE_PHASES = membersOf({
85
- pending: true, confirmed: true, preview: true,
86
+ pending: true,
86
87
  });
87
88
  const PREVIEW_DEADLINES = membersOf({ calls: true, expires: true });
88
89
  const PHONES = membersOf({ softphone: true, deskPhone: true });
@@ -117,6 +118,16 @@ const isChannel = (value) => CHANNELS.includes(value);
117
118
  // ---------------------------------------------------------------------------
118
119
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
119
120
  const isFilled = (value) => typeof value === "string" && value.trim().length > 0;
121
+ /** Error text must not invoke conversion methods supplied by an untrusted object or array. */
122
+ function describeValue(value) {
123
+ if (value === null)
124
+ return "null";
125
+ if (typeof value === "object")
126
+ return Array.isArray(value) ? "an array" : "an object";
127
+ if (typeof value === "function")
128
+ return "a function";
129
+ return String(value);
130
+ }
120
131
  /**
121
132
  * An RFC-3339 timestamp carrying a zone.
122
133
  *
@@ -124,9 +135,22 @@ const isFilled = (value) => typeof value === "string" && value.trim().length > 0
124
135
  * happens to be in, so two hosts would read the same wire value as two different instants. The
125
136
  * contract calls those invalid, and this is where that is enforced rather than assumed.
126
137
  */
127
- const isIsoTimestamp = (value) => typeof value === "string"
128
- && /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/.test(value)
129
- && !Number.isNaN(Date.parse(value));
138
+ const isIsoTimestamp = (value) => {
139
+ if (typeof value !== "string")
140
+ return false;
141
+ const match = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/.exec(value);
142
+ if (match === null)
143
+ return false;
144
+ const year = Number(match[1]);
145
+ const month = Number(match[2]);
146
+ const day = Number(match[3]);
147
+ // RFC 3339 sections 5.6–5.7: days depend on the month/year and hours are 00–23.
148
+ // Date.parse alone normalizes February 30 and 24:00 into different instants.
149
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
150
+ const daysInMonth = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
151
+ return daysInMonth !== undefined && day >= 1 && day <= daysInMonth
152
+ && Number(match[4]) < 24 && !Number.isNaN(Date.parse(value));
153
+ };
130
154
  /** A non-negative integer count of seconds. */
131
155
  const isDurationSeconds = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
132
156
  /** Opaque, non-empty, provider-issued. Never parsed and never compared across providers. */
@@ -168,7 +192,7 @@ class Collector {
168
192
  return this.require(isIsoTimestamp(value), rule, path, "must be an RFC-3339 timestamp carrying a zone, such as 2026-08-21T09:00:00Z");
169
193
  }
170
194
  oneOf(value, allowed, rule, path) {
171
- return this.require(typeof value === "string" && allowed.includes(value), rule, path, `must be one of ${allowed.join(", ")}; received ${String(value)}`);
195
+ return this.require(typeof value === "string" && allowed.includes(value), rule, path, `must be one of ${allowed.join(", ")}; received ${describeValue(value)}`);
172
196
  }
173
197
  }
174
198
  // ---------------------------------------------------------------------------
@@ -279,9 +303,8 @@ function validateIdleCapabilities(value, channel, path, into) {
279
303
  }
280
304
  else {
281
305
  validateBrowserAccess(browser.access, `${path}.personalBrowser.access`, into);
282
- if (browser.accessAppliesTo !== undefined) {
283
- into.oneOf(browser.accessAppliesTo, ACCESS_APPLIES_TO, "manifest.personalBrowser.accessAppliesTo", `${path}.personalBrowser.accessAppliesTo`);
284
- }
306
+ // Stated, never defaulted: an absent value read as all-navigation was a second spelling of one fact.
307
+ into.oneOf(browser.accessAppliesTo, ACCESS_APPLIES_TO, "manifest.personalBrowser.accessAppliesTo", `${path}.personalBrowser.accessAppliesTo`);
285
308
  }
286
309
  }
287
310
  if (value.dial !== undefined) {
@@ -310,7 +333,7 @@ function validateDialOutcomes(manifest, path, into) {
310
333
  into.require(!dials, "manifest.dialOutcomes.required", path, "a manifest that declares dial says how a dial ends: dialOutcomes with answered and at least one way of not reaching");
311
334
  return;
312
335
  }
313
- if (!into.require(manifest.channel === "voice", "manifest.dialOutcomes.channel", path, `a ${String(manifest.channel)} provider dials nothing and declares no dial outcomes`))
336
+ if (!into.require(manifest.channel === "voice", "manifest.dialOutcomes.channel", path, `a ${describeValue(manifest.channel)} provider dials nothing and declares no dial outcomes`))
314
337
  return;
315
338
  if (!Array.isArray(declared) || declared.length === 0) {
316
339
  into.add("manifest.dialOutcomes.shape", path, "dialOutcomes is a non-empty array of the outcomes the platform distinguishes");
@@ -318,7 +341,7 @@ function validateDialOutcomes(manifest, path, into) {
318
341
  }
319
342
  declared.forEach((outcome, index) => {
320
343
  if (into.oneOf(outcome, DIAL_OUTCOMES, "manifest.dialOutcome", `${path}[${index}]`) && declared.indexOf(outcome) !== index) {
321
- into.add("manifest.dialOutcome.unique", `${path}[${index}]`, `duplicate dial outcome: ${String(outcome)}`);
344
+ into.add("manifest.dialOutcome.unique", `${path}[${index}]`, `duplicate dial outcome: ${describeValue(outcome)}`);
322
345
  }
323
346
  });
324
347
  into.require(declared.includes("answered"), "manifest.dialOutcomes.answered", path, "a dial that reached its destination is stated, never inferred: dialOutcomes includes answered");
@@ -336,7 +359,7 @@ function validateDialOutcomes(manifest, path, into) {
336
359
  function validatePhones(manifest, path, into) {
337
360
  const declared = manifest.phones;
338
361
  if (manifest.channel !== "voice") {
339
- into.require(declared === undefined, "manifest.phones.channel", path, `a ${String(manifest.channel)} provider has no call to hear and declares no phones`);
362
+ into.require(declared === undefined, "manifest.phones.channel", path, `a ${describeValue(manifest.channel)} provider has no call to hear and declares no phones`);
340
363
  return;
341
364
  }
342
365
  if (!Array.isArray(declared) || declared.length === 0) {
@@ -345,7 +368,7 @@ function validatePhones(manifest, path, into) {
345
368
  }
346
369
  declared.forEach((phone, index) => {
347
370
  if (into.oneOf(phone, PHONES, "manifest.phone", `${path}[${index}]`) && declared.indexOf(phone) !== index) {
348
- into.add("manifest.phone.unique", `${path}[${index}]`, `duplicate phone: ${String(phone)}`);
371
+ into.add("manifest.phone.unique", `${path}[${index}]`, `duplicate phone: ${describeValue(phone)}`);
349
372
  }
350
373
  });
351
374
  }
@@ -358,13 +381,13 @@ export function validatePhone(value, manifest, path = "context.phone") {
358
381
  const channel = isPlainObject(manifest) ? manifest.channel : undefined;
359
382
  const phones = isPlainObject(manifest) && Array.isArray(manifest.phones) ? manifest.phones : [];
360
383
  if (channel !== "voice") {
361
- into.require(value === undefined, "context.phone.unexpected", path, `a ${String(channel)} login has no call to hear and chooses no phone`);
384
+ into.require(value === undefined, "context.phone.unexpected", path, `a ${describeValue(channel)} login has no call to hear and chooses no phone`);
362
385
  return into.violations;
363
386
  }
364
387
  if (!into.require(value !== undefined, "context.phone.required", path, "a voice login says how the agent hears the call: one of the manifest's phones"))
365
388
  return into.violations;
366
389
  if (into.oneOf(value, PHONES, "context.phone", path)) {
367
- into.require(phones.includes(value), "context.phone.unsupported", path, `the host chose ${String(value)} and the manifest lists ${phones.length === 0 ? "no phones" : phones.join(", ")}`);
390
+ into.require(phones.includes(value), "context.phone.unsupported", path, `the host chose ${describeValue(value)} and the manifest lists ${phones.length === 0 ? "no phones" : phones.map(describeValue).join(", ")}`);
368
391
  }
369
392
  return into.violations;
370
393
  }
@@ -401,7 +424,7 @@ export function validateManifest(manifest, path = "manifest") {
401
424
  else {
402
425
  methods.forEach((method, index) => {
403
426
  if (into.oneOf(method, AUTHENTICATION_METHODS, "manifest.authenticationMethod", `${path}.authenticationMethods[${index}]`) && methods.indexOf(method) !== index) {
404
- into.add("manifest.authenticationMethod.unique", `${path}.authenticationMethods[${index}]`, `duplicate authentication method: ${String(method)}`);
427
+ into.add("manifest.authenticationMethod.unique", `${path}.authenticationMethods[${index}]`, `duplicate authentication method: ${describeValue(method)}`);
405
428
  }
406
429
  });
407
430
  }
@@ -568,8 +591,7 @@ function validateCustomCapabilities(value, path, into) {
568
591
  into.oneOf(custom.ui.control, CUSTOM_UI_CONTROLS, "task.custom.ui.control", `${at}.ui.control`);
569
592
  into.filled(custom.ui.label, "task.custom.ui.label", `${at}.ui.label`, "a custom control needs a label");
570
593
  into.oneOf(custom.ui.placement, CUSTOM_UI_PLACEMENTS, "task.custom.ui.placement", `${at}.ui.placement`);
571
- if (custom.ui.render !== undefined)
572
- into.oneOf(custom.ui.render, CUSTOM_RENDERS, "task.custom.ui.render", `${at}.ui.render`);
594
+ into.oneOf(custom.ui.render, CUSTOM_RENDERS, "task.custom.ui.render", `${at}.ui.render`);
573
595
  if (custom.prompt !== undefined) {
574
596
  if (!isPlainObject(custom.prompt) || !Array.isArray(custom.prompt.fields)) {
575
597
  into.add("task.custom.prompt.shape", `${at}.prompt`, "a prompt is an object with the fields the agent fills");
@@ -585,6 +607,10 @@ function validateCustomCapabilities(value, path, into) {
585
607
  into.filled(field.name, "task.custom.prompt.field.name", `${where}.name`, "a prompt field needs a name");
586
608
  into.filled(field.label, "task.custom.prompt.field.label", `${where}.label`, "a prompt field needs a label");
587
609
  into.oneOf(field.type, CREDENTIAL_FIELD_TYPES, "task.custom.prompt.field.type", `${where}.type`);
610
+ if (field.required !== undefined)
611
+ into.require(typeof field.required === "boolean", "task.custom.prompt.field.required", `${where}.required`, "required must be a boolean when present");
612
+ if (field.autocomplete !== undefined)
613
+ into.require(typeof field.autocomplete === "string", "task.custom.prompt.field.autocomplete", `${where}.autocomplete`, "autocomplete must be a string when present");
588
614
  });
589
615
  }
590
616
  }
@@ -651,7 +677,7 @@ function validateLockedByInto(value, rule, path, levels, into) {
651
677
  if (!into.filled(value, rule, path, "lockedBy names the level that locked it"))
652
678
  return;
653
679
  into.require(value !== "person", `${rule}.person`, path, "a person never locks their own value");
654
- into.require(levelIds(levels).includes(value), `${rule}.unknown`, path, `${String(value)} is not a level this manifest declares: in force are ${levelIds(levels).join(", ")}`);
680
+ into.require(levelIds(levels).includes(value), `${rule}.unknown`, path, `${describeValue(value)} is not a level this manifest declares: in force are ${levelIds(levels).join(", ")}`);
655
681
  }
656
682
  /** `{ lockedBy, reason? }` standing in for a value: who locked it, and a reason if given. */
657
683
  function validateLockedInto(value, rule, path, levels, into) {
@@ -661,8 +687,8 @@ function validateLockedInto(value, rule, path, levels, into) {
661
687
  }
662
688
  /** What every resolved value carries: who set it, and who locked it if anyone. */
663
689
  function validateResolvedInto(value, rule, path, levels, into) {
664
- if (into.filled(value.setBy, `${rule}.setBy`, `${path}.setBy`, "setBy names who stated the value: a level, or provisioning")) {
665
- 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`);
690
+ if (into.filled(value.setBy, `${rule}.setBy`, `${path}.setBy`, "setBy names who stated the value: a level, or provider")) {
691
+ into.require(value.setBy === "provider" || levelIds(levels).includes(value.setBy), `${rule}.setBy.unknown`, `${path}.setBy`, `${describeValue(value.setBy)} is neither provider nor a level this manifest declares`);
666
692
  }
667
693
  if (value.lockedBy !== undefined)
668
694
  validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`, levels, into);
@@ -773,7 +799,7 @@ function validateTaskAttributes(value, path, into, levels) {
773
799
  into.timestamp(attribute.at, "task.attribute.timestamp", `${at}.at`);
774
800
  break;
775
801
  default:
776
- into.add("task.attribute.type", `${at}.type`, `unsupported attribute type: ${String(attribute.type)}`);
802
+ into.add("task.attribute.type", `${at}.type`, `unsupported attribute type: ${describeValue(attribute.type)}`);
777
803
  }
778
804
  });
779
805
  }
@@ -824,7 +850,7 @@ function validateHandlingHistory(value, path, into, task = {}) {
824
850
  // the task is paused, and a mute only while its media is up. An open entry after that is a leg
825
851
  // nobody closed, which reads exactly like a leg running now.
826
852
  if (entry.step === "held") {
827
- into.require(task.phase === "paused", "task.handlingHistory.held.open", `${at}.seconds`, `a held entry without seconds is a hold still running, and the task is ${String(task.phase)}: the hold has ended, and its duration is stated`);
853
+ into.require(task.phase === "paused", "task.handlingHistory.held.open", `${at}.seconds`, `a held entry without seconds is a hold still running, and the task is ${describeValue(task.phase)}: the hold has ended, and its duration is stated`);
828
854
  }
829
855
  if (entry.step === "muted") {
830
856
  into.require(task.media !== "ended" && task.phase !== "completing", "task.handlingHistory.muted.open", `${at}.seconds`, "a muted entry without seconds is a mute still running, and the call is over: the provider closes every open leg at the instant the media ends, and the duration is stated");
@@ -851,7 +877,7 @@ function validateHandlingHistory(value, path, into, task = {}) {
851
877
  if (entry[field] === undefined)
852
878
  continue;
853
879
  if (into.filled(entry[field], `task.handlingHistory.${field}`, `${at}.${field}`, `${field} must not be empty when present`)) {
854
- into.require(dialled, `task.handlingHistory.${field}.unexpected`, `${at}.${field}`, `${String(entry.step)} dialled nothing; ${field} belongs on transferred, conferenced, or unanswered`);
880
+ into.require(dialled, `task.handlingHistory.${field}.unexpected`, `${at}.${field}`, `${describeValue(entry.step)} dialled nothing; ${field} belongs on transferred, conferenced, or unanswered`);
855
881
  }
856
882
  }
857
883
  });
@@ -859,7 +885,11 @@ function validateHandlingHistory(value, path, into, task = {}) {
859
885
  /** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
860
886
  const TASK_MEDIA_STATES = membersOf({ started: true, ended: true });
861
887
  /** Real-time media is a voice affair, and its state is one of two words. */
862
- function validateTaskMedia(value, channel, phase, path, into) {
888
+ /** Whether a host dial is ringing the party: the party entry carries `stage: "ringing"` and the host's `dialId`. */
889
+ function partyRingingByHost(task) {
890
+ return Array.isArray(task.onCall) && task.onCall.some(entry => isPlainObject(entry) && entry.role === "party" && entry.stage === "ringing" && typeof entry.dialId === "string");
891
+ }
892
+ function validateTaskMedia(task, value, channel, phase, path, into) {
863
893
  if (value === undefined)
864
894
  return;
865
895
  if (!into.require(channel === "voice", "task.media.channel", path, `a ${channel} task carries no real-time media state`))
@@ -867,7 +897,9 @@ function validateTaskMedia(value, channel, phase, path, into) {
867
897
  if (into.oneOf(value, TASK_MEDIA_STATES, "task.media", path)) {
868
898
  // Nothing is acquired while pending, and a preview has placed no call: media names a task whose
869
899
  // work has begun, on a snapshot as on the event, or a host would open the microphone on an offer.
870
- into.require(!WORK_NOT_BEGUN.includes(phase), "task.media.beforeWork", path, `a ${String(phase)} task has no media: audio arrives once its work has begun`);
900
+ // The one task not yet at work that has audio is one whose party the host is dialling: ring-back
901
+ // is audio, and every host-placed dial's media starts on dialling.
902
+ into.require(!WORK_NOT_BEGUN.includes(phase) || partyRingingByHost(task), "task.media.beforeWork", path, `a ${describeValue(phase)} task has no media: audio arrives once its work has begun, or once the host is dialling its party`);
871
903
  }
872
904
  }
873
905
  const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
@@ -1075,7 +1107,7 @@ function validateTaskInto(task, context, path, into) {
1075
1107
  }
1076
1108
  // The channel is fixed per provider by its manifest, so a task claiming another one is a
1077
1109
  // task Omni would render with the wrong controls.
1078
- into.require(task.channel === context.channel, "task.channel", `${path}.channel`, `a ${context.channel} provider may not publish a ${String(task.channel)} task`);
1110
+ into.require(task.channel === context.channel, "task.channel", `${path}.channel`, `a ${context.channel} provider may not publish a ${describeValue(task.channel)} task`);
1079
1111
  if (task.reference !== undefined) {
1080
1112
  into.filled(task.reference, "task.reference", `${path}.reference`, "a reference must not be empty when present");
1081
1113
  }
@@ -1100,7 +1132,7 @@ function validateTaskInto(task, context, path, into) {
1100
1132
  if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.media === "ended")) {
1101
1133
  into.add("task.onCall.ended", `${path}.onCall`, `the call has ended (${task.phase === "completing" ? "the task is completing" : "its media ended"}) and onCall still names people on it: the room is who is on the call now, and now nobody is`);
1102
1134
  }
1103
- validateTaskMedia(task.media, context.channel, task.phase, `${path}.media`, into);
1135
+ validateTaskMedia(task, task.media, context.channel, task.phase, `${path}.media`, into);
1104
1136
  validateLeadAssist(task.leadAssist, context.channel, `${path}.leadAssist`, into);
1105
1137
  validateAssisting(task.assisting, context.channel, `${path}.assisting`, into);
1106
1138
  validateMonitoring(task.monitoring, context.channel, `${path}.monitoring`, into);
@@ -1217,7 +1249,9 @@ function validateBreakState(value, path, into) {
1217
1249
  if (value.imposed !== undefined) {
1218
1250
  validateImposedBreak(value.imposed, `${path}.imposed`, into);
1219
1251
  // An imposed break is a break somebody placed; beside `not-requested` there is no break.
1220
- 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");
1252
+ // An imposed break is a break in progress or about to be: it travels with in-effect or
1253
+ // starting-after-task and nothing else. Beside granted, the host would commit a break nobody asked for.
1254
+ into.require(value.approval === "in-effect" || value.approval === "starting-after-task", "break.imposed.approval", `${path}.imposed`, `an imposed break is in effect or starting after the task; ${describeValue(value.approval)} says the agent asked, or that there is none`);
1221
1255
  }
1222
1256
  if (value.reasons === undefined)
1223
1257
  return;
@@ -1252,6 +1286,13 @@ function validateBreakState(value, path, into) {
1252
1286
  if (typeof value.activeReasonId === "string" && value.activeReasonId.length > 0) {
1253
1287
  into.require(seen.has(value.activeReasonId), "break.activeReasonId.known", `${path}.activeReasonId`, `activeReasonId names a reason the provider did not publish: ${value.activeReasonId}`);
1254
1288
  }
1289
+ // A break in effect, or about to be, on a provider that publishes reasons is on one of them: an
1290
+ // imposed one included, since the lead's place named it. A break of no kind is a break whose
1291
+ // rules -- who may monitor through it, whether it counts -- nobody can apply.
1292
+ ruleEvaluated("break.activeReasonId.required");
1293
+ if (seen.size > 0 && (value.approval === "in-effect" || value.approval === "starting-after-task")) {
1294
+ into.require(typeof value.activeReasonId === "string" && value.activeReasonId.length > 0, "break.activeReasonId.required", `${path}.activeReasonId`, `a break ${describeValue(value.approval)} on a provider that publishes reasons names the one it is on`);
1295
+ }
1255
1296
  }
1256
1297
  export function validateTeamRoster(roster, path = "team", context = {}) {
1257
1298
  const into = new Collector();
@@ -1357,10 +1398,10 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1357
1398
  }
1358
1399
  const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
1359
1400
  const levels = context.levels ?? manifestLevels(manifest);
1360
- into.oneOf(snapshot.transport, TRANSPORT_STATUSES, "snapshot.transport", `${path}.status`);
1401
+ into.oneOf(snapshot.transport, TRANSPORT_STATUSES, "snapshot.transport", `${path}.transport`);
1361
1402
  if (into.filled(snapshot.loginId, "snapshot.loginId", `${path}.loginId`, "a snapshot needs the login id it belongs to")
1362
1403
  && context.loginId !== undefined) {
1363
- 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}`);
1404
+ into.require(snapshot.loginId === context.loginId, "snapshot.loginId.mismatch", `${path}.loginId`, `a snapshot for session ${describeValue(snapshot.loginId)} on a login whose session is ${context.loginId}`);
1364
1405
  }
1365
1406
  validateBreakState(snapshot.break, `${path}.break`, into);
1366
1407
  // The count is the provider's confirmation of how much work it answered with. Stated, never
@@ -1408,6 +1449,14 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1408
1449
  // describes a state the agent cannot be in, whichever half is stale. The one exception is a
1409
1450
  // lead listening to a call during a break that is work of another sort -- coaching,
1410
1451
  // administrative, training -- and on no other.
1452
+ // The converse: a committed break with nothing outstanding has begun. starting-after-task beside
1453
+ // no task is a break waiting on work that does not exist, and an empty list reading as "still
1454
+ // finishing" is the plausible nought.
1455
+ ruleEvaluated("break.starting-after-task.tasks");
1456
+ if (isPlainObject(snapshot.break) && snapshot.break.approval === "starting-after-task"
1457
+ && Array.isArray(snapshot.tasks) && snapshot.tasks.length === 0) {
1458
+ into.add("break.starting-after-task.tasks", `${path}.break.approval`, "a break starting after the task waits on a task, and the snapshot carries none: with nothing outstanding the break is in-effect");
1459
+ }
1411
1460
  if (isPlainObject(snapshot.break) && snapshot.break.approval === "in-effect"
1412
1461
  && Array.isArray(snapshot.tasks) && snapshot.tasks.length > 0) {
1413
1462
  const listening = snapshot.tasks.every((task) => isPlainObject(task) && task.monitoring !== undefined);
@@ -1419,7 +1468,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1419
1468
  const reasons = Array.isArray(state.reasons) ? state.reasons : [];
1420
1469
  const active = reasons.find((reason) => isPlainObject(reason) && reason.id === state.activeReasonId);
1421
1470
  const kind = isPlainObject(active) ? active.kind : undefined;
1422
- into.require(typeof kind === "string" && MONITORING_BREAK_KINDS.includes(kind), "snapshot.monitoring.break", `${path}.tasks`, `a lead listens during a ${MONITORING_BREAK_KINDS.join(", ")} break and no other; the active reason is ${kind === undefined ? "unstated" : String(kind)}`);
1471
+ into.require(typeof kind === "string" && MONITORING_BREAK_KINDS.includes(kind), "snapshot.monitoring.break", `${path}.tasks`, `a lead listens during a ${MONITORING_BREAK_KINDS.join(", ")} break and no other; the active reason is ${kind === undefined ? "unstated" : describeValue(kind)}`);
1423
1472
  }
1424
1473
  }
1425
1474
  // Presence is the permission, and it cuts both ways: data a provider never declared a
@@ -1487,6 +1536,10 @@ function validateTaskOutcome(value, path, into) {
1487
1536
  into.filled(value.destinationId, "event.taskEnded.outcome.transferred", `${path}.destinationId`, "a destinationId must not be empty when present");
1488
1537
  }
1489
1538
  break;
1539
+ case "taken-over":
1540
+ // A lead is not a directory item: the ending names the lead, and the host resolves the name.
1541
+ into.require(isUserId(value.leadId), "event.taskEnded.outcome.takenOver", `${path}.leadId`, "a take-over names the lead who took the call, by user id");
1542
+ break;
1490
1543
  case "cancelled":
1491
1544
  // One word covered the agent declining, the provider withdrawing and the party abandoning the
1492
1545
  // ring; a record that cannot tell them apart is not a record. by says who, as completed does.
@@ -1510,7 +1563,7 @@ function validateTaskOutcome(value, path, into) {
1510
1563
  }
1511
1564
  break;
1512
1565
  default:
1513
- into.add("event.taskEnded.outcome.type", `${path}.type`, `unsupported outcome: ${String(value.type)}`);
1566
+ into.add("event.taskEnded.outcome.type", `${path}.type`, `unsupported outcome: ${describeValue(value.type)}`);
1514
1567
  }
1515
1568
  }
1516
1569
  function validateQueueSummary(value, path, into) {
@@ -1558,7 +1611,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1558
1611
  into.filled(envelope.id, "event.id", `${path}.id`, "an event needs an id");
1559
1612
  if (into.filled(envelope.loginId, "event.loginId", `${path}.loginId`, "an event needs the login id it belongs to")
1560
1613
  && context.loginId !== undefined) {
1561
- 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}`);
1614
+ into.require(envelope.loginId === context.loginId, "event.loginId.mismatch", `${path}.loginId`, `an event for session ${describeValue(envelope.loginId)} on a login whose session is ${context.loginId}`);
1562
1615
  }
1563
1616
  const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
1564
1617
  into.timestamp(envelope.occurredAt, "event.occurredAt", `${path}.occurredAt`);
@@ -1604,10 +1657,12 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1604
1657
  validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
1605
1658
  break;
1606
1659
  case "task-media-started":
1660
+ into.require(channel === "voice", "event.media.channel", `${at}.type`, "only a voice provider publishes media transitions");
1607
1661
  into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
1608
1662
  into.require(isAllocationId(event.allocationId), "event.taskMediaStarted.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
1609
1663
  break;
1610
1664
  case "task-media-ended":
1665
+ into.require(channel === "voice", "event.media.channel", `${at}.type`, "only a voice provider publishes media transitions");
1611
1666
  into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
1612
1667
  into.require(isAllocationId(event.allocationId), "event.taskMediaEnded.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
1613
1668
  break;
@@ -1621,7 +1676,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1621
1676
  // Only a distinction the manifest declared: an undeclared word is a guess dressed as a code.
1622
1677
  const declared = isPlainObject(manifest) && Array.isArray(manifest.dialOutcomes) ? manifest.dialOutcomes : undefined;
1623
1678
  if (into.oneOf(event.outcome, DIAL_OUTCOMES, "event.dialOutcome.outcome", `${at}.outcome`)) {
1624
- into.require(declared !== undefined && declared.includes(event.outcome), "event.dialOutcome.undeclared", `${at}.outcome`, `the manifest does not declare ${String(event.outcome)} among its dialOutcomes`);
1679
+ into.require(declared !== undefined && declared.includes(event.outcome), "event.dialOutcome.undeclared", `${at}.outcome`, `the manifest does not declare ${describeValue(event.outcome)} among its dialOutcomes`);
1625
1680
  }
1626
1681
  if (event.taskId !== undefined)
1627
1682
  into.require(isTaskId(event.taskId), "event.dialOutcome.taskId", `${at}.taskId`, "taskId must be a task id when present");
@@ -1681,7 +1736,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1681
1736
  }
1682
1737
  break;
1683
1738
  default:
1684
- into.add("event.type", `${at}.type`, `unsupported event type: ${String(event.type)}`);
1739
+ into.add("event.type", `${at}.type`, `unsupported event type: ${describeValue(event.type)}`);
1685
1740
  }
1686
1741
  return into.violations;
1687
1742
  }
@@ -1917,7 +1972,7 @@ export function validateHostReport(report, path = "host") {
1917
1972
  into.require(input.mutedBy === undefined, "host.audio.input.mutedBy.unexpected", `${at}.mutedBy`, "an unavailable input was not silenced; it is absent");
1918
1973
  }
1919
1974
  else {
1920
- into.add("host.audio.input.status", `${at}.status`, `an input is available or unavailable, not ${String(input.status)}`);
1975
+ into.add("host.audio.input.status", `${at}.status`, `an input is available or unavailable, not ${describeValue(input.status)}`);
1921
1976
  }
1922
1977
  const output = report.audio.output;
1923
1978
  const out = `${path}.audio.output`;
@@ -1940,7 +1995,7 @@ export function validateHostReport(report, path = "host") {
1940
1995
  into.require(output.mutedBy === undefined, "host.audio.output.mutedBy.unexpected", `${out}.mutedBy`, "an unavailable output was not silenced; it is absent");
1941
1996
  }
1942
1997
  else {
1943
- into.add("host.audio.output.status", `${out}.status`, `an output is available or unavailable, not ${String(output.status)}`);
1998
+ into.add("host.audio.output.status", `${out}.status`, `an output is available or unavailable, not ${describeValue(output.status)}`);
1944
1999
  }
1945
2000
  return into.violations;
1946
2001
  }
@@ -1971,25 +2026,8 @@ export function validateTaskCommand(command, task, path = "command") {
1971
2026
  : [...new Set([...TASK_COMMAND_NAMES.voice, ...TASK_COMMAND_NAMES.chat, ...TASK_COMMAND_NAMES.email])];
1972
2027
  if (type === "custom") {
1973
2028
  into.filled(command.name, "command.custom.name", `${path}.name`, "a custom command names the control it presses");
1974
- if (task === undefined)
1975
- return into.violations;
1976
- // Held to the task: the control it presses is one the task published, and what the control asked for travels with it.
1977
- const custom = isPlainObject(task) && isPlainObject(task.capabilities) ? task.capabilities.custom : undefined;
1978
- const control = Array.isArray(custom) ? custom.filter(isPlainObject).find(entry => entry.id === command.name) : undefined;
1979
- if (control === undefined) {
1980
- into.add("command.capability.custom", path, `custom ${String(command.name)} presses a control the task never published`);
1981
- return into.violations;
1982
- }
1983
- if (isPlainObject(control.prompt) && Array.isArray(control.prompt.fields)) {
1984
- for (const field of control.prompt.fields.filter(isPlainObject)) {
1985
- if (typeof field.name === "string") {
1986
- into.require(typeof command[field.name] === "string" && command[field.name].length > 0, "command.custom.prompt", `${path}.${field.name}`, `the control asked for ${field.name} and the command does not carry it`);
1987
- }
1988
- }
1989
- }
1990
- return into.violations;
1991
2029
  }
1992
- if (!into.require(typeof type === "string" && names.includes(type), "command.type", `${path}.type`, channel === undefined ? `unsupported command: ${String(type)}` : `a ${channel} task has no ${String(type)} command`)) {
2030
+ if (type !== "custom" && !into.require(typeof type === "string" && names.includes(type), "command.type", `${path}.type`, channel === undefined ? `unsupported command: ${describeValue(type)}` : `a ${channel} task has no ${describeValue(type)} command`)) {
1993
2031
  return into.violations;
1994
2032
  }
1995
2033
  const dial = (rule) => into.filled(command.dialId, rule, `${path}.dialId`, "a command that dials carries the host's dialId");
@@ -2007,7 +2045,7 @@ export function validateTaskCommand(command, task, path = "command") {
2007
2045
  into.filled(command.destinationId, "command.transfer.destinationId", `${path}.destinationId`, "a transfer names the directory item it goes to");
2008
2046
  }
2009
2047
  else {
2010
- into.require(command.dialId === undefined && command.destinationId === undefined, "command.transfer.unexpected", path, `${String(command.action)} names no destination: there is exactly one it could mean`);
2048
+ into.require(command.dialId === undefined && command.destinationId === undefined, "command.transfer.unexpected", path, `${describeValue(command.action)} names no destination: there is exactly one it could mean`);
2011
2049
  }
2012
2050
  }
2013
2051
  break;
@@ -2041,7 +2079,7 @@ export function validateTaskCommand(command, task, path = "command") {
2041
2079
  default:
2042
2080
  break;
2043
2081
  }
2044
- if (!isPlainObject(task))
2082
+ if (task === undefined)
2045
2083
  return into.violations;
2046
2084
  // The task the command is held to has to be one the wire published. A host's own mapping of it,
2047
2085
  // handed in for want of the original, is not, and checking a command against it would be
@@ -2049,25 +2087,45 @@ export function validateTaskCommand(command, task, path = "command") {
2049
2087
  // only to a task that stands.
2050
2088
  const published = new Collector();
2051
2089
  validateTaskInto(task, { channel: channel ?? "voice" }, `${path}.task`, published);
2052
- if (published.violations.length > 0) {
2090
+ if (published.violations.length > 0 || !isPlainObject(task)) {
2053
2091
  into.add("command.task", `${path}.task`, `the task the command is held to is not one the wire published (${published.violations.map(v => v.rule).join(", ")}): pass the task as the provider sent it, or validate the command's shape alone`);
2054
2092
  return into.violations;
2055
2093
  }
2094
+ if (type === "custom") {
2095
+ // Held to the task: the control it presses is one the task published, and what the control asked for travels with it.
2096
+ const custom = isPlainObject(task) && isPlainObject(task.capabilities) ? task.capabilities.custom : undefined;
2097
+ const control = Array.isArray(custom) ? custom.filter(isPlainObject).find(entry => entry.id === command.name) : undefined;
2098
+ if (control === undefined) {
2099
+ into.add("command.capability.custom", path, `custom ${describeValue(command.name)} presses a control the task never published`);
2100
+ return into.violations;
2101
+ }
2102
+ if (isPlainObject(control.ui) && control.ui.control === "toggle") {
2103
+ into.require(typeof command.on === "boolean", "command.custom.on", `${path}.on`, "a custom toggle carries the state it wants as a boolean");
2104
+ }
2105
+ if (isPlainObject(control.prompt) && Array.isArray(control.prompt.fields)) {
2106
+ for (const field of control.prompt.fields.filter(isPlainObject)) {
2107
+ if (typeof field.name === "string" && (field.required === true || command[field.name] !== undefined)) {
2108
+ into.require(typeof command[field.name] === "string" && (field.required !== true || command[field.name].length > 0), "command.custom.prompt", `${path}.${field.name}`, `${field.name} must be a string when supplied, and a required field must have a value`);
2109
+ }
2110
+ }
2111
+ }
2112
+ return into.violations;
2113
+ }
2056
2114
  // What the task has to offer or be in for the command to be issuable.
2057
2115
  const capabilities = isPlainObject(task.capabilities) ? task.capabilities : {};
2058
2116
  const offered = (name) => {
2059
2117
  const declared = capabilities[name];
2060
2118
  if (declared === undefined) {
2061
- into.add(`command.capability.${name}`, path, `${String(type)} needs the ${name} capability, and the task does not offer it`);
2119
+ into.add(`command.capability.${name}`, path, `${describeValue(type)} needs the ${name} capability, and the task does not offer it`);
2062
2120
  return false;
2063
2121
  }
2064
2122
  if (isLocked(declared)) {
2065
- into.add("command.capability.locked", path, `${name} stands locked by ${String(declared.lockedBy)}: the control is present without permission`);
2123
+ into.add("command.capability.locked", path, `${name} stands locked by ${describeValue(declared.lockedBy)}: the control is present without permission`);
2066
2124
  return false;
2067
2125
  }
2068
2126
  return true;
2069
2127
  };
2070
- const inPhase = (rule, ...phases) => into.require(phases.includes(String(task.phase)), rule, path, `${String(type)} belongs to ${phases.join(" or ")}, and the task is ${String(task.phase)}`);
2128
+ const inPhase = (rule, ...phases) => into.require(typeof task.phase === "string" && phases.includes(task.phase), rule, path, `${describeValue(type)} belongs to ${phases.join(" or ")}, and the task is ${describeValue(task.phase)}`);
2071
2129
  // A control on the contact acts on a contact being handled. Before in-progress there is nothing
2072
2130
  // to act on yet, and in completing the handling has ended: a call with nobody on it, a
2073
2131
  // conversation closed. What a capability offers, the phase decides whether there is anything to use it on.
@@ -2076,7 +2134,7 @@ export function validateTaskCommand(command, task, path = "command") {
2076
2134
  const listed = (name) => {
2077
2135
  const declared = capabilities[name];
2078
2136
  const directory = isPlainObject(declared) && Array.isArray(declared.destinations) ? declared.destinations.filter(isPlainObject) : [];
2079
- into.require(directory.some(item => item.id === command.destinationId), "command.destination.unknown", `${path}.destinationId`, `${String(command.destinationId)} is not a destination the task's ${name} directory offered`);
2137
+ into.require(directory.some(item => item.id === command.destinationId), "command.destination.unknown", `${path}.destinationId`, `${describeValue(command.destinationId)} is not a destination the task's ${name} directory offered`);
2080
2138
  };
2081
2139
  const onCall = Array.isArray(task.onCall) ? task.onCall.filter(isPlainObject) : [];
2082
2140
  switch (type) {
@@ -2119,7 +2177,7 @@ export function validateTaskCommand(command, task, path = "command") {
2119
2177
  listed("warmTransfer");
2120
2178
  }
2121
2179
  else if (command.action === "complete" || command.action === "cancel") {
2122
- into.require(onCall.some(entry => entry.role === "consulted"), "command.transfer.consulted", path, `${String(command.action)} needs a consulted entry on onCall: without one there is nothing to complete or cancel`);
2180
+ into.require(onCall.some(entry => entry.role === "consulted"), "command.transfer.consulted", path, `${describeValue(command.action)} needs a consulted entry on onCall: without one there is nothing to complete or cancel`);
2123
2181
  }
2124
2182
  break;
2125
2183
  case "lead-assist":
@@ -2131,7 +2189,7 @@ export function validateTaskCommand(command, task, path = "command") {
2131
2189
  }
2132
2190
  }
2133
2191
  else {
2134
- into.require(task.assisting !== undefined, "command.leadAssist.assisting", path, `${String(command.action)} is the lead's own act on a call they joined: the task carries assisting`);
2192
+ into.require(task.assisting !== undefined, "command.leadAssist.assisting", path, `${describeValue(command.action)} is the lead's own act on a call they joined: the task carries assisting`);
2135
2193
  }
2136
2194
  break;
2137
2195
  case "conference": {
@@ -2145,7 +2203,7 @@ export function validateTaskCommand(command, task, path = "command") {
2145
2203
  into.require(others.some(entry => entry.role !== "party"), "command.conference.remove.alone", path, "removing the party would leave the agent alone: that is end-call");
2146
2204
  }
2147
2205
  else if (isFilled(command.destinationId)) {
2148
- into.require(onCall.some(entry => entry.role === "conferenced" && entry.destinationId === command.destinationId), "command.conference.remove.unknown", path, `no conferenced entry on onCall has destinationId ${String(command.destinationId)}`);
2206
+ into.require(onCall.some(entry => entry.role === "conferenced" && entry.destinationId === command.destinationId), "command.conference.remove.unknown", path, `no conferenced entry on onCall has destinationId ${describeValue(command.destinationId)}`);
2149
2207
  into.require(others.length > 1, "command.conference.remove.alone", path, "removing the last other person would leave the agent alone: that is end-call");
2150
2208
  }
2151
2209
  }
@@ -2160,9 +2218,12 @@ export function validateTaskCommand(command, task, path = "command") {
2160
2218
  into.require(command.disposition === undefined, "command.complete.disposition.unexpected", `${path}.disposition`, "the task declares no dispositions; complete travels with no code");
2161
2219
  into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task declares no dispositions; complete travels with no notes");
2162
2220
  }
2221
+ else if (dispositions === true) {
2222
+ into.require(command.disposition === undefined, "command.complete.disposition.unexpected", `${path}.disposition`, "a bare dispositions control publishes no code, so complete carries none");
2223
+ }
2163
2224
  else if (isPlainObject(dispositions)) {
2164
2225
  if (Array.isArray(dispositions.codes) && command.disposition !== undefined) {
2165
- into.require(dispositions.codes.some(code => isPlainObject(code) && code.id === command.disposition), "command.complete.disposition.unknown", `${path}.disposition`, `${String(command.disposition)} is not a code the task published`);
2226
+ into.require(dispositions.codes.some(code => isPlainObject(code) && code.id === command.disposition), "command.complete.disposition.unknown", `${path}.disposition`, `${describeValue(command.disposition)} is not a code the task published`);
2166
2227
  }
2167
2228
  if (dispositions.required === true) {
2168
2229
  into.require(command.disposition !== undefined, "command.complete.disposition.required", `${path}.disposition`, "the task requires a disposition and complete carries none");
@@ -2186,7 +2247,8 @@ export function validateTaskCommand(command, task, path = "command") {
2186
2247
  const RESULT_STATUSES = {
2187
2248
  execute: { success: "applied", failure: "failed" },
2188
2249
  dial: { success: "dialling", failure: "failed" },
2189
- setCapacity: { success: "applied", failure: "failed" },
2250
+ // A statement, not a request: taken, never refused.
2251
+ setCapacity: { success: "applied", failure: undefined },
2190
2252
  requestBreak: { success: "requested", failure: "failed" },
2191
2253
  commitBreak: { success: "committed", failure: "failed" },
2192
2254
  cancelBreak: { success: "cancelled", failure: "failed" },
@@ -2215,21 +2277,34 @@ export function validateResult(result, method, path = "result", dialId) {
2215
2277
  into.add("result.shape", path, `${method} must answer an object`);
2216
2278
  return into.violations;
2217
2279
  }
2280
+ if (method === "openMedia" && result.status !== "opened") {
2281
+ into.require(result.session === undefined, "result.session.unexpected", `${path}.session`, "only opened carries a media session");
2282
+ }
2218
2283
  if (result.status === statuses.success) {
2219
2284
  into.require(result.failure === undefined, "result.failure.unexpected", `${path}.failure`, `${statuses.success} carries no failure`);
2220
2285
  if (method === "openMedia") {
2221
- into.require(isPlainObject(result.session), "result.session", `${path}.session`, "opened carries the media session");
2286
+ if (isPlainObject(result.session)) {
2287
+ // Streams may come from another realm, and Node conformance runs have no MediaStream
2288
+ // constructor. Check the boundary shape without claiming to verify live audio.
2289
+ into.require(isPlainObject(result.session.remoteAudio), "result.session.remoteAudio", `${path}.session.remoteAudio`, "an opened session carries its remote audio stream");
2290
+ for (const method of ["setMuted", "close"]) {
2291
+ into.require(typeof result.session[method] === "function", `result.session.${method}`, `${path}.session.${method}`, `a media session implements ${method}`);
2292
+ }
2293
+ }
2294
+ else {
2295
+ into.add("result.session", `${path}.session`, "opened carries the media session");
2296
+ }
2222
2297
  }
2223
2298
  if (dials) {
2224
2299
  if (into.filled(result.dialId, "result.dialId", `${path}.dialId`, "dialling restates the dialId the host sent") && dialId !== undefined) {
2225
- into.require(result.dialId === dialId, "result.dialId.mismatch", `${path}.dialId`, `the host dialled ${dialId} and the provider answered for ${String(result.dialId)}`);
2300
+ into.require(result.dialId === dialId, "result.dialId.mismatch", `${path}.dialId`, `the host dialled ${dialId} and the provider answered for ${describeValue(result.dialId)}`);
2226
2301
  }
2227
2302
  }
2228
2303
  else {
2229
2304
  into.require(result.dialId === undefined, "result.dialId.unexpected", `${path}.dialId`, `${statuses.success} dialled nothing and names no dial`);
2230
2305
  }
2231
2306
  }
2232
- else if (result.status === statuses.failure) {
2307
+ else if (statuses.failure !== undefined && result.status === statuses.failure) {
2233
2308
  if (result.failure === undefined) {
2234
2309
  into.add("result.failure.required", `${path}.failure`, `${statuses.failure} carries the failure that says why`);
2235
2310
  }
@@ -2238,7 +2313,7 @@ export function validateResult(result, method, path = "result", dialId) {
2238
2313
  }
2239
2314
  }
2240
2315
  else {
2241
- into.add("result.status", `${path}.status`, `${method} answers ${statuses.success} or ${statuses.failure}, not ${String(result.status)}`);
2316
+ into.add("result.status", `${path}.status`, `${method} answers ${statuses.success} or ${statuses.failure}, not ${describeValue(result.status)}`);
2242
2317
  }
2243
2318
  return into.violations;
2244
2319
  }
@@ -2351,7 +2426,7 @@ function validateMonitorControl(value, path, into) {
2351
2426
  }
2352
2427
  value.forEach((mode, index) => {
2353
2428
  if (into.oneOf(mode, MONITOR_MODES, "authentication.capability.team.monitorControl.mode", `${path}[${index}]`) && value.indexOf(mode) !== index) {
2354
- into.add("authentication.capability.team.monitorControl.unique", `${path}[${index}]`, `duplicate monitor mode: ${String(mode)}`);
2429
+ into.add("authentication.capability.team.monitorControl.unique", `${path}[${index}]`, `duplicate monitor mode: ${describeValue(mode)}`);
2355
2430
  }
2356
2431
  });
2357
2432
  into.require(value.includes("monitor"), "authentication.capability.team.monitorControl.monitor", path, "whisper and barge begin from a monitor: a lead who may listen in any mode may monitor");
@@ -2368,7 +2443,7 @@ function validateAuthenticationFailureInto(value, path, into) {
2368
2443
  return;
2369
2444
  }
2370
2445
  if (into.filled(value.code, "authentication.failure.code", `${path}.code`, "a failure needs a code") && value.code.startsWith("omni.")) {
2371
- into.require(OMNI_FAILURE_CODES.includes(value.code), "failure.code.unknown", `${path}.code`, `${String(value.code)} is not a failure code this contract defines`);
2446
+ into.require(OMNI_FAILURE_CODES.includes(value.code), "failure.code.unknown", `${path}.code`, `${describeValue(value.code)} is not a failure code this contract defines`);
2372
2447
  }
2373
2448
  into.filled(value.message, "authentication.failure.message", `${path}.message`, "a failure needs a message");
2374
2449
  into.require(typeof value.retryable === "boolean", "authentication.failure.retryable", `${path}.retryable`, "a failure must say whether it is retryable");
@@ -2376,7 +2451,7 @@ function validateAuthenticationFailureInto(value, path, into) {
2376
2451
  into.require(value.retryable === false, "authentication.failure.phone.retryable", `${path}.retryable`, "a phone the platform does not permit for this agent is configuration: trying again does not change it, so the refusal is not retryable");
2377
2452
  }
2378
2453
  if (value.retryAfterMs !== undefined) {
2379
- into.require(typeof value.retryAfterMs === "number" && value.retryAfterMs >= 0, "authentication.failure.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
2454
+ into.require(typeof value.retryAfterMs === "number" && Number.isFinite(value.retryAfterMs) && value.retryAfterMs >= 0, "authentication.failure.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
2380
2455
  }
2381
2456
  if (value.field !== undefined)
2382
2457
  into.filled(value.field, "authentication.failure.field", `${path}.field`, "field names a declared credentials field when present");
@@ -2386,6 +2461,42 @@ export function validateAuthenticationFailure(failure, path = "failure") {
2386
2461
  validateAuthenticationFailureInto(failure, path, into);
2387
2462
  return into.violations;
2388
2463
  }
2464
+ function validateAuthenticationChallenge(value, path, into) {
2465
+ if (!isPlainObject(value)) {
2466
+ into.add("authentication.result.challenge", path, "interaction-required carries the challenge to put to the person");
2467
+ return;
2468
+ }
2469
+ into.filled(value.flowId, "authentication.challenge.flowId", `${path}.flowId`, "a challenge names the flow to complete or cancel");
2470
+ if (!into.oneOf(value.method, AUTHENTICATION_METHODS, "authentication.challenge.method", `${path}.method`))
2471
+ return;
2472
+ if (value.method === "browser-sso") {
2473
+ into.filled(value.authorizationUrl, "authentication.challenge.authorizationUrl", `${path}.authorizationUrl`, "browser SSO carries the authorization URL to open");
2474
+ into.oneOf(value.browser, AUTHENTICATION_BROWSERS, "authentication.challenge.browser", `${path}.browser`);
2475
+ }
2476
+ else if (!Array.isArray(value.fields)) {
2477
+ into.add("authentication.challenge.fields", `${path}.fields`, "credentials carries the fields to render");
2478
+ }
2479
+ else {
2480
+ const seen = new Set();
2481
+ value.fields.forEach((field, index) => {
2482
+ const at = `${path}.fields[${index}]`;
2483
+ if (!isPlainObject(field)) {
2484
+ into.add("authentication.challenge.field.shape", at, "a credentials field must be an object");
2485
+ return;
2486
+ }
2487
+ if (into.filled(field.name, "authentication.challenge.field.name", `${at}.name`, "a credentials field needs a name")) {
2488
+ into.require(!seen.has(field.name), "authentication.challenge.field.unique", `${at}.name`, "each credentials field has its own key in the submitted values");
2489
+ seen.add(field.name);
2490
+ }
2491
+ into.filled(field.label, "authentication.challenge.field.label", `${at}.label`, "a credentials field needs a label");
2492
+ into.oneOf(field.type, CREDENTIAL_FIELD_TYPES, "authentication.challenge.field.type", `${at}.type`);
2493
+ if (field.required !== undefined)
2494
+ into.require(typeof field.required === "boolean", "authentication.challenge.field.required", `${at}.required`, "required must be a boolean when present");
2495
+ if (field.autocomplete !== undefined)
2496
+ into.require(typeof field.autocomplete === "string", "authentication.challenge.field.autocomplete", `${at}.autocomplete`, "autocomplete must be a string when present");
2497
+ });
2498
+ }
2499
+ }
2389
2500
  /**
2390
2501
  * What `start()` or `complete()` answered: a challenge or a rejection from `start`, a login or a
2391
2502
  * rejection from `complete`. A rejection carries a failure the host can show, and a login is held
@@ -2401,13 +2512,17 @@ export function validateAuthenticationResult(result, method, path = "result", co
2401
2512
  validateAuthenticationFailureInto(result.failure, `${path}.failure`, into);
2402
2513
  }
2403
2514
  else if (method === "start" && result.status === "interaction-required") {
2404
- into.require(isPlainObject(result.challenge), "authentication.result.challenge", `${path}.challenge`, "interaction-required carries the challenge to put to the person");
2515
+ validateAuthenticationChallenge(result.challenge, `${path}.challenge`, into);
2516
+ into.require(result.failure === undefined, "authentication.failure.unexpected", `${path}.failure`, "an authentication challenge carries no failure");
2405
2517
  }
2406
2518
  else if (method === "complete" && result.status === "authenticated") {
2407
2519
  into.violations.push(...validateAuthenticationState(result, path, context));
2408
2520
  }
2409
2521
  else {
2410
- into.add("authentication.result.status", `${path}.status`, `${method} answers ${method === "start" ? "interaction-required" : "authenticated"} or rejected, not ${String(result.status)}`);
2522
+ into.add("authentication.result.status", `${path}.status`, `${method} answers ${method === "start" ? "interaction-required" : "authenticated"} or rejected, not ${describeValue(result.status)}`);
2523
+ }
2524
+ if (result.status !== "interaction-required") {
2525
+ into.require(result.challenge === undefined, "authentication.challenge.unexpected", `${path}.challenge`, "only interaction-required carries a challenge");
2411
2526
  }
2412
2527
  return into.violations;
2413
2528
  }