@xema/omni-protocol 0.1.78 → 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.
package/README.md CHANGED
@@ -126,11 +126,18 @@ const request = { reasonId, requestedAt } satisfies BreakRequest; // error: requ
126
126
  ## Building
127
127
 
128
128
  ```
129
- pnpm install
129
+ pnpm install --frozen-lockfile
130
+ pnpm check # clean build, type checks, tests, and packed-package checks
130
131
  pnpm build # emits dist/
131
132
  pnpm test # type-checks the tests, then runs them
133
+ pnpm check:package # verifies the current dist/ as a consumer would
132
134
  ```
133
135
 
136
+ CI runs the complete check on Node 20, 22, and 24 for pull requests (including drafts)
137
+ and pushes to main. Package checks create a temporary npm tarball, verify its contents,
138
+ and check JavaScript imports and TypeScript declarations for every exported entry point
139
+ from an isolated consumer. Publishing also runs `pnpm check` through `prepublishOnly`.
140
+
134
141
  ## The guide is authoritative
135
142
 
136
143
  Where `guide.md` and any code here disagree, the guide is right and the code is a defect. It has
package/dist/testing.js CHANGED
@@ -1251,7 +1251,7 @@ export class TaskStream {
1251
1251
  this.noteDials(event.task);
1252
1252
  break;
1253
1253
  case "task-media-started":
1254
- ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate", "stream.allocation.ended", "stream.allocation.unknown");
1254
+ ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate", "stream.taskMedia.channel", "stream.allocation.ended", "stream.allocation.unknown");
1255
1255
  if (id === undefined)
1256
1256
  break;
1257
1257
  if (known === undefined) {
@@ -1262,6 +1262,10 @@ export class TaskStream {
1262
1262
  break;
1263
1263
  // Ring-back is audio: a task whose party the host is dialling has media from dialling on,
1264
1264
  // whatever its phase; every other task not at work has none.
1265
+ if (known.channel !== "voice") {
1266
+ refuse("stream.taskMedia.channel", `${at}.type`, "only a voice task has media transitions");
1267
+ break;
1268
+ }
1265
1269
  if (!AT_WORK.has(known.phase) && !known.partyRingingByHost) {
1266
1270
  refuse("stream.taskMediaStarted.beforeWork", `${at}.taskId`, `media cannot arrive on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
1267
1271
  }
@@ -1271,7 +1275,7 @@ export class TaskStream {
1271
1275
  known.media = "started";
1272
1276
  break;
1273
1277
  case "task-media-ended":
1274
- ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent", "stream.allocation.ended", "stream.allocation.unknown");
1278
+ ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent", "stream.taskMedia.channel", "stream.allocation.ended", "stream.allocation.unknown");
1275
1279
  if (id === undefined)
1276
1280
  break;
1277
1281
  if (known === undefined) {
@@ -1280,6 +1284,10 @@ export class TaskStream {
1280
1284
  }
1281
1285
  if (!this.namesTheOpenLife(event, known, at, refuse))
1282
1286
  break;
1287
+ if (known.channel !== "voice") {
1288
+ refuse("stream.taskMedia.channel", `${at}.type`, "only a voice task has media transitions");
1289
+ break;
1290
+ }
1283
1291
  if (!WORK_BEGUN.has(known.phase)) {
1284
1292
  refuse("stream.taskMediaEnded.beforeWork", `${at}.taskId`, `media cannot end on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
1285
1293
  }
@@ -1477,10 +1485,11 @@ async function driveOneCall(drive) {
1477
1485
  session = undefined;
1478
1486
  ruleEvaluated("drive.reload.openMedia");
1479
1487
  const reopened = await second.openMedia?.({ taskId, allocationId: allocationOf(), localAudio: drive.localAudio });
1480
- found.push(...validateResult(reopened, "openMedia", "drive.reload.openMedia"));
1481
- if (isRecord(reopened) && reopened.status === "opened")
1488
+ const malformed = validateResult(reopened, "openMedia", "drive.reload.openMedia");
1489
+ found.push(...malformed);
1490
+ if (malformed.length === 0 && isRecord(reopened) && reopened.status === "opened")
1482
1491
  session = reopened.session;
1483
- else
1492
+ else if (malformed.length === 0)
1484
1493
  refuse("drive.reload.openMedia", "drive.reload.openMedia", `a second adapter built from the same login could not open the audio of ${taskId}, which its own snapshot carries with media started`);
1485
1494
  }
1486
1495
  }
@@ -1630,10 +1639,11 @@ async function driveOneCall(drive) {
1630
1639
  cursor = Math.max(cursor, started.at);
1631
1640
  }
1632
1641
  const opened = await drive.connection.openMedia?.({ taskId, allocationId: allocationOf(), localAudio: drive.localAudio });
1633
- found.push(...validateResult(opened, "openMedia", "drive.openMedia"));
1634
- if (isRecord(opened) && opened.status === "opened")
1642
+ const malformed = validateResult(opened, "openMedia", "drive.openMedia");
1643
+ found.push(...malformed);
1644
+ if (malformed.length === 0 && isRecord(opened) && opened.status === "opened")
1635
1645
  session = opened.session;
1636
- else if (isRecord(opened))
1646
+ else if (malformed.length === 0 && isRecord(opened))
1637
1647
  refuse("drive.openMedia.unavailable", "drive.openMedia", "a softphone login's adapter could not open the call's audio");
1638
1648
  }
1639
1649
  // 3b. The microphone is the host's. With the audio open, the drive mutes it for a moment and
@@ -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
  });
@@ -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
  // ---------------------------------------------------------------------------
@@ -309,7 +333,7 @@ function validateDialOutcomes(manifest, path, into) {
309
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");
310
334
  return;
311
335
  }
312
- 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`))
313
337
  return;
314
338
  if (!Array.isArray(declared) || declared.length === 0) {
315
339
  into.add("manifest.dialOutcomes.shape", path, "dialOutcomes is a non-empty array of the outcomes the platform distinguishes");
@@ -317,7 +341,7 @@ function validateDialOutcomes(manifest, path, into) {
317
341
  }
318
342
  declared.forEach((outcome, index) => {
319
343
  if (into.oneOf(outcome, DIAL_OUTCOMES, "manifest.dialOutcome", `${path}[${index}]`) && declared.indexOf(outcome) !== index) {
320
- 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)}`);
321
345
  }
322
346
  });
323
347
  into.require(declared.includes("answered"), "manifest.dialOutcomes.answered", path, "a dial that reached its destination is stated, never inferred: dialOutcomes includes answered");
@@ -335,7 +359,7 @@ function validateDialOutcomes(manifest, path, into) {
335
359
  function validatePhones(manifest, path, into) {
336
360
  const declared = manifest.phones;
337
361
  if (manifest.channel !== "voice") {
338
- 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`);
339
363
  return;
340
364
  }
341
365
  if (!Array.isArray(declared) || declared.length === 0) {
@@ -344,7 +368,7 @@ function validatePhones(manifest, path, into) {
344
368
  }
345
369
  declared.forEach((phone, index) => {
346
370
  if (into.oneOf(phone, PHONES, "manifest.phone", `${path}[${index}]`) && declared.indexOf(phone) !== index) {
347
- into.add("manifest.phone.unique", `${path}[${index}]`, `duplicate phone: ${String(phone)}`);
371
+ into.add("manifest.phone.unique", `${path}[${index}]`, `duplicate phone: ${describeValue(phone)}`);
348
372
  }
349
373
  });
350
374
  }
@@ -357,13 +381,13 @@ export function validatePhone(value, manifest, path = "context.phone") {
357
381
  const channel = isPlainObject(manifest) ? manifest.channel : undefined;
358
382
  const phones = isPlainObject(manifest) && Array.isArray(manifest.phones) ? manifest.phones : [];
359
383
  if (channel !== "voice") {
360
- 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`);
361
385
  return into.violations;
362
386
  }
363
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"))
364
388
  return into.violations;
365
389
  if (into.oneOf(value, PHONES, "context.phone", path)) {
366
- 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(", ")}`);
367
391
  }
368
392
  return into.violations;
369
393
  }
@@ -400,7 +424,7 @@ export function validateManifest(manifest, path = "manifest") {
400
424
  else {
401
425
  methods.forEach((method, index) => {
402
426
  if (into.oneOf(method, AUTHENTICATION_METHODS, "manifest.authenticationMethod", `${path}.authenticationMethods[${index}]`) && methods.indexOf(method) !== index) {
403
- 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)}`);
404
428
  }
405
429
  });
406
430
  }
@@ -583,6 +607,10 @@ function validateCustomCapabilities(value, path, into) {
583
607
  into.filled(field.name, "task.custom.prompt.field.name", `${where}.name`, "a prompt field needs a name");
584
608
  into.filled(field.label, "task.custom.prompt.field.label", `${where}.label`, "a prompt field needs a label");
585
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");
586
614
  });
587
615
  }
588
616
  }
@@ -649,7 +677,7 @@ function validateLockedByInto(value, rule, path, levels, into) {
649
677
  if (!into.filled(value, rule, path, "lockedBy names the level that locked it"))
650
678
  return;
651
679
  into.require(value !== "person", `${rule}.person`, path, "a person never locks their own value");
652
- 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(", ")}`);
653
681
  }
654
682
  /** `{ lockedBy, reason? }` standing in for a value: who locked it, and a reason if given. */
655
683
  function validateLockedInto(value, rule, path, levels, into) {
@@ -660,7 +688,7 @@ function validateLockedInto(value, rule, path, levels, into) {
660
688
  /** What every resolved value carries: who set it, and who locked it if anyone. */
661
689
  function validateResolvedInto(value, rule, path, levels, into) {
662
690
  if (into.filled(value.setBy, `${rule}.setBy`, `${path}.setBy`, "setBy names who stated the value: a level, or provider")) {
663
- into.require(value.setBy === "provider" || levelIds(levels).includes(value.setBy), `${rule}.setBy.unknown`, `${path}.setBy`, `${String(value.setBy)} is neither provider nor a level this manifest declares`);
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`);
664
692
  }
665
693
  if (value.lockedBy !== undefined)
666
694
  validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`, levels, into);
@@ -771,7 +799,7 @@ function validateTaskAttributes(value, path, into, levels) {
771
799
  into.timestamp(attribute.at, "task.attribute.timestamp", `${at}.at`);
772
800
  break;
773
801
  default:
774
- 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)}`);
775
803
  }
776
804
  });
777
805
  }
@@ -822,7 +850,7 @@ function validateHandlingHistory(value, path, into, task = {}) {
822
850
  // the task is paused, and a mute only while its media is up. An open entry after that is a leg
823
851
  // nobody closed, which reads exactly like a leg running now.
824
852
  if (entry.step === "held") {
825
- 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`);
826
854
  }
827
855
  if (entry.step === "muted") {
828
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");
@@ -849,7 +877,7 @@ function validateHandlingHistory(value, path, into, task = {}) {
849
877
  if (entry[field] === undefined)
850
878
  continue;
851
879
  if (into.filled(entry[field], `task.handlingHistory.${field}`, `${at}.${field}`, `${field} must not be empty when present`)) {
852
- 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`);
853
881
  }
854
882
  }
855
883
  });
@@ -871,7 +899,7 @@ function validateTaskMedia(task, value, channel, phase, path, into) {
871
899
  // work has begun, on a snapshot as on the event, or a host would open the microphone on an offer.
872
900
  // The one task not yet at work that has audio is one whose party the host is dialling: ring-back
873
901
  // is audio, and every host-placed dial's media starts on dialling.
874
- into.require(!WORK_NOT_BEGUN.includes(phase) || partyRingingByHost(task), "task.media.beforeWork", path, `a ${String(phase)} task has no media: audio arrives once its work has begun, or once the host is dialling its party`);
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`);
875
903
  }
876
904
  }
877
905
  const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
@@ -1079,7 +1107,7 @@ function validateTaskInto(task, context, path, into) {
1079
1107
  }
1080
1108
  // The channel is fixed per provider by its manifest, so a task claiming another one is a
1081
1109
  // task Omni would render with the wrong controls.
1082
- 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`);
1083
1111
  if (task.reference !== undefined) {
1084
1112
  into.filled(task.reference, "task.reference", `${path}.reference`, "a reference must not be empty when present");
1085
1113
  }
@@ -1223,7 +1251,7 @@ function validateBreakState(value, path, into) {
1223
1251
  // An imposed break is a break somebody placed; beside `not-requested` there is no break.
1224
1252
  // An imposed break is a break in progress or about to be: it travels with in-effect or
1225
1253
  // starting-after-task and nothing else. Beside granted, the host would commit a break nobody asked for.
1226
- 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; ${String(value.approval)} says the agent asked, or that there is none`);
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`);
1227
1255
  }
1228
1256
  if (value.reasons === undefined)
1229
1257
  return;
@@ -1263,7 +1291,7 @@ function validateBreakState(value, path, into) {
1263
1291
  // rules -- who may monitor through it, whether it counts -- nobody can apply.
1264
1292
  ruleEvaluated("break.activeReasonId.required");
1265
1293
  if (seen.size > 0 && (value.approval === "in-effect" || value.approval === "starting-after-task")) {
1266
- into.require(typeof value.activeReasonId === "string" && value.activeReasonId.length > 0, "break.activeReasonId.required", `${path}.activeReasonId`, `a break ${String(value.approval)} on a provider that publishes reasons names the one it is on`);
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`);
1267
1295
  }
1268
1296
  }
1269
1297
  export function validateTeamRoster(roster, path = "team", context = {}) {
@@ -1370,10 +1398,10 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1370
1398
  }
1371
1399
  const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
1372
1400
  const levels = context.levels ?? manifestLevels(manifest);
1373
- into.oneOf(snapshot.transport, TRANSPORT_STATUSES, "snapshot.transport", `${path}.status`);
1401
+ into.oneOf(snapshot.transport, TRANSPORT_STATUSES, "snapshot.transport", `${path}.transport`);
1374
1402
  if (into.filled(snapshot.loginId, "snapshot.loginId", `${path}.loginId`, "a snapshot needs the login id it belongs to")
1375
1403
  && context.loginId !== undefined) {
1376
- 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}`);
1377
1405
  }
1378
1406
  validateBreakState(snapshot.break, `${path}.break`, into);
1379
1407
  // The count is the provider's confirmation of how much work it answered with. Stated, never
@@ -1440,7 +1468,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1440
1468
  const reasons = Array.isArray(state.reasons) ? state.reasons : [];
1441
1469
  const active = reasons.find((reason) => isPlainObject(reason) && reason.id === state.activeReasonId);
1442
1470
  const kind = isPlainObject(active) ? active.kind : undefined;
1443
- 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)}`);
1444
1472
  }
1445
1473
  }
1446
1474
  // Presence is the permission, and it cuts both ways: data a provider never declared a
@@ -1535,7 +1563,7 @@ function validateTaskOutcome(value, path, into) {
1535
1563
  }
1536
1564
  break;
1537
1565
  default:
1538
- 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)}`);
1539
1567
  }
1540
1568
  }
1541
1569
  function validateQueueSummary(value, path, into) {
@@ -1583,7 +1611,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1583
1611
  into.filled(envelope.id, "event.id", `${path}.id`, "an event needs an id");
1584
1612
  if (into.filled(envelope.loginId, "event.loginId", `${path}.loginId`, "an event needs the login id it belongs to")
1585
1613
  && context.loginId !== undefined) {
1586
- 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}`);
1587
1615
  }
1588
1616
  const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
1589
1617
  into.timestamp(envelope.occurredAt, "event.occurredAt", `${path}.occurredAt`);
@@ -1629,10 +1657,12 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1629
1657
  validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
1630
1658
  break;
1631
1659
  case "task-media-started":
1660
+ into.require(channel === "voice", "event.media.channel", `${at}.type`, "only a voice provider publishes media transitions");
1632
1661
  into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
1633
1662
  into.require(isAllocationId(event.allocationId), "event.taskMediaStarted.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
1634
1663
  break;
1635
1664
  case "task-media-ended":
1665
+ into.require(channel === "voice", "event.media.channel", `${at}.type`, "only a voice provider publishes media transitions");
1636
1666
  into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
1637
1667
  into.require(isAllocationId(event.allocationId), "event.taskMediaEnded.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
1638
1668
  break;
@@ -1646,7 +1676,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1646
1676
  // Only a distinction the manifest declared: an undeclared word is a guess dressed as a code.
1647
1677
  const declared = isPlainObject(manifest) && Array.isArray(manifest.dialOutcomes) ? manifest.dialOutcomes : undefined;
1648
1678
  if (into.oneOf(event.outcome, DIAL_OUTCOMES, "event.dialOutcome.outcome", `${at}.outcome`)) {
1649
- 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`);
1650
1680
  }
1651
1681
  if (event.taskId !== undefined)
1652
1682
  into.require(isTaskId(event.taskId), "event.dialOutcome.taskId", `${at}.taskId`, "taskId must be a task id when present");
@@ -1706,7 +1736,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1706
1736
  }
1707
1737
  break;
1708
1738
  default:
1709
- 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)}`);
1710
1740
  }
1711
1741
  return into.violations;
1712
1742
  }
@@ -1942,7 +1972,7 @@ export function validateHostReport(report, path = "host") {
1942
1972
  into.require(input.mutedBy === undefined, "host.audio.input.mutedBy.unexpected", `${at}.mutedBy`, "an unavailable input was not silenced; it is absent");
1943
1973
  }
1944
1974
  else {
1945
- 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)}`);
1946
1976
  }
1947
1977
  const output = report.audio.output;
1948
1978
  const out = `${path}.audio.output`;
@@ -1965,7 +1995,7 @@ export function validateHostReport(report, path = "host") {
1965
1995
  into.require(output.mutedBy === undefined, "host.audio.output.mutedBy.unexpected", `${out}.mutedBy`, "an unavailable output was not silenced; it is absent");
1966
1996
  }
1967
1997
  else {
1968
- 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)}`);
1969
1999
  }
1970
2000
  return into.violations;
1971
2001
  }
@@ -1996,25 +2026,8 @@ export function validateTaskCommand(command, task, path = "command") {
1996
2026
  : [...new Set([...TASK_COMMAND_NAMES.voice, ...TASK_COMMAND_NAMES.chat, ...TASK_COMMAND_NAMES.email])];
1997
2027
  if (type === "custom") {
1998
2028
  into.filled(command.name, "command.custom.name", `${path}.name`, "a custom command names the control it presses");
1999
- if (task === undefined)
2000
- return into.violations;
2001
- // Held to the task: the control it presses is one the task published, and what the control asked for travels with it.
2002
- const custom = isPlainObject(task) && isPlainObject(task.capabilities) ? task.capabilities.custom : undefined;
2003
- const control = Array.isArray(custom) ? custom.filter(isPlainObject).find(entry => entry.id === command.name) : undefined;
2004
- if (control === undefined) {
2005
- into.add("command.capability.custom", path, `custom ${String(command.name)} presses a control the task never published`);
2006
- return into.violations;
2007
- }
2008
- if (isPlainObject(control.prompt) && Array.isArray(control.prompt.fields)) {
2009
- for (const field of control.prompt.fields.filter(isPlainObject)) {
2010
- if (typeof field.name === "string") {
2011
- 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`);
2012
- }
2013
- }
2014
- }
2015
- return into.violations;
2016
2029
  }
2017
- 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`)) {
2018
2031
  return into.violations;
2019
2032
  }
2020
2033
  const dial = (rule) => into.filled(command.dialId, rule, `${path}.dialId`, "a command that dials carries the host's dialId");
@@ -2032,7 +2045,7 @@ export function validateTaskCommand(command, task, path = "command") {
2032
2045
  into.filled(command.destinationId, "command.transfer.destinationId", `${path}.destinationId`, "a transfer names the directory item it goes to");
2033
2046
  }
2034
2047
  else {
2035
- 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`);
2036
2049
  }
2037
2050
  }
2038
2051
  break;
@@ -2066,7 +2079,7 @@ export function validateTaskCommand(command, task, path = "command") {
2066
2079
  default:
2067
2080
  break;
2068
2081
  }
2069
- if (!isPlainObject(task))
2082
+ if (task === undefined)
2070
2083
  return into.violations;
2071
2084
  // The task the command is held to has to be one the wire published. A host's own mapping of it,
2072
2085
  // handed in for want of the original, is not, and checking a command against it would be
@@ -2074,25 +2087,45 @@ export function validateTaskCommand(command, task, path = "command") {
2074
2087
  // only to a task that stands.
2075
2088
  const published = new Collector();
2076
2089
  validateTaskInto(task, { channel: channel ?? "voice" }, `${path}.task`, published);
2077
- if (published.violations.length > 0) {
2090
+ if (published.violations.length > 0 || !isPlainObject(task)) {
2078
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`);
2079
2092
  return into.violations;
2080
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
+ }
2081
2114
  // What the task has to offer or be in for the command to be issuable.
2082
2115
  const capabilities = isPlainObject(task.capabilities) ? task.capabilities : {};
2083
2116
  const offered = (name) => {
2084
2117
  const declared = capabilities[name];
2085
2118
  if (declared === undefined) {
2086
- 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`);
2087
2120
  return false;
2088
2121
  }
2089
2122
  if (isLocked(declared)) {
2090
- 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`);
2091
2124
  return false;
2092
2125
  }
2093
2126
  return true;
2094
2127
  };
2095
- 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)}`);
2096
2129
  // A control on the contact acts on a contact being handled. Before in-progress there is nothing
2097
2130
  // to act on yet, and in completing the handling has ended: a call with nobody on it, a
2098
2131
  // conversation closed. What a capability offers, the phase decides whether there is anything to use it on.
@@ -2101,7 +2134,7 @@ export function validateTaskCommand(command, task, path = "command") {
2101
2134
  const listed = (name) => {
2102
2135
  const declared = capabilities[name];
2103
2136
  const directory = isPlainObject(declared) && Array.isArray(declared.destinations) ? declared.destinations.filter(isPlainObject) : [];
2104
- 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`);
2105
2138
  };
2106
2139
  const onCall = Array.isArray(task.onCall) ? task.onCall.filter(isPlainObject) : [];
2107
2140
  switch (type) {
@@ -2144,7 +2177,7 @@ export function validateTaskCommand(command, task, path = "command") {
2144
2177
  listed("warmTransfer");
2145
2178
  }
2146
2179
  else if (command.action === "complete" || command.action === "cancel") {
2147
- 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`);
2148
2181
  }
2149
2182
  break;
2150
2183
  case "lead-assist":
@@ -2156,7 +2189,7 @@ export function validateTaskCommand(command, task, path = "command") {
2156
2189
  }
2157
2190
  }
2158
2191
  else {
2159
- 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`);
2160
2193
  }
2161
2194
  break;
2162
2195
  case "conference": {
@@ -2170,7 +2203,7 @@ export function validateTaskCommand(command, task, path = "command") {
2170
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");
2171
2204
  }
2172
2205
  else if (isFilled(command.destinationId)) {
2173
- 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)}`);
2174
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");
2175
2208
  }
2176
2209
  }
@@ -2185,9 +2218,12 @@ export function validateTaskCommand(command, task, path = "command") {
2185
2218
  into.require(command.disposition === undefined, "command.complete.disposition.unexpected", `${path}.disposition`, "the task declares no dispositions; complete travels with no code");
2186
2219
  into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task declares no dispositions; complete travels with no notes");
2187
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
+ }
2188
2224
  else if (isPlainObject(dispositions)) {
2189
2225
  if (Array.isArray(dispositions.codes) && command.disposition !== undefined) {
2190
- 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`);
2191
2227
  }
2192
2228
  if (dispositions.required === true) {
2193
2229
  into.require(command.disposition !== undefined, "command.complete.disposition.required", `${path}.disposition`, "the task requires a disposition and complete carries none");
@@ -2241,14 +2277,27 @@ export function validateResult(result, method, path = "result", dialId) {
2241
2277
  into.add("result.shape", path, `${method} must answer an object`);
2242
2278
  return into.violations;
2243
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
+ }
2244
2283
  if (result.status === statuses.success) {
2245
2284
  into.require(result.failure === undefined, "result.failure.unexpected", `${path}.failure`, `${statuses.success} carries no failure`);
2246
2285
  if (method === "openMedia") {
2247
- 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
+ }
2248
2297
  }
2249
2298
  if (dials) {
2250
2299
  if (into.filled(result.dialId, "result.dialId", `${path}.dialId`, "dialling restates the dialId the host sent") && dialId !== undefined) {
2251
- 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)}`);
2252
2301
  }
2253
2302
  }
2254
2303
  else {
@@ -2264,7 +2313,7 @@ export function validateResult(result, method, path = "result", dialId) {
2264
2313
  }
2265
2314
  }
2266
2315
  else {
2267
- 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)}`);
2268
2317
  }
2269
2318
  return into.violations;
2270
2319
  }
@@ -2377,7 +2426,7 @@ function validateMonitorControl(value, path, into) {
2377
2426
  }
2378
2427
  value.forEach((mode, index) => {
2379
2428
  if (into.oneOf(mode, MONITOR_MODES, "authentication.capability.team.monitorControl.mode", `${path}[${index}]`) && value.indexOf(mode) !== index) {
2380
- 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)}`);
2381
2430
  }
2382
2431
  });
2383
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");
@@ -2394,7 +2443,7 @@ function validateAuthenticationFailureInto(value, path, into) {
2394
2443
  return;
2395
2444
  }
2396
2445
  if (into.filled(value.code, "authentication.failure.code", `${path}.code`, "a failure needs a code") && value.code.startsWith("omni.")) {
2397
- 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`);
2398
2447
  }
2399
2448
  into.filled(value.message, "authentication.failure.message", `${path}.message`, "a failure needs a message");
2400
2449
  into.require(typeof value.retryable === "boolean", "authentication.failure.retryable", `${path}.retryable`, "a failure must say whether it is retryable");
@@ -2402,7 +2451,7 @@ function validateAuthenticationFailureInto(value, path, into) {
2402
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");
2403
2452
  }
2404
2453
  if (value.retryAfterMs !== undefined) {
2405
- 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");
2406
2455
  }
2407
2456
  if (value.field !== undefined)
2408
2457
  into.filled(value.field, "authentication.failure.field", `${path}.field`, "field names a declared credentials field when present");
@@ -2412,6 +2461,42 @@ export function validateAuthenticationFailure(failure, path = "failure") {
2412
2461
  validateAuthenticationFailureInto(failure, path, into);
2413
2462
  return into.violations;
2414
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
+ }
2415
2500
  /**
2416
2501
  * What `start()` or `complete()` answered: a challenge or a rejection from `start`, a login or a
2417
2502
  * rejection from `complete`. A rejection carries a failure the host can show, and a login is held
@@ -2427,13 +2512,17 @@ export function validateAuthenticationResult(result, method, path = "result", co
2427
2512
  validateAuthenticationFailureInto(result.failure, `${path}.failure`, into);
2428
2513
  }
2429
2514
  else if (method === "start" && result.status === "interaction-required") {
2430
- 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");
2431
2517
  }
2432
2518
  else if (method === "complete" && result.status === "authenticated") {
2433
2519
  into.violations.push(...validateAuthenticationState(result, path, context));
2434
2520
  }
2435
2521
  else {
2436
- 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");
2437
2526
  }
2438
2527
  return into.violations;
2439
2528
  }
package/guide.md CHANGED
@@ -1306,7 +1306,7 @@ dropped. An adapter that wanted only a better noun loses a field it never meant
1306
1306
 
1307
1307
  ## Protocol contract rules
1308
1308
 
1309
- Nine rules govern protocol data and behavior.
1309
+ Ten rules govern protocol data and behavior.
1310
1310
 
1311
1311
  ### 1. The protocol is authoritative
1312
1312
 
@@ -3181,7 +3181,7 @@ inside Omni.
3181
3181
 
3182
3182
  ## Every dial has an outcome
3183
3183
 
3184
- Four commands place a call: `dial` from the idle dialpad, a blind or consult `transfer`, a
3184
+ Five commands place a call: `dial` from the idle dialpad, `call` from preview, a cold or warm `transfer`, a
3185
3185
  `conference` `add`, and `connect-back`. Each is accepted or refused at once, and each then ends later
3186
3186
  and apart from its answer -- the destination picks up, is busy, or never does -- and a dial placed
3187
3187
  late in a call routinely outlives the call. Nothing in between is reported: the wire says
@@ -3979,18 +3979,17 @@ decision or it is not.
3979
3979
 
3980
3980
  ## Real-time media
3981
3981
 
3982
- Every voice provider has media. There is nothing to announce, no capability to declare and no
3983
- endpoint to choose: `channel: "voice"` says audio exists, and **Omni is the device it lands on**.
3982
+ Every voice provider has media: `channel: "voice"` says audio exists. Where the agent hears it
3983
+ depends on the `phone` the host selected at authentication and connect from the manifest's
3984
+ `phones`. On a softphone, audio lands in Omni; on a desk phone, it lands on the handset and Omni
3985
+ opens no audio. See **How the agent hears the call**. Media transitions are voice-only;
3986
+ chat and email publish none (`event.media.channel`, `stream.taskMedia.channel`).
3984
3987
 
3985
- Other endpoints exist in a deployment — desk phones, the provider's own hardware, whatever the
3986
- platform already rings — and none of them is the agent's. Omni does not enumerate them, map the
3987
- agent onto one, or follow a change made to one. There is no device list, no device selection and
3988
- no device on the snapshot, because there is no choice to record: audio for this agent arrives in
3989
- Omni, and Omni registers the endpoint for it.
3988
+ The provider owns signalling and the platform's endpoint configuration. The host selects the
3989
+ declared phone mode; it does not enumerate or reconfigure the platform's devices. The selected
3990
+ mode stays with the login rather than being inferred from a snapshot or a device failure.
3990
3991
 
3991
- That removes a whole class of state the provider would otherwise own and Omni would have to track,
3992
- and it removes the branch that came with it: no command has to ask where the audio went before
3993
- deciding who performs it. Nor does the audio ever stand in for the task: a task's presence and
3992
+ Nor does the audio ever stand in for the task: a task's presence and
3994
3993
  phase follow the provider's reports about the work, and the media — attaching, moving through a
3995
3994
  hold, a consult, a conference or a transfer, and ending — is transient beside it. See **A task is
3996
3995
  never its audio** under **Task allocation lifecycle**.
@@ -4165,7 +4164,7 @@ carries as `audio.input.localAudio`, and absent while that input is `unavailable
4165
4164
  bridges audio without a host-side input may ignore it; one that needs it and finds it absent
4166
4165
  answers `unavailable` with a failure Omni shows the agent.
4167
4166
 
4168
- **When to ask is the provider's word, not Omni's guess.** Omni opens media on `task-media-started`,
4167
+ **When to ask is the provider's word, not Omni's guess.** On a softphone login, Omni opens media on `task-media-started`,
4169
4168
  and on a task arriving with `media: "started"` on a snapshot; it closes on `task-media-ended` and
4170
4169
  when the task ends. Between those words, nothing Omni's own senses report — a stream that drops, a
4171
4170
  track that ends — moves the task or its audio.
@@ -4302,7 +4301,7 @@ declared:
4302
4301
  | `lead-assist` with `action: "request"` or `"cancel"` | The `leadAssist` capability. `cancel` needs a request standing -- `Task.leadAssist` with status `requested`. |
4303
4302
  | `lead-assist` with `action: "take-over"` or `"leave"` | The lead's own task, on a call they joined -- `Task.assisting` present. An agent's task never has it, and a provider that receives either without it answers `failed`. |
4304
4303
  | `transfer` with a destination, `conference` with `action: "add"` | Its capability, and a `destinationId` the directory offered: the id Omni sends is the id the provider published (`command.destination.unknown`). |
4305
- | `custom` | A control the task published under `capabilities.custom`, by its `id` (`command.capability.custom`), carrying every field the control's `prompt` asked for (`command.custom.prompt`). |
4304
+ | `custom` | A control the task published under `capabilities.custom`, by its `id` (`command.capability.custom`), carrying a non-empty string for each `required` prompt field and strings for any optional fields supplied (`command.custom.prompt`). A toggle carries its target `on` boolean (`command.custom.on`). |
4306
4305
  | Everything else | Its own named capability. |
4307
4306
 
4308
4307
  **A control on the contact belongs to the handling phases**, `in-progress` and `paused`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.78",
3
+ "version": "0.1.79",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,11 @@
30
30
  "scripts": {
31
31
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
32
32
  "build": "pnpm run clean && tsc -p tsconfig.json",
33
- "test": "tsc -p tsconfig.test.json && vitest run"
33
+ "typecheck": "tsc -p tsconfig.test.json",
34
+ "test": "pnpm run typecheck && vitest run",
35
+ "check:package": "node scripts/check-package.mjs",
36
+ "check": "pnpm run build && pnpm run test && pnpm run check:package",
37
+ "prepublishOnly": "pnpm run check"
34
38
  },
35
39
  "devDependencies": {
36
40
  "@types/node": "^22.20.1",